round65: Android-Startbildschirm nur mit Favoriten, echtes Drag-and-Drop per Pointer-Events, selbst erstellbare Ordner

This commit is contained in:
2026-07-26 18:34:46 +02:00
parent 538fba5fc3
commit ff87f9812c
6 changed files with 590 additions and 263 deletions

View File

@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Link } from "@tanstack/react-router";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
@@ -7,22 +7,31 @@ import {
faXmark,
faClockRotateLeft,
faBookmark,
faPen,
} from "@fortawesome/free-solid-svg-icons";
import type { Category, Service, Bookmark, SearchResult } from "@launchpad/shared";
import { ResultsList, Button } from "@launchpad/ui";
import { useHomeLayout, type HomeSlot, type ItemRef } from "../hooks/useHomeLayout.js";
/**
* Android-Startbildschirm-artiges alternatives Design für die mobile Ansicht
* (siehe Einstellungen -> "Design"). Bewusst als eigenständige Komponente
* statt Umbau der bestehenden HomePage: beide Designs bleiben unabhängig
* wart- und weiterentwickelbar, HomePage.tsx entscheidet nur, welche der
* beiden gerendert wird (siehe dort).
* (siehe Einstellungen -> "Design"). Zeigt AUSSCHLIESSLICH als Favorit
* markierte Dienste/Lesezeichen (siehe Bugreport round65: vorher wurden auch
* alle Kategorien automatisch als Ordner gezeigt - das war zu viel/nicht
* kuratiert genug). Die Anordnung (Reihenfolge, eigene Ordner, Aufteilung
* Hauptbereich/Dock) legt der Nutzer selbst per Ziehen fest, genau wie auf
* einem echten Android-Handy - siehe useHomeLayout.ts für die
* Datenstruktur/Persistierung und die Drag-Logik weiter unten in dieser Datei.
*
* Signatur-Element: der "Wallpaper"-Hintergrund besteht NICHT aus einem
* generischen Farbverlauf, sondern aus den tatsächlichen, in den eigenen
* Kategorien hinterlegten Farben (Admin -> Kategorien) - jede Installation
* bekommt dadurch ein optisch anderes, zum eigenen Homelab passendes
* Hintergrundbild, ganz ohne eigene Bilder hochladen zu müssen.
* WICHTIG zur Interaktion: klassisches HTML5-Drag&Drop (draggable=true,
* onDragStart/onDrop, siehe FavoritesBar.tsx an anderer Stelle im Projekt)
* feuert auf Touchscreens NICHT zuverlässig - für eine mobile Oberfläche
* daher bewusst mit der Pointer-Events-API nachgebaut (vereinheitlicht
* Maus/Touch/Stift), inklusive Lang-Drücken zum Start (350ms), damit
* normales Scrollen nicht versehentlich als Ziehen interpretiert wird.
*
* Signatur-Element: der Wallpaper-Hintergrund besteht aus den tatsächlichen,
* in den eigenen Kategorien hinterlegten Farben (Admin -> Kategorien).
*/
const FALLBACK_PALETTE = ["#6366f1", "#a855f7", "#06b6d4", "#f97316"];
@@ -32,32 +41,97 @@ const BLOB_POSITIONS = [
{ top: "70%", left: "-20%" },
{ top: "-15%", left: "55%" },
];
const LONG_PRESS_MS = 350;
const MOVE_CANCEL_PX = 10;
function resolveIconSrc(src: string): string {
if (src.startsWith("data:")) return src;
return `/api/favicon-proxy?url=${encodeURIComponent(src)}`;
}
interface TileIconProps {
favicon?: string | null;
label: string;
color?: string | null;
size?: "normal" | "small";
interface ResolvedItem {
id: string;
type: "service" | "bookmark";
displayName: string;
favicon: string | null;
url: string;
category: string | null;
}
/** Ein einzelnes "App-Icon" im Android-Stil: farbiges abgerundetes Quadrat (per Kategorie-Farbe getönt), Favicon mittig. */
function TileIcon({ favicon, label, color, size = "normal" }: TileIconProps) {
function slotId(): string {
return `slot-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
}
/**
* Reine Funktion, die aus dem Ist-Zustand (Layout) + der abgeschlossenen
* Ziehgeste das NEUE Layout berechnet - bewusst ohne React-State-Zugriff,
* damit sie unabhängig testbar/nachvollziehbar bleibt.
*
* Regeln (angelehnt an Android):
* - Item auf Item gezogen -> neuer Ordner mit beiden.
* - Item auf Ordner gezogen -> tritt dem Ordner bei.
* - Ordner auf irgendwas gezogen (oder Item auf leeren Bereich) -> reine
* Neusortierung, kein Verschmelzen (keine verschachtelten Ordner).
* - Dock hat eine feste Obergrenze (siehe maxDockSlots) - ein Ablegen dort,
* das die Grenze sprengen würde, wird verworfen (Layout bleibt
* unverändert, wie ein Android-Dock, das "voll" ist).
*/
function applyDragResult(
layout: { grid: HomeSlot[]; dock: HomeSlot[] },
dragged: HomeSlot,
hover: { area: "grid" | "dock"; slotId: string } | null,
maxDockSlots: number
): { grid: HomeSlot[]; dock: HomeSlot[] } | null {
if (!hover) return null;
const grid = layout.grid.filter((s) => s.id !== dragged.id);
const dock = layout.dock.filter((s) => s.id !== dragged.id);
const targetArr = hover.area === "grid" ? grid : dock;
if (hover.slotId === "__end__") {
if (hover.area === "dock" && dock.length >= maxDockSlots) return null;
targetArr.push(dragged);
return { grid, dock };
}
const targetIndex = targetArr.findIndex((s) => s.id === hover.slotId);
if (targetIndex === -1) return null;
const targetSlot = targetArr[targetIndex];
if (dragged.kind === "item" && targetSlot.kind === "item") {
targetArr.splice(targetIndex, 1, {
id: slotId(),
kind: "folder",
name: "Neuer Ordner",
items: [targetSlot.ref, dragged.ref],
});
} else if (dragged.kind === "item" && targetSlot.kind === "folder") {
targetArr.splice(targetIndex, 1, { ...targetSlot, items: [...targetSlot.items, dragged.ref] });
} else {
if (hover.area === "dock" && dock.length >= maxDockSlots) return null;
targetArr.splice(targetIndex, 0, dragged);
}
return { grid, dock };
}
interface TileVisualProps {
favicon?: string | null;
label: string;
size?: "normal" | "small";
dimmed?: boolean;
}
function TileIcon({ favicon, label, size = "normal", dimmed }: TileVisualProps) {
const [failed, setFailed] = useState(false);
const dimension = size === "small" ? "h-9 w-9" : "h-14 w-14";
const rounding = size === "small" ? "rounded-xl" : "rounded-[20px]";
const backdrop = color ? `${color}33` : undefined;
return (
<span
className={`flex ${dimension} ${rounding} shrink-0 items-center justify-center
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm
dark:ring-white/10`}
style={{ background: backdrop ?? "rgba(255,255,255,0.75)" }}
className={`flex ${dimension} ${rounding} shrink-0 items-center justify-center bg-white/75
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
dark:bg-white/10 dark:ring-white/10 ${dimmed ? "opacity-30" : ""}`}
>
{favicon && !failed ? (
<img
@@ -67,7 +141,7 @@ function TileIcon({ favicon, label, color, size = "normal" }: TileIconProps) {
onError={() => setFailed(true)}
/>
) : (
<span className={`font-semibold text-black/70 ${size === "small" ? "text-xs" : "text-lg"}`}>
<span className={`font-semibold text-black/70 dark:text-white/70 ${size === "small" ? "text-xs" : "text-lg"}`}>
{label.charAt(0).toUpperCase()}
</span>
)}
@@ -75,19 +149,12 @@ function TileIcon({ favicon, label, color, size = "normal" }: TileIconProps) {
);
}
/** Ordner-Kachel: Mini-2x2-Vorschau der ersten 4 enthaltenen Icons statt eines einzelnen Favicons. */
function FolderTile({
icons,
color,
}: {
icons: Array<{ favicon: string | null; label: string }>;
color: string | null;
}) {
function FolderTile({ icons, dimmed }: { icons: Array<{ favicon: string | null; label: string }>; dimmed?: boolean }) {
return (
<span
className="grid h-14 w-14 shrink-0 grid-cols-2 grid-rows-2 gap-1 rounded-[20px] p-1.5
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm dark:ring-white/10"
style={{ background: color ? `${color}33` : "rgba(255,255,255,0.6)" }}
className={`grid h-14 w-14 shrink-0 grid-cols-2 grid-rows-2 gap-1 rounded-[20px] bg-white/60 p-1.5
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
dark:bg-white/10 dark:ring-white/10 ${dimmed ? "opacity-30" : ""}`}
>
{Array.from({ length: 4 }).map((_, i) => {
const item = icons[i];
@@ -123,7 +190,6 @@ export interface MobileAndroidHomeProps {
bookmarks: Bookmark[];
categories: Category[];
categoryColors: Record<string, string>;
folderMode: "folders" | "scroll";
allSearchItems: SearchResult[];
recentVisits: MinimalVisitItem[];
readLaterItems: Array<{ id: string; url: string; displayName: string; favicon: string | null }>;
@@ -138,7 +204,6 @@ export function MobileAndroidHome({
bookmarks,
categories,
categoryColors,
folderMode,
allSearchItems,
recentVisits,
readLaterItems,
@@ -150,56 +215,142 @@ export function MobileAndroidHome({
const [searchOpen, setSearchOpen] = useState(false);
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const [openFolder, setOpenFolder] = useState<string | null>(null);
const [openFolderId, setOpenFolderId] = useState<string | null>(null);
const [readLaterUrl, setReadLaterUrl] = useState("");
const [renamingFolder, setRenamingFolder] = useState(false);
const [folderNameDraft, setFolderNameDraft] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
const palette = useMemo(() => {
const fromCategories = (categories ?? [])
.map((c) => c.color)
.filter((c): c is string => Boolean(c));
const fromCategories = (categories ?? []).map((c) => c.color).filter((c): c is string => Boolean(c));
const unique = Array.from(new Set(fromCategories));
return unique.length > 0 ? unique.slice(0, 4) : FALLBACK_PALETTE;
}, [categories]);
const visibleServices = useMemo(() => services.filter((s) => s.visible), [services]);
const serviceById = useMemo(() => new Map(services.map((s) => [s.id, s])), [services]);
const bookmarkById = useMemo(() => new Map(bookmarks.map((b) => [b.id, b])), [bookmarks]);
const grouped = useMemo(() => {
const byCategory = new Map<string, Array<Service | Bookmark>>();
const ungrouped: Array<Service | Bookmark> = [];
for (const item of [...visibleServices, ...bookmarks]) {
if (item.category) {
const list = byCategory.get(item.category);
if (list) list.push(item);
else byCategory.set(item.category, [item]);
} else {
ungrouped.push(item);
}
const resolveRef = (ref: ItemRef): ResolvedItem | null => {
if (ref.type === "service") {
const s = serviceById.get(ref.id);
if (!s || !s.visible) return null;
return { id: s.id, type: "service", displayName: s.displayName, favicon: s.favicon, url: s.url, category: s.category };
}
return { byCategory, ungrouped };
}, [visibleServices, bookmarks]);
const b = bookmarkById.get(ref.id);
if (!b) return null;
return { id: b.id, type: "bookmark", displayName: b.displayName, favicon: b.favicon, url: b.url, category: b.category };
};
const favorites = useMemo(
() => [...visibleServices, ...bookmarks].filter((i) => i.favorite).slice(0, 6),
[visibleServices, bookmarks]
const favoriteRefs: ItemRef[] = useMemo(
() => [
...services.filter((s) => s.visible && s.favorite).map((s) => ({ id: s.id, type: "service" as const })),
...bookmarks.filter((b) => b.favorite).map((b) => ({ id: b.id, type: "bookmark" as const })),
],
[services, bookmarks]
);
const itemToOpenTarget = (item: Service | Bookmark): OpenTarget => ({
url: item.url,
id: item.id,
kind: "deviceId" in item ? "service" : "bookmark",
});
const { layout, saveLayout, maxDockSlots } = useHomeLayout(favoriteRefs);
// --- Ziehen (Pointer Events, siehe Kommentar oben an der Datei) ---
const [drag, setDrag] = useState<{
slot: HomeSlot;
pointerId: number;
x: number;
y: number;
offsetX: number;
offsetY: number;
} | null>(null);
const [hover, setHover] = useState<{ area: "grid" | "dock"; slotId: string } | null>(null);
const longPressTimer = useRef<number | null>(null);
const pressStart = useRef<{ x: number; y: number } | null>(null);
const suppressNextClick = useRef(false);
function cancelPendingPress() {
if (longPressTimer.current !== null) {
window.clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
pressStart.current = null;
}
function handlePointerDown(e: ReactPointerEvent<HTMLButtonElement>, slot: HomeSlot) {
if (e.pointerType === "mouse" && e.button !== 0) return;
pressStart.current = { x: e.clientX, y: e.clientY };
const target = e.currentTarget;
const pointerId = e.pointerId;
const clientX = e.clientX;
const clientY = e.clientY;
longPressTimer.current = window.setTimeout(() => {
target.setPointerCapture(pointerId);
const rect = target.getBoundingClientRect();
setDrag({
slot,
pointerId,
x: clientX,
y: clientY,
offsetX: clientX - rect.left,
offsetY: clientY - rect.top,
});
}, LONG_PRESS_MS);
}
function handlePointerMove(e: ReactPointerEvent<HTMLButtonElement>) {
if (!drag) {
if (pressStart.current && longPressTimer.current !== null) {
const dx = e.clientX - pressStart.current.x;
const dy = e.clientY - pressStart.current.y;
if (Math.hypot(dx, dy) > MOVE_CANCEL_PX) cancelPendingPress();
}
return;
}
if (e.pointerId !== drag.pointerId) return;
setDrag((d) => (d ? { ...d, x: e.clientX, y: e.clientY } : d));
const el = document.elementFromPoint(e.clientX, e.clientY);
const slotEl = el?.closest("[data-slot-id]") as HTMLElement | null;
const areaEl = el?.closest("[data-drop-area]") as HTMLElement | null;
if (slotEl && slotEl.dataset.slotId && slotEl.dataset.slotId !== drag.slot.id) {
setHover({ area: (slotEl.dataset.area as "grid" | "dock") ?? "grid", slotId: slotEl.dataset.slotId });
} else if (areaEl?.dataset.dropArea) {
setHover({ area: areaEl.dataset.dropArea as "grid" | "dock", slotId: "__end__" });
} else {
setHover(null);
}
}
function handlePointerUp(e: ReactPointerEvent<HTMLButtonElement>) {
cancelPendingPress();
if (!drag || e.pointerId !== drag.pointerId) return;
const next = applyDragResult(layout, drag.slot, hover, maxDockSlots);
if (next) {
saveLayout(next);
suppressNextClick.current = true;
}
setDrag(null);
setHover(null);
}
function handlePointerCancel() {
cancelPendingPress();
setDrag(null);
setHover(null);
}
function handleTileClick(action: () => void) {
if (suppressNextClick.current) {
suppressNextClick.current = false;
return;
}
if (drag) return;
action();
}
const searchResults = useMemo(() => {
if (query.trim().length === 0) return [];
const q = query.toLowerCase();
return allSearchItems
.filter((i) => i.kind !== "device")
.filter(
(i) =>
i.displayName.toLowerCase().includes(q) ||
i.alias?.some((a) => a.toLowerCase().includes(q))
)
.filter((i) => i.displayName.toLowerCase().includes(q) || i.alias?.some((a) => a.toLowerCase().includes(q)))
.slice(0, 30);
}, [query, allSearchItems]);
@@ -208,32 +359,92 @@ export function MobileAndroidHome({
setQuery("");
};
const toMinimalItem = (item: Service | Bookmark): MinimalVisitItem => ({
id: item.id,
url: item.url,
displayName: item.displayName,
favicon: item.favicon,
kind: "deviceId" in item ? "service" : "bookmark",
});
const openFolder =
openFolderId && openFolderId !== "__recent__" && openFolderId !== "__readlater__"
? ([...layout.grid, ...layout.dock].find((s) => s.id === openFolderId && s.kind === "folder") as
| Extract<HomeSlot, { kind: "folder" }>
| undefined)
: null;
const openFolderData =
openFolder === "__recent__"
? { title: "Zuletzt besucht", items: recentVisits }
: openFolder === "__readlater__"
? { title: "Später lesen", items: null }
: openFolder
? { title: openFolder, items: (grouped.byCategory.get(openFolder) ?? []).map(toMinimalItem) }
: null;
function removeFromFolder(folder: Extract<HomeSlot, { kind: "folder" }>, ref: ItemRef) {
const items = folder.items.filter((i) => !(i.id === ref.id && i.type === ref.type));
const patch = (slots: HomeSlot[]) =>
slots
.map((s) => (s.id === folder.id ? (items.length > 0 ? { ...s, items } : null) : s))
.filter((s): s is HomeSlot => s !== null);
saveLayout({ grid: patch(layout.grid), dock: patch(layout.dock) });
if (items.length === 0) setOpenFolderId(null);
}
function renameFolder(folder: Extract<HomeSlot, { kind: "folder" }>, name: string) {
const patch = (slots: HomeSlot[]) => slots.map((s) => (s.id === folder.id ? { ...s, name } : s));
saveLayout({ grid: patch(layout.grid), dock: patch(layout.dock) });
}
function renderTile(slot: HomeSlot, area: "grid" | "dock", size: "normal" | "small" = "normal") {
const isDragging = drag?.slot.id === slot.id;
const isHoverTarget = hover?.slotId === slot.id;
if (slot.kind === "folder") {
const icons = slot.items
.map((ref) => resolveRef(ref))
.filter((i): i is ResolvedItem => i !== null)
.slice(0, 4)
.map((i) => ({ favicon: i.favicon, label: i.displayName }));
return (
<button
key={slot.id}
type="button"
data-slot-id={slot.id}
data-area={area}
onPointerDown={(e) => handlePointerDown(e, slot)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onClick={() => handleTileClick(() => setOpenFolderId(slot.id))}
className={`flex flex-col items-center gap-1.5 ${isHoverTarget ? "scale-110" : ""} transition-transform`}
>
<FolderTile icons={icons} dimmed={isDragging} />
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
{slot.name}
</span>
</button>
);
}
const item = resolveRef(slot.ref);
if (!item) return null;
return (
<button
key={slot.id}
type="button"
data-slot-id={slot.id}
data-area={area}
onPointerDown={(e) => handlePointerDown(e, slot)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onClick={() => handleTileClick(() => onOpen({ url: item.url, id: item.id, kind: item.type }))}
className={`flex flex-col items-center gap-1.5 ${isHoverTarget ? "scale-110" : ""} transition-transform`}
>
<TileIcon favicon={item.favicon} label={item.displayName} dimmed={isDragging} />
{size === "normal" ? (
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
{item.displayName}
</span>
) : null}
</button>
);
}
return (
<div className="relative min-h-dvh overflow-hidden bg-neutral-100 dark:bg-neutral-950">
{/* Wallpaper: aus den eigenen Kategorie-Farben gebaut, siehe Kommentar oben an der Datei. */}
{/* Wallpaper */}
<div className="pointer-events-none fixed inset-0 overflow-hidden">
{palette.map((color, i) => (
<div
key={i}
className="absolute h-[60vmax] w-[60vmax] rounded-full opacity-25
motion-safe:animate-drift dark:opacity-30"
className="absolute h-[60vmax] w-[60vmax] rounded-full opacity-25 motion-safe:animate-drift dark:opacity-30"
style={{
background: color,
filter: "blur(60px)",
@@ -246,7 +457,7 @@ export function MobileAndroidHome({
<div className="absolute inset-0 bg-white/50 dark:bg-black/55" />
</div>
{/* Suchleiste: fest oben, immer sichtbar (auch beim Scrollen) - explizit gewünscht. */}
{/* Suchleiste: fest oben */}
<div className="sticky top-0 z-20 flex items-center gap-2 px-4 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
<button
type="button"
@@ -272,154 +483,105 @@ export function MobileAndroidHome({
</div>
{/* Haupt-Raster */}
<div className="px-4 pb-32 pt-2">
{favorites.length === 0 && grouped.ungrouped.length === 0 && grouped.byCategory.size === 0 ? (
<p className="mt-10 text-center text-sm text-black/40 dark:text-white/40">
Noch nichts angelegt. Füge Dienste oder Lesezeichen im Adminbereich hinzu.
</p>
) : (
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
{/* Zuletzt besucht / Später lesen als feste "Ordner" ganz vorne */}
{recentVisits.length > 0 ? (
<button
type="button"
onClick={() => setOpenFolder("__recent__")}
className="flex flex-col items-center gap-1.5"
>
<span
className="flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60
text-black/50 shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40
backdrop-blur-sm dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
>
<FontAwesomeIcon icon={faClockRotateLeft} />
</span>
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
Zuletzt besucht
</span>
</button>
) : null}
<div data-drop-area="grid" className="min-h-[40vh] px-4 pb-32 pt-2">
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
{recentVisits.length > 0 ? (
<button
type="button"
onClick={() => setOpenFolder("__readlater__")}
onClick={() => setOpenFolderId("__recent__")}
className="flex flex-col items-center gap-1.5"
>
<span
className="flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60
text-black/50 shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40
backdrop-blur-sm dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
className="flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60 text-black/50
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm
dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
>
<FontAwesomeIcon icon={faBookmark} />
<FontAwesomeIcon icon={faClockRotateLeft} />
</span>
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
Später lesen
Zuletzt besucht
</span>
</button>
) : null}
<button type="button" onClick={() => setOpenFolderId("__readlater__")} className="flex flex-col items-center gap-1.5">
<span
className="flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60 text-black/50
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm
dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
>
<FontAwesomeIcon icon={faBookmark} />
</span>
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
Später lesen
</span>
</button>
{folderMode === "folders" ? (
<>
{/* Kategorien als antippbare Ordner */}
{Array.from(grouped.byCategory.entries()).map(([name, items]) => (
<button
key={name}
type="button"
onClick={() => setOpenFolder(name)}
className="flex flex-col items-center gap-1.5"
>
<FolderTile
icons={items.slice(0, 4).map((i) => ({ favicon: i.favicon, label: i.displayName }))}
color={categoryColors[name] ?? null}
/>
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
{name}
</span>
</button>
))}
{/* Dienste/Lesezeichen ohne Kategorie direkt als Icon */}
{grouped.ungrouped.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onOpen(itemToOpenTarget(item))}
className="flex flex-col items-center gap-1.5"
>
<TileIcon favicon={item.favicon} label={item.displayName} color={null} />
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
{item.displayName}
</span>
</button>
))}
</>
) : null}
</div>
)}
{layout.grid.map((slot) => renderTile(slot, "grid"))}
</div>
{/* Scroll-Modus: alles auf einer Seite, nach Kategorie gruppiert mit Überschrift statt Ordnern. */}
{folderMode === "scroll" ? (
<div className="mt-6 flex flex-col gap-6">
{Array.from(grouped.byCategory.entries()).map(([name, items]) => (
<div key={name}>
<div className="mb-2.5 flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ background: categoryColors[name] ?? "#a3a3a3" }}
/>
<span className="text-xs font-medium uppercase tracking-wide text-black/45 dark:text-white/45">
{name}
</span>
</div>
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => onOpen(itemToOpenTarget(item))}
className="flex flex-col items-center gap-1.5"
>
<TileIcon
favicon={item.favicon}
label={item.displayName}
color={categoryColors[name] ?? null}
/>
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
{item.displayName}
</span>
</button>
))}
</div>
</div>
))}
</div>
{layout.grid.length === 0 ? (
<p className="mt-8 text-center text-sm text-black/40 dark:text-white/40">
Markiere Dienste/Lesezeichen im Adminbereich als Favorit sie erscheinen dann hier.
</p>
) : null}
</div>
{/* Dock: angeheftete Favoriten, immer sichtbar - klassisches Android-Verhalten. */}
{favorites.length > 0 ? (
<div className="fixed inset-x-0 bottom-0 z-20 flex justify-center px-4 pb-[calc(env(safe-area-inset-bottom)+0.75rem)]">
<div
className="flex items-center gap-3 rounded-[28px] border border-white/40 bg-white/70 px-4
py-3 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/70"
>
{favorites.map((item) => (
<button key={item.id} type="button" onClick={() => onOpen(itemToOpenTarget(item))} title={item.displayName}>
<TileIcon favicon={item.favicon} label={item.displayName} color={null} size="small" />
</button>
))}
</div>
{/* Dock */}
<div
data-drop-area="dock"
className="fixed inset-x-0 bottom-0 z-20 flex justify-center px-4 pb-[calc(env(safe-area-inset-bottom)+0.75rem)]"
>
<div
className="flex min-h-[4.5rem] min-w-[5rem] items-center gap-3 rounded-[28px] border border-white/40
bg-white/70 px-4 py-3 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/70"
>
{layout.dock.length === 0 ? (
<span className="px-2 text-xs text-black/30 dark:text-white/30">Icon hierher ziehen zum Anheften</span>
) : (
layout.dock.map((slot) => renderTile(slot, "dock", "small"))
)}
</div>
</div>
{/* Verbindungsstatus */}
<div
title={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"}
className={`fixed left-4 top-[calc(env(safe-area-inset-top)+0.1rem)] z-10 h-2 w-2 rounded-full ${
isOnline ? "bg-emerald-500" : "bg-red-500"
}`}
/>
{/* Schwebendes Icon während des Ziehens */}
{drag ? (
<div
className="pointer-events-none fixed z-50 opacity-90"
style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY }}
>
{drag.slot.kind === "folder" ? (
<FolderTile
icons={drag.slot.items
.map((r) => resolveRef(r))
.filter((i): i is ResolvedItem => i !== null)
.slice(0, 4)
.map((i) => ({ favicon: i.favicon, label: i.displayName }))}
/>
) : (
(() => {
const item = resolveRef(drag.slot.ref);
return item ? <TileIcon favicon={item.favicon} label={item.displayName} /> : null;
})()
)}
</div>
) : null}
{/* Verbindungsstatus: dezenter Punkt statt eigener Fußzeile - dieses Design hat bewusst keinen Platz für eine lange Statuszeile. */}
<div
title={isOnline ? "Backend verbunden" : "Backend nicht erreichbar"}
className={`fixed left-4 top-[calc(env(safe-area-inset-top)+0.1rem)] z-10 h-2 w-2 rounded-full
${isOnline ? "bg-emerald-500" : "bg-red-500"}`}
/>
{/* Ordner-Übersicht als Sheet */}
{openFolderData ? (
{/* Ordner-Sheet: Zuletzt besucht / Später lesen / eigener Ordner */}
{openFolderId ? (
<div
className="fixed inset-0 z-30 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
onClick={() => setOpenFolder(null)}
onClick={() => {
setOpenFolderId(null);
setRenamingFolder(false);
}}
>
<div
onClick={(e) => e.stopPropagation()}
@@ -427,20 +589,55 @@ export function MobileAndroidHome({
bg-white/90 p-5 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/90
sm:rounded-3xl"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-black dark:text-white">{openFolderData.title}</h2>
<div className="mb-4 flex items-center justify-between gap-2">
{openFolder && renamingFolder ? (
<input
autoFocus
value={folderNameDraft}
onChange={(e) => setFolderNameDraft(e.target.value)}
onBlur={() => {
setRenamingFolder(false);
if (folderNameDraft.trim()) renameFolder(openFolder, folderNameDraft.trim());
}}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-base font-semibold
text-black outline-none dark:border-white/10 dark:bg-white/10 dark:text-white"
/>
) : (
<h2 className="flex items-center gap-2 text-base font-semibold text-black dark:text-white">
{openFolderId === "__recent__" ? "Zuletzt besucht" : openFolderId === "__readlater__" ? "Später lesen" : openFolder?.name}
{openFolder ? (
<button
type="button"
onClick={() => {
setFolderNameDraft(openFolder.name);
setRenamingFolder(true);
}}
aria-label="Ordner umbenennen"
className="text-black/30 hover:text-black/60 dark:text-white/30 dark:hover:text-white/60"
>
<FontAwesomeIcon icon={faPen} className="text-xs" />
</button>
) : null}
</h2>
)}
<button
type="button"
onClick={() => setOpenFolder(null)}
onClick={() => {
setOpenFolderId(null);
setRenamingFolder(false);
}}
aria-label="Schließen"
className="flex h-8 w-8 items-center justify-center rounded-full text-black/50
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-black/50
hover:bg-black/5 dark:text-white/50 dark:hover:bg-white/10"
>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
{openFolder === "__readlater__" ? (
{openFolderId === "__readlater__" ? (
<div className="flex flex-col gap-4">
<form
onSubmit={(e) => {
@@ -455,9 +652,9 @@ export function MobileAndroidHome({
value={readLaterUrl}
onChange={(e) => setReadLaterUrl(e.target.value)}
placeholder="Link einfügen …"
className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-sm
text-black outline-none placeholder:text-black/30 focus:border-indigo-400/60
dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-white/30"
className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-sm text-black
outline-none placeholder:text-black/30 focus:border-indigo-400/60 dark:border-white/10
dark:bg-white/5 dark:text-white dark:placeholder:text-white/30"
/>
<Button type="submit" variant="secondary" size="sm">
Merken
@@ -472,7 +669,7 @@ export function MobileAndroidHome({
onClick={() => onOpen({ url: item.url, id: item.id, kind: "bookmark" })}
className="flex flex-col items-center gap-1.5"
>
<TileIcon favicon={item.favicon} label={item.displayName} color={null} />
<TileIcon favicon={item.favicon} label={item.displayName} />
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
{item.displayName}
</span>
@@ -483,30 +680,53 @@ export function MobileAndroidHome({
<p className="text-sm text-black/40 dark:text-white/40">Noch nichts gemerkt.</p>
)}
</div>
) : (
) : openFolderId === "__recent__" ? (
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
{(openFolderData.items ?? []).map((item) => (
{recentVisits.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onOpen({ url: item.url, id: item.id, kind: item.kind ?? "service" });
setOpenFolder(null);
}}
onClick={() => onOpen({ url: item.url, id: item.id, kind: item.kind ?? "service" })}
className="flex flex-col items-center gap-1.5"
>
<TileIcon
favicon={item.favicon}
label={item.displayName}
color={openFolder && openFolder !== "__recent__" ? (categoryColors[openFolder] ?? null) : null}
/>
<TileIcon favicon={item.favicon} label={item.displayName} />
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
{item.displayName}
</span>
</button>
))}
</div>
)}
) : openFolder ? (
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
{openFolder.items.map((ref) => {
const item = resolveRef(ref);
if (!item) return null;
return (
<div key={`${ref.type}:${ref.id}`} className="relative flex flex-col items-center gap-1.5">
<button
type="button"
onClick={() => removeFromFolder(openFolder, ref)}
aria-label={`${item.displayName} aus Ordner entfernen`}
className="absolute -right-1 -top-1 z-10 flex h-5 w-5 items-center justify-center
rounded-full bg-black/60 text-[10px] text-white shadow hover:bg-black/80"
>
<FontAwesomeIcon icon={faXmark} />
</button>
<button
type="button"
onClick={() => onOpen({ url: item.url, id: item.id, kind: item.type })}
className="flex flex-col items-center gap-1.5"
>
<TileIcon favicon={item.favicon} label={item.displayName} />
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
{item.displayName}
</span>
</button>
</div>
);
})}
</div>
) : null}
</div>
</div>
) : null}
@@ -515,10 +735,7 @@ export function MobileAndroidHome({
{searchOpen ? (
<div className="fixed inset-0 z-40 flex flex-col bg-white/95 backdrop-blur-xl dark:bg-neutral-950/95">
<div className="flex items-center gap-2 px-4 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
<div
className="flex flex-1 items-center gap-3 rounded-full border border-black/10 bg-white
px-4 py-3 shadow-sm dark:border-white/10 dark:bg-neutral-900"
>
<div className="flex flex-1 items-center gap-3 rounded-full border border-black/10 bg-white px-4 py-3 shadow-sm dark:border-white/10 dark:bg-neutral-900">
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-black/40 dark:text-white/40" />
<input
ref={searchInputRef}

View File

@@ -0,0 +1,110 @@
import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "./useSettings.js";
export interface ItemRef {
id: string;
type: "service" | "bookmark";
}
export type HomeSlot =
| { id: string; kind: "item"; ref: ItemRef }
| { id: string; kind: "folder"; name: string; items: ItemRef[] };
export interface HomeLayout {
grid: HomeSlot[];
dock: HomeSlot[];
}
const MAX_DOCK_SLOTS = 5;
function refKey(ref: ItemRef): string {
return `${ref.type}:${ref.id}`;
}
function slotId(): string {
return `slot-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
}
/**
* Gleicht ein gespeichertes Layout mit der AKTUELLEN Favoriten-Liste ab:
* - Favoriten, die noch in keinem Slot vorkommen (neu als Favorit markiert,
* oder allererster Aufruf ohne gespeichertes Layout), werden hinten an den
* Hauptbereich angehängt.
* - Einträge, die nicht mehr in der Favoriten-Liste sind (Favorit entfernt
* oder Dienst/Lesezeichen gelöscht), werden aus Slots UND aus Ordnern
* entfernt. Dadurch leer gewordene Ordner werden mit entfernt.
* Rein bereinigend, verändert nie die vom Nutzer gewählte Reihenfolge
* bestehender Einträge.
*/
function reconcile(layout: HomeLayout, favorites: ItemRef[]): HomeLayout {
const favoriteKeys = new Set(favorites.map(refKey));
const placedKeys = new Set<string>();
const cleanSlots = (slots: HomeSlot[]): HomeSlot[] =>
slots
.map((slot): HomeSlot | null => {
if (slot.kind === "item") {
if (!favoriteKeys.has(refKey(slot.ref))) return null;
placedKeys.add(refKey(slot.ref));
return slot;
}
const items = slot.items.filter((ref) => {
if (!favoriteKeys.has(refKey(ref))) return false;
placedKeys.add(refKey(ref));
return true;
});
if (items.length === 0) return null;
return { ...slot, items };
})
.filter((s): s is HomeSlot => s !== null);
const grid = cleanSlots(layout.grid);
const dock = cleanSlots(layout.dock);
const missing = favorites.filter((ref) => !placedKeys.has(refKey(ref)));
const newSlots: HomeSlot[] = missing.map((ref) => ({ id: slotId(), kind: "item", ref }));
return { grid: [...grid, ...newSlots], dock };
}
export function useHomeLayout(favorites: ItemRef[]) {
const { data: settings } = useSettings();
const queryClient = useQueryClient();
const layout = useMemo<HomeLayout>(() => {
let parsed: HomeLayout = { grid: [], dock: [] };
if (settings?.mobileHomeLayout) {
try {
const raw = JSON.parse(settings.mobileHomeLayout) as Partial<HomeLayout>;
parsed = { grid: raw.grid ?? [], dock: raw.dock ?? [] };
} catch {
parsed = { grid: [], dock: [] };
}
}
return reconcile(parsed, favorites);
// favorites als JSON-String in der dep-Liste, damit sich das Memo nur bei
// TATSÄCHLICH geänderten Favoriten neu berechnet, nicht bei jedem Render
// (favorites wird von außen als neues Array-Objekt reincegeben).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings?.mobileHomeLayout, JSON.stringify(favorites)]);
const saveMutation = useMutation({
mutationFn: async (next: HomeLayout) => {
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mobileHomeLayout: next }),
});
if (!res.ok) throw new Error(`Layout konnte nicht gespeichert werden (HTTP ${res.status})`);
return res.json();
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
return {
layout,
saveLayout: (next: HomeLayout) => saveMutation.mutate(next),
maxDockSlots: MAX_DOCK_SLOTS,
};
}

View File

@@ -8,7 +8,7 @@ export interface AppSettings {
liveStatusIntervalMinutes: number;
liveStatusLastRunAt: string | null;
mobileHomeStyle: "classic" | "android";
mobileHomeFolderMode: "folders" | "scroll";
mobileHomeLayout: string | null;
}
async function fetchSettings(): Promise<AppSettings> {

View File

@@ -325,7 +325,6 @@ export function HomePage() {
bookmarks={bookmarks ?? []}
categories={categories ?? []}
categoryColors={categoryColors}
folderMode={settings?.mobileHomeFolderMode ?? "folders"}
allSearchItems={allItems}
recentVisits={recentVisits ?? []}
readLaterItems={readLaterItems ?? []}

View File

@@ -85,7 +85,7 @@ function SelectSetting<T extends string>({
}: {
label: string;
hint: string;
settingKey: "mobileHomeStyle" | "mobileHomeFolderMode";
settingKey: "mobileHomeStyle";
value: T | undefined;
options: Array<{ value: T; label: string }>;
}) {
@@ -506,18 +506,6 @@ export function SettingsPage() {
{ value: "android", label: "Startbildschirm (Android-Stil)" },
]}
/>
{settings?.mobileHomeStyle === "android" ? (
<SelectSetting
label="Kategorien im Android-Design"
hint="Ordner: antippen zum Öffnen. Einzelseite: alles untereinander mit Überschriften."
settingKey="mobileHomeFolderMode"
value={settings?.mobileHomeFolderMode}
options={[
{ value: "folders", label: "Als Ordner" },
{ value: "scroll", label: "Alles auf einer Seite" },
]}
/>
) : null}
</div>
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">