generated from Dicken/dickendock
645 lines
24 KiB
TypeScript
645 lines
24 KiB
TypeScript
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<Bookmark> {
|
||
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<Bookmark> {
|
||
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 (
|
||
<div className="flex flex-col gap-1">
|
||
<select
|
||
value={isNew ? NEW_CATEGORY_VALUE : value}
|
||
onChange={(e) => {
|
||
if (e.target.value === NEW_CATEGORY_VALUE) {
|
||
setIsNew(true);
|
||
onChange("");
|
||
} else {
|
||
setIsNew(false);
|
||
onChange(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"
|
||
>
|
||
<option value="">– Keine –</option>
|
||
{categories?.map((c) => (
|
||
<option key={c.id} value={c.name}>
|
||
{c.name}
|
||
</option>
|
||
))}
|
||
<option value={NEW_CATEGORY_VALUE}>+ Neue Kategorie …</option>
|
||
</select>
|
||
{isNew ? (
|
||
<input
|
||
value={value}
|
||
onChange={(e) => 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}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<form onSubmit={handleSubmit} className="mb-6 flex flex-wrap items-end gap-2">
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">URL</label>
|
||
<input
|
||
value={url}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
||
Kategorie
|
||
</label>
|
||
<CategorySelect value={category} onChange={setCategory} />
|
||
{suggestedCategory ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setCategory(suggestedCategory)}
|
||
className="mt-1 block text-xs text-blue-600 hover:underline dark:text-blue-400"
|
||
>
|
||
Vorschlag: {suggestedCategory} übernehmen?
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs font-medium text-black/50 dark:text-white/50">
|
||
Beschreibung
|
||
</label>
|
||
<input
|
||
value={description}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<Button type="submit" variant="primary" disabled={mutation.isPending}>
|
||
{mutation.isPending ? "Lade Titel/Favicon …" : "Anlegen"}
|
||
</Button>
|
||
{mutation.isError ? (
|
||
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
||
) : null}
|
||
<span className="w-full text-xs text-black/40 dark:text-white/40">
|
||
Titel und Favicon werden automatisch von der Seite geladen, falls verfügbar.
|
||
</span>
|
||
</form>
|
||
);
|
||
}
|
||
|
||
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<string | null | undefined>(undefined);
|
||
const [faviconError, setFaviconError] = useState<string | null>(null);
|
||
const [pickerOpen, setPickerOpen] = useState(false);
|
||
const FAVICON_MAX_BYTES = 300 * 1024;
|
||
|
||
const existingFavicons = useMemo(() => {
|
||
const seen = new Map<string, string>();
|
||
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 (
|
||
<tr className="border-b border-black/5 bg-black/[0.02] last:border-0 dark:border-white/5 dark:bg-white/5">
|
||
<td colSpan={EDIT_FORM_COLSPAN} className="px-4 py-3">
|
||
<div className="flex flex-wrap items-end gap-3">
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Favicon</label>
|
||
<div className="flex items-center gap-2">
|
||
<Favicon src={faviconPreview} fallbackLetter={displayName} size="sm" />
|
||
<label className="cursor-pointer rounded-lg border border-black/10 px-2 py-1 text-xs
|
||
text-black/70 hover:bg-black/5 dark:border-white/10 dark:text-white/70 dark:hover:bg-white/10">
|
||
Hochladen
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={(e) => handleFaviconFile(e.target.files?.[0])}
|
||
/>
|
||
</label>
|
||
{existingFavicons.length > 0 ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setPickerOpen((v) => !v)}
|
||
className="rounded-lg border border-black/10 px-2 py-1 text-xs text-black/70
|
||
hover:bg-black/5 dark:border-white/10 dark:text-white/70 dark:hover:bg-white/10"
|
||
>
|
||
Vorhandenes wählen
|
||
</button>
|
||
) : null}
|
||
{faviconPreview ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setFavicon(null)}
|
||
className="text-xs text-black/40 underline dark:text-white/40"
|
||
>
|
||
Entfernen
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
{faviconError ? <p className="mt-1 text-xs text-red-500">{faviconError}</p> : null}
|
||
{pickerOpen ? (
|
||
<div className="mt-2 flex max-w-xs flex-wrap gap-1.5 rounded-lg border border-black/10 p-2
|
||
dark:border-white/10">
|
||
{existingFavicons.map(([iconUrl, name]) => (
|
||
<button
|
||
key={iconUrl}
|
||
type="button"
|
||
title={name}
|
||
onClick={() => {
|
||
setFavicon(iconUrl);
|
||
setPickerOpen(false);
|
||
}}
|
||
className="rounded p-0.5 hover:bg-black/5 dark:hover:bg-white/10"
|
||
>
|
||
<Favicon src={iconUrl} fallbackLetter={name} size="sm" />
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Name</label>
|
||
<input
|
||
value={displayName}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">URL</label>
|
||
<input
|
||
value={url}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">Kategorie</label>
|
||
<CategorySelect value={category} onChange={setCategory} />
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||
Beschreibung
|
||
</label>
|
||
<input
|
||
value={description}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="mb-1 block text-xs text-black/50 dark:text-white/50">
|
||
Alias (kommagetrennt)
|
||
</label>
|
||
<input
|
||
value={alias}
|
||
onChange={(e) => 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"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Button size="icon" variant="primary" onClick={() => mutation.mutate()} disabled={mutation.isPending} title="Speichern" aria-label="Speichern"><FontAwesomeIcon icon={faFloppyDisk} /></Button>
|
||
<Button size="icon" variant="ghost" onClick={onDone} title="Abbrechen" aria-label="Abbrechen"><FontAwesomeIcon icon={faXmark} /></Button>
|
||
{mutation.isError ? (
|
||
<span className="text-xs text-red-500">{(mutation.error as Error).message}</span>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function BookmarkRow({
|
||
bookmark,
|
||
draggable,
|
||
onDragStart,
|
||
onDragOver,
|
||
onDrop,
|
||
isDragging,
|
||
}: {
|
||
bookmark: Bookmark;
|
||
draggable: boolean;
|
||
onDragStart: (e: DragEvent<HTMLTableRowElement>) => void;
|
||
onDragOver: (e: DragEvent<HTMLTableRowElement>) => void;
|
||
onDrop: (e: DragEvent<HTMLTableRowElement>) => 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 <EditForm bookmark={bookmark} onDone={() => setEditing(false)} />;
|
||
}
|
||
|
||
return (
|
||
<tr
|
||
draggable={draggable}
|
||
onDragStart={onDragStart}
|
||
onDragOver={onDragOver}
|
||
onDrop={onDrop}
|
||
className={`border-b border-black/5 last:border-0 dark:border-white/5 ${isDragging ? "opacity-40" : ""}`}
|
||
>
|
||
<td className="px-2 py-3 text-center">
|
||
<span
|
||
className={`select-none ${draggable ? "cursor-grab text-black/30 dark:text-white/30" : "text-black/10 dark:text-white/10"}`}
|
||
aria-hidden
|
||
>
|
||
⠿⠿
|
||
</span>
|
||
</td>
|
||
<td className="px-2 py-3">
|
||
<button
|
||
onClick={() => favoriteMutation.mutate()}
|
||
aria-label={bookmark.favorite ? "Favorit entfernen" : "Als Favorit markieren"}
|
||
className={`text-lg ${bookmark.favorite ? "text-amber-500" : "text-black/15 hover:text-amber-400 dark:text-white/15"}`}
|
||
>
|
||
<FontAwesomeIcon icon={bookmark.favorite ? faStarSolid : faStar} />
|
||
</button>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-2">
|
||
<Favicon src={bookmark.favicon} fallbackLetter={bookmark.displayName} size="sm" />
|
||
<div>
|
||
<div className="font-medium text-black dark:text-white">{bookmark.displayName}</div>
|
||
<div className="text-xs text-black/40 dark:text-white/40">{bookmark.hostname}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3 text-black/60 dark:text-white/60">{bookmark.category ?? "–"}</td>
|
||
<td className="px-4 py-3 text-black/60 dark:text-white/60">
|
||
{bookmark.description ?? "–"}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<a
|
||
href={bookmark.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-black/60 underline decoration-black/20 hover:text-black dark:text-white/60 dark:decoration-white/20 dark:hover:text-white"
|
||
>
|
||
öffnen
|
||
</a>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center justify-end gap-2">
|
||
<Button size="icon" onClick={() => setEditing(true)} title="Bearbeiten" aria-label="Bearbeiten"><FontAwesomeIcon icon={faPen} /></Button>
|
||
<Button size="icon" variant="danger" onClick={() => deleteMutation.mutate()} disabled={deleteMutation.isPending} title="Löschen" aria-label="Löschen"><FontAwesomeIcon icon={faTrash} /></Button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<th className="px-4 py-2 font-medium">
|
||
<button
|
||
onClick={() => onClick(column)}
|
||
className={`flex items-center gap-1 hover:text-black dark:hover:text-white ${
|
||
active ? "text-black dark:text-white" : ""
|
||
}`}
|
||
>
|
||
{label}
|
||
<span className="text-[10px]"><FontAwesomeIcon icon={active ? (direction === "asc" ? faSortUp : faSortDown) : faSort} /></span>
|
||
</button>
|
||
</th>
|
||
);
|
||
}
|
||
|
||
export function BookmarksPage() {
|
||
const { data: bookmarks, isLoading, isError } = useBookmarks();
|
||
const queryClient = useQueryClient();
|
||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||
const [localOrder, setLocalOrder] = useState<Bookmark[] | null>(null);
|
||
const [sortColumn, setSortColumn] = useState<SortColumn>(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<HTMLTableRowElement>) => setDraggedId(id);
|
||
}
|
||
|
||
function handleDragOver(targetId: string) {
|
||
return (e: DragEvent<HTMLTableRowElement>) => {
|
||
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<HTMLTableRowElement>) => {
|
||
e.preventDefault();
|
||
if (!dragEnabled) return;
|
||
setDraggedId(null);
|
||
const current = localOrder ?? bookmarks ?? [];
|
||
reorderMutation.mutate(current.map((b, index) => ({ id: b.id, order: index })));
|
||
};
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<AdminPageHeader
|
||
title="Lesezeichen"
|
||
description="Eigenständig von Diensten – erscheinen zusammen mit ihnen in der Suche, aber als eigene Favoriten-Gruppe auf der Startseite. Spaltenköpfe anklickbar zum Sortieren; Drag & Drop (⠿⠿) nur in der Standard-Reihenfolge."
|
||
/>
|
||
|
||
<AddBookmarkForm />
|
||
|
||
{sortColumn ? (
|
||
<div className="mb-3">
|
||
<Button size="sm" variant="ghost" onClick={() => setSortColumn(null)}>
|
||
<FontAwesomeIcon icon={faArrowLeft} /> Zur manuellen Reihenfolge (Drag & Drop) zurück
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
|
||
{isLoading ? (
|
||
<p className="text-sm text-black/40 dark:text-white/40">Lade Lesezeichen …</p>
|
||
) : isError ? (
|
||
<p className="text-sm text-red-500">Lesezeichen konnten nicht geladen werden.</p>
|
||
) : list.length > 0 ? (
|
||
<div className="overflow-hidden rounded-2xl border border-black/10 dark:border-white/10">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[720px] text-sm">
|
||
<thead>
|
||
<tr className="border-b border-black/10 bg-black/[0.02] text-left text-xs
|
||
uppercase tracking-wide text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white/40">
|
||
<th className="px-2 py-2" />
|
||
<th className="px-2 py-2" />
|
||
<SortableHeader label="Lesezeichen" column="displayName" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<SortableHeader label="Kategorie" column="category" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<th className="px-4 py-2 font-medium">Beschreibung</th>
|
||
<SortableHeader label="URL" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
|
||
<th className="px-4 py-2" />
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{list.map((bookmark) => (
|
||
<BookmarkRow
|
||
key={bookmark.id}
|
||
bookmark={bookmark}
|
||
draggable={dragEnabled}
|
||
isDragging={draggedId === bookmark.id}
|
||
onDragStart={handleDragStart(bookmark.id)}
|
||
onDragOver={handleDragOver(bookmark.id)}
|
||
onDrop={handleDrop()}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-black/40 dark:text-white/40">
|
||
Noch keine Lesezeichen angelegt. Füge oben eine URL hinzu.
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|