Bugfixes (Dark Mode, Dropdown-Kontrast, IP-Suche), Lesezeichen-Ausbau (Farbe, Beschreibung, Favicon), Zuletzt-besucht, Spaeter-lesen, kombinierter Import-Export, Scanner-Reconciliation, Admin-Ueberarbeitung

This commit is contained in:
2026-07-20 07:52:38 +02:00
parent dc91a9aba9
commit 96cf29fef4
37 changed files with 1988 additions and 494 deletions

View File

@@ -1,4 +1,4 @@
import { useState, type DragEvent, type FormEvent } from "react";
import { useMemo, useState, type DragEvent, type FormEvent } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui";
import type { Bookmark } from "@launchpad/shared";
@@ -274,12 +274,14 @@ function EditForm({ bookmark, onDone }: { bookmark: Bookmark; onDone: () => void
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;
@@ -304,14 +306,17 @@ function BookmarkRow({
return (
<tr
draggable
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="cursor-grab select-none text-black/30 dark:text-white/30" aria-hidden>
<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>
@@ -361,11 +366,44 @@ function BookmarkRow({
);
}
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]">{active ? (direction === "asc" ? "▲" : "▼") : "⇅"}</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,
@@ -376,7 +414,38 @@ export function BookmarksPage() {
onError: () => setLocalOrder(null),
});
const list = localOrder ?? bookmarks ?? [];
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);
@@ -385,7 +454,7 @@ export function BookmarksPage() {
function handleDragOver(targetId: string) {
return (e: DragEvent<HTMLTableRowElement>) => {
e.preventDefault();
if (!draggedId || draggedId === targetId) return;
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);
@@ -400,6 +469,7 @@ export function BookmarksPage() {
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 })));
@@ -410,11 +480,19 @@ export function BookmarksPage() {
<div>
<AdminPageHeader
title="Lesezeichen"
description="Eigenständig von Diensten erscheinen zusammen mit ihnen in der Suche, aber als eigene Favoriten-Gruppe auf der Startseite. Per Drag & Drop sortierbar."
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)}>
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 ? (
@@ -428,10 +506,10 @@ export function BookmarksPage() {
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" />
<th className="px-4 py-2 font-medium">Lesezeichen</th>
<th className="px-4 py-2 font-medium">Kategorie</th>
<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>
<th className="px-4 py-2 font-medium">URL</th>
<SortableHeader label="URL" column="hostname" activeColumn={sortColumn} direction={sortDirection} onClick={handleHeaderClick} />
<th className="px-4 py-2" />
</tr>
</thead>
@@ -440,6 +518,7 @@ export function BookmarksPage() {
<BookmarkRow
key={bookmark.id}
bookmark={bookmark}
draggable={dragEnabled}
isDragging={draggedId === bookmark.id}
onDragStart={handleDragStart(bookmark.id)}
onDragOver={handleDragOver(bookmark.id)}