frontend: add bookmark management and homepage navigation

Admin-only /bookmarks page for managing entries; homepage now renders
public bookmarks as a category-grouped navigation grid (empty state
links admin to the manager). Dashboard gains a recent-bookmarks card,
dock and main layout get a bookmark entry for admins, and the
middleware protects /bookmarks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-03 01:51:55 +08:00
parent ffecc9451d
commit 37cecaa1ce
8 changed files with 625 additions and 42 deletions

View File

@@ -1,13 +1,45 @@
import { auth } from "@/auth";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Globe, ExternalLink } from "lucide-react";
import Link from "next/link";
const SERVER_API_URL = process.env.SERVER_API_URL || "http://backend:8080";
interface Bookmark {
id: number;
title: string;
url: string;
description: string;
icon: string;
category: string;
}
async function fetchPublicBookmarks(): Promise<Bookmark[]> {
try {
const res = await fetch(`${SERVER_API_URL}/api/bookmarks/public`, {
next: { revalidate: 0 },
});
if (!res.ok) return [];
const data = await res.json();
return data.bookmarks?.slice(0, 6) || [];
} catch {
return [];
}
}
export default async function DashboardPage() {
const session = await auth();
const user = session?.user as any;
const isAdmin = user?.role === "admin";
const bookmarks = await fetchPublicBookmarks();
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold">{user?.name || user?.email}</h1>
<h1 className="text-2xl font-bold">
{user?.name || user?.email}
</h1>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle></CardTitle>
@@ -27,6 +59,54 @@ export default async function DashboardPage() {
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle></CardTitle>
{isAdmin && (
<Link
href="/bookmarks"
className="text-sm text-primary hover:underline"
>
</Link>
)}
</CardHeader>
<CardContent>
{bookmarks.length === 0 ? (
<p className="text-sm text-muted-foreground">
</p>
) : (
<div className="space-y-2">
{bookmarks.map((bm) => (
<a
key={bm.id}
href={bm.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg p-2 text-sm transition-colors hover:bg-muted"
>
<div className="flex size-6 shrink-0 items-center justify-center">
{bm.icon ? (
<img
src={bm.icon}
alt=""
className="size-4 object-contain"
/>
) : (
<Globe className="size-4 text-muted-foreground" />
)}
</div>
<span className="truncate font-medium">{bm.title}</span>
<ExternalLink className="ml-auto size-3 shrink-0 text-muted-foreground" />
</a>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -22,6 +22,11 @@ export default async function MainLayout({
<Link href="/dashboard" className="text-sm hover:underline">
</Link>
{user?.role === "admin" && (
<Link href="/bookmarks" className="text-sm hover:underline">
</Link>
)}
<form
action={async () => {
"use server";

View File

@@ -0,0 +1,321 @@
"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>
);
}

View File

@@ -0,0 +1,22 @@
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { BookmarkManager } from "./bookmark-manager";
export default async function BookmarksPage() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
const role = (session.user as any)?.role;
if (role !== "admin") {
redirect("/unauthorized");
}
return (
<div>
<h1 className="mb-6 text-2xl font-bold"></h1>
<BookmarkManager />
</div>
);
}

View File

@@ -2,13 +2,15 @@
import { Dock, DockIcon } from "@/components/ui/dock";
import { signOut } from "next-auth/react";
import { Home, User, LogOut, Download, BookOpen, LayoutDashboard } from "lucide-react";
import { Home, User, LogOut, Download, BookOpen, LayoutDashboard, Bookmark } from "lucide-react";
export function HomeDock({
isAuthenticated,
isAdmin,
onLoginClick,
}: {
isAuthenticated: boolean;
isAdmin: boolean;
onLoginClick?: () => void;
}) {
return (
@@ -46,6 +48,13 @@ export function HomeDock({
</a>
</DockIcon>
)}
{isAdmin && (
<DockIcon>
<a href="/bookmarks" aria-label="书签">
<Bookmark className="h-5 w-5 text-slate-700" />
</a>
</DockIcon>
)}
<DockIcon>
{isAuthenticated ? (
<button

View File

@@ -11,15 +11,31 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { LoginForm } from "./login/login-form";
import { Card, CardContent } from "@/components/ui/card";
import { Globe, ExternalLink, Link2 } from "lucide-react";
import Link from "next/link";
interface Bookmark {
id: number;
title: string;
url: string;
description: string;
icon: string;
category: string;
}
export function HomePageClient({
isAuthenticated,
isAdmin,
healthText,
hasKeycloak,
bookmarks,
}: {
isAuthenticated: boolean;
isAdmin: boolean;
healthText: string;
hasKeycloak: boolean;
bookmarks: Bookmark[];
}) {
const searchParams = useSearchParams();
const [loginOpen, setLoginOpen] = useState(false);
@@ -30,8 +46,16 @@ export function HomePageClient({
}
}, [searchParams, isAuthenticated]);
const grouped = bookmarks.reduce<Record<string, Bookmark[]>>((acc, bm) => {
const cat = bm.category || "默认";
if (!acc[cat]) acc[cat] = [];
acc[cat].push(bm);
return acc;
}, {});
return (
<div className="relative flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4 pb-24">
<div className="relative flex min-h-screen flex-col items-center bg-gradient-to-br from-slate-50 to-slate-100 p-4 pb-32">
<div className="flex flex-1 flex-col items-center justify-center py-12">
<BlurFade inView delay={0.1}>
<h1 className="mb-4 text-center text-4xl font-extrabold tracking-tight text-slate-900">
EvanPage
@@ -40,7 +64,7 @@ export function HomePageClient({
<BlurFade inView delay={0.2}>
<p className="mb-8 max-w-md text-center text-lg text-slate-600">
</p>
</BlurFade>
@@ -54,9 +78,102 @@ export function HomePageClient({
</p>
</div>
</BlurFade>
</div>
{bookmarks.length > 0 && (
<div className="w-full max-w-4xl space-y-6">
<BlurFade inView delay={0.4}>
<div className="flex items-center justify-between px-1">
<h2 className="text-lg font-semibold text-slate-800">
</h2>
{isAdmin && (
<Link
href="/bookmarks"
className="text-sm text-slate-500 hover:text-slate-800 hover:underline"
>
</Link>
)}
</div>
</BlurFade>
{Object.entries(grouped).map(([cat, items], catIdx) => (
<BlurFade key={cat} inView delay={0.4 + catIdx * 0.1}>
<div>
<h3 className="mb-3 px-1 text-xs font-semibold uppercase tracking-wide text-slate-400">
{cat}
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((bm) => (
<Card
key={bm.id}
className="group transition-shadow hover:shadow-md"
>
<CardContent className="p-0">
<a
href={bm.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-4"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-slate-100">
{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-slate-400" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-slate-800">
{bm.title}
</p>
{bm.description && (
<p className="mt-0.5 line-clamp-1 text-xs text-slate-400">
{bm.description}
</p>
)}
</div>
<ExternalLink className="size-4 shrink-0 text-slate-300 transition-colors group-hover:text-slate-500" />
</a>
</CardContent>
</Card>
))}
</div>
</div>
</BlurFade>
))}
</div>
)}
{bookmarks.length === 0 && (
<BlurFade inView delay={0.4}>
<div className="w-full max-w-md rounded-2xl border border-dashed bg-white/60 py-10 text-center backdrop-blur">
<Link2 className="mx-auto mb-3 size-8 text-slate-300" />
<p className="mb-2 text-slate-500"></p>
{isAdmin && (
<Link
href="/bookmarks"
className="text-sm font-medium text-slate-800 hover:underline"
>
</Link>
)}
</div>
</BlurFade>
)}
<HomeDock
isAuthenticated={isAuthenticated}
isAdmin={isAdmin}
onLoginClick={() => !isAuthenticated && setLoginOpen(true)}
/>

View File

@@ -5,11 +5,35 @@ import { HomePageClient } from "./home-page-client";
const SERVER_API_URL = process.env.SERVER_API_URL || "http://backend:8080";
const hasKeycloak = !!process.env.AUTH_KEYCLOAK_ISSUER;
interface Bookmark {
id: number;
title: string;
url: string;
description: string;
icon: string;
category: string;
}
async function fetchPublicBookmarks(): Promise<Bookmark[]> {
try {
const res = await fetch(`${SERVER_API_URL}/api/bookmarks/public`, {
next: { revalidate: 0 },
});
if (!res.ok) return [];
const data = await res.json();
return data.bookmarks || [];
} catch {
return [];
}
}
export default async function HomePage() {
const session = await auth();
const isAuthenticated = !!session?.user;
const isAdmin = (session?.user as any)?.role === "admin";
let healthText = "无法连接到后端服务";
let bookmarks: Bookmark[] = [];
try {
const res = await fetch(`${SERVER_API_URL}/api/health`, {
@@ -20,16 +44,20 @@ export default async function HomePage() {
} else {
healthText = `后端异常: ${res.status}`;
}
} catch (err) {
} catch {
healthText = "后端连接失败";
}
bookmarks = await fetchPublicBookmarks();
return (
<Suspense>
<HomePageClient
isAuthenticated={isAuthenticated}
isAdmin={isAdmin}
healthText={healthText}
hasKeycloak={hasKeycloak}
bookmarks={bookmarks}
/>
</Suspense>
);

View File

@@ -11,6 +11,7 @@ export default auth((req) => {
nextUrl.pathname.startsWith("/bind-account");
const isProtected = nextUrl.pathname.startsWith("/dashboard") ||
nextUrl.pathname.startsWith("/bookmarks") ||
nextUrl.pathname.startsWith("/admin");
if (isLoggedIn && isAuthPage) {