generated from Dicken/dickendock
round64: Neues Android-Startbildschirm-artiges Design fuer die mobile Ansicht, umschaltbar in den Einstellungen
This commit is contained in:
563
apps/frontend/src/components/MobileAndroidHome.tsx
Normal file
563
apps/frontend/src/components/MobileAndroidHome.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/frontend/src/hooks/useIsMobile.ts
Normal file
28
apps/frontend/src/hooks/useIsMobile.ts
Normal 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;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user