"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; }; export function BookmarkManager() { const [bookmarks, setBookmarks] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [activeCategory, setActiveCategory] = useState(null); const [selected, setSelected] = useState>(new Set()); const [editorOpen, setEditorOpen] = useState(false); const [editing, setEditing] = useState(null); const [importOpen, setImportOpen] = useState(false); const [moveOpen, setMoveOpen] = useState(false); const [renameTarget, setRenameTarget] = useState(null); const [confirmState, setConfirmState] = useState({ open: false, title: "", }); function askConfirm(opts: Omit) { 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(); 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(); 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 (

书签管理

添加、整理与导入导出公共导航书签

{!loading && bookmarks.length > 0 && (
{isFiltering && ( )}
)}
setSearch(e.target.value)} placeholder="搜索标题、链接或域名" className="bg-background/70 pl-9 backdrop-blur" aria-label="搜索书签" /> {search && ( )}
{categories.length > 0 && ( )}
{selected.size > 0 && ( setMoveOpen(true)} onDelete={handleBulkDelete} /> )} {loading ? ( ) : bookmarks.length === 0 ? ( setImportOpen(true)} /> ) : filtered.length === 0 ? (
没有匹配的书签
) : (
{grouped.map(([cat, items], catIdx) => ( handleDragEnd(ev, items)} onRename={() => setRenameTarget(cat)} /> ))}
)} { setEditorOpen(false); fetchBookmarks(); }} /> { setImportOpen(false); fetchBookmarks(); }} /> setRenameTarget(null)} onSubmit={(to) => renameTarget && handleRename(renameTarget, to)} />
); } function BookmarksSkeleton() { return (
{[0, 1].map((s) => (
{[0, 1, 2].map((c) => (
))}
))}
); } 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 ( !v && !busy && onClose()}> {state.title} {state.description && ( {state.description} )} ); } function categoryCounts(bms: Bookmark[]) { const m = new Map(); 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; total: number; onSelect: (c: string | null) => void; onRename: (c: string) => void; }) { if (categories.length === 0) return null; return (
onSelect(null)} /> {categories.map((c) => ( onSelect(c)} onLongClick={() => onRename(c)} /> ))}
); } function Chip({ label, count, active, onClick, onLongClick, }: { label: string; count: number; active: boolean; onClick: () => void; onLongClick?: () => void; }) { return ( ); } function StatPill({ value, label, emphasis, }: { value: number; label: string; emphasis?: boolean; }) { return ( {value} {label} ); } function BulkBar({ count, totalVisible, onSelectAll, onClear, onMove, onDelete, }: { count: number; totalVisible: number; onSelectAll: () => void; onClear: () => void; onMove: () => void; onDelete: () => void; }) { return (
已选 {count}
); } function CategorySection({ category, items, selected, draggable, onToggleSelect, onEdit, onDelete, onDragEnd, onRename, }: { category: string; items: Bookmark[]; selected: Set; 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 (

{category} {items.length}

{items.map((bm) => ( onToggleSelect(bm.id)} onEdit={() => onEdit(bm)} onDelete={() => onDelete(bm.id)} /> ))}
); } 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 (
{bookmark.title} {host && (

{host}

)} {bookmark.description && (

{bookmark.description}

)}
{draggable && ( )}
); } 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 (
); } return (
{ if (icon && src === icon) setSrc(faviconFallback(url)); else setFailed(true); }} />
); } function EmptyState({ onAdd, onImport, }: { onAdd: () => void; onImport: () => void; }) { return (

还没有书签

); } 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 ( {editing ? "编辑书签" : "添加书签"}
setUrl(e.target.value)} placeholder="https://github.com" />

填好链接后点击抓取,自动补全标题、描述、图标

setTitle(e.target.value)} placeholder="GitHub" />
setDescription(e.target.value)} placeholder="简短描述" />
setCategory(e.target.value)} placeholder={DEFAULT_CATEGORY} list="bookmark-cats" /> {categories.map((c) => (
setSortOrder(Number(e.target.value))} />
setIcon(e.target.value)} placeholder="留空时自动从域名拉取 favicon" />
); } 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(null); useEffect(() => { if (!open) setText(""); }, [open]); async function handleFile(e: React.ChangeEvent) { 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 ( 导入书签

支持浏览器导出的 HTML 书签文件,或粘贴 JSON 数组。

{text && ( {(text.length / 1024).toFixed(1)} KB 已就绪 )}