Files
evanpage/frontend/app/(main)/dashboard/bookmarks/bookmark-manager.tsx
root 832512469a backend: expand bookmark API with bulk ops and metadata fetcher
- bulk create/delete/move, reorder, rename-category endpoints
- /bookmarks/meta with SSRF-safe fetcher (blocks private/loopback IPs,
  8s timeout, 1 MiB body cap)

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

322 lines
9.8 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, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Card, CardContent } from "@/components/ui/card";
import {
Pencil,
Trash2,
Plus,
ExternalLink,
Globe,
} from "lucide-react";
interface Bookmark {
id: number;
title: string;
url: string;
description: string;
icon: string;
category: string;
sortOrder: number;
}
export function BookmarkManager() {
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Bookmark | null>(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);
async function fetchBookmarks() {
setLoading(true);
try {
const res = await fetch("/api/proxy/bookmarks");
if (!res.ok) throw new Error("failed to fetch");
const data = await res.json();
setBookmarks(data.bookmarks || []);
setCategories(data.categories || []);
} catch {
setBookmarks([]);
setCategories([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchBookmarks();
}, []);
function openAdd() {
setEditing(null);
setTitle("");
setUrl("");
setDescription("");
setIcon("");
setCategory("");
setSortOrder(0);
setDialogOpen(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);
}
async function handleSave() {
const payload = {
title,
url,
description,
icon,
category: category || "默认",
sortOrder,
};
try {
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) throw new Error("save failed");
setDialogOpen(false);
fetchBookmarks();
} catch {
alert("保存失败,请检查输入");
}
}
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<Record<string, Bookmark[]>>((acc, bm) => {
const cat = bm.category || "默认";
if (!acc[cat]) acc[cat] = [];
acc[cat].push(bm);
return acc;
}, {});
if (loading) {
return <p className="text-muted-foreground">...</p>;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{bookmarks.length}
</p>
<Button size="sm" onClick={openAdd}>
<Plus className="mr-1 size-4" />
</Button>
</div>
{bookmarks.length === 0 ? (
<div className="rounded-xl border border-dashed py-12 text-center">
<Globe className="mx-auto mb-3 size-8 text-muted-foreground" />
<p className="text-muted-foreground"></p>
<Button variant="link" size="sm" onClick={openAdd}>
</Button>
</div>
) : (
Object.entries(grouped).map(([cat, items]) => (
<div key={cat}>
<h2 className="mb-3 text-sm font-semibold text-muted-foreground uppercase tracking-wide">
{cat}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((bm) => (
<Card key={bm.id} className="group relative">
<CardContent className="flex items-start gap-3 p-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted">
{bm.icon ? (
<img
src={bm.icon}
alt=""
className="size-5 object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display =
"none";
}}
/>
) : (
<Globe className="size-5 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<a
href={bm.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 font-medium hover:underline"
>
<span className="truncate">{bm.title}</span>
<ExternalLink className="size-3 shrink-0 text-muted-foreground" />
</a>
{bm.description && (
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{bm.description}
</p>
)}
</div>
<div className="flex shrink-0 gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="ghost"
size="icon-xs"
onClick={() => openEdit(bm)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={() => handleDelete(bm.id)}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
))
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="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>
<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={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://github.com"
/>
</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="默认"
list="categories"
/>
<datalist id="categories">
{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="https://example.com/favicon.ico"
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(false)}
>
</Button>
<Button size="sm" onClick={handleSave}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}