Files
evanpage/frontend/app/home-page-client.tsx
root 694b02e848 frontend: rebuild bookmark page with drag-and-drop, search, and theme system
- 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>
2026-05-02 22:53:17 +00:00

366 lines
11 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { BlurFade } from "@/components/magicui/blur-fade";
import { HomeDock } from "./home-dock";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { LoginForm } from "./login/login-form";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Globe,
ExternalLink,
Link2,
Search,
X,
} from "lucide-react";
import Link from "next/link";
import { hostnameOf, faviconFallback } from "@/lib/bookmarks";
interface Bookmark {
id: number;
title: string;
url: string;
description: string;
icon: string;
category: string;
}
const DEFAULT_CATEGORY = "默认";
const MAX_DELAY = 0.6;
function clampDelay(value: number) {
return Math.min(value, MAX_DELAY);
}
export function HomePageClient({
isAuthenticated,
isAdmin,
hasKeycloak,
bookmarks,
}: {
isAuthenticated: boolean;
isAdmin: boolean;
hasKeycloak: boolean;
bookmarks: Bookmark[];
}) {
const searchParams = useSearchParams();
const [loginOpen, setLoginOpen] = useState(false);
const [search, setSearch] = useState("");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const wantLogin = searchParams.get("login") === "1" && !isAuthenticated;
const [prevWantLogin, setPrevWantLogin] = useState(false);
if (wantLogin !== prevWantLogin) {
setPrevWantLogin(wantLogin);
if (wantLogin) setLoginOpen(true);
}
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target as HTMLElement | null;
if (
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable)
) {
return;
}
e.preventDefault();
searchInputRef.current?.focus();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
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 counts = useMemo(() => {
const m = new Map<string, number>();
for (const b of bookmarks) {
const cat = b.category || DEFAULT_CATEGORY;
m.set(cat, (m.get(cat) || 0) + 1);
}
return m;
}, [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]);
return (
<div className="relative flex min-h-screen flex-col items-center bg-gradient-to-br from-background via-background to-muted/40 px-4 pb-32 pt-4">
<div className="flex flex-col items-center justify-center pb-8 pt-12">
<BlurFade inView delay={0.1}>
<h1 className="mb-3 text-center text-4xl font-extrabold tracking-tight text-foreground">
EvanPage
</h1>
</BlurFade>
</div>
{bookmarks.length > 0 && (
<div className="w-full max-w-5xl space-y-5">
<BlurFade inView delay={0.3}>
<div className="flex items-center justify-between gap-2">
<div className="relative w-full max-w-sm">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchInputRef}
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-md p-1 text-muted-foreground hover:bg-muted"
>
<X className="size-3.5" />
</button>
)}
</div>
{isAdmin && (
<Link
href="/dashboard/bookmarks"
className="text-xs text-muted-foreground transition-colors hover:text-primary hover:underline"
>
</Link>
)}
</div>
</BlurFade>
<BlurFade inView delay={0.35}>
<div className="flex flex-wrap gap-2">
<CategoryChip
label="全部"
count={bookmarks.length}
active={activeCategory === null}
onClick={() => setActiveCategory(null)}
/>
{categories.map((c) => (
<CategoryChip
key={c}
label={c}
count={counts.get(c) || 0}
active={activeCategory === c}
onClick={() => setActiveCategory(c)}
/>
))}
</div>
</BlurFade>
{filtered.length === 0 ? (
<BlurFade inView delay={0.4}>
<div className="rounded-xl border border-dashed border-border bg-card/50 py-10 text-center text-sm text-muted-foreground backdrop-blur">
{search || activeCategory}
</div>
</BlurFade>
) : (
grouped.map(([cat, items], catIdx) => (
<BlurFade key={cat} inView delay={clampDelay(0.4 + catIdx * 0.05)}>
<div>
<h3 className="mb-3 px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{cat}
<span className="ml-2 text-muted-foreground/60">
{items.length}
</span>
</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((bm) => (
<BookmarkCard key={bm.id} bookmark={bm} />
))}
</div>
</div>
</BlurFade>
))
)}
</div>
)}
{bookmarks.length === 0 && (
<BlurFade inView delay={0.4}>
<div className="w-full max-w-md rounded-2xl border border-dashed border-border bg-card/60 py-10 text-center backdrop-blur">
<Link2 className="mx-auto mb-3 size-8 text-muted-foreground" />
<p className="mb-2 text-muted-foreground"></p>
{isAdmin && (
<Link
href="/dashboard/bookmarks"
className="text-sm font-medium text-primary hover:underline"
>
</Link>
)}
</div>
</BlurFade>
)}
<HomeDock
isAuthenticated={isAuthenticated}
isAdmin={isAdmin}
onLoginClick={() => !isAuthenticated && setLoginOpen(true)}
/>
<Dialog open={loginOpen} onOpenChange={setLoginOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-center"></DialogTitle>
</DialogHeader>
<LoginForm
hasKeycloak={hasKeycloak}
onSuccess={() => setLoginOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
);
}
function CategoryChip({
label,
count,
active,
onClick,
}: {
label: string;
count: number;
active: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`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 BookmarkCard({ bookmark }: { bookmark: Bookmark }) {
const host = hostnameOf(bookmark.url);
return (
<Card className="group transition-all hover:-translate-y-0.5 hover:shadow-md focus-within:ring-2 focus-within:ring-ring/40 active:translate-y-0">
<CardContent className="p-0">
<a
href={bookmark.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 outline-none"
>
<BookmarkIcon icon={bookmark.icon} url={bookmark.url} />
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
{bookmark.title}
</p>
{host && (
<p className="truncate text-[11px] text-muted-foreground/70">{host}</p>
)}
{bookmark.description && (
<p className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
{bookmark.description}
</p>
)}
</div>
<ExternalLink className="size-4 shrink-0 text-muted-foreground/50 transition-colors group-hover:text-primary" />
</a>
</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>
);
}