"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(null); const searchInputRef = useRef(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(); 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(); 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(); 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 (

EvanPage

{bookmarks.length > 0 && (
setSearch(e.target.value)} placeholder="搜索书签… 按 / 聚焦" className="bg-background/70 pl-9 backdrop-blur" aria-label="搜索书签" /> {search && ( )}
{isAdmin && ( 管理书签 → )}
setActiveCategory(null)} /> {categories.map((c) => ( setActiveCategory(c)} /> ))}
{filtered.length === 0 ? (
没有匹配「{search || activeCategory}」的书签
) : ( grouped.map(([cat, items], catIdx) => (

{cat} {items.length}

{items.map((bm) => ( ))}
)) )}
)} {bookmarks.length === 0 && (

还没有书签

{isAdmin && ( 添加第一个书签 → )}
)} !isAuthenticated && setLoginOpen(true)} /> 登录 setLoginOpen(false)} />
); } function CategoryChip({ label, count, active, onClick, }: { label: string; count: number; active: boolean; onClick: () => void; }) { return ( ); } function BookmarkCard({ bookmark }: { bookmark: Bookmark }) { const host = hostnameOf(bookmark.url); return (

{bookmark.title}

{host && (

{host}

)} {bookmark.description && (

{bookmark.description}

)}
); } 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); }} />
); }