import { useMemo, useState, type DragEvent, type FormEvent } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faStar as faStarSolid, faEye, faEyeSlash, faArrowLeft } from "@fortawesome/free-solid-svg-icons"; import { faStar } from "@fortawesome/free-regular-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button, Favicon } from "@launchpad/ui"; import type { Bookmark } from "@launchpad/shared"; import { suggestBookmarkCategory } from "@launchpad/shared"; import { useBookmarks } from "../../hooks/useBookmarks.js"; import { useServices } from "../../hooks/useServices.js"; import { useCategories } from "../../hooks/useCategories.js"; import { AdminPageHeader } from "./AdminPageHeader.js"; interface BookmarkPatch { url?: string; displayName?: string; description?: string | null; category?: string | null; favorite?: boolean; alias?: string[]; order?: number; favicon?: string | null; } async function createBookmarkRequest(input: { url: string; category?: string; description?: string; }): Promise { const res = await fetch("/api/bookmarks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error ?? `Lesezeichen konnte nicht angelegt werden (HTTP ${res.status})`); } return res.json(); } async function patchBookmark(id: string, patch: BookmarkPatch): Promise { const res = await fetch(`/api/bookmarks/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error ?? `Lesezeichen konnte nicht aktualisiert werden (HTTP ${res.status})`); } return res.json(); } async function deleteBookmarkRequest(id: string) { const res = await fetch(`/api/bookmarks/${id}`, { method: "DELETE" }); if (!res.ok && res.status !== 404) { throw new Error(`Lesezeichen konnte nicht gelöscht werden (HTTP ${res.status})`); } } async function reorderBookmarksRequest(entries: { id: string; order: number }[]) { const res = await fetch("/api/bookmarks/reorder", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(entries), }); if (!res.ok) { throw new Error(`Reihenfolge konnte nicht gespeichert werden (HTTP ${res.status})`); } return res.json(); } const NEW_CATEGORY_VALUE = "__new__"; function CategorySelect({ value, onChange }: { value: string; onChange: (v: string) => void }) { const { data: categories } = useCategories(); const isKnown = !value || categories?.some((c) => c.name === value); const [isNew, setIsNew] = useState(!isKnown); return (
{isNew ? ( onChange(e.target.value)} placeholder="Name der neuen Kategorie" className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" /> ) : null}
); } function AddBookmarkForm() { const queryClient = useQueryClient(); const [url, setUrl] = useState(""); const [category, setCategory] = useState(""); const [description, setDescription] = useState(""); const mutation = useMutation({ mutationFn: () => createBookmarkRequest({ url: url.trim(), category: category.trim() || undefined, description: description.trim() || undefined, }), onSuccess: () => { setUrl(""); setCategory(""); setDescription(""); queryClient.invalidateQueries({ queryKey: ["bookmarks"] }); queryClient.invalidateQueries({ queryKey: ["categories"] }); }, }); function handleSubmit(e: FormEvent) { e.preventDefault(); if (!url.trim()) return; mutation.mutate(); } // Kategorie-Vorschlag anhand der eingegebenen URL (rein heuristisch anhand // des Hostnamens, siehe suggestBookmarkCategory in @launchpad/shared) - nur // solange der Nutzer noch keine eigene Kategorie gewählt hat. const suggestedCategory = category.trim() === "" ? suggestBookmarkCategory(url.trim()) : null; return (
setUrl(e.target.value)} placeholder="https://example.com" className="w-64 rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm text-black outline-none focus:border-black/30 dark:border-white/10 dark:bg-white/5 dark:text-white dark:focus:border-white/30" />
{suggestedCategory ? ( ) : null}
setDescription(e.target.value)} placeholder="optional" className="w-48 rounded-lg border border-black/10 bg-white px-3 py-1.5 text-sm text-black outline-none focus:border-black/30 dark:border-white/10 dark:bg-white/5 dark:text-white dark:focus:border-white/30" />
{mutation.isError ? ( {(mutation.error as Error).message} ) : null} Titel und Favicon werden automatisch von der Seite geladen, falls verfügbar.
); } const EDIT_FORM_COLSPAN = 7; function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void }) { const queryClient = useQueryClient(); const { data: allBookmarks } = useBookmarks(); const { data: allServices } = useServices(); const [displayName, setDisplayName] = useState(bookmark.displayName); const [url, setUrl] = useState(bookmark.url); const [category, setCategory] = useState(bookmark.category ?? ""); const [description, setDescription] = useState(bookmark.description ?? ""); const [alias, setAlias] = useState(bookmark.alias.join(", ")); const [favicon, setFavicon] = useState(undefined); const [faviconError, setFaviconError] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const FAVICON_MAX_BYTES = 300 * 1024; const existingFavicons = useMemo(() => { const seen = new Map(); for (const b of allBookmarks ?? []) { if (b.favicon && b.id !== bookmark.id && !seen.has(b.favicon)) seen.set(b.favicon, b.displayName); } for (const s of allServices ?? []) { if (s.favicon && !seen.has(s.favicon)) seen.set(s.favicon, s.displayName); } return Array.from(seen.entries()); }, [allBookmarks, allServices, bookmark.id]); function handleFaviconFile(file: File | undefined) { setFaviconError(null); if (!file) return; if (file.size > FAVICON_MAX_BYTES) { setFaviconError("Datei zu groß (max. 300 KB) – bitte ein kleineres Bild wählen."); return; } const reader = new FileReader(); reader.onload = () => setFavicon(reader.result as string); reader.onerror = () => setFaviconError("Datei konnte nicht gelesen werden."); reader.readAsDataURL(file); } const faviconPreview = favicon === undefined ? bookmark.favicon : favicon; const mutation = useMutation({ mutationFn: () => patchBookmark(bookmark.id, { displayName: displayName.trim(), url: url.trim(), category: category.trim() || null, description: description.trim() || null, alias: alias .split(",") .map((a) => a.trim()) .filter(Boolean), ...(favicon !== undefined ? { favicon } : {}), }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["bookmarks"] }); queryClient.invalidateQueries({ queryKey: ["categories"] }); onDone(); }, }); return (
{existingFavicons.length > 0 ? ( ) : null} {faviconPreview ? ( ) : null}
{faviconError ?

{faviconError}

: null} {pickerOpen ? (
{existingFavicons.map(([iconUrl, name]) => ( ))}
) : null}
setDisplayName(e.target.value)} className="rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" />
setUrl(e.target.value)} className="w-56 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" />
setDescription(e.target.value)} className="w-48 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" />
setAlias(e.target.value)} className="w-40 rounded-lg border border-black/10 bg-white px-2 py-1 text-sm dark:border-white/10 dark:bg-white/10 dark:text-white" />
{mutation.isError ? ( {(mutation.error as Error).message} ) : null}
); } function BookmarkRow({ bookmark, draggable, onDragStart, onDragOver, onDrop, isDragging, }: { bookmark: Bookmark; draggable: boolean; onDragStart: (e: DragEvent) => void; onDragOver: (e: DragEvent) => void; onDrop: (e: DragEvent) => void; isDragging: boolean; }) { const queryClient = useQueryClient(); const [editing, setEditing] = useState(false); const favoriteMutation = useMutation({ mutationFn: () => patchBookmark(bookmark.id, { favorite: !bookmark.favorite }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }), }); const deleteMutation = useMutation({ mutationFn: () => deleteBookmarkRequest(bookmark.id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["bookmarks"] }), }); if (editing) { return setEditing(false)} />; } return ( ⠿⠿
{bookmark.displayName}
{bookmark.hostname}
{bookmark.category ?? "–"} {bookmark.description ?? "–"} öffnen
); } type SortColumn = "displayName" | "hostname" | "category" | null; function SortableHeader({ label, column, activeColumn, direction, onClick, }: { label: string; column: SortColumn; activeColumn: SortColumn; direction: "asc" | "desc"; onClick: (column: SortColumn) => void; }) { const active = activeColumn === column; return ( ); } export function BookmarksPage() { const { data: bookmarks, isLoading, isError } = useBookmarks(); const queryClient = useQueryClient(); const [draggedId, setDraggedId] = useState(null); const [localOrder, setLocalOrder] = useState(null); const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const reorderMutation = useMutation({ mutationFn: reorderBookmarksRequest, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["bookmarks"] }); setLocalOrder(null); }, onError: () => setLocalOrder(null), }); const baseList = localOrder ?? bookmarks ?? []; const list = useMemo(() => { if (!sortColumn) return baseList; const sorted = [...baseList].sort((a, b) => { let cmp = 0; switch (sortColumn) { case "displayName": cmp = a.displayName.localeCompare(b.displayName); break; case "hostname": cmp = a.hostname.localeCompare(b.hostname); break; case "category": cmp = (a.category ?? "").localeCompare(b.category ?? ""); break; } return sortDirection === "asc" ? cmp : -cmp; }); return sorted; }, [baseList, sortColumn, sortDirection]); function handleHeaderClick(column: SortColumn) { if (sortColumn === column) { setSortDirection((d) => (d === "asc" ? "desc" : "asc")); } else { setSortColumn(column); setSortDirection("asc"); } } const dragEnabled = sortColumn === null; function handleDragStart(id: string) { return (_e: DragEvent) => setDraggedId(id); } function handleDragOver(targetId: string) { return (e: DragEvent) => { e.preventDefault(); if (!dragEnabled || !draggedId || draggedId === targetId) return; const current = localOrder ?? bookmarks ?? []; const fromIndex = current.findIndex((b) => b.id === draggedId); const toIndex = current.findIndex((b) => b.id === targetId); if (fromIndex === -1 || toIndex === -1) return; const next = [...current]; const [moved] = next.splice(fromIndex, 1); next.splice(toIndex, 0, moved); setLocalOrder(next); }; } function handleDrop() { return (e: DragEvent) => { e.preventDefault(); if (!dragEnabled) return; setDraggedId(null); const current = localOrder ?? bookmarks ?? []; reorderMutation.mutate(current.map((b, index) => ({ id: b.id, order: index }))); }; } return (
{sortColumn ? (
) : null} {isLoading ? (

Lade Lesezeichen …

) : isError ? (

Lesezeichen konnten nicht geladen werden.

) : list.length > 0 ? (
{list.map((bookmark) => ( ))}
Beschreibung
) : (

Noch keine Lesezeichen angelegt. Füge oben eine URL hinzu.

)}
); }