From 538fba5fc3f6183eba77046aa397881984119389 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 26 Jul 2026 16:55:00 +0200 Subject: [PATCH] round64: Neues Android-Startbildschirm-artiges Design fuer die mobile Ansicht, umschaltbar in den Einstellungen --- apps/backend/src/routes/settings.ts | 58 +- .../src/components/MobileAndroidHome.tsx | 563 ++++++++++++++++++ apps/frontend/src/hooks/useIsMobile.ts | 28 + apps/frontend/src/hooks/useSettings.ts | 2 + apps/frontend/src/routes/HomePage.tsx | 29 + .../src/routes/admin/SettingsPage.tsx | 72 +++ apps/frontend/tailwind.config.ts | 13 + 7 files changed, 747 insertions(+), 18 deletions(-) create mode 100644 apps/frontend/src/components/MobileAndroidHome.tsx create mode 100644 apps/frontend/src/hooks/useIsMobile.ts diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts index 4f0dbf2..98a2615 100644 --- a/apps/backend/src/routes/settings.ts +++ b/apps/backend/src/routes/settings.ts @@ -1,17 +1,31 @@ import type { FastifyInstance } from "fastify"; import * as settingsRepo from "../db/repositories/settings.js"; +function serializeSettings() { + const all = settingsRepo.listSettings(); + return { + recentVisitsLimit: Number(all.recentVisitsLimit ?? 5), + readLaterLimit: Number(all.readLaterLimit ?? 5), + staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7), + liveStatusEnabled: all.liveStatusEnabled === "true", + liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5), + liveStatusLastRunAt: all.liveStatusLastRunAt ?? null, + // NEU (round64): Wahl zwischen dem bisherigen (Listen-)Design und einem + // neuen, Android-Startbildschirm-artigen Design für die MOBILE Ansicht + // (Desktop bleibt davon unberührt). Bewusst hier in den serverseitigen + // 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", + }; +} + export async function settingsRoutes(app: FastifyInstance): Promise { app.get("/api/settings", async () => { - const all = settingsRepo.listSettings(); - return { - recentVisitsLimit: Number(all.recentVisitsLimit ?? 5), - readLaterLimit: Number(all.readLaterLimit ?? 5), - staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7), - liveStatusEnabled: all.liveStatusEnabled === "true", - liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5), - liveStatusLastRunAt: all.liveStatusLastRunAt ?? null, - }; + return serializeSettings(); }); app.patch("/api/settings", async (request, reply) => { @@ -22,6 +36,8 @@ export async function settingsRoutes(app: FastifyInstance): Promise { staleDeviceThresholdDays?: number; liveStatusEnabled?: boolean; liveStatusIntervalMinutes?: number; + mobileHomeStyle?: string; + mobileHomeFolderMode?: string; } | undefined; @@ -61,14 +77,20 @@ export async function settingsRoutes(app: FastifyInstance): Promise { settingsRepo.setSetting("liveStatusIntervalMinutes", String(Math.round(value))); } - const all = settingsRepo.listSettings(); - return { - recentVisitsLimit: Number(all.recentVisitsLimit ?? 5), - readLaterLimit: Number(all.readLaterLimit ?? 5), - staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7), - liveStatusEnabled: all.liveStatusEnabled === "true", - liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5), - liveStatusLastRunAt: all.liveStatusLastRunAt ?? null, - }; + if (body?.mobileHomeStyle !== undefined) { + if (body.mobileHomeStyle !== "classic" && body.mobileHomeStyle !== "android") { + return reply.code(400).send({ error: "mobileHomeStyle muss 'classic' oder 'android' sein" }); + } + 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" }); + } + settingsRepo.setSetting("mobileHomeFolderMode", body.mobileHomeFolderMode); + } + + return serializeSettings(); }); } diff --git a/apps/frontend/src/components/MobileAndroidHome.tsx b/apps/frontend/src/components/MobileAndroidHome.tsx new file mode 100644 index 0000000..1b112e6 --- /dev/null +++ b/apps/frontend/src/components/MobileAndroidHome.tsx @@ -0,0 +1,563 @@ +import { useMemo, useRef, useState } from "react"; +import { Link } from "@tanstack/react-router"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faGear, + faMagnifyingGlass, + faXmark, + faClockRotateLeft, + faBookmark, +} from "@fortawesome/free-solid-svg-icons"; +import type { Category, Service, Bookmark, SearchResult } from "@launchpad/shared"; +import { ResultsList, Button } from "@launchpad/ui"; + +/** + * 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). + * + * 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. + */ + +const FALLBACK_PALETTE = ["#6366f1", "#a855f7", "#06b6d4", "#f97316"]; +const BLOB_POSITIONS = [ + { top: "-10%", left: "-15%" }, + { top: "55%", left: "60%" }, + { top: "70%", left: "-20%" }, + { top: "-15%", left: "55%" }, +]; + +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"; +} + +/** 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) { + 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()} + + )} + + ); +} + +/** 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; +}) { + return ( + + {Array.from({ length: 4 }).map((_, i) => { + const item = icons[i]; + return ( + + {item?.favicon ? ( + + ) : item ? ( + {item.label.charAt(0).toUpperCase()} + ) : null} + + ); + })} + + ); +} + +type OpenTarget = { url: string; id: string; kind: "service" | "bookmark" }; + +interface MinimalVisitItem { + id: string; + url: string; + displayName: string; + favicon: string | null; + kind?: "service" | "bookmark"; +} + +export interface MobileAndroidHomeProps { + services: Service[]; + bookmarks: Bookmark[]; + categories: Category[]; + categoryColors: Record; + folderMode: "folders" | "scroll"; + allSearchItems: SearchResult[]; + recentVisits: MinimalVisitItem[]; + readLaterItems: Array<{ id: string; url: string; displayName: string; favicon: string | null }>; + onOpen: (item: OpenTarget) => void; + onToggleFavorite: (item: SearchResult) => void; + onAddReadLater: (url: string) => void; + isOnline: boolean; +} + +export function MobileAndroidHome({ + services, + bookmarks, + categories, + categoryColors, + folderMode, + allSearchItems, + recentVisits, + readLaterItems, + onOpen, + onToggleFavorite, + onAddReadLater, + isOnline, +}: MobileAndroidHomeProps) { + const [searchOpen, setSearchOpen] = useState(false); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const [openFolder, setOpenFolder] = useState(null); + const [readLaterUrl, setReadLaterUrl] = useState(""); + const searchInputRef = useRef(null); + + const palette = useMemo(() => { + 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 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); + } + } + return { byCategory, ungrouped }; + }, [visibleServices, bookmarks]); + + const favorites = useMemo( + () => [...visibleServices, ...bookmarks].filter((i) => i.favorite).slice(0, 6), + [visibleServices, bookmarks] + ); + + const itemToOpenTarget = (item: Service | Bookmark): OpenTarget => ({ + url: item.url, + id: item.id, + kind: "deviceId" in item ? "service" : "bookmark", + }); + + 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)) + ) + .slice(0, 30); + }, [query, allSearchItems]); + + const closeSearch = () => { + setSearchOpen(false); + 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 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; + + return ( +
+ {/* Wallpaper: aus den eigenen Kategorie-Farben gebaut, siehe Kommentar oben an der Datei. */} +
+ {palette.map((color, i) => ( +
+ ))} +
+
+ + {/* Suchleiste: fest oben, immer sichtbar (auch beim Scrollen) - explizit gewünscht. */} +
+ + + + +
+ + {/* Haupt-Raster */} +
+ {favorites.length === 0 && grouped.ungrouped.length === 0 && grouped.byCategory.size === 0 ? ( +

+ Noch nichts angelegt. Füge Dienste oder Lesezeichen im Adminbereich hinzu. +

+ ) : ( +
+ {/* Zuletzt besucht / Später lesen als feste "Ordner" ganz vorne */} + {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} +
+ )} + + {/* 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) => ( + + ))} +
+
+ ))} +
+ ) : null} +
+ + {/* Dock: angeheftete Favoriten, immer sichtbar - klassisches Android-Verhalten. */} + {favorites.length > 0 ? ( +
+
+ {favorites.map((item) => ( + + ))} +
+
+ ) : null} + + {/* Verbindungsstatus: dezenter Punkt statt eigener Fußzeile - dieses Design hat bewusst keinen Platz für eine lange Statuszeile. */} +
+ + {/* Ordner-Übersicht als Sheet */} + {openFolderData ? ( +
setOpenFolder(null)} + > +
e.stopPropagation()} + className="max-h-[75vh] w-full max-w-md overflow-y-auto rounded-t-3xl border border-white/40 + 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 === "__readlater__" ? ( +
+
{ + e.preventDefault(); + if (!readLaterUrl.trim()) return; + onAddReadLater(readLaterUrl.trim()); + setReadLaterUrl(""); + }} + className="flex gap-2" + > + 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" + /> + +
+ {readLaterItems.length > 0 ? ( +
+ {readLaterItems.map((item) => ( + + ))} +
+ ) : ( +

Noch nichts gemerkt.

+ )} +
+ ) : ( +
+ {(openFolderData.items ?? []).map((item) => ( + + ))} +
+ )} +
+
+ ) : null} + + {/* Such-Overlay */} + {searchOpen ? ( +
+
+
+ + { + setQuery(e.target.value); + setSelectedIndex(0); + }} + placeholder="Dienst oder Lesezeichen suchen …" + className="flex-1 bg-transparent text-sm text-black outline-none placeholder:text-black/30 + dark:text-white dark:placeholder:text-white/30" + /> +
+ +
+
+ { + onOpen(item as OpenTarget); + closeSearch(); + }} + onToggleFavorite={onToggleFavorite} + emptyLabel={query.trim().length === 0 ? "Tippe, um zu suchen." : "Keine Treffer."} + /> +
+
+ ) : null} +
+ ); +} diff --git a/apps/frontend/src/hooks/useIsMobile.ts b/apps/frontend/src/hooks/useIsMobile.ts new file mode 100644 index 0000000..3297ebd --- /dev/null +++ b/apps/frontend/src/hooks/useIsMobile.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react"; + +const MOBILE_BREAKPOINT_QUERY = "(max-width: 640px)"; + +/** + * Erkennt eine mobile Bildschirmbreite (Tailwinds "sm"-Breakpoint, 640px) - + * genutzt, um das neue Android-Startbildschirm-Design (siehe + * MobileAndroidHome.tsx) NUR auf schmalen Bildschirmen zu zeigen, auch wenn + * es in den Einstellungen aktiviert ist. Auf Desktop-Breite bleibt immer das + * bisherige Design, unabhängig von der Einstellung - das neue Design ist + * ausdrücklich als mobile Alternative gedacht, nicht als Ersatz für die + * Desktop-Ansicht. + */ +export function useIsMobile(): boolean { + const [isMobile, setIsMobile] = useState( + () => typeof window !== "undefined" && window.matchMedia(MOBILE_BREAKPOINT_QUERY).matches + ); + + useEffect(() => { + const mql = window.matchMedia(MOBILE_BREAKPOINT_QUERY); + const onChange = () => setIsMobile(mql.matches); + onChange(); + mql.addEventListener("change", onChange); + return () => mql.removeEventListener("change", onChange); + }, []); + + return isMobile; +} diff --git a/apps/frontend/src/hooks/useSettings.ts b/apps/frontend/src/hooks/useSettings.ts index ca4f619..c0e1fa4 100644 --- a/apps/frontend/src/hooks/useSettings.ts +++ b/apps/frontend/src/hooks/useSettings.ts @@ -7,6 +7,8 @@ export interface AppSettings { liveStatusEnabled: boolean; liveStatusIntervalMinutes: number; liveStatusLastRunAt: string | null; + mobileHomeStyle: "classic" | "android"; + mobileHomeFolderMode: "folders" | "scroll"; } async function fetchSettings(): Promise { diff --git a/apps/frontend/src/routes/HomePage.tsx b/apps/frontend/src/routes/HomePage.tsx index 4cb19d1..42f1e81 100644 --- a/apps/frontend/src/routes/HomePage.tsx +++ b/apps/frontend/src/routes/HomePage.tsx @@ -13,6 +13,8 @@ import { useBackendHealth } from "../hooks/useBackendHealth.js"; import { useCategories } from "../hooks/useCategories.js"; import { useRecentVisits, recordVisit } from "../hooks/useRecentVisits.js"; import { useReadLater } from "../hooks/useReadLater.js"; +import { useIsMobile } from "../hooks/useIsMobile.js"; +import { MobileAndroidHome } from "../components/MobileAndroidHome.js"; async function toggleFavoriteRequest(item: SearchResult): Promise { const path = item.kind === "service" ? `/api/services/${item.id}` : `/api/bookmarks/${item.id}`; @@ -308,6 +310,33 @@ export function HomePage() { const showShelf = !isSearching || !resultsVisible; const showResults = isSearching && resultsVisible; + const isMobile = useIsMobile(); + const useAndroidHome = isMobile && settings?.mobileHomeStyle === "android"; + + const addReadLaterMutation = useMutation({ + mutationFn: (url: string) => saveReadLaterRequest(url), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["read-later"] }), + }); + + if (useAndroidHome) { + return ( + toggleFavorite.mutate(item)} + onAddReadLater={(url) => addReadLaterMutation.mutate(url)} + isOnline={isOnline} + /> + ); + } + return (
({ + label, + hint, + settingKey, + value: currentValue, + options, +}: { + label: string; + hint: string; + settingKey: "mobileHomeStyle" | "mobileHomeFolderMode"; + value: T | undefined; + options: Array<{ value: T; label: string }>; +}) { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: async (next: T) => { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [settingKey]: next }), + }); + if (!res.ok) throw new Error(`Speichern fehlgeschlagen (HTTP ${res.status})`); + return res.json(); + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }), + }); + + return ( +
+
+ {label} +

{hint}

+
+ +
+ ); +} + function LimitSetting({ label, hint, @@ -446,6 +496,28 @@ export function SettingsPage() { settingKey="readLaterLimit" value={settings?.readLaterLimit} /> + + {settings?.mobileHomeStyle === "android" ? ( + + ) : null}
diff --git a/apps/frontend/tailwind.config.ts b/apps/frontend/tailwind.config.ts index 871fb98..032cd77 100644 --- a/apps/frontend/tailwind.config.ts +++ b/apps/frontend/tailwind.config.ts @@ -18,6 +18,19 @@ export default { "sans-serif", ], }, + keyframes: { + // Sehr langsames, kaum wahrnehmbares Wandern des Ambient-Gradient- + // Hintergrunds beim Android-Startbildschirm-Design (siehe + // MobileAndroidHome.tsx) - über motion-safe: nur aktiv, wenn das + // Betriebssystem "reduzierte Bewegung" nicht eingeschaltet hat. + drift: { + "0%, 100%": { transform: "translate(0%, 0%) scale(1)" }, + "50%": { transform: "translate(-4%, 3%) scale(1.06)" }, + }, + }, + animation: { + drift: "drift 26s ease-in-out infinite", + }, }, }, plugins: [],