- 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>
1396 lines
39 KiB
TypeScript
1396 lines
39 KiB
TypeScript
"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>
|
||
);
|
||
}
|