generated from Dicken/dickendock
round65: Android-Startbildschirm nur mit Favoriten, echtes Drag-and-Drop per Pointer-Events, selbst erstellbare Ordner
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
@@ -7,22 +7,31 @@ import {
|
||||
faXmark,
|
||||
faClockRotateLeft,
|
||||
faBookmark,
|
||||
faPen,
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import type { Category, Service, Bookmark, SearchResult } from "@launchpad/shared";
|
||||
import { ResultsList, Button } from "@launchpad/ui";
|
||||
import { useHomeLayout, type HomeSlot, type ItemRef } from "../hooks/useHomeLayout.js";
|
||||
|
||||
/**
|
||||
* Android-Startbildschirm-artiges alternatives Design für die mobile Ansicht
|
||||
* (siehe Einstellungen -> "Design"). Bewusst als eigenständige Komponente
|
||||
* statt Umbau der bestehenden HomePage: beide Designs bleiben unabhängig
|
||||
* wart- und weiterentwickelbar, HomePage.tsx entscheidet nur, welche der
|
||||
* beiden gerendert wird (siehe dort).
|
||||
* (siehe Einstellungen -> "Design"). Zeigt AUSSCHLIESSLICH als Favorit
|
||||
* markierte Dienste/Lesezeichen (siehe Bugreport round65: vorher wurden auch
|
||||
* alle Kategorien automatisch als Ordner gezeigt - das war zu viel/nicht
|
||||
* kuratiert genug). Die Anordnung (Reihenfolge, eigene Ordner, Aufteilung
|
||||
* Hauptbereich/Dock) legt der Nutzer selbst per Ziehen fest, genau wie auf
|
||||
* einem echten Android-Handy - siehe useHomeLayout.ts für die
|
||||
* Datenstruktur/Persistierung und die Drag-Logik weiter unten in dieser Datei.
|
||||
*
|
||||
* Signatur-Element: der "Wallpaper"-Hintergrund besteht NICHT aus einem
|
||||
* generischen Farbverlauf, sondern aus den tatsächlichen, in den eigenen
|
||||
* Kategorien hinterlegten Farben (Admin -> Kategorien) - jede Installation
|
||||
* bekommt dadurch ein optisch anderes, zum eigenen Homelab passendes
|
||||
* Hintergrundbild, ganz ohne eigene Bilder hochladen zu müssen.
|
||||
* WICHTIG zur Interaktion: klassisches HTML5-Drag&Drop (draggable=true,
|
||||
* onDragStart/onDrop, siehe FavoritesBar.tsx an anderer Stelle im Projekt)
|
||||
* feuert auf Touchscreens NICHT zuverlässig - für eine mobile Oberfläche
|
||||
* daher bewusst mit der Pointer-Events-API nachgebaut (vereinheitlicht
|
||||
* Maus/Touch/Stift), inklusive Lang-Drücken zum Start (350ms), damit
|
||||
* normales Scrollen nicht versehentlich als Ziehen interpretiert wird.
|
||||
*
|
||||
* Signatur-Element: der Wallpaper-Hintergrund besteht aus den tatsächlichen,
|
||||
* in den eigenen Kategorien hinterlegten Farben (Admin -> Kategorien).
|
||||
*/
|
||||
|
||||
const FALLBACK_PALETTE = ["#6366f1", "#a855f7", "#06b6d4", "#f97316"];
|
||||
@@ -32,32 +41,97 @@ const BLOB_POSITIONS = [
|
||||
{ top: "70%", left: "-20%" },
|
||||
{ top: "-15%", left: "55%" },
|
||||
];
|
||||
const LONG_PRESS_MS = 350;
|
||||
const MOVE_CANCEL_PX = 10;
|
||||
|
||||
function resolveIconSrc(src: string): string {
|
||||
if (src.startsWith("data:")) return src;
|
||||
return `/api/favicon-proxy?url=${encodeURIComponent(src)}`;
|
||||
}
|
||||
|
||||
interface TileIconProps {
|
||||
favicon?: string | null;
|
||||
label: string;
|
||||
color?: string | null;
|
||||
size?: "normal" | "small";
|
||||
interface ResolvedItem {
|
||||
id: string;
|
||||
type: "service" | "bookmark";
|
||||
displayName: string;
|
||||
favicon: string | null;
|
||||
url: string;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
/** Ein einzelnes "App-Icon" im Android-Stil: farbiges abgerundetes Quadrat (per Kategorie-Farbe getönt), Favicon mittig. */
|
||||
function TileIcon({ favicon, label, color, size = "normal" }: TileIconProps) {
|
||||
function slotId(): string {
|
||||
return `slot-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Funktion, die aus dem Ist-Zustand (Layout) + der abgeschlossenen
|
||||
* Ziehgeste das NEUE Layout berechnet - bewusst ohne React-State-Zugriff,
|
||||
* damit sie unabhängig testbar/nachvollziehbar bleibt.
|
||||
*
|
||||
* Regeln (angelehnt an Android):
|
||||
* - Item auf Item gezogen -> neuer Ordner mit beiden.
|
||||
* - Item auf Ordner gezogen -> tritt dem Ordner bei.
|
||||
* - Ordner auf irgendwas gezogen (oder Item auf leeren Bereich) -> reine
|
||||
* Neusortierung, kein Verschmelzen (keine verschachtelten Ordner).
|
||||
* - Dock hat eine feste Obergrenze (siehe maxDockSlots) - ein Ablegen dort,
|
||||
* das die Grenze sprengen würde, wird verworfen (Layout bleibt
|
||||
* unverändert, wie ein Android-Dock, das "voll" ist).
|
||||
*/
|
||||
function applyDragResult(
|
||||
layout: { grid: HomeSlot[]; dock: HomeSlot[] },
|
||||
dragged: HomeSlot,
|
||||
hover: { area: "grid" | "dock"; slotId: string } | null,
|
||||
maxDockSlots: number
|
||||
): { grid: HomeSlot[]; dock: HomeSlot[] } | null {
|
||||
if (!hover) return null;
|
||||
|
||||
const grid = layout.grid.filter((s) => s.id !== dragged.id);
|
||||
const dock = layout.dock.filter((s) => s.id !== dragged.id);
|
||||
const targetArr = hover.area === "grid" ? grid : dock;
|
||||
|
||||
if (hover.slotId === "__end__") {
|
||||
if (hover.area === "dock" && dock.length >= maxDockSlots) return null;
|
||||
targetArr.push(dragged);
|
||||
return { grid, dock };
|
||||
}
|
||||
|
||||
const targetIndex = targetArr.findIndex((s) => s.id === hover.slotId);
|
||||
if (targetIndex === -1) return null;
|
||||
const targetSlot = targetArr[targetIndex];
|
||||
|
||||
if (dragged.kind === "item" && targetSlot.kind === "item") {
|
||||
targetArr.splice(targetIndex, 1, {
|
||||
id: slotId(),
|
||||
kind: "folder",
|
||||
name: "Neuer Ordner",
|
||||
items: [targetSlot.ref, dragged.ref],
|
||||
});
|
||||
} else if (dragged.kind === "item" && targetSlot.kind === "folder") {
|
||||
targetArr.splice(targetIndex, 1, { ...targetSlot, items: [...targetSlot.items, dragged.ref] });
|
||||
} else {
|
||||
if (hover.area === "dock" && dock.length >= maxDockSlots) return null;
|
||||
targetArr.splice(targetIndex, 0, dragged);
|
||||
}
|
||||
|
||||
return { grid, dock };
|
||||
}
|
||||
|
||||
interface TileVisualProps {
|
||||
favicon?: string | null;
|
||||
label: string;
|
||||
size?: "normal" | "small";
|
||||
dimmed?: boolean;
|
||||
}
|
||||
|
||||
function TileIcon({ favicon, label, size = "normal", dimmed }: TileVisualProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const dimension = size === "small" ? "h-9 w-9" : "h-14 w-14";
|
||||
const rounding = size === "small" ? "rounded-xl" : "rounded-[20px]";
|
||||
const backdrop = color ? `${color}33` : undefined;
|
||||
|
||||
return (
|
||||
<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)" }}
|
||||
className={`flex ${dimension} ${rounding} shrink-0 items-center justify-center bg-white/75
|
||||
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
|
||||
dark:bg-white/10 dark:ring-white/10 ${dimmed ? "opacity-30" : ""}`}
|
||||
>
|
||||
{favicon && !failed ? (
|
||||
<img
|
||||
@@ -67,7 +141,7 @@ function TileIcon({ favicon, label, color, size = "normal" }: TileIconProps) {
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className={`font-semibold text-black/70 ${size === "small" ? "text-xs" : "text-lg"}`}>
|
||||
<span className={`font-semibold text-black/70 dark:text-white/70 ${size === "small" ? "text-xs" : "text-lg"}`}>
|
||||
{label.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
@@ -75,19 +149,12 @@ function TileIcon({ favicon, label, color, size = "normal" }: TileIconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Ordner-Kachel: Mini-2x2-Vorschau der ersten 4 enthaltenen Icons statt eines einzelnen Favicons. */
|
||||
function FolderTile({
|
||||
icons,
|
||||
color,
|
||||
}: {
|
||||
icons: Array<{ favicon: string | null; label: string }>;
|
||||
color: string | null;
|
||||
}) {
|
||||
function FolderTile({ icons, dimmed }: { icons: Array<{ favicon: string | null; label: string }>; dimmed?: boolean }) {
|
||||
return (
|
||||
<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)" }}
|
||||
className={`grid h-14 w-14 shrink-0 grid-cols-2 grid-rows-2 gap-1 rounded-[20px] bg-white/60 p-1.5
|
||||
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
|
||||
dark:bg-white/10 dark:ring-white/10 ${dimmed ? "opacity-30" : ""}`}
|
||||
>
|
||||
{Array.from({ length: 4 }).map((_, i) => {
|
||||
const item = icons[i];
|
||||
@@ -123,7 +190,6 @@ export interface MobileAndroidHomeProps {
|
||||
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 }>;
|
||||
@@ -138,7 +204,6 @@ export function MobileAndroidHome({
|
||||
bookmarks,
|
||||
categories,
|
||||
categoryColors,
|
||||
folderMode,
|
||||
allSearchItems,
|
||||
recentVisits,
|
||||
readLaterItems,
|
||||
@@ -150,56 +215,142 @@ export function MobileAndroidHome({
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [openFolder, setOpenFolder] = useState<string | null>(null);
|
||||
const [openFolderId, setOpenFolderId] = useState<string | null>(null);
|
||||
const [readLaterUrl, setReadLaterUrl] = useState("");
|
||||
const [renamingFolder, setRenamingFolder] = useState(false);
|
||||
const [folderNameDraft, setFolderNameDraft] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const palette = useMemo(() => {
|
||||
const fromCategories = (categories ?? [])
|
||||
.map((c) => c.color)
|
||||
.filter((c): c is string => Boolean(c));
|
||||
const fromCategories = (categories ?? []).map((c) => c.color).filter((c): c is string => Boolean(c));
|
||||
const unique = Array.from(new Set(fromCategories));
|
||||
return unique.length > 0 ? unique.slice(0, 4) : FALLBACK_PALETTE;
|
||||
}, [categories]);
|
||||
|
||||
const visibleServices = useMemo(() => services.filter((s) => s.visible), [services]);
|
||||
const serviceById = useMemo(() => new Map(services.map((s) => [s.id, s])), [services]);
|
||||
const bookmarkById = useMemo(() => new Map(bookmarks.map((b) => [b.id, b])), [bookmarks]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const byCategory = new Map<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);
|
||||
}
|
||||
const resolveRef = (ref: ItemRef): ResolvedItem | null => {
|
||||
if (ref.type === "service") {
|
||||
const s = serviceById.get(ref.id);
|
||||
if (!s || !s.visible) return null;
|
||||
return { id: s.id, type: "service", displayName: s.displayName, favicon: s.favicon, url: s.url, category: s.category };
|
||||
}
|
||||
return { byCategory, ungrouped };
|
||||
}, [visibleServices, bookmarks]);
|
||||
const b = bookmarkById.get(ref.id);
|
||||
if (!b) return null;
|
||||
return { id: b.id, type: "bookmark", displayName: b.displayName, favicon: b.favicon, url: b.url, category: b.category };
|
||||
};
|
||||
|
||||
const favorites = useMemo(
|
||||
() => [...visibleServices, ...bookmarks].filter((i) => i.favorite).slice(0, 6),
|
||||
[visibleServices, bookmarks]
|
||||
const favoriteRefs: ItemRef[] = useMemo(
|
||||
() => [
|
||||
...services.filter((s) => s.visible && s.favorite).map((s) => ({ id: s.id, type: "service" as const })),
|
||||
...bookmarks.filter((b) => b.favorite).map((b) => ({ id: b.id, type: "bookmark" as const })),
|
||||
],
|
||||
[services, bookmarks]
|
||||
);
|
||||
|
||||
const itemToOpenTarget = (item: Service | Bookmark): OpenTarget => ({
|
||||
url: item.url,
|
||||
id: item.id,
|
||||
kind: "deviceId" in item ? "service" : "bookmark",
|
||||
});
|
||||
const { layout, saveLayout, maxDockSlots } = useHomeLayout(favoriteRefs);
|
||||
|
||||
// --- Ziehen (Pointer Events, siehe Kommentar oben an der Datei) ---
|
||||
const [drag, setDrag] = useState<{
|
||||
slot: HomeSlot;
|
||||
pointerId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
} | null>(null);
|
||||
const [hover, setHover] = useState<{ area: "grid" | "dock"; slotId: string } | null>(null);
|
||||
const longPressTimer = useRef<number | null>(null);
|
||||
const pressStart = useRef<{ x: number; y: number } | null>(null);
|
||||
const suppressNextClick = useRef(false);
|
||||
|
||||
function cancelPendingPress() {
|
||||
if (longPressTimer.current !== null) {
|
||||
window.clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
pressStart.current = null;
|
||||
}
|
||||
|
||||
function handlePointerDown(e: ReactPointerEvent<HTMLButtonElement>, slot: HomeSlot) {
|
||||
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||
pressStart.current = { x: e.clientX, y: e.clientY };
|
||||
const target = e.currentTarget;
|
||||
const pointerId = e.pointerId;
|
||||
const clientX = e.clientX;
|
||||
const clientY = e.clientY;
|
||||
longPressTimer.current = window.setTimeout(() => {
|
||||
target.setPointerCapture(pointerId);
|
||||
const rect = target.getBoundingClientRect();
|
||||
setDrag({
|
||||
slot,
|
||||
pointerId,
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
offsetX: clientX - rect.left,
|
||||
offsetY: clientY - rect.top,
|
||||
});
|
||||
}, LONG_PRESS_MS);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: ReactPointerEvent<HTMLButtonElement>) {
|
||||
if (!drag) {
|
||||
if (pressStart.current && longPressTimer.current !== null) {
|
||||
const dx = e.clientX - pressStart.current.x;
|
||||
const dy = e.clientY - pressStart.current.y;
|
||||
if (Math.hypot(dx, dy) > MOVE_CANCEL_PX) cancelPendingPress();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.pointerId !== drag.pointerId) return;
|
||||
setDrag((d) => (d ? { ...d, x: e.clientX, y: e.clientY } : d));
|
||||
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const slotEl = el?.closest("[data-slot-id]") as HTMLElement | null;
|
||||
const areaEl = el?.closest("[data-drop-area]") as HTMLElement | null;
|
||||
if (slotEl && slotEl.dataset.slotId && slotEl.dataset.slotId !== drag.slot.id) {
|
||||
setHover({ area: (slotEl.dataset.area as "grid" | "dock") ?? "grid", slotId: slotEl.dataset.slotId });
|
||||
} else if (areaEl?.dataset.dropArea) {
|
||||
setHover({ area: areaEl.dataset.dropArea as "grid" | "dock", slotId: "__end__" });
|
||||
} else {
|
||||
setHover(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(e: ReactPointerEvent<HTMLButtonElement>) {
|
||||
cancelPendingPress();
|
||||
if (!drag || e.pointerId !== drag.pointerId) return;
|
||||
const next = applyDragResult(layout, drag.slot, hover, maxDockSlots);
|
||||
if (next) {
|
||||
saveLayout(next);
|
||||
suppressNextClick.current = true;
|
||||
}
|
||||
setDrag(null);
|
||||
setHover(null);
|
||||
}
|
||||
|
||||
function handlePointerCancel() {
|
||||
cancelPendingPress();
|
||||
setDrag(null);
|
||||
setHover(null);
|
||||
}
|
||||
|
||||
function handleTileClick(action: () => void) {
|
||||
if (suppressNextClick.current) {
|
||||
suppressNextClick.current = false;
|
||||
return;
|
||||
}
|
||||
if (drag) return;
|
||||
action();
|
||||
}
|
||||
|
||||
const searchResults = useMemo(() => {
|
||||
if (query.trim().length === 0) return [];
|
||||
const q = query.toLowerCase();
|
||||
return allSearchItems
|
||||
.filter((i) => i.kind !== "device")
|
||||
.filter(
|
||||
(i) =>
|
||||
i.displayName.toLowerCase().includes(q) ||
|
||||
i.alias?.some((a) => a.toLowerCase().includes(q))
|
||||
)
|
||||
.filter((i) => i.displayName.toLowerCase().includes(q) || i.alias?.some((a) => a.toLowerCase().includes(q)))
|
||||
.slice(0, 30);
|
||||
}, [query, allSearchItems]);
|
||||
|
||||
@@ -208,32 +359,92 @@ export function MobileAndroidHome({
|
||||
setQuery("");
|
||||
};
|
||||
|
||||
const toMinimalItem = (item: Service | Bookmark): MinimalVisitItem => ({
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
displayName: item.displayName,
|
||||
favicon: item.favicon,
|
||||
kind: "deviceId" in item ? "service" : "bookmark",
|
||||
});
|
||||
const openFolder =
|
||||
openFolderId && openFolderId !== "__recent__" && openFolderId !== "__readlater__"
|
||||
? ([...layout.grid, ...layout.dock].find((s) => s.id === openFolderId && s.kind === "folder") as
|
||||
| Extract<HomeSlot, { kind: "folder" }>
|
||||
| undefined)
|
||||
: null;
|
||||
|
||||
const openFolderData =
|
||||
openFolder === "__recent__"
|
||||
? { title: "Zuletzt besucht", items: recentVisits }
|
||||
: openFolder === "__readlater__"
|
||||
? { title: "Später lesen", items: null }
|
||||
: openFolder
|
||||
? { title: openFolder, items: (grouped.byCategory.get(openFolder) ?? []).map(toMinimalItem) }
|
||||
: null;
|
||||
function removeFromFolder(folder: Extract<HomeSlot, { kind: "folder" }>, ref: ItemRef) {
|
||||
const items = folder.items.filter((i) => !(i.id === ref.id && i.type === ref.type));
|
||||
const patch = (slots: HomeSlot[]) =>
|
||||
slots
|
||||
.map((s) => (s.id === folder.id ? (items.length > 0 ? { ...s, items } : null) : s))
|
||||
.filter((s): s is HomeSlot => s !== null);
|
||||
saveLayout({ grid: patch(layout.grid), dock: patch(layout.dock) });
|
||||
if (items.length === 0) setOpenFolderId(null);
|
||||
}
|
||||
|
||||
function renameFolder(folder: Extract<HomeSlot, { kind: "folder" }>, name: string) {
|
||||
const patch = (slots: HomeSlot[]) => slots.map((s) => (s.id === folder.id ? { ...s, name } : s));
|
||||
saveLayout({ grid: patch(layout.grid), dock: patch(layout.dock) });
|
||||
}
|
||||
|
||||
function renderTile(slot: HomeSlot, area: "grid" | "dock", size: "normal" | "small" = "normal") {
|
||||
const isDragging = drag?.slot.id === slot.id;
|
||||
const isHoverTarget = hover?.slotId === slot.id;
|
||||
|
||||
if (slot.kind === "folder") {
|
||||
const icons = slot.items
|
||||
.map((ref) => resolveRef(ref))
|
||||
.filter((i): i is ResolvedItem => i !== null)
|
||||
.slice(0, 4)
|
||||
.map((i) => ({ favicon: i.favicon, label: i.displayName }));
|
||||
return (
|
||||
<button
|
||||
key={slot.id}
|
||||
type="button"
|
||||
data-slot-id={slot.id}
|
||||
data-area={area}
|
||||
onPointerDown={(e) => handlePointerDown(e, slot)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onClick={() => handleTileClick(() => setOpenFolderId(slot.id))}
|
||||
className={`flex flex-col items-center gap-1.5 ${isHoverTarget ? "scale-110" : ""} transition-transform`}
|
||||
>
|
||||
<FolderTile icons={icons} dimmed={isDragging} />
|
||||
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||
{slot.name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const item = resolveRef(slot.ref);
|
||||
if (!item) return null;
|
||||
return (
|
||||
<button
|
||||
key={slot.id}
|
||||
type="button"
|
||||
data-slot-id={slot.id}
|
||||
data-area={area}
|
||||
onPointerDown={(e) => handlePointerDown(e, slot)}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onClick={() => handleTileClick(() => onOpen({ url: item.url, id: item.id, kind: item.type }))}
|
||||
className={`flex flex-col items-center gap-1.5 ${isHoverTarget ? "scale-110" : ""} transition-transform`}
|
||||
>
|
||||
<TileIcon favicon={item.favicon} label={item.displayName} dimmed={isDragging} />
|
||||
{size === "normal" ? (
|
||||
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||
{item.displayName}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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. */}
|
||||
{/* Wallpaper */}
|
||||
<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"
|
||||
className="absolute h-[60vmax] w-[60vmax] rounded-full opacity-25 motion-safe:animate-drift dark:opacity-30"
|
||||
style={{
|
||||
background: color,
|
||||
filter: "blur(60px)",
|
||||
@@ -246,7 +457,7 @@ export function MobileAndroidHome({
|
||||
<div className="absolute inset-0 bg-white/50 dark:bg-black/55" />
|
||||
</div>
|
||||
|
||||
{/* Suchleiste: fest oben, immer sichtbar (auch beim Scrollen) - explizit gewünscht. */}
|
||||
{/* Suchleiste: fest oben */}
|
||||
<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"
|
||||
@@ -272,154 +483,105 @@ export function MobileAndroidHome({
|
||||
</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}
|
||||
<div data-drop-area="grid" className="min-h-[40vh] px-4 pb-32 pt-2">
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
||||
{recentVisits.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenFolder("__readlater__")}
|
||||
onClick={() => setOpenFolderId("__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"
|
||||
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} />
|
||||
<FontAwesomeIcon icon={faClockRotateLeft} />
|
||||
</span>
|
||||
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||
Später lesen
|
||||
Zuletzt besucht
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => setOpenFolderId("__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>
|
||||
)}
|
||||
{layout.grid.map((slot) => renderTile(slot, "grid"))}
|
||||
</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>
|
||||
{layout.grid.length === 0 ? (
|
||||
<p className="mt-8 text-center text-sm text-black/40 dark:text-white/40">
|
||||
Markiere Dienste/Lesezeichen im Adminbereich als Favorit – sie erscheinen dann hier.
|
||||
</p>
|
||||
) : 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>
|
||||
{/* Dock */}
|
||||
<div
|
||||
data-drop-area="dock"
|
||||
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 min-h-[4.5rem] min-w-[5rem] 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"
|
||||
>
|
||||
{layout.dock.length === 0 ? (
|
||||
<span className="px-2 text-xs text-black/30 dark:text-white/30">Icon hierher ziehen zum Anheften</span>
|
||||
) : (
|
||||
layout.dock.map((slot) => renderTile(slot, "dock", "small"))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verbindungsstatus */}
|
||||
<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"
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Schwebendes Icon während des Ziehens */}
|
||||
{drag ? (
|
||||
<div
|
||||
className="pointer-events-none fixed z-50 opacity-90"
|
||||
style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY }}
|
||||
>
|
||||
{drag.slot.kind === "folder" ? (
|
||||
<FolderTile
|
||||
icons={drag.slot.items
|
||||
.map((r) => resolveRef(r))
|
||||
.filter((i): i is ResolvedItem => i !== null)
|
||||
.slice(0, 4)
|
||||
.map((i) => ({ favicon: i.favicon, label: i.displayName }))}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const item = resolveRef(drag.slot.ref);
|
||||
return item ? <TileIcon favicon={item.favicon} label={item.displayName} /> : null;
|
||||
})()
|
||||
)}
|
||||
</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 ? (
|
||||
{/* Ordner-Sheet: Zuletzt besucht / Später lesen / eigener Ordner */}
|
||||
{openFolderId ? (
|
||||
<div
|
||||
className="fixed inset-0 z-30 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
|
||||
onClick={() => setOpenFolder(null)}
|
||||
onClick={() => {
|
||||
setOpenFolderId(null);
|
||||
setRenamingFolder(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -427,20 +589,55 @@ export function MobileAndroidHome({
|
||||
bg-white/90 p-5 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/90
|
||||
sm:rounded-3xl"
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-black dark:text-white">{openFolderData.title}</h2>
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
{openFolder && renamingFolder ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={folderNameDraft}
|
||||
onChange={(e) => setFolderNameDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
setRenamingFolder(false);
|
||||
if (folderNameDraft.trim()) renameFolder(openFolder, folderNameDraft.trim());
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
className="flex-1 rounded-lg border border-black/10 bg-white px-2 py-1 text-base font-semibold
|
||||
text-black outline-none dark:border-white/10 dark:bg-white/10 dark:text-white"
|
||||
/>
|
||||
) : (
|
||||
<h2 className="flex items-center gap-2 text-base font-semibold text-black dark:text-white">
|
||||
{openFolderId === "__recent__" ? "Zuletzt besucht" : openFolderId === "__readlater__" ? "Später lesen" : openFolder?.name}
|
||||
{openFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFolderNameDraft(openFolder.name);
|
||||
setRenamingFolder(true);
|
||||
}}
|
||||
aria-label="Ordner umbenennen"
|
||||
className="text-black/30 hover:text-black/60 dark:text-white/30 dark:hover:text-white/60"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPen} className="text-xs" />
|
||||
</button>
|
||||
) : null}
|
||||
</h2>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenFolder(null)}
|
||||
onClick={() => {
|
||||
setOpenFolderId(null);
|
||||
setRenamingFolder(false);
|
||||
}}
|
||||
aria-label="Schließen"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-black/50
|
||||
className="flex h-8 w-8 shrink-0 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__" ? (
|
||||
{openFolderId === "__readlater__" ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
@@ -455,9 +652,9 @@ export function MobileAndroidHome({
|
||||
value={readLaterUrl}
|
||||
onChange={(e) => setReadLaterUrl(e.target.value)}
|
||||
placeholder="Link einfügen …"
|
||||
className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-sm
|
||||
text-black outline-none placeholder:text-black/30 focus:border-indigo-400/60
|
||||
dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-white/30"
|
||||
className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-sm text-black
|
||||
outline-none placeholder:text-black/30 focus:border-indigo-400/60 dark:border-white/10
|
||||
dark:bg-white/5 dark:text-white dark:placeholder:text-white/30"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
Merken
|
||||
@@ -472,7 +669,7 @@ export function MobileAndroidHome({
|
||||
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} />
|
||||
<TileIcon favicon={item.favicon} label={item.displayName} />
|
||||
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||
{item.displayName}
|
||||
</span>
|
||||
@@ -483,30 +680,53 @@ export function MobileAndroidHome({
|
||||
<p className="text-sm text-black/40 dark:text-white/40">Noch nichts gemerkt.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
) : openFolderId === "__recent__" ? (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
||||
{(openFolderData.items ?? []).map((item) => (
|
||||
{recentVisits.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onOpen({ url: item.url, id: item.id, kind: item.kind ?? "service" });
|
||||
setOpenFolder(null);
|
||||
}}
|
||||
onClick={() => onOpen({ url: item.url, id: item.id, kind: item.kind ?? "service" })}
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
>
|
||||
<TileIcon
|
||||
favicon={item.favicon}
|
||||
label={item.displayName}
|
||||
color={openFolder && openFolder !== "__recent__" ? (categoryColors[openFolder] ?? null) : null}
|
||||
/>
|
||||
<TileIcon favicon={item.favicon} label={item.displayName} />
|
||||
<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>
|
||||
)}
|
||||
) : openFolder ? (
|
||||
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
||||
{openFolder.items.map((ref) => {
|
||||
const item = resolveRef(ref);
|
||||
if (!item) return null;
|
||||
return (
|
||||
<div key={`${ref.type}:${ref.id}`} className="relative flex flex-col items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFromFolder(openFolder, ref)}
|
||||
aria-label={`${item.displayName} aus Ordner entfernen`}
|
||||
className="absolute -right-1 -top-1 z-10 flex h-5 w-5 items-center justify-center
|
||||
rounded-full bg-black/60 text-[10px] text-white shadow hover:bg-black/80"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen({ url: item.url, id: item.id, kind: item.type })}
|
||||
className="flex flex-col items-center gap-1.5"
|
||||
>
|
||||
<TileIcon favicon={item.favicon} label={item.displayName} />
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -515,10 +735,7 @@ export function MobileAndroidHome({
|
||||
{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"
|
||||
>
|
||||
<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}
|
||||
|
||||
110
apps/frontend/src/hooks/useHomeLayout.ts
Normal file
110
apps/frontend/src/hooks/useHomeLayout.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSettings } from "./useSettings.js";
|
||||
|
||||
export interface ItemRef {
|
||||
id: string;
|
||||
type: "service" | "bookmark";
|
||||
}
|
||||
|
||||
export type HomeSlot =
|
||||
| { id: string; kind: "item"; ref: ItemRef }
|
||||
| { id: string; kind: "folder"; name: string; items: ItemRef[] };
|
||||
|
||||
export interface HomeLayout {
|
||||
grid: HomeSlot[];
|
||||
dock: HomeSlot[];
|
||||
}
|
||||
|
||||
const MAX_DOCK_SLOTS = 5;
|
||||
|
||||
function refKey(ref: ItemRef): string {
|
||||
return `${ref.type}:${ref.id}`;
|
||||
}
|
||||
|
||||
function slotId(): string {
|
||||
return `slot-${Math.random().toString(36).slice(2, 10)}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gleicht ein gespeichertes Layout mit der AKTUELLEN Favoriten-Liste ab:
|
||||
* - Favoriten, die noch in keinem Slot vorkommen (neu als Favorit markiert,
|
||||
* oder allererster Aufruf ohne gespeichertes Layout), werden hinten an den
|
||||
* Hauptbereich angehängt.
|
||||
* - Einträge, die nicht mehr in der Favoriten-Liste sind (Favorit entfernt
|
||||
* oder Dienst/Lesezeichen gelöscht), werden aus Slots UND aus Ordnern
|
||||
* entfernt. Dadurch leer gewordene Ordner werden mit entfernt.
|
||||
* Rein bereinigend, verändert nie die vom Nutzer gewählte Reihenfolge
|
||||
* bestehender Einträge.
|
||||
*/
|
||||
function reconcile(layout: HomeLayout, favorites: ItemRef[]): HomeLayout {
|
||||
const favoriteKeys = new Set(favorites.map(refKey));
|
||||
const placedKeys = new Set<string>();
|
||||
|
||||
const cleanSlots = (slots: HomeSlot[]): HomeSlot[] =>
|
||||
slots
|
||||
.map((slot): HomeSlot | null => {
|
||||
if (slot.kind === "item") {
|
||||
if (!favoriteKeys.has(refKey(slot.ref))) return null;
|
||||
placedKeys.add(refKey(slot.ref));
|
||||
return slot;
|
||||
}
|
||||
const items = slot.items.filter((ref) => {
|
||||
if (!favoriteKeys.has(refKey(ref))) return false;
|
||||
placedKeys.add(refKey(ref));
|
||||
return true;
|
||||
});
|
||||
if (items.length === 0) return null;
|
||||
return { ...slot, items };
|
||||
})
|
||||
.filter((s): s is HomeSlot => s !== null);
|
||||
|
||||
const grid = cleanSlots(layout.grid);
|
||||
const dock = cleanSlots(layout.dock);
|
||||
|
||||
const missing = favorites.filter((ref) => !placedKeys.has(refKey(ref)));
|
||||
const newSlots: HomeSlot[] = missing.map((ref) => ({ id: slotId(), kind: "item", ref }));
|
||||
|
||||
return { grid: [...grid, ...newSlots], dock };
|
||||
}
|
||||
|
||||
export function useHomeLayout(favorites: ItemRef[]) {
|
||||
const { data: settings } = useSettings();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const layout = useMemo<HomeLayout>(() => {
|
||||
let parsed: HomeLayout = { grid: [], dock: [] };
|
||||
if (settings?.mobileHomeLayout) {
|
||||
try {
|
||||
const raw = JSON.parse(settings.mobileHomeLayout) as Partial<HomeLayout>;
|
||||
parsed = { grid: raw.grid ?? [], dock: raw.dock ?? [] };
|
||||
} catch {
|
||||
parsed = { grid: [], dock: [] };
|
||||
}
|
||||
}
|
||||
return reconcile(parsed, favorites);
|
||||
// favorites als JSON-String in der dep-Liste, damit sich das Memo nur bei
|
||||
// TATSÄCHLICH geänderten Favoriten neu berechnet, nicht bei jedem Render
|
||||
// (favorites wird von außen als neues Array-Objekt reincegeben).
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settings?.mobileHomeLayout, JSON.stringify(favorites)]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (next: HomeLayout) => {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mobileHomeLayout: next }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Layout konnte nicht gespeichert werden (HTTP ${res.status})`);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
|
||||
});
|
||||
|
||||
return {
|
||||
layout,
|
||||
saveLayout: (next: HomeLayout) => saveMutation.mutate(next),
|
||||
maxDockSlots: MAX_DOCK_SLOTS,
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export interface AppSettings {
|
||||
liveStatusIntervalMinutes: number;
|
||||
liveStatusLastRunAt: string | null;
|
||||
mobileHomeStyle: "classic" | "android";
|
||||
mobileHomeFolderMode: "folders" | "scroll";
|
||||
mobileHomeLayout: string | null;
|
||||
}
|
||||
|
||||
async function fetchSettings(): Promise<AppSettings> {
|
||||
|
||||
@@ -325,7 +325,6 @@ export function HomePage() {
|
||||
bookmarks={bookmarks ?? []}
|
||||
categories={categories ?? []}
|
||||
categoryColors={categoryColors}
|
||||
folderMode={settings?.mobileHomeFolderMode ?? "folders"}
|
||||
allSearchItems={allItems}
|
||||
recentVisits={recentVisits ?? []}
|
||||
readLaterItems={readLaterItems ?? []}
|
||||
|
||||
@@ -85,7 +85,7 @@ function SelectSetting<T extends string>({
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
settingKey: "mobileHomeStyle" | "mobileHomeFolderMode";
|
||||
settingKey: "mobileHomeStyle";
|
||||
value: T | undefined;
|
||||
options: Array<{ value: T; label: string }>;
|
||||
}) {
|
||||
@@ -506,18 +506,6 @@ export function SettingsPage() {
|
||||
{ value: "android", label: "Startbildschirm (Android-Stil)" },
|
||||
]}
|
||||
/>
|
||||
{settings?.mobileHomeStyle === "android" ? (
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user