round64: Neues Android-Startbildschirm-artiges Design fuer die mobile Ansicht, umschaltbar in den Einstellungen

This commit is contained in:
2026-07-26 16:55:00 +02:00
parent c05562c4cd
commit 538fba5fc3
7 changed files with 747 additions and 18 deletions

View File

@@ -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<void> {
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<void> {
staleDeviceThresholdDays?: number;
liveStatusEnabled?: boolean;
liveStatusIntervalMinutes?: number;
mobileHomeStyle?: string;
mobileHomeFolderMode?: string;
}
| undefined;
@@ -61,14 +77,20 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
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();
});
}

View File

@@ -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 (
<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)" }}
>
{favicon && !failed ? (
<img
src={resolveIconSrc(favicon)}
alt=""
className={size === "small" ? "h-5 w-5 object-contain" : "h-8 w-8 object-contain"}
onError={() => setFailed(true)}
/>
) : (
<span className={`font-semibold text-black/70 ${size === "small" ? "text-xs" : "text-lg"}`}>
{label.charAt(0).toUpperCase()}
</span>
)}
</span>
);
}
/** 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 (
<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)" }}
>
{Array.from({ length: 4 }).map((_, i) => {
const item = icons[i];
return (
<span
key={i}
className="flex items-center justify-center overflow-hidden rounded-md bg-white/70 dark:bg-white/10"
>
{item?.favicon ? (
<img src={resolveIconSrc(item.favicon)} alt="" className="h-full w-full object-contain p-0.5" />
) : item ? (
<span className="text-[8px] font-semibold text-black/50">{item.label.charAt(0).toUpperCase()}</span>
) : null}
</span>
);
})}
</span>
);
}
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<string, string>;
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<string | null>(null);
const [readLaterUrl, setReadLaterUrl] = useState("");
const searchInputRef = useRef<HTMLInputElement>(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<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);
}
}
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 (
<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. */}
<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"
style={{
background: color,
filter: "blur(60px)",
top: BLOB_POSITIONS[i % BLOB_POSITIONS.length].top,
left: BLOB_POSITIONS[i % BLOB_POSITIONS.length].left,
animationDelay: `${i * 4}s`,
}}
/>
))}
<div className="absolute inset-0 bg-white/50 dark:bg-black/55" />
</div>
{/* Suchleiste: fest oben, immer sichtbar (auch beim Scrollen) - explizit gewünscht. */}
<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"
onClick={() => {
setSearchOpen(true);
setTimeout(() => searchInputRef.current?.focus(), 0);
}}
className="flex flex-1 items-center gap-3 rounded-full border border-white/40 bg-white/70
px-4 py-3 text-left shadow-lg backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/70"
>
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-black/40 dark:text-white/40" />
<span className="text-sm text-black/40 dark:text-white/40">Dienst oder Lesezeichen suchen </span>
</button>
<Link
to="/admin"
aria-label="Adminbereich öffnen"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border
border-white/40 bg-white/70 text-black/60 shadow-lg backdrop-blur-xl
dark:border-white/10 dark:bg-neutral-900/70 dark:text-white/60"
>
<FontAwesomeIcon icon={faGear} />
</Link>
</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}
<button
type="button"
onClick={() => setOpenFolder("__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>
)}
{/* 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>
) : 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>
</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 ? (
<div
className="fixed inset-0 z-30 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
onClick={() => setOpenFolder(null)}
>
<div
onClick={(e) => 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"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-black dark:text-white">{openFolderData.title}</h2>
<button
type="button"
onClick={() => setOpenFolder(null)}
aria-label="Schließen"
className="flex h-8 w-8 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__" ? (
<div className="flex flex-col gap-4">
<form
onSubmit={(e) => {
e.preventDefault();
if (!readLaterUrl.trim()) return;
onAddReadLater(readLaterUrl.trim());
setReadLaterUrl("");
}}
className="flex gap-2"
>
<input
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"
/>
<Button type="submit" variant="secondary" size="sm">
Merken
</Button>
</form>
{readLaterItems.length > 0 ? (
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
{readLaterItems.map((item) => (
<button
key={item.id}
type="button"
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} />
<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>
) : (
<p className="text-sm text-black/40 dark:text-white/40">Noch nichts gemerkt.</p>
)}
</div>
) : (
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
{(openFolderData.items ?? []).map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onOpen({ url: item.url, id: item.id, kind: item.kind ?? "service" });
setOpenFolder(null);
}}
className="flex flex-col items-center gap-1.5"
>
<TileIcon
favicon={item.favicon}
label={item.displayName}
color={openFolder && openFolder !== "__recent__" ? (categoryColors[openFolder] ?? null) : 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>
) : null}
{/* Such-Overlay */}
{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"
>
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-black/40 dark:text-white/40" />
<input
ref={searchInputRef}
value={query}
onChange={(e) => {
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"
/>
</div>
<button
type="button"
onClick={closeSearch}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-black/60
hover:bg-black/5 dark:text-white/60 dark:hover:bg-white/10"
aria-label="Suche schließen"
>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">
<ResultsList
results={searchResults}
selectedIndex={selectedIndex}
categoryColors={categoryColors}
onHover={setSelectedIndex}
onOpen={(item) => {
onOpen(item as OpenTarget);
closeSearch();
}}
onToggleFavorite={onToggleFavorite}
emptyLabel={query.trim().length === 0 ? "Tippe, um zu suchen." : "Keine Treffer."}
/>
</div>
</div>
) : null}
</div>
);
}

View File

@@ -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;
}

View File

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

View File

@@ -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<void> {
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 (
<MobileAndroidHome
services={services ?? []}
bookmarks={bookmarks ?? []}
categories={categories ?? []}
categoryColors={categoryColors}
folderMode={settings?.mobileHomeFolderMode ?? "folders"}
allSearchItems={allItems}
recentVisits={recentVisits ?? []}
readLaterItems={readLaterItems ?? []}
onOpen={openItem}
onToggleFavorite={(item) => toggleFavorite.mutate(item)}
onAddReadLater={(url) => addReadLaterMutation.mutate(url)}
isOnline={isOnline}
/>
);
}
return (
<div
className={`flex flex-col bg-gradient-to-b from-white to-neutral-100 dark:from-black

View File

@@ -76,6 +76,56 @@ function LiveStatusToggle({ enabled }: { enabled: boolean }) {
);
}
function SelectSetting<T extends string>({
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 (
<div className="flex items-center justify-between py-2">
<div>
<span className="text-sm text-black/50 dark:text-white/50">{label}</span>
<p className="text-xs text-black/30 dark:text-white/30">{hint}</p>
</div>
<select
value={currentValue}
onChange={(e) => mutation.mutate(e.target.value as T)}
className="rounded-lg border border-black/10 bg-white px-2 py-1.5 text-sm text-black
dark:border-white/10 dark:bg-white/10 dark:text-white"
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
);
}
function LimitSetting({
label,
hint,
@@ -446,6 +496,28 @@ export function SettingsPage() {
settingKey="readLaterLimit"
value={settings?.readLaterLimit}
/>
<SelectSetting
label="Design (mobile Ansicht)"
hint="Gilt nur auf schmalen Bildschirmen (Handy). Am Desktop bleibt immer das gewohnte Design."
settingKey="mobileHomeStyle"
value={settings?.mobileHomeStyle}
options={[
{ value: "classic", label: "Klassisch (Liste)" },
{ 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">

View File

@@ -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: [],