diff --git a/frontend/app/(main)/dashboard/bookmarks/bookmark-manager.tsx b/frontend/app/(main)/dashboard/bookmarks/bookmark-manager.tsx index 8a733b3..00ec100 100644 --- a/frontend/app/(main)/dashboard/bookmarks/bookmark-manager.tsx +++ b/frontend/app/(main)/dashboard/bookmarks/bookmark-manager.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +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, @@ -10,14 +11,60 @@ import { 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; @@ -29,31 +76,56 @@ interface Bookmark { 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 [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); - const [dialogOpen, setDialogOpen] = useState(false); - const [editing, setEditing] = useState(null); - 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 [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() { - setLoading(true); try { const res = await fetch("/api/proxy/bookmarks"); - if (!res.ok) throw new Error("failed to fetch"); + if (!res.ok) throw new Error("failed"); const data = await res.json(); setBookmarks(data.bookmarks || []); - setCategories(data.categories || []); } catch { + toast.error("加载书签失败"); setBookmarks([]); - setCategories([]); } finally { setLoading(false); } @@ -63,39 +135,934 @@ export function BookmarkManager() { fetchBookmarks(); }, []); - function openAdd() { + 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); - setTitle(""); - setUrl(""); - setDescription(""); - setIcon(""); - setCategory(""); - setSortOrder(0); - setDialogOpen(true); + setEditorOpen(true); } function openEdit(bm: Bookmark) { setEditing(bm); - setTitle(bm.title); - setUrl(bm.url); - setDescription(bm.description); - setIcon(bm.icon); - setCategory(bm.category); - setSortOrder(bm.sortOrder); - setDialogOpen(true); + 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() { - const payload = { - title, - url, - description, - icon, - category: category || "默认", - sortOrder, - }; - + 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", @@ -107,215 +1074,322 @@ export function BookmarkManager() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); - - if (!res.ok) throw new Error("save failed"); - setDialogOpen(false); - fetchBookmarks(); - } catch { - alert("保存失败,请检查输入"); + if (!res.ok) { + toast.error("保存失败"); + return; + } + toast.success(editing ? "已更新" : "已添加"); + onSaved(); + } finally { + setSaving(false); } } - async function handleDelete(id: number) { - if (!confirm("确定要删除这个书签吗?")) return; - try { - const res = await fetch(`/api/proxy/bookmarks/${id}`, { - method: "DELETE", - }); - if (!res.ok) throw new Error("delete failed"); - fetchBookmarks(); - } catch { - alert("删除失败"); - } - } - - const grouped = bookmarks.reduce>((acc, bm) => { - const cat = bm.category || "默认"; - if (!acc[cat]) acc[cat] = []; - acc[cat].push(bm); - return acc; - }, {}); - - if (loading) { - return

加载中...

; - } - return ( -
-
-

- 共 {bookmarks.length} 个书签 -

- -
- - {bookmarks.length === 0 ? ( -
- -

还没有书签

- -
- ) : ( - Object.entries(grouped).map(([cat, items]) => ( -
-

- {cat} -

-
- {items.map((bm) => ( - - -
- {bm.icon ? ( - { - (e.target as HTMLImageElement).style.display = - "none"; - }} - /> - ) : ( - - )} -
-
- - {bm.title} - - - {bm.description && ( -

- {bm.description} -

- )} -
-
- - -
-
-
- ))} -
-
- )) - )} - - - - - - {editing ? "编辑书签" : "添加书签"} - - -
-
- - setTitle(e.target.value)} - placeholder="例如:GitHub" - /> -
-
- + + + + {editing ? "编辑书签" : "添加书签"} + +
+
+ +
setUrl(e.target.value)} placeholder="https://github.com" /> +
+

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

+
+
+ + setTitle(e.target.value)} + placeholder="GitHub" + /> +
+
+ + setDescription(e.target.value)} + placeholder="简短描述" + /> +
+
- + setDescription(e.target.value)} - placeholder="简短描述" + value={category} + onChange={(e) => setCategory(e.target.value)} + placeholder={DEFAULT_CATEGORY} + list="bookmark-cats" /> -
-
-
- - setCategory(e.target.value)} - placeholder="默认" - list="categories" - /> - - {categories.map((c) => ( - -
-
- - - setSortOrder(Number(e.target.value)) - } - /> -
+ + {categories.map((c) => ( +
- + setIcon(e.target.value)} - placeholder="https://example.com/favicon.ico" + type="number" + value={sortOrder} + onChange={(e) => 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 已就绪 + + )} +
+