From ff87f9812cfb4de2deb80bef4915891b783a7264 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 26 Jul 2026 18:34:46 +0200 Subject: [PATCH] round65: Android-Startbildschirm nur mit Favoriten, echtes Drag-and-Drop per Pointer-Events, selbst erstellbare Ordner --- apps/backend/src/routes/settings.ts | 31 +- .../src/components/MobileAndroidHome.tsx | 695 ++++++++++++------ apps/frontend/src/hooks/useHomeLayout.ts | 110 +++ apps/frontend/src/hooks/useSettings.ts | 2 +- apps/frontend/src/routes/HomePage.tsx | 1 - .../src/routes/admin/SettingsPage.tsx | 14 +- 6 files changed, 590 insertions(+), 263 deletions(-) create mode 100644 apps/frontend/src/hooks/useHomeLayout.ts diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts index 98a2615..2c5cb38 100644 --- a/apps/backend/src/routes/settings.ts +++ b/apps/backend/src/routes/settings.ts @@ -16,10 +16,14 @@ function serializeSettings() { // Einstellungen statt nur im sessionStorage/localStorage des jeweiligen // Browsers, damit die Wahl wirklich app-weit gilt, nicht nur pro Gerät. mobileHomeStyle: all.mobileHomeStyle === "android" ? "android" : "classic", - // Nur relevant, wenn mobileHomeStyle "android" ist: Kategorien als - // antippbare "Ordner" (wie ein Android-Ordner mit Mini-Vorschau) oder - // alles auf einer durchgehend scrollbaren Seite mit Abschnitts-Überschriften. - mobileHomeFolderMode: all.mobileHomeFolderMode === "scroll" ? "scroll" : "folders", + // NEU (round65): Frei angeordnetes Layout des Android-Startbildschirms + // (welche Favoriten wo liegen, selbst angelegte Ordner, Aufteilung + // zwischen Hauptbereich und angehefteter Dock-Leiste unten) - als JSON + // gespeichert, roh durchgereicht. Der genaue Aufbau ist reine + // Frontend-Angelegenheit (siehe useHomeLayout.ts) - das Backend + // validiert hier nur, dass es sich überhaupt um ein Objekt mit den + // erwarteten Top-Level-Schlüsseln handelt, nicht den vollen Inhalt. + mobileHomeLayout: all.mobileHomeLayout || null, }; } @@ -37,7 +41,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise { liveStatusEnabled?: boolean; liveStatusIntervalMinutes?: number; mobileHomeStyle?: string; - mobileHomeFolderMode?: string; + mobileHomeLayout?: unknown; } | undefined; @@ -84,11 +88,20 @@ export async function settingsRoutes(app: FastifyInstance): Promise { settingsRepo.setSetting("mobileHomeStyle", body.mobileHomeStyle); } - if (body?.mobileHomeFolderMode !== undefined) { - if (body.mobileHomeFolderMode !== "folders" && body.mobileHomeFolderMode !== "scroll") { - return reply.code(400).send({ error: "mobileHomeFolderMode muss 'folders' oder 'scroll' sein" }); + if (body?.mobileHomeLayout !== undefined) { + const layout = body.mobileHomeLayout; + const looksValid = + layout === null || + (typeof layout === "object" && + layout !== null && + Array.isArray((layout as Record).grid) && + Array.isArray((layout as Record).dock)); + if (!looksValid) { + return reply + .code(400) + .send({ error: "mobileHomeLayout muss null oder ein Objekt mit 'grid' und 'dock' als Arrays sein" }); } - settingsRepo.setSetting("mobileHomeFolderMode", body.mobileHomeFolderMode); + settingsRepo.setSetting("mobileHomeLayout", layout === null ? "" : JSON.stringify(layout)); } return serializeSettings(); diff --git a/apps/frontend/src/components/MobileAndroidHome.tsx b/apps/frontend/src/components/MobileAndroidHome.tsx index 1b112e6..ec5aacf 100644 --- a/apps/frontend/src/components/MobileAndroidHome.tsx +++ b/apps/frontend/src/components/MobileAndroidHome.tsx @@ -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 ( {favicon && !failed ? ( setFailed(true)} /> ) : ( - + {label.charAt(0).toUpperCase()} )} @@ -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 ( {Array.from({ length: 4 }).map((_, i) => { const item = icons[i]; @@ -123,7 +190,6 @@ export interface MobileAndroidHomeProps { bookmarks: Bookmark[]; categories: Category[]; categoryColors: Record; - 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(null); + const [openFolderId, setOpenFolderId] = useState(null); const [readLaterUrl, setReadLaterUrl] = useState(""); + const [renamingFolder, setRenamingFolder] = useState(false); + const [folderNameDraft, setFolderNameDraft] = useState(""); const searchInputRef = useRef(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>(); - const ungrouped: Array = []; - 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(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, 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) { + 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) { + 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 + | 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, 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, 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 ( + + ); + } + + const item = resolveRef(slot.ref); + if (!item) return null; + return ( + + ); + } return (
- {/* Wallpaper: aus den eigenen Kategorie-Farben gebaut, siehe Kommentar oben an der Datei. */} + {/* Wallpaper */}
{palette.map((color, i) => (
- {/* Suchleiste: fest oben, immer sichtbar (auch beim Scrollen) - explizit gewünscht. */} + {/* Suchleiste: fest oben */}
- ) : null} +
+
+ {recentVisits.length > 0 ? ( + ) : null} + - {folderMode === "folders" ? ( - <> - {/* Kategorien als antippbare Ordner */} - {Array.from(grouped.byCategory.entries()).map(([name, items]) => ( - - ))} - {/* Dienste/Lesezeichen ohne Kategorie direkt als Icon */} - {grouped.ungrouped.map((item) => ( - - ))} - - ) : null} -
- )} + {layout.grid.map((slot) => renderTile(slot, "grid"))} +
- {/* Scroll-Modus: alles auf einer Seite, nach Kategorie gruppiert mit Überschrift statt Ordnern. */} - {folderMode === "scroll" ? ( -
- {Array.from(grouped.byCategory.entries()).map(([name, items]) => ( -
-
- - - {name} - -
-
- {items.map((item) => ( - - ))} -
-
- ))} -
+ {layout.grid.length === 0 ? ( +

+ Markiere Dienste/Lesezeichen im Adminbereich als Favorit – sie erscheinen dann hier. +

) : null}
- {/* Dock: angeheftete Favoriten, immer sichtbar - klassisches Android-Verhalten. */} - {favorites.length > 0 ? ( -
-
- {favorites.map((item) => ( - - ))} -
+ {/* Dock */} +
+
+ {layout.dock.length === 0 ? ( + Icon hierher ziehen zum Anheften + ) : ( + layout.dock.map((slot) => renderTile(slot, "dock", "small")) + )} +
+
+ + {/* Verbindungsstatus */} +
+ + {/* Schwebendes Icon während des Ziehens */} + {drag ? ( +
+ {drag.slot.kind === "folder" ? ( + 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 ? : null; + })() + )}
) : null} - {/* Verbindungsstatus: dezenter Punkt statt eigener Fußzeile - dieses Design hat bewusst keinen Platz für eine lange Statuszeile. */} -
- - {/* Ordner-Übersicht als Sheet */} - {openFolderData ? ( + {/* Ordner-Sheet: Zuletzt besucht / Später lesen / eigener Ordner */} + {openFolderId ? (
setOpenFolder(null)} + onClick={() => { + setOpenFolderId(null); + setRenamingFolder(false); + }} >
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" > -
-

{openFolderData.title}

+
+ {openFolder && renamingFolder ? ( + 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" + /> + ) : ( +

+ {openFolderId === "__recent__" ? "Zuletzt besucht" : openFolderId === "__readlater__" ? "Später lesen" : openFolder?.name} + {openFolder ? ( + + ) : null} +

+ )}
- {openFolder === "__readlater__" ? ( + {openFolderId === "__readlater__" ? (
{ @@ -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" />
- ) : ( + ) : openFolderId === "__recent__" ? (
- {(openFolderData.items ?? []).map((item) => ( + {recentVisits.map((item) => ( ))}
- )} + ) : openFolder ? ( +
+ {openFolder.items.map((ref) => { + const item = resolveRef(ref); + if (!item) return null; + return ( +
+ + +
+ ); + })} +
+ ) : null}
) : null} @@ -515,10 +735,7 @@ export function MobileAndroidHome({ {searchOpen ? (
-
+
(); + + 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(() => { + let parsed: HomeLayout = { grid: [], dock: [] }; + if (settings?.mobileHomeLayout) { + try { + const raw = JSON.parse(settings.mobileHomeLayout) as Partial; + 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, + }; +} diff --git a/apps/frontend/src/hooks/useSettings.ts b/apps/frontend/src/hooks/useSettings.ts index c0e1fa4..d7b84ee 100644 --- a/apps/frontend/src/hooks/useSettings.ts +++ b/apps/frontend/src/hooks/useSettings.ts @@ -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 { diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index 42f1e81..03912d0 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -325,7 +325,6 @@ export function HomePage() { bookmarks={bookmarks ?? []} categories={categories ?? []} categoryColors={categoryColors} - folderMode={settings?.mobileHomeFolderMode ?? "folders"} allSearchItems={allItems} recentVisits={recentVisits ?? []} readLaterItems={readLaterItems ?? []} diff --git a/apps/frontend/src/routes/admin/SettingsPage.tsx b/apps/frontend/src/routes/admin/SettingsPage.tsx index 297e7cc..54440d0 100644 --- a/apps/frontend/src/routes/admin/SettingsPage.tsx +++ b/apps/frontend/src/routes/admin/SettingsPage.tsx @@ -85,7 +85,7 @@ function SelectSetting({ }: { 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" ? ( - - ) : null}