generated from Dicken/dickendock
round64: Neues Android-Startbildschirm-artiges Design fuer die mobile Ansicht, umschaltbar in den Einstellungen
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import * as settingsRepo from "../db/repositories/settings.js";
|
import * as settingsRepo from "../db/repositories/settings.js";
|
||||||
|
|
||||||
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
function serializeSettings() {
|
||||||
app.get("/api/settings", async () => {
|
|
||||||
const all = settingsRepo.listSettings();
|
const all = settingsRepo.listSettings();
|
||||||
return {
|
return {
|
||||||
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
|
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
|
||||||
@@ -11,7 +10,22 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
liveStatusEnabled: all.liveStatusEnabled === "true",
|
liveStatusEnabled: all.liveStatusEnabled === "true",
|
||||||
liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5),
|
liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5),
|
||||||
liveStatusLastRunAt: all.liveStatusLastRunAt ?? null,
|
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 () => {
|
||||||
|
return serializeSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.patch("/api/settings", async (request, reply) => {
|
app.patch("/api/settings", async (request, reply) => {
|
||||||
@@ -22,6 +36,8 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
staleDeviceThresholdDays?: number;
|
staleDeviceThresholdDays?: number;
|
||||||
liveStatusEnabled?: boolean;
|
liveStatusEnabled?: boolean;
|
||||||
liveStatusIntervalMinutes?: number;
|
liveStatusIntervalMinutes?: number;
|
||||||
|
mobileHomeStyle?: string;
|
||||||
|
mobileHomeFolderMode?: string;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -61,14 +77,20 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
settingsRepo.setSetting("liveStatusIntervalMinutes", String(Math.round(value)));
|
settingsRepo.setSetting("liveStatusIntervalMinutes", String(Math.round(value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const all = settingsRepo.listSettings();
|
if (body?.mobileHomeStyle !== undefined) {
|
||||||
return {
|
if (body.mobileHomeStyle !== "classic" && body.mobileHomeStyle !== "android") {
|
||||||
recentVisitsLimit: Number(all.recentVisitsLimit ?? 5),
|
return reply.code(400).send({ error: "mobileHomeStyle muss 'classic' oder 'android' sein" });
|
||||||
readLaterLimit: Number(all.readLaterLimit ?? 5),
|
}
|
||||||
staleDeviceThresholdDays: Number(all.staleDeviceThresholdDays ?? 7),
|
settingsRepo.setSetting("mobileHomeStyle", body.mobileHomeStyle);
|
||||||
liveStatusEnabled: all.liveStatusEnabled === "true",
|
}
|
||||||
liveStatusIntervalMinutes: Number(all.liveStatusIntervalMinutes ?? 5),
|
|
||||||
liveStatusLastRunAt: all.liveStatusLastRunAt ?? null,
|
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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
liveStatusEnabled: boolean;
|
||||||
liveStatusIntervalMinutes: number;
|
liveStatusIntervalMinutes: number;
|
||||||
liveStatusLastRunAt: string | null;
|
liveStatusLastRunAt: string | null;
|
||||||
|
mobileHomeStyle: "classic" | "android";
|
||||||
|
mobileHomeFolderMode: "folders" | "scroll";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSettings(): Promise<AppSettings> {
|
async function fetchSettings(): Promise<AppSettings> {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import { useBackendHealth } from "../hooks/useBackendHealth.js";
|
|||||||
import { useCategories } from "../hooks/useCategories.js";
|
import { useCategories } from "../hooks/useCategories.js";
|
||||||
import { useRecentVisits, recordVisit } from "../hooks/useRecentVisits.js";
|
import { useRecentVisits, recordVisit } from "../hooks/useRecentVisits.js";
|
||||||
import { useReadLater } from "../hooks/useReadLater.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> {
|
async function toggleFavoriteRequest(item: SearchResult): Promise<void> {
|
||||||
const path = item.kind === "service" ? `/api/services/${item.id}` : `/api/bookmarks/${item.id}`;
|
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 showShelf = !isSearching || !resultsVisible;
|
||||||
const showResults = 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex flex-col bg-gradient-to-b from-white to-neutral-100 dark:from-black
|
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({
|
function LimitSetting({
|
||||||
label,
|
label,
|
||||||
hint,
|
hint,
|
||||||
@@ -446,6 +496,28 @@ export function SettingsPage() {
|
|||||||
settingKey="readLaterLimit"
|
settingKey="readLaterLimit"
|
||||||
value={settings?.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>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
<div className="rounded-2xl border border-black/10 p-5 dark:border-white/10">
|
||||||
|
|||||||
@@ -18,6 +18,19 @@ export default {
|
|||||||
"sans-serif",
|
"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: [],
|
plugins: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user