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:
@@ -16,10 +16,14 @@ function serializeSettings() {
|
|||||||
// Einstellungen statt nur im sessionStorage/localStorage des jeweiligen
|
// Einstellungen statt nur im sessionStorage/localStorage des jeweiligen
|
||||||
// Browsers, damit die Wahl wirklich app-weit gilt, nicht nur pro Gerät.
|
// Browsers, damit die Wahl wirklich app-weit gilt, nicht nur pro Gerät.
|
||||||
mobileHomeStyle: all.mobileHomeStyle === "android" ? "android" : "classic",
|
mobileHomeStyle: all.mobileHomeStyle === "android" ? "android" : "classic",
|
||||||
// Nur relevant, wenn mobileHomeStyle "android" ist: Kategorien als
|
// NEU (round65): Frei angeordnetes Layout des Android-Startbildschirms
|
||||||
// antippbare "Ordner" (wie ein Android-Ordner mit Mini-Vorschau) oder
|
// (welche Favoriten wo liegen, selbst angelegte Ordner, Aufteilung
|
||||||
// alles auf einer durchgehend scrollbaren Seite mit Abschnitts-Überschriften.
|
// zwischen Hauptbereich und angehefteter Dock-Leiste unten) - als JSON
|
||||||
mobileHomeFolderMode: all.mobileHomeFolderMode === "scroll" ? "scroll" : "folders",
|
// gespeichert, roh durchgereicht. Der genaue Aufbau ist reine
|
||||||
|
// Frontend-Angelegenheit (siehe useHomeLayout.ts) - das Backend
|
||||||
|
// validiert hier nur, dass es sich überhaupt um ein Objekt mit den
|
||||||
|
// erwarteten Top-Level-Schlüsseln handelt, nicht den vollen Inhalt.
|
||||||
|
mobileHomeLayout: all.mobileHomeLayout || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +41,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
liveStatusEnabled?: boolean;
|
liveStatusEnabled?: boolean;
|
||||||
liveStatusIntervalMinutes?: number;
|
liveStatusIntervalMinutes?: number;
|
||||||
mobileHomeStyle?: string;
|
mobileHomeStyle?: string;
|
||||||
mobileHomeFolderMode?: string;
|
mobileHomeLayout?: unknown;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -84,11 +88,20 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
settingsRepo.setSetting("mobileHomeStyle", body.mobileHomeStyle);
|
settingsRepo.setSetting("mobileHomeStyle", body.mobileHomeStyle);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body?.mobileHomeFolderMode !== undefined) {
|
if (body?.mobileHomeLayout !== undefined) {
|
||||||
if (body.mobileHomeFolderMode !== "folders" && body.mobileHomeFolderMode !== "scroll") {
|
const layout = body.mobileHomeLayout;
|
||||||
return reply.code(400).send({ error: "mobileHomeFolderMode muss 'folders' oder 'scroll' sein" });
|
const looksValid =
|
||||||
|
layout === null ||
|
||||||
|
(typeof layout === "object" &&
|
||||||
|
layout !== null &&
|
||||||
|
Array.isArray((layout as Record<string, unknown>).grid) &&
|
||||||
|
Array.isArray((layout as Record<string, unknown>).dock));
|
||||||
|
if (!looksValid) {
|
||||||
|
return reply
|
||||||
|
.code(400)
|
||||||
|
.send({ error: "mobileHomeLayout muss null oder ein Objekt mit 'grid' und 'dock' als Arrays sein" });
|
||||||
}
|
}
|
||||||
settingsRepo.setSetting("mobileHomeFolderMode", body.mobileHomeFolderMode);
|
settingsRepo.setSetting("mobileHomeLayout", layout === null ? "" : JSON.stringify(layout));
|
||||||
}
|
}
|
||||||
|
|
||||||
return serializeSettings();
|
return serializeSettings();
|
||||||
|
|||||||
@@ -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 { Link } from "@tanstack/react-router";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import {
|
import {
|
||||||
@@ -7,22 +7,31 @@ import {
|
|||||||
faXmark,
|
faXmark,
|
||||||
faClockRotateLeft,
|
faClockRotateLeft,
|
||||||
faBookmark,
|
faBookmark,
|
||||||
|
faPen,
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import type { Category, Service, Bookmark, SearchResult } from "@launchpad/shared";
|
import type { Category, Service, Bookmark, SearchResult } from "@launchpad/shared";
|
||||||
import { ResultsList, Button } from "@launchpad/ui";
|
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
|
* Android-Startbildschirm-artiges alternatives Design für die mobile Ansicht
|
||||||
* (siehe Einstellungen -> "Design"). Bewusst als eigenständige Komponente
|
* (siehe Einstellungen -> "Design"). Zeigt AUSSCHLIESSLICH als Favorit
|
||||||
* statt Umbau der bestehenden HomePage: beide Designs bleiben unabhängig
|
* markierte Dienste/Lesezeichen (siehe Bugreport round65: vorher wurden auch
|
||||||
* wart- und weiterentwickelbar, HomePage.tsx entscheidet nur, welche der
|
* alle Kategorien automatisch als Ordner gezeigt - das war zu viel/nicht
|
||||||
* beiden gerendert wird (siehe dort).
|
* 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
|
* WICHTIG zur Interaktion: klassisches HTML5-Drag&Drop (draggable=true,
|
||||||
* generischen Farbverlauf, sondern aus den tatsächlichen, in den eigenen
|
* onDragStart/onDrop, siehe FavoritesBar.tsx an anderer Stelle im Projekt)
|
||||||
* Kategorien hinterlegten Farben (Admin -> Kategorien) - jede Installation
|
* feuert auf Touchscreens NICHT zuverlässig - für eine mobile Oberfläche
|
||||||
* bekommt dadurch ein optisch anderes, zum eigenen Homelab passendes
|
* daher bewusst mit der Pointer-Events-API nachgebaut (vereinheitlicht
|
||||||
* Hintergrundbild, ganz ohne eigene Bilder hochladen zu müssen.
|
* 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"];
|
const FALLBACK_PALETTE = ["#6366f1", "#a855f7", "#06b6d4", "#f97316"];
|
||||||
@@ -32,32 +41,97 @@ const BLOB_POSITIONS = [
|
|||||||
{ top: "70%", left: "-20%" },
|
{ top: "70%", left: "-20%" },
|
||||||
{ top: "-15%", left: "55%" },
|
{ top: "-15%", left: "55%" },
|
||||||
];
|
];
|
||||||
|
const LONG_PRESS_MS = 350;
|
||||||
|
const MOVE_CANCEL_PX = 10;
|
||||||
|
|
||||||
function resolveIconSrc(src: string): string {
|
function resolveIconSrc(src: string): string {
|
||||||
if (src.startsWith("data:")) return src;
|
if (src.startsWith("data:")) return src;
|
||||||
return `/api/favicon-proxy?url=${encodeURIComponent(src)}`;
|
return `/api/favicon-proxy?url=${encodeURIComponent(src)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TileIconProps {
|
interface ResolvedItem {
|
||||||
favicon?: string | null;
|
id: string;
|
||||||
label: string;
|
type: "service" | "bookmark";
|
||||||
color?: string | null;
|
displayName: string;
|
||||||
size?: "normal" | "small";
|
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 slotId(): string {
|
||||||
function TileIcon({ favicon, label, color, size = "normal" }: TileIconProps) {
|
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 [failed, setFailed] = useState(false);
|
||||||
const dimension = size === "small" ? "h-9 w-9" : "h-14 w-14";
|
const dimension = size === "small" ? "h-9 w-9" : "h-14 w-14";
|
||||||
const rounding = size === "small" ? "rounded-xl" : "rounded-[20px]";
|
const rounding = size === "small" ? "rounded-xl" : "rounded-[20px]";
|
||||||
const backdrop = color ? `${color}33` : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`flex ${dimension} ${rounding} shrink-0 items-center justify-center
|
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
|
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
|
||||||
dark:ring-white/10`}
|
dark:bg-white/10 dark:ring-white/10 ${dimmed ? "opacity-30" : ""}`}
|
||||||
style={{ background: backdrop ?? "rgba(255,255,255,0.75)" }}
|
|
||||||
>
|
>
|
||||||
{favicon && !failed ? (
|
{favicon && !failed ? (
|
||||||
<img
|
<img
|
||||||
@@ -67,7 +141,7 @@ function TileIcon({ favicon, label, color, size = "normal" }: TileIconProps) {
|
|||||||
onError={() => setFailed(true)}
|
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()}
|
{label.charAt(0).toUpperCase()}
|
||||||
</span>
|
</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, dimmed }: { icons: Array<{ favicon: string | null; label: string }>; dimmed?: boolean }) {
|
||||||
function FolderTile({
|
|
||||||
icons,
|
|
||||||
color,
|
|
||||||
}: {
|
|
||||||
icons: Array<{ favicon: string | null; label: string }>;
|
|
||||||
color: string | null;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="grid h-14 w-14 shrink-0 grid-cols-2 grid-rows-2 gap-1 rounded-[20px] p-1.5
|
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 dark:ring-white/10"
|
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm transition-opacity
|
||||||
style={{ background: color ? `${color}33` : "rgba(255,255,255,0.6)" }}
|
dark:bg-white/10 dark:ring-white/10 ${dimmed ? "opacity-30" : ""}`}
|
||||||
>
|
>
|
||||||
{Array.from({ length: 4 }).map((_, i) => {
|
{Array.from({ length: 4 }).map((_, i) => {
|
||||||
const item = icons[i];
|
const item = icons[i];
|
||||||
@@ -123,7 +190,6 @@ export interface MobileAndroidHomeProps {
|
|||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
categoryColors: Record<string, string>;
|
categoryColors: Record<string, string>;
|
||||||
folderMode: "folders" | "scroll";
|
|
||||||
allSearchItems: SearchResult[];
|
allSearchItems: SearchResult[];
|
||||||
recentVisits: MinimalVisitItem[];
|
recentVisits: MinimalVisitItem[];
|
||||||
readLaterItems: Array<{ id: string; url: string; displayName: string; favicon: string | null }>;
|
readLaterItems: Array<{ id: string; url: string; displayName: string; favicon: string | null }>;
|
||||||
@@ -138,7 +204,6 @@ export function MobileAndroidHome({
|
|||||||
bookmarks,
|
bookmarks,
|
||||||
categories,
|
categories,
|
||||||
categoryColors,
|
categoryColors,
|
||||||
folderMode,
|
|
||||||
allSearchItems,
|
allSearchItems,
|
||||||
recentVisits,
|
recentVisits,
|
||||||
readLaterItems,
|
readLaterItems,
|
||||||
@@ -150,56 +215,142 @@ export function MobileAndroidHome({
|
|||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
const [openFolder, setOpenFolder] = useState<string | null>(null);
|
const [openFolderId, setOpenFolderId] = useState<string | null>(null);
|
||||||
const [readLaterUrl, setReadLaterUrl] = useState("");
|
const [readLaterUrl, setReadLaterUrl] = useState("");
|
||||||
|
const [renamingFolder, setRenamingFolder] = useState(false);
|
||||||
|
const [folderNameDraft, setFolderNameDraft] = useState("");
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const palette = useMemo(() => {
|
const palette = useMemo(() => {
|
||||||
const fromCategories = (categories ?? [])
|
const fromCategories = (categories ?? []).map((c) => c.color).filter((c): c is string => Boolean(c));
|
||||||
.map((c) => c.color)
|
|
||||||
.filter((c): c is string => Boolean(c));
|
|
||||||
const unique = Array.from(new Set(fromCategories));
|
const unique = Array.from(new Set(fromCategories));
|
||||||
return unique.length > 0 ? unique.slice(0, 4) : FALLBACK_PALETTE;
|
return unique.length > 0 ? unique.slice(0, 4) : FALLBACK_PALETTE;
|
||||||
}, [categories]);
|
}, [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 resolveRef = (ref: ItemRef): ResolvedItem | null => {
|
||||||
const byCategory = new Map<string, Array<Service | Bookmark>>();
|
if (ref.type === "service") {
|
||||||
const ungrouped: Array<Service | Bookmark> = [];
|
const s = serviceById.get(ref.id);
|
||||||
for (const item of [...visibleServices, ...bookmarks]) {
|
if (!s || !s.visible) return null;
|
||||||
if (item.category) {
|
return { id: s.id, type: "service", displayName: s.displayName, favicon: s.favicon, url: s.url, category: s.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 };
|
const b = bookmarkById.get(ref.id);
|
||||||
}, [visibleServices, bookmarks]);
|
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(
|
const favoriteRefs: ItemRef[] = useMemo(
|
||||||
() => [...visibleServices, ...bookmarks].filter((i) => i.favorite).slice(0, 6),
|
() => [
|
||||||
[visibleServices, bookmarks]
|
...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 => ({
|
const { layout, saveLayout, maxDockSlots } = useHomeLayout(favoriteRefs);
|
||||||
url: item.url,
|
|
||||||
id: item.id,
|
// --- Ziehen (Pointer Events, siehe Kommentar oben an der Datei) ---
|
||||||
kind: "deviceId" in item ? "service" : "bookmark",
|
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(() => {
|
const searchResults = useMemo(() => {
|
||||||
if (query.trim().length === 0) return [];
|
if (query.trim().length === 0) return [];
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
return allSearchItems
|
return allSearchItems
|
||||||
.filter((i) => i.kind !== "device")
|
.filter((i) => i.kind !== "device")
|
||||||
.filter(
|
.filter((i) => i.displayName.toLowerCase().includes(q) || i.alias?.some((a) => a.toLowerCase().includes(q)))
|
||||||
(i) =>
|
|
||||||
i.displayName.toLowerCase().includes(q) ||
|
|
||||||
i.alias?.some((a) => a.toLowerCase().includes(q))
|
|
||||||
)
|
|
||||||
.slice(0, 30);
|
.slice(0, 30);
|
||||||
}, [query, allSearchItems]);
|
}, [query, allSearchItems]);
|
||||||
|
|
||||||
@@ -208,32 +359,92 @@ export function MobileAndroidHome({
|
|||||||
setQuery("");
|
setQuery("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const toMinimalItem = (item: Service | Bookmark): MinimalVisitItem => ({
|
const openFolder =
|
||||||
id: item.id,
|
openFolderId && openFolderId !== "__recent__" && openFolderId !== "__readlater__"
|
||||||
url: item.url,
|
? ([...layout.grid, ...layout.dock].find((s) => s.id === openFolderId && s.kind === "folder") as
|
||||||
displayName: item.displayName,
|
| Extract<HomeSlot, { kind: "folder" }>
|
||||||
favicon: item.favicon,
|
| undefined)
|
||||||
kind: "deviceId" in item ? "service" : "bookmark",
|
: null;
|
||||||
});
|
|
||||||
|
|
||||||
const openFolderData =
|
function removeFromFolder(folder: Extract<HomeSlot, { kind: "folder" }>, ref: ItemRef) {
|
||||||
openFolder === "__recent__"
|
const items = folder.items.filter((i) => !(i.id === ref.id && i.type === ref.type));
|
||||||
? { title: "Zuletzt besucht", items: recentVisits }
|
const patch = (slots: HomeSlot[]) =>
|
||||||
: openFolder === "__readlater__"
|
slots
|
||||||
? { title: "Später lesen", items: null }
|
.map((s) => (s.id === folder.id ? (items.length > 0 ? { ...s, items } : null) : s))
|
||||||
: openFolder
|
.filter((s): s is HomeSlot => s !== null);
|
||||||
? { title: openFolder, items: (grouped.byCategory.get(openFolder) ?? []).map(toMinimalItem) }
|
saveLayout({ grid: patch(layout.grid), dock: patch(layout.dock) });
|
||||||
: null;
|
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 (
|
return (
|
||||||
<div className="relative min-h-dvh overflow-hidden bg-neutral-100 dark:bg-neutral-950">
|
<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">
|
<div className="pointer-events-none fixed inset-0 overflow-hidden">
|
||||||
{palette.map((color, i) => (
|
{palette.map((color, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute h-[60vmax] w-[60vmax] rounded-full opacity-25
|
className="absolute h-[60vmax] w-[60vmax] rounded-full opacity-25 motion-safe:animate-drift dark:opacity-30"
|
||||||
motion-safe:animate-drift dark:opacity-30"
|
|
||||||
style={{
|
style={{
|
||||||
background: color,
|
background: color,
|
||||||
filter: "blur(60px)",
|
filter: "blur(60px)",
|
||||||
@@ -246,7 +457,7 @@ export function MobileAndroidHome({
|
|||||||
<div className="absolute inset-0 bg-white/50 dark:bg-black/55" />
|
<div className="absolute inset-0 bg-white/50 dark:bg-black/55" />
|
||||||
</div>
|
</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)]">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -272,154 +483,105 @@ export function MobileAndroidHome({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Haupt-Raster */}
|
{/* Haupt-Raster */}
|
||||||
<div className="px-4 pb-32 pt-2">
|
<div data-drop-area="grid" className="min-h-[40vh] px-4 pb-32 pt-2">
|
||||||
{favorites.length === 0 && grouped.ungrouped.length === 0 && grouped.byCategory.size === 0 ? (
|
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
||||||
<p className="mt-10 text-center text-sm text-black/40 dark:text-white/40">
|
{recentVisits.length > 0 ? (
|
||||||
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpenFolder("__readlater__")}
|
onClick={() => setOpenFolderId("__recent__")}
|
||||||
className="flex flex-col items-center gap-1.5"
|
className="flex flex-col items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60
|
className="flex h-14 w-14 items-center justify-center rounded-[20px] bg-white/60 text-black/50
|
||||||
text-black/50 shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40
|
shadow-[0_2px_10px_rgba(0,0,0,0.18)] ring-1 ring-white/40 backdrop-blur-sm
|
||||||
backdrop-blur-sm dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
|
dark:bg-white/10 dark:text-white/50 dark:ring-white/10"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faBookmark} />
|
<FontAwesomeIcon icon={faClockRotateLeft} />
|
||||||
</span>
|
</span>
|
||||||
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
<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>
|
</span>
|
||||||
</button>
|
</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" ? (
|
{layout.grid.map((slot) => renderTile(slot, "grid"))}
|
||||||
<>
|
</div>
|
||||||
{/* 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. */}
|
{layout.grid.length === 0 ? (
|
||||||
{folderMode === "scroll" ? (
|
<p className="mt-8 text-center text-sm text-black/40 dark:text-white/40">
|
||||||
<div className="mt-6 flex flex-col gap-6">
|
Markiere Dienste/Lesezeichen im Adminbereich als Favorit – sie erscheinen dann hier.
|
||||||
{Array.from(grouped.byCategory.entries()).map(([name, items]) => (
|
</p>
|
||||||
<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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dock: angeheftete Favoriten, immer sichtbar - klassisches Android-Verhalten. */}
|
{/* Dock */}
|
||||||
{favorites.length > 0 ? (
|
<div
|
||||||
<div className="fixed inset-x-0 bottom-0 z-20 flex justify-center px-4 pb-[calc(env(safe-area-inset-bottom)+0.75rem)]">
|
data-drop-area="dock"
|
||||||
<div
|
className="fixed inset-x-0 bottom-0 z-20 flex justify-center px-4 pb-[calc(env(safe-area-inset-bottom)+0.75rem)]"
|
||||||
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"
|
<div
|
||||||
>
|
className="flex min-h-[4.5rem] min-w-[5rem] items-center gap-3 rounded-[28px] border border-white/40
|
||||||
{favorites.map((item) => (
|
bg-white/70 px-4 py-3 shadow-xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/70"
|
||||||
<button key={item.id} type="button" onClick={() => onOpen(itemToOpenTarget(item))} title={item.displayName}>
|
>
|
||||||
<TileIcon favicon={item.favicon} label={item.displayName} color={null} size="small" />
|
{layout.dock.length === 0 ? (
|
||||||
</button>
|
<span className="px-2 text-xs text-black/30 dark:text-white/30">Icon hierher ziehen zum Anheften</span>
|
||||||
))}
|
) : (
|
||||||
</div>
|
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Verbindungsstatus: dezenter Punkt statt eigener Fußzeile - dieses Design hat bewusst keinen Platz für eine lange Statuszeile. */}
|
{/* Ordner-Sheet: Zuletzt besucht / Später lesen / eigener Ordner */}
|
||||||
<div
|
{openFolderId ? (
|
||||||
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
|
<div
|
||||||
className="fixed inset-0 z-30 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
|
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
|
<div
|
||||||
onClick={(e) => e.stopPropagation()}
|
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
|
bg-white/90 p-5 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-neutral-900/90
|
||||||
sm:rounded-3xl"
|
sm:rounded-3xl"
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between gap-2">
|
||||||
<h2 className="text-base font-semibold text-black dark:text-white">{openFolderData.title}</h2>
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpenFolder(null)}
|
onClick={() => {
|
||||||
|
setOpenFolderId(null);
|
||||||
|
setRenamingFolder(false);
|
||||||
|
}}
|
||||||
aria-label="Schließen"
|
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"
|
hover:bg-black/5 dark:text-white/50 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faXmark} />
|
<FontAwesomeIcon icon={faXmark} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{openFolder === "__readlater__" ? (
|
{openFolderId === "__readlater__" ? (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
@@ -455,9 +652,9 @@ export function MobileAndroidHome({
|
|||||||
value={readLaterUrl}
|
value={readLaterUrl}
|
||||||
onChange={(e) => setReadLaterUrl(e.target.value)}
|
onChange={(e) => setReadLaterUrl(e.target.value)}
|
||||||
placeholder="Link einfügen …"
|
placeholder="Link einfügen …"
|
||||||
className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-sm
|
className="flex-1 rounded-xl border border-black/10 bg-white/70 px-3 py-2 text-sm text-black
|
||||||
text-black outline-none placeholder:text-black/30 focus:border-indigo-400/60
|
outline-none placeholder:text-black/30 focus:border-indigo-400/60 dark:border-white/10
|
||||||
dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-white/30"
|
dark:bg-white/5 dark:text-white dark:placeholder:text-white/30"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" variant="secondary" size="sm">
|
<Button type="submit" variant="secondary" size="sm">
|
||||||
Merken
|
Merken
|
||||||
@@ -472,7 +669,7 @@ export function MobileAndroidHome({
|
|||||||
onClick={() => onOpen({ url: item.url, id: item.id, kind: "bookmark" })}
|
onClick={() => onOpen({ url: item.url, id: item.id, kind: "bookmark" })}
|
||||||
className="flex flex-col items-center gap-1.5"
|
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">
|
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||||
{item.displayName}
|
{item.displayName}
|
||||||
</span>
|
</span>
|
||||||
@@ -483,30 +680,53 @@ export function MobileAndroidHome({
|
|||||||
<p className="text-sm text-black/40 dark:text-white/40">Noch nichts gemerkt.</p>
|
<p className="text-sm text-black/40 dark:text-white/40">Noch nichts gemerkt.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : openFolderId === "__recent__" ? (
|
||||||
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
<div className="grid grid-cols-4 gap-x-3 gap-y-5">
|
||||||
{(openFolderData.items ?? []).map((item) => (
|
{recentVisits.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => onOpen({ url: item.url, id: item.id, kind: item.kind ?? "service" })}
|
||||||
onOpen({ url: item.url, id: item.id, kind: item.kind ?? "service" });
|
|
||||||
setOpenFolder(null);
|
|
||||||
}}
|
|
||||||
className="flex flex-col items-center gap-1.5"
|
className="flex flex-col items-center gap-1.5"
|
||||||
>
|
>
|
||||||
<TileIcon
|
<TileIcon favicon={item.favicon} label={item.displayName} />
|
||||||
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">
|
<span className="line-clamp-2 w-full text-center text-[11px] leading-tight text-black/70 dark:text-white/70">
|
||||||
{item.displayName}
|
{item.displayName}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -515,10 +735,7 @@ export function MobileAndroidHome({
|
|||||||
{searchOpen ? (
|
{searchOpen ? (
|
||||||
<div className="fixed inset-0 z-40 flex flex-col bg-white/95 backdrop-blur-xl dark:bg-neutral-950/95">
|
<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 items-center gap-2 px-4 pb-3 pt-[calc(env(safe-area-inset-top)+0.75rem)]">
|
||||||
<div
|
<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">
|
||||||
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" />
|
<FontAwesomeIcon icon={faMagnifyingGlass} className="text-black/40 dark:text-white/40" />
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
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;
|
liveStatusIntervalMinutes: number;
|
||||||
liveStatusLastRunAt: string | null;
|
liveStatusLastRunAt: string | null;
|
||||||
mobileHomeStyle: "classic" | "android";
|
mobileHomeStyle: "classic" | "android";
|
||||||
mobileHomeFolderMode: "folders" | "scroll";
|
mobileHomeLayout: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSettings(): Promise<AppSettings> {
|
async function fetchSettings(): Promise<AppSettings> {
|
||||||
|
|||||||
@@ -325,7 +325,6 @@ export function HomePage() {
|
|||||||
bookmarks={bookmarks ?? []}
|
bookmarks={bookmarks ?? []}
|
||||||
categories={categories ?? []}
|
categories={categories ?? []}
|
||||||
categoryColors={categoryColors}
|
categoryColors={categoryColors}
|
||||||
folderMode={settings?.mobileHomeFolderMode ?? "folders"}
|
|
||||||
allSearchItems={allItems}
|
allSearchItems={allItems}
|
||||||
recentVisits={recentVisits ?? []}
|
recentVisits={recentVisits ?? []}
|
||||||
readLaterItems={readLaterItems ?? []}
|
readLaterItems={readLaterItems ?? []}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ function SelectSetting<T extends string>({
|
|||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
hint: string;
|
hint: string;
|
||||||
settingKey: "mobileHomeStyle" | "mobileHomeFolderMode";
|
settingKey: "mobileHomeStyle";
|
||||||
value: T | undefined;
|
value: T | undefined;
|
||||||
options: Array<{ value: T; label: string }>;
|
options: Array<{ value: T; label: string }>;
|
||||||
}) {
|
}) {
|
||||||
@@ -506,18 +506,6 @@ export function SettingsPage() {
|
|||||||
{ value: "android", label: "Startbildschirm (Android-Stil)" },
|
{ 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user