Files
evanpage/frontend/app/(main)/dashboard/bookmarks/bookmark-manager.tsx
root 694b02e848 frontend: rebuild bookmark page with drag-and-drop, search, and theme system
- bookmark management with dnd-kit reordering, bulk edit, search,
  category filter/rename, and meta auto-fetch
- migrate /bookmarks → /dashboard/bookmarks under (main) layout
- homepage redesign with category grid, /-key search, dock tooltips
- theme toggle + use-theme, sonner toasts, alert-dialog/skeleton,
  visual refresh of auth pages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:53:17 +00:00

1396 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { BlurFade } from "@/components/magicui/blur-fade";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Pencil,
Trash2,
Plus,
ExternalLink,
Globe,
Search,
Sparkles,
Upload,
Download,
GripVertical,
Loader2,
X,
FolderEdit,
CheckSquare,
Check,
Library,
} from "lucide-react";
import { toast } from "sonner";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
rectSortingStrategy,
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
hostnameOf,
faviconFallback,
normalizeURL,
exportToJSON,
downloadFile,
parseImport,
} from "@/lib/bookmarks";
interface Bookmark {
id: number;
title: string;
url: string;
description: string;
icon: string;
category: string;
sortOrder: number;
}
const DEFAULT_CATEGORY = "默认";
const MAX_DELAY = 0.5;
function clampDelay(value: number) {
return Math.min(value, MAX_DELAY);
}
type ConfirmState = {
open: boolean;
title: string;
description?: string;
confirmText?: string;
destructive?: boolean;
onConfirm?: () => void | Promise<void>;
};
export function BookmarkManager() {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [editorOpen, setEditorOpen] = useState(false);
const [editing, setEditing] = useState<Bookmark | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [moveOpen, setMoveOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [confirmState, setConfirmState] = useState<ConfirmState>({
open: false,
title: "",
});
function askConfirm(opts: Omit<ConfirmState, "open">) {
setConfirmState({ ...opts, open: true });
}
function closeConfirm() {
setConfirmState((s) => ({ ...s, open: false }));
}
async function fetchBookmarks() {
try {
const res = await fetch("/api/proxy/bookmarks");
if (!res.ok) throw new Error("failed");
const data = await res.json();
setBookmarks(data.bookmarks || []);
} catch {
toast.error("加载书签失败");
setBookmarks([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchBookmarks();
}, []);
const categories = useMemo(() => {
const set = new Set<string>();
for (const b of bookmarks) set.add(b.category || DEFAULT_CATEGORY);
return Array.from(set).sort((a, b) => a.localeCompare(b, "zh"));
}, [bookmarks]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return bookmarks.filter((b) => {
if (activeCategory && (b.category || DEFAULT_CATEGORY) !== activeCategory) {
return false;
}
if (!q) return true;
return (
b.title.toLowerCase().includes(q) ||
b.url.toLowerCase().includes(q) ||
(b.description || "").toLowerCase().includes(q) ||
hostnameOf(b.url).toLowerCase().includes(q)
);
});
}, [bookmarks, search, activeCategory]);
const grouped = useMemo(() => {
const map = new Map<string, Bookmark[]>();
for (const b of filtered) {
const cat = b.category || DEFAULT_CATEGORY;
if (!map.has(cat)) map.set(cat, []);
map.get(cat)!.push(b);
}
return Array.from(map.entries()).sort(([a], [b]) =>
a.localeCompare(b, "zh"),
);
}, [filtered]);
const isFiltering = !!search.trim() || !!activeCategory;
function toggleSelect(id: number) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function selectAllVisible() {
setSelected(new Set(filtered.map((b) => b.id)));
}
function clearSelected() {
setSelected(new Set());
}
function openCreate() {
setEditing(null);
setEditorOpen(true);
}
function openEdit(bm: Bookmark) {
setEditing(bm);
setEditorOpen(true);
}
async function handleDelete(id: number) {
const target = bookmarks.find((b) => b.id === id);
askConfirm({
title: "删除书签",
description: target
? `确认删除「${target.title}」?此操作无法撤销。`
: "确认删除这个书签?",
confirmText: "删除",
destructive: true,
onConfirm: async () => {
const res = await fetch(`/api/proxy/bookmarks/${id}`, {
method: "DELETE",
});
if (!res.ok) {
toast.error("删除失败");
return;
}
toast.success("已删除");
fetchBookmarks();
},
});
}
async function handleBulkDelete() {
if (!selected.size) return;
askConfirm({
title: "批量删除",
description: `确认删除选中的 ${selected.size} 个书签?此操作无法撤销。`,
confirmText: "删除",
destructive: true,
onConfirm: async () => {
const res = await fetch("/api/proxy/bookmarks/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: Array.from(selected) }),
});
if (!res.ok) {
toast.error("批量删除失败");
return;
}
const data = await res.json();
toast.success(`已删除 ${data.deleted} 个书签`);
clearSelected();
fetchBookmarks();
},
});
}
async function handleBulkMove(category: string) {
if (!selected.size) return;
const res = await fetch("/api/proxy/bookmarks/bulk-move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: Array.from(selected), category }),
});
if (!res.ok) {
toast.error("移动失败");
return;
}
toast.success(`已移动到「${category}`);
setMoveOpen(false);
clearSelected();
fetchBookmarks();
}
async function handleRename(from: string, to: string) {
const target = to.trim();
if (!target || target === from) {
setRenameTarget(null);
return;
}
const res = await fetch("/api/proxy/bookmarks/rename-category", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from, to: target }),
});
if (!res.ok) {
toast.error("重命名失败");
return;
}
if (activeCategory === from) setActiveCategory(target);
toast.success("分类已重命名");
setRenameTarget(null);
fetchBookmarks();
}
async function persistOrder(next: Bookmark[]) {
const ids = next.map((b) => b.id);
const res = await fetch("/api/proxy/bookmarks/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order: ids }),
});
if (!res.ok) {
toast.error("排序保存失败");
fetchBookmarks();
}
}
function handleDragEnd(event: DragEndEvent, scope: Bookmark[]) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = scope.findIndex((b) => b.id === active.id);
const newIndex = scope.findIndex((b) => b.id === over.id);
if (oldIndex < 0 || newIndex < 0) return;
const reordered = arrayMove(scope, oldIndex, newIndex);
const idsByCat = new Set(reordered.map((b) => b.id));
const next = [
...reordered,
...bookmarks.filter((b) => !idsByCat.has(b.id)),
];
setBookmarks(next.map((b, i) => ({ ...b, sortOrder: i })));
persistOrder(next);
}
return (
<div className="space-y-6">
<BlurFade inView delay={0.05}>
<header className="flex flex-wrap items-end justify-between gap-3">
<div className="space-y-1.5">
<div className="inline-flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Library className="size-5" />
</div>
<h1 className="text-2xl font-bold tracking-tight sm:text-3xl">
</h1>
</div>
<p className="text-sm text-muted-foreground">
</p>
</div>
{!loading && bookmarks.length > 0 && (
<div className="flex flex-wrap gap-2 text-xs">
<StatPill
value={bookmarks.length}
label="书签"
/>
<StatPill value={categories.length} label="分类" />
{isFiltering && (
<StatPill
value={filtered.length}
label="筛选后"
emphasis
/>
)}
</div>
)}
</header>
</BlurFade>
<BlurFade inView delay={0.1}>
<div className="rounded-2xl border bg-card/60 p-3 backdrop-blur sm:p-4">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative w-full sm:max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索标题、链接或域名"
className="bg-background/70 pl-9 backdrop-blur"
aria-label="搜索书签"
/>
{search && (
<button
type="button"
onClick={() => setSearch("")}
aria-label="清空搜索"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground hover:bg-muted"
>
<X className="size-3.5" />
</button>
)}
</div>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
>
<Upload className="mr-1 size-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
downloadFile(
`bookmarks-${Date.now()}.json`,
exportToJSON(bookmarks),
)
}
>
<Download className="mr-1 size-4" />
</Button>
<Button size="sm" onClick={openCreate}>
<Plus className="mr-1 size-4" />
</Button>
</div>
</div>
{categories.length > 0 && (
<CategoryBar
categories={categories}
active={activeCategory}
counts={categoryCounts(bookmarks)}
total={bookmarks.length}
onSelect={setActiveCategory}
onRename={setRenameTarget}
/>
)}
</div>
</div>
</BlurFade>
{selected.size > 0 && (
<BulkBar
count={selected.size}
totalVisible={filtered.length}
onSelectAll={selectAllVisible}
onClear={clearSelected}
onMove={() => setMoveOpen(true)}
onDelete={handleBulkDelete}
/>
)}
{loading ? (
<BookmarksSkeleton />
) : bookmarks.length === 0 ? (
<BlurFade inView delay={0.2}>
<EmptyState onAdd={openCreate} onImport={() => setImportOpen(true)} />
</BlurFade>
) : filtered.length === 0 ? (
<BlurFade inView delay={0.2}>
<div className="rounded-xl border border-dashed border-border bg-card/50 py-10 text-center text-sm text-muted-foreground backdrop-blur">
</div>
</BlurFade>
) : (
<div className="space-y-6">
{grouped.map(([cat, items], catIdx) => (
<BlurFade key={cat} inView delay={clampDelay(0.2 + catIdx * 0.05)}>
<CategorySection
category={cat}
items={items}
selected={selected}
draggable={!isFiltering}
onToggleSelect={toggleSelect}
onEdit={openEdit}
onDelete={handleDelete}
onDragEnd={(ev) => handleDragEnd(ev, items)}
onRename={() => setRenameTarget(cat)}
/>
</BlurFade>
))}
</div>
)}
<BookmarkEditor
open={editorOpen}
onOpenChange={setEditorOpen}
editing={editing}
categories={categories}
onSaved={() => {
setEditorOpen(false);
fetchBookmarks();
}}
/>
<ImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImported={() => {
setImportOpen(false);
fetchBookmarks();
}}
/>
<MoveDialog
open={moveOpen}
onOpenChange={setMoveOpen}
categories={categories}
onMove={handleBulkMove}
/>
<RenameDialog
from={renameTarget}
onCancel={() => setRenameTarget(null)}
onSubmit={(to) => renameTarget && handleRename(renameTarget, to)}
/>
<ConfirmDialog
state={confirmState}
onClose={closeConfirm}
/>
</div>
);
}
function BookmarksSkeleton() {
return (
<div className="space-y-6">
{[0, 1].map((s) => (
<div key={s}>
<Skeleton className="mb-3 h-3 w-24" />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2].map((c) => (
<Card key={c}>
<CardContent className="flex items-start gap-3 p-3">
<Skeleton className="size-9 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
);
}
function ConfirmDialog({
state,
onClose,
}: {
state: ConfirmState;
onClose: () => void;
}) {
const [busy, setBusy] = useState(false);
async function handleConfirm() {
if (!state.onConfirm) {
onClose();
return;
}
setBusy(true);
try {
await state.onConfirm();
} finally {
setBusy(false);
onClose();
}
}
return (
<AlertDialog open={state.open} onOpenChange={(v) => !v && !busy && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
{state.description && (
<AlertDialogDescription>{state.description}</AlertDialogDescription>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<Button
type="button"
variant="outline"
size="sm"
onClick={onClose}
disabled={busy}
>
</Button>
<Button
type="button"
variant={state.destructive ? "destructive" : "default"}
size="sm"
onClick={handleConfirm}
disabled={busy}
>
{busy && <Loader2 className="mr-1 size-4 animate-spin" />}
{state.confirmText || "确认"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
function categoryCounts(bms: Bookmark[]) {
const m = new Map<string, number>();
for (const b of bms) {
const cat = b.category || DEFAULT_CATEGORY;
m.set(cat, (m.get(cat) || 0) + 1);
}
return m;
}
function CategoryBar({
categories,
active,
counts,
total,
onSelect,
onRename,
}: {
categories: string[];
active: string | null;
counts: Map<string, number>;
total: number;
onSelect: (c: string | null) => void;
onRename: (c: string) => void;
}) {
if (categories.length === 0) return null;
return (
<div className="flex flex-wrap gap-2">
<Chip
label="全部"
count={total}
active={active === null}
onClick={() => onSelect(null)}
/>
{categories.map((c) => (
<Chip
key={c}
label={c}
count={counts.get(c) || 0}
active={active === c}
onClick={() => onSelect(c)}
onLongClick={() => onRename(c)}
/>
))}
</div>
);
}
function Chip({
label,
count,
active,
onClick,
onLongClick,
}: {
label: string;
count: number;
active: boolean;
onClick: () => void;
onLongClick?: () => void;
}) {
return (
<button
onClick={onClick}
onContextMenu={(e) => {
if (!onLongClick) return;
e.preventDefault();
onLongClick();
}}
className={`group inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors ${
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card/60 text-foreground/70 hover:bg-muted hover:text-foreground"
}`}
>
<span>{label}</span>
<span
className={`rounded-full px-1.5 text-[10px] ${
active
? "bg-primary-foreground/20"
: "bg-muted text-muted-foreground"
}`}
>
{count}
</span>
</button>
);
}
function StatPill({
value,
label,
emphasis,
}: {
value: number;
label: string;
emphasis?: boolean;
}) {
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 backdrop-blur ${
emphasis
? "border-primary/30 bg-primary/10 text-primary"
: "border-border bg-card/60 text-muted-foreground"
}`}
>
<span
className={`font-semibold tabular-nums ${
emphasis ? "text-primary" : "text-foreground"
}`}
>
{value}
</span>
<span>{label}</span>
</span>
);
}
function BulkBar({
count,
totalVisible,
onSelectAll,
onClear,
onMove,
onDelete,
}: {
count: number;
totalVisible: number;
onSelectAll: () => void;
onClear: () => void;
onMove: () => void;
onDelete: () => void;
}) {
return (
<div className="sticky top-2 z-10 flex flex-wrap items-center gap-2 rounded-xl border bg-background/95 p-2 shadow-sm backdrop-blur">
<span className="px-2 text-sm font-medium"> {count}</span>
<Button variant="ghost" size="sm" onClick={onSelectAll}>
<CheckSquare className="mr-1 size-4" />
({totalVisible})
</Button>
<div className="ml-auto flex gap-2">
<Button variant="outline" size="sm" onClick={onMove}>
<FolderEdit className="mr-1 size-4" />
</Button>
<Button variant="outline" size="sm" onClick={onDelete}>
<Trash2 className="mr-1 size-4 text-destructive" />
</Button>
<Button variant="ghost" size="sm" onClick={onClear} aria-label="清空选择">
<X className="size-4" />
</Button>
</div>
</div>
);
}
function CategorySection({
category,
items,
selected,
draggable,
onToggleSelect,
onEdit,
onDelete,
onDragEnd,
onRename,
}: {
category: string;
items: Bookmark[];
selected: Set<number>;
draggable: boolean;
onToggleSelect: (id: number) => void;
onEdit: (bm: Bookmark) => void;
onDelete: (id: number) => void;
onDragEnd: (e: DragEndEvent) => void;
onRename: () => void;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
const ids = items.map((b) => b.id);
return (
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{category}
<span className="ml-2 text-muted-foreground/60">{items.length}</span>
</h2>
<button
onClick={onRename}
className="text-xs text-muted-foreground hover:text-foreground"
>
</button>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
<SortableContext items={ids} strategy={rectSortingStrategy}>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((bm) => (
<SortableBookmarkCard
key={bm.id}
bookmark={bm}
selected={selected.has(bm.id)}
draggable={draggable}
onToggleSelect={() => onToggleSelect(bm.id)}
onEdit={() => onEdit(bm)}
onDelete={() => onDelete(bm.id)}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
);
}
function SortableBookmarkCard({
bookmark,
selected,
draggable,
onToggleSelect,
onEdit,
onDelete,
}: {
bookmark: Bookmark;
selected: boolean;
draggable: boolean;
onToggleSelect: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: bookmark.id, disabled: !draggable });
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : 0,
};
const host = hostnameOf(bookmark.url);
return (
<Card
ref={setNodeRef}
style={style}
className={`group relative transition-all ${
isDragging
? ""
: "hover:-translate-y-0.5 hover:shadow-md focus-within:ring-2 focus-within:ring-ring/40 active:translate-y-0"
} ${selected ? "ring-2 ring-primary" : ""}`}
>
<CardContent className="flex items-start gap-3 p-3">
<button
type="button"
aria-label={selected ? "取消选中" : "选中"}
aria-pressed={selected}
onClick={onToggleSelect}
className={`mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border transition-colors sm:size-4 ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/40 group-hover:opacity-100 sm:opacity-0"
}`}
>
{selected && <Check className="size-3" strokeWidth={3} />}
</button>
<BookmarkIcon icon={bookmark.icon} url={bookmark.url} />
<div className="min-w-0 flex-1">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 font-medium hover:underline"
>
<span className="truncate">{bookmark.title}</span>
<ExternalLink className="size-3 shrink-0 text-muted-foreground" />
</a>
{host && (
<p className="truncate text-[11px] text-muted-foreground/70">
{host}
</p>
)}
{bookmark.description && (
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{bookmark.description}
</p>
)}
</div>
<div className="flex shrink-0 flex-col gap-1">
<div className="flex gap-1 transition-opacity sm:opacity-0 sm:group-hover:opacity-100">
<Button
variant="ghost"
size="icon-sm"
onClick={onEdit}
aria-label="编辑"
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={onDelete}
aria-label="删除"
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
{draggable && (
<button
type="button"
{...attributes}
{...listeners}
className="flex size-7 cursor-grab items-center justify-center rounded text-muted-foreground/40 transition-colors hover:bg-muted hover:text-muted-foreground active:cursor-grabbing"
aria-label="拖动排序"
>
<GripVertical className="size-4" />
</button>
)}
</div>
</CardContent>
</Card>
);
}
function BookmarkIcon({ icon, url }: { icon: string; url: string }) {
const initial = icon || faviconFallback(url);
const [prevInitial, setPrevInitial] = useState(initial);
const [src, setSrc] = useState(initial);
const [failed, setFailed] = useState(false);
if (prevInitial !== initial) {
setPrevInitial(initial);
setSrc(initial);
setFailed(false);
}
if (!src || failed) {
return (
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Globe className="size-4 text-muted-foreground" />
</div>
);
}
return (
<div className="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted">
<img
src={src}
alt=""
width={20}
height={20}
loading="lazy"
className="size-5 object-contain"
onError={() => {
if (icon && src === icon) setSrc(faviconFallback(url));
else setFailed(true);
}}
/>
</div>
);
}
function EmptyState({
onAdd,
onImport,
}: {
onAdd: () => void;
onImport: () => void;
}) {
return (
<div className="rounded-2xl border border-dashed border-border bg-card/60 py-12 text-center backdrop-blur">
<Globe className="mx-auto mb-3 size-8 text-muted-foreground" />
<p className="mb-3 text-muted-foreground"></p>
<div className="flex justify-center gap-2">
<Button size="sm" onClick={onAdd}>
<Plus className="mr-1 size-4" />
</Button>
<Button variant="outline" size="sm" onClick={onImport}>
<Upload className="mr-1 size-4" />
</Button>
</div>
</div>
);
}
function BookmarkEditor({
open,
onOpenChange,
editing,
categories,
onSaved,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
editing: Bookmark | null;
categories: string[];
onSaved: () => void;
}) {
const [title, setTitle] = useState("");
const [url, setUrl] = useState("");
const [description, setDescription] = useState("");
const [icon, setIcon] = useState("");
const [category, setCategory] = useState("");
const [sortOrder, setSortOrder] = useState(0);
const [fetching, setFetching] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
if (editing) {
setTitle(editing.title);
setUrl(editing.url);
setDescription(editing.description);
setIcon(editing.icon);
setCategory(editing.category);
setSortOrder(editing.sortOrder);
} else {
setTitle("");
setUrl("");
setDescription("");
setIcon("");
setCategory("");
setSortOrder(0);
}
}, [open, editing]);
async function autoFetch() {
if (!url.trim()) {
toast.warning("先填写链接");
return;
}
setFetching(true);
try {
const res = await fetch("/api/proxy/bookmarks/meta", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: normalizeURL(url) }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
toast.error(data.error ? `抓取失败:${data.error}` : "抓取失败");
return;
}
const data = await res.json();
if (!title && data.title) setTitle(data.title);
if (!description && data.description) setDescription(data.description);
if (!icon && data.icon) setIcon(data.icon);
toast.success("已抓取页面信息");
} catch (err) {
toast.error(`抓取失败:${err instanceof Error ? err.message : "网络错误"}`);
} finally {
setFetching(false);
}
}
async function handleSave() {
if (!title.trim() || !url.trim()) {
toast.warning("标题和链接必填");
return;
}
setSaving(true);
try {
const payload = {
title: title.trim(),
url: normalizeURL(url),
description: description.trim(),
icon: icon.trim(),
category: category.trim() || DEFAULT_CATEGORY,
sortOrder,
};
const res = editing
? await fetch(`/api/proxy/bookmarks/${editing.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
: await fetch("/api/proxy/bookmarks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
toast.error("保存失败");
return;
}
toast.success(editing ? "已更新" : "已添加");
onSaved();
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>{editing ? "编辑书签" : "添加书签"}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="mb-1 block text-sm font-medium"> *</label>
<div className="flex gap-2">
<Input
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://github.com"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={autoFetch}
disabled={fetching}
>
{fetching ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Sparkles className="size-4" />
)}
<span className="ml-1 hidden sm:inline"></span>
</Button>
</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium"> *</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="GitHub"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium"></label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="简短描述"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-sm font-medium"></label>
<Input
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder={DEFAULT_CATEGORY}
list="bookmark-cats"
/>
<datalist id="bookmark-cats">
{categories.map((c) => (
<option key={c} value={c} />
))}
</datalist>
</div>
<div>
<label className="mb-1 block text-sm font-medium"></label>
<Input
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value))}
/>
</div>
</div>
<div>
<label className="mb-1 block text-sm font-medium"> URL</label>
<Input
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="留空时自动从域名拉取 favicon"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={saving}
>
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="mr-1 size-4 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ImportDialog({
open,
onOpenChange,
onImported,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onImported: () => void;
}) {
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) setText("");
}, [open]);
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const content = await file.text();
setText(content);
}
async function handleImport() {
const items = parseImport(text);
if (items.length === 0) {
toast.warning("没有可导入的书签,请检查格式");
return;
}
setBusy(true);
try {
const res = await fetch("/api/proxy/bookmarks/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bookmarks: items }),
});
if (!res.ok) {
toast.error("导入失败");
return;
}
const data = await res.json();
toast.success(`已导入 ${data.created} 个书签`);
onImported();
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-sm text-muted-foreground">
HTML JSON
</p>
<div className="flex items-center gap-2">
<input
ref={fileRef}
type="file"
accept=".html,.htm,.json,.txt"
onChange={handleFile}
className="hidden"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
>
<Upload className="mr-1 size-4" />
</Button>
{text && (
<span className="text-xs text-muted-foreground">
{(text.length / 1024).toFixed(1)} KB
</span>
)}
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="或者直接粘贴 HTML / JSON 内容"
className="h-48 w-full rounded-md border bg-background p-2 text-xs font-mono"
/>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
disabled={busy}
>
</Button>
<Button size="sm" onClick={handleImport} disabled={busy || !text}>
{busy ? <Loader2 className="mr-1 size-4 animate-spin" /> : null}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function MoveDialog({
open,
onOpenChange,
categories,
onMove,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
categories: string[];
onMove: (cat: string) => void;
}) {
const [value, setValue] = useState("");
const [prevOpen, setPrevOpen] = useState(open);
if (prevOpen !== open) {
setPrevOpen(open);
if (!open) setValue("");
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="flex flex-wrap gap-2">
{categories.map((c) => (
<button
key={c}
onClick={() => onMove(c)}
className="rounded-full border px-3 py-1 text-xs hover:bg-muted"
>
{c}
</button>
))}
</div>
<div className="flex gap-2">
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="或输入新分类名"
onKeyDown={(e) => e.key === "Enter" && value && onMove(value.trim())}
/>
<Button
size="sm"
onClick={() => value.trim() && onMove(value.trim())}
disabled={!value.trim()}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function RenameDialog({
from,
onCancel,
onSubmit,
}: {
from: string | null;
onCancel: () => void;
onSubmit: (to: string) => void;
}) {
const [value, setValue] = useState(from || "");
const [prevFrom, setPrevFrom] = useState(from);
if (prevFrom !== from) {
setPrevFrom(from);
setValue(from || "");
}
return (
<Dialog open={!!from} onOpenChange={(v) => !v && onCancel()}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="py-2">
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSubmit(value)}
placeholder="新分类名"
/>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onCancel}>
</Button>
<Button size="sm" onClick={() => onSubmit(value)} disabled={!value.trim()}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}