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>
This commit is contained in:
root
2026-05-02 22:53:17 +00:00
parent 832512469a
commit 694b02e848
26 changed files with 2377 additions and 561 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { BookmarkManager } from "./bookmark-manager";
export const metadata: Metadata = {
title: "书签管理",
};
export default async function BookmarksPage() {
const session = await auth();
const role = (session?.user as { role?: string } | undefined)?.role;
if (role !== "admin") redirect("/unauthorized");
return <BookmarkManager />;
}

View File

@@ -1,8 +1,13 @@
import type { Metadata } from "next";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Globe, ExternalLink } from "lucide-react"; import { Globe, ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
export const metadata: Metadata = {
title: "仪表盘",
};
const SERVER_API_URL = process.env.SERVER_API_URL || "http://backend:8080"; const SERVER_API_URL = process.env.SERVER_API_URL || "http://backend:8080";
interface Bookmark { interface Bookmark {
@@ -65,7 +70,7 @@ export default async function DashboardPage() {
<CardTitle></CardTitle> <CardTitle></CardTitle>
{isAdmin && ( {isAdmin && (
<Link <Link
href="/bookmarks" href="/dashboard/bookmarks"
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
> >
@@ -92,6 +97,9 @@ export default async function DashboardPage() {
<img <img
src={bm.icon} src={bm.icon}
alt="" alt=""
width={16}
height={16}
loading="lazy"
className="size-4 object-contain" className="size-4 object-contain"
/> />
) : ( ) : (

View File

@@ -1,7 +1,5 @@
import Link from "next/link";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { Button } from "@/components/ui/button"; import { HomeDock } from "../home-dock";
import { signOut } from "@/auth";
export default async function MainLayout({ export default async function MainLayout({
children, children,
@@ -9,38 +7,15 @@ export default async function MainLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const session = await auth(); const session = await auth();
const user = session?.user as any; const user = session?.user as { role?: string } | undefined;
const isAdmin = user?.role === "admin";
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/40">
<header className="border-b bg-white"> <main className="mx-auto max-w-6xl px-4 pt-8 pb-32 sm:px-6 sm:pt-12 lg:px-8">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3"> {children}
<Link href="/" className="text-lg font-bold"> </main>
EvanPage <HomeDock isAuthenticated={!!session?.user} isAdmin={isAdmin} />
</Link>
<nav className="flex items-center gap-4">
<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";
await signOut({ redirectTo: "/login" });
}}
>
<Button variant="ghost" size="sm" type="submit">
退
</Button>
</form>
</nav>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-6">{children}</main>
</div> </div>
); );
} }

View File

@@ -52,7 +52,7 @@ function BindForm() {
<CardTitle className="text-center"></CardTitle> <CardTitle className="text-center"></CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-sm text-gray-500"> <p className="text-sm text-muted-foreground">
Keycloak {keycloakEmail || keycloakId} Keycloak {keycloakEmail || keycloakId}
</p> </p>
@@ -76,7 +76,7 @@ function BindForm() {
required required
/> />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">
</Button> </Button>
@@ -88,8 +88,8 @@ function BindForm() {
export default function BindAccountPage() { export default function BindAccountPage() {
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4"> <div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 p-4">
<Suspense fallback={<div>...</div>}> <Suspense fallback={<div className="text-sm text-muted-foreground">...</div>}>
<BindForm /> <BindForm />
</Suspense> </Suspense>
</div> </div>

View File

@@ -1,22 +0,0 @@
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

@@ -49,72 +49,72 @@
} }
:root { :root {
--background: oklch(1 0 0); --background: oklch(0.99 0.002 240);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.546 0.218 263);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0); --muted: oklch(0.965 0.005 240);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.5 0.02 240);
--accent: oklch(0.97 0 0); --accent: oklch(0.95 0.03 263);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.35 0.18 263);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.92 0.01 240);
--input: oklch(0.922 0 0); --input: oklch(0.92 0.01 240);
--ring: oklch(0.708 0 0); --ring: oklch(0.546 0.218 263);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.546 0.218 263);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.65 0.16 200);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.7 0.18 145);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.75 0.18 80);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.65 0.22 25);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.546 0.218 263);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.95 0.03 263);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.35 0.18 263);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.92 0.01 240);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.546 0.218 263);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.16 0.01 240);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.21 0.012 240);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.21 0.012 240);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.65 0.21 263);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.16 0.01 240);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.27 0.012 240);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.25 0.012 240);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.72 0.015 240);
--accent: oklch(0.269 0 0); --accent: oklch(0.3 0.05 263);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.85 0.1 263);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 14%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 18%);
--ring: oklch(0.556 0 0); --ring: oklch(0.65 0.21 263);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.65 0.21 263);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.7 0.16 200);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.72 0.18 145);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.78 0.18 80);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.7 0.22 25);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.21 0.012 240);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.65 0.21 263);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.16 0.01 240);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.3 0.05 263);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.85 0.1 263);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 14%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.65 0.21 263);
} }
@layer base { @layer base {
@@ -126,5 +126,19 @@
} }
html { html {
@apply font-sans; @apply font-sans;
color-scheme: light;
}
html.dark {
color-scheme: dark;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
} }
} }

View File

@@ -1,8 +1,33 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { Dock, DockIcon } from "@/components/ui/dock"; import { Dock, DockIcon } from "@/components/ui/dock";
import { ThemeToggle } from "@/components/theme-toggle";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { Home, User, LogOut, Download, BookOpen, LayoutDashboard, Bookmark } from "lucide-react"; import {
Home,
LogIn,
LogOut,
Download,
BookOpen,
LayoutDashboard,
Bookmark,
} from "lucide-react";
function useIsTouch() {
const [touch, setTouch] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia("(hover: none) and (pointer: coarse)");
const update = () => setTouch(mq.matches);
update();
mq.addEventListener?.("change", update);
return () => mq.removeEventListener?.("change", update);
}, []);
return touch;
}
export function HomeDock({ export function HomeDock({
isAuthenticated, isAuthenticated,
@@ -13,71 +38,168 @@ export function HomeDock({
isAdmin: boolean; isAdmin: boolean;
onLoginClick?: () => void; onLoginClick?: () => void;
}) { }) {
const isTouch = useIsTouch();
const pathname = usePathname() || "/";
const isActive = (path: string) =>
path === "/" ? pathname === "/" : pathname.startsWith(path);
return ( return (
<div className="fixed bottom-8 left-1/2 -translate-x-1/2"> <div
<Dock> className="fixed left-1/2 z-40 -translate-x-1/2"
style={{ bottom: "max(1.25rem, env(safe-area-inset-bottom))" }}
>
<Dock disableMagnification={isTouch}>
<DockIcon> <DockIcon>
<a href="/" aria-label="首页"> <NavLink href="/" label="首页" active={isActive("/")}>
<Home className="h-5 w-5 text-slate-700" /> <Home className="h-5 w-5" />
</a> </NavLink>
</DockIcon> </DockIcon>
<DockIcon> <DockIcon>
<a <ExternalNavLink href="https://file.liukersun.com" label="下载网站">
href="https://file.liukersun.com" <Download className="h-5 w-5" />
target="_blank" </ExternalNavLink>
rel="noopener noreferrer"
aria-label="下载网站"
>
<Download className="h-5 w-5 text-slate-700" />
</a>
</DockIcon> </DockIcon>
<DockIcon> <DockIcon>
<a <ExternalNavLink href="https://blog.liukersun.com" label="博客">
href="https://blog.liukersun.com" <BookOpen className="h-5 w-5" />
target="_blank" </ExternalNavLink>
rel="noopener noreferrer"
aria-label="博客"
>
<BookOpen className="h-5 w-5 text-slate-700" />
</a>
</DockIcon> </DockIcon>
{isAuthenticated && ( {isAuthenticated && (
<DockIcon> <DockIcon>
<a href="/dashboard" aria-label="仪表盘"> <NavLink
<LayoutDashboard className="h-5 w-5 text-slate-700" /> href="/dashboard"
</a> label="仪表盘"
active={pathname === "/dashboard"}
>
<LayoutDashboard className="h-5 w-5" />
</NavLink>
</DockIcon> </DockIcon>
)} )}
{isAdmin && ( {isAdmin && (
<DockIcon> <DockIcon>
<a href="/bookmarks" aria-label="书签"> <NavLink
<Bookmark className="h-5 w-5 text-slate-700" /> href="/dashboard/bookmarks"
</a> label="书签管理"
active={pathname.startsWith("/dashboard/bookmarks")}
>
<Bookmark className="h-5 w-5" />
</NavLink>
</DockIcon> </DockIcon>
)} )}
<DockIcon>
<DockTooltip label="主题">
<ThemeToggle />
</DockTooltip>
</DockIcon>
<DockIcon> <DockIcon>
{isAuthenticated ? ( {isAuthenticated ? (
<DockTooltip label="退出登录">
<button <button
onClick={async () => { type="button"
await signOut({ redirect: false }); onClick={() => signOut({ redirectTo: "/" })}
window.location.href = "/"; aria-label="退出登录"
}} className="flex h-full w-full items-center justify-center text-foreground/80 hover:text-foreground"
aria-label="退出"
className="flex h-full w-full items-center justify-center"
> >
<LogOut className="h-5 w-5 text-slate-700" /> <LogOut className="h-5 w-5" />
</button> </button>
</DockTooltip>
) : ( ) : (
<DockTooltip label="登录">
<button <button
type="button"
onClick={onLoginClick} onClick={onLoginClick}
aria-label="登录" aria-label="登录"
className="flex h-full w-full items-center justify-center" className="flex h-full w-full items-center justify-center text-foreground/80 hover:text-foreground"
> >
<User className="h-5 w-5 text-slate-700" /> <LogIn className="h-5 w-5" />
</button> </button>
</DockTooltip>
)} )}
</DockIcon> </DockIcon>
</Dock> </Dock>
</div> </div>
); );
} }
function DockTooltip({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="group relative flex h-full w-full items-center justify-center">
{children}
<TooltipBubble>{label}</TooltipBubble>
</div>
);
}
function TooltipBubble({ children }: { children: React.ReactNode }) {
return (
<span
role="tooltip"
className="pointer-events-none absolute -top-9 left-1/2 z-50 -translate-x-1/2 translate-y-1 whitespace-nowrap rounded-md border border-border bg-popover px-2 py-1 text-xs font-medium text-popover-foreground opacity-0 shadow-md transition-[opacity,transform] duration-150 group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
>
{children}
</span>
);
}
function NavLink({
href,
label,
active,
children,
}: {
href: string;
label: string;
active: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
aria-label={label}
aria-current={active ? "page" : undefined}
className={`group relative flex h-full w-full items-center justify-center transition-colors ${
active
? "text-primary"
: "text-foreground/80 hover:text-foreground"
}`}
>
{children}
{active && (
<span
aria-hidden
className="absolute -bottom-1 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-primary"
/>
)}
<TooltipBubble>{label}</TooltipBubble>
</Link>
);
}
function ExternalNavLink({
href,
label,
children,
}: {
href: string;
label: string;
children: React.ReactNode;
}) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="group relative flex h-full w-full items-center justify-center text-foreground/80 transition-colors hover:text-foreground"
>
{children}
<TooltipBubble>{label}</TooltipBubble>
</a>
);
}

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { BlurFade } from "@/components/magicui/blur-fade"; import { BlurFade } from "@/components/magicui/blur-fade";
import { HomeDock } from "./home-dock"; import { HomeDock } from "./home-dock";
@@ -12,8 +12,16 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { LoginForm } from "./login/login-form"; import { LoginForm } from "./login/login-form";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Globe, ExternalLink, Link2 } from "lucide-react"; import { Input } from "@/components/ui/input";
import {
Globe,
ExternalLink,
Link2,
Search,
X,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { hostnameOf, faviconFallback } from "@/lib/bookmarks";
interface Bookmark { interface Bookmark {
id: number; id: number;
@@ -24,73 +32,141 @@ interface Bookmark {
category: string; category: string;
} }
const DEFAULT_CATEGORY = "默认";
const MAX_DELAY = 0.6;
function clampDelay(value: number) {
return Math.min(value, MAX_DELAY);
}
export function HomePageClient({ export function HomePageClient({
isAuthenticated, isAuthenticated,
isAdmin, isAdmin,
healthText,
hasKeycloak, hasKeycloak,
bookmarks, bookmarks,
}: { }: {
isAuthenticated: boolean; isAuthenticated: boolean;
isAdmin: boolean; isAdmin: boolean;
healthText: string;
hasKeycloak: boolean; hasKeycloak: boolean;
bookmarks: Bookmark[]; bookmarks: Bookmark[];
}) { }) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [loginOpen, setLoginOpen] = useState(false); 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(() => { useEffect(() => {
if (searchParams.get("login") === "1" && !isAuthenticated) { function onKey(e: KeyboardEvent) {
setLoginOpen(true); 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;
} }
}, [searchParams, isAuthenticated]); e.preventDefault();
searchInputRef.current?.focus();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const grouped = bookmarks.reduce<Record<string, Bookmark[]>>((acc, bm) => { const categories = useMemo(() => {
const cat = bm.category || "默认"; const set = new Set<string>();
if (!acc[cat]) acc[cat] = []; for (const b of bookmarks) set.add(b.category || DEFAULT_CATEGORY);
acc[cat].push(bm); return Array.from(set).sort((a, b) => a.localeCompare(b, "zh"));
return acc; }, [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 ( return (
<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="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-1 flex-col items-center justify-center py-12"> <div className="flex flex-col items-center justify-center pb-8 pt-12">
<BlurFade inView delay={0.1}> <BlurFade inView delay={0.1}>
<h1 className="mb-4 text-center text-4xl font-extrabold tracking-tight text-slate-900"> <h1 className="mb-3 text-center text-4xl font-extrabold tracking-tight text-foreground">
EvanPage EvanPage
</h1> </h1>
</BlurFade> </BlurFade>
<BlurFade inView delay={0.2}>
<p className="mb-8 max-w-md text-center text-lg text-slate-600">
</p>
</BlurFade>
<BlurFade inView delay={0.3}>
<div className="rounded-2xl border bg-white/80 px-8 py-6 shadow-lg backdrop-blur">
<p className="text-center text-sm font-medium text-slate-500">
</p>
<p className="mt-2 text-center text-xl font-semibold text-slate-800">
{healthText}
</p>
</div>
</BlurFade>
</div> </div>
{bookmarks.length > 0 && ( {bookmarks.length > 0 && (
<div className="w-full max-w-4xl space-y-6"> <div className="w-full max-w-5xl space-y-5">
<BlurFade inView delay={0.4}> <BlurFade inView delay={0.3}>
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-slate-800"> <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" />
</h2> <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 && ( {isAdmin && (
<Link <Link
href="/bookmarks" href="/dashboard/bookmarks"
className="text-sm text-slate-500 hover:text-slate-800 hover:underline" className="text-xs text-muted-foreground transition-colors hover:text-primary hover:underline"
> >
</Link> </Link>
@@ -98,71 +174,63 @@ export function HomePageClient({
</div> </div>
</BlurFade> </BlurFade>
{Object.entries(grouped).map(([cat, items], catIdx) => ( <BlurFade inView delay={0.35}>
<BlurFade key={cat} inView delay={0.4 + catIdx * 0.1}> <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> <div>
<h3 className="mb-3 px-1 text-xs font-semibold uppercase tracking-wide text-slate-400"> <h3 className="mb-3 px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{cat} {cat}
<span className="ml-2 text-muted-foreground/60">
{items.length}
</span>
</h3> </h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((bm) => ( {items.map((bm) => (
<Card <BookmarkCard key={bm.id} bookmark={bm} />
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>
</div> </div>
</BlurFade> </BlurFade>
))} ))
)}
</div> </div>
)} )}
{bookmarks.length === 0 && ( {bookmarks.length === 0 && (
<BlurFade inView delay={0.4}> <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"> <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-slate-300" /> <Link2 className="mx-auto mb-3 size-8 text-muted-foreground" />
<p className="mb-2 text-slate-500"></p> <p className="mb-2 text-muted-foreground"></p>
{isAdmin && ( {isAdmin && (
<Link <Link
href="/bookmarks" href="/dashboard/bookmarks"
className="text-sm font-medium text-slate-800 hover:underline" className="text-sm font-medium text-primary hover:underline"
> >
</Link> </Link>
@@ -191,3 +259,107 @@ export function HomePageClient({
</div> </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>
);
}

View File

@@ -64,7 +64,7 @@ export default function InitPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 text-sm text-muted-foreground">
... ...
</div> </div>
); );
@@ -72,13 +72,13 @@ export default function InitPage() {
if (initialized) { if (initialized) {
return ( return (
<div className="flex min-h-screen items-center justify-center p-4"> <div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle className="text-center"></CardTitle> <CardTitle className="text-center"></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-center text-gray-500"> <p className="text-center text-sm text-muted-foreground">
</p> </p>
</CardContent> </CardContent>
@@ -88,13 +88,13 @@ export default function InitPage() {
} }
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4"> <div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 p-4">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle className="text-center"></CardTitle> <CardTitle className="text-center"></CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<p className="text-sm text-gray-500"> <p className="text-sm text-muted-foreground">
</p> </p>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -139,7 +139,7 @@ export default function InitPage() {
required required
/> />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">
</Button> </Button>

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -13,10 +14,36 @@ const geistMono = Geist_Mono({
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: {
description: "Generated by create next app", default: "EvanPage",
template: "%s · EvanPage",
},
description: "个人主页与导航",
icons: {
icon: "/favicon.ico",
},
}; };
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#fafbff" },
{ media: "(prefers-color-scheme: dark)", color: "#0e1117" },
],
};
const themeInitScript = `
(function() {
try {
var stored = localStorage.getItem('theme');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var resolved = stored === 'dark' || stored === 'light' ? stored : (prefersDark ? 'dark' : 'light');
if (resolved === 'dark') document.documentElement.classList.add('dark');
} catch (e) {}
})();
`;
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
@@ -24,10 +51,17 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html <html
lang="en" lang="zh-CN"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
suppressHydrationWarning
> >
<body className="min-h-full flex flex-col">{children}</body> <head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="min-h-full flex flex-col bg-background text-foreground">
{children}
<Toaster richColors position="top-right" />
</body>
</html> </html>
); );
} }

View File

@@ -21,7 +21,7 @@ export function LoginDialog({
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="rounded-lg bg-slate-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-slate-800"> <DialogTrigger className="rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
{children ?? "登录"} {children ?? "登录"}
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">

View File

@@ -66,7 +66,7 @@ export function LoginForm({
required required
/> />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full"> <Button type="submit" className="w-full">
</Button> </Button>
@@ -76,14 +76,15 @@ export function LoginForm({
<> <>
<div className="relative"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<span className="w-full border-t" /> <span className="w-full border-t border-border" />
</div> </div>
<div className="relative flex justify-center text-xs uppercase"> <div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-gray-500"></span> <span className="bg-popover px-2 text-muted-foreground"></span>
</div> </div>
</div> </div>
<Button <Button
type="button"
variant="outline" variant="outline"
className="w-full" className="w-full"
onClick={() => signIn("keycloak", { callbackUrl })} onClick={() => signIn("keycloak", { callbackUrl })}

View File

@@ -1,7 +1,6 @@
"use client"; import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { Suspense, useEffect } from "react"; import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -10,41 +9,42 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { LoginForm } from "./login-form"; import { LoginForm } from "./login-form";
export const metadata: Metadata = {
title: "登录",
};
const hasKeycloak = !!process.env.AUTH_KEYCLOAK_ISSUER; const hasKeycloak = !!process.env.AUTH_KEYCLOAK_ISSUER;
function LoginPageContent() { export default async function LoginPage({
const router = useRouter(); searchParams,
const searchParams = useSearchParams(); }: {
const callbackUrl = searchParams.get("callbackUrl"); searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
const error = searchParams.get("error"); }) {
const sp = await searchParams;
const callbackUrl = typeof sp.callbackUrl === "string" ? sp.callbackUrl : null;
const error = typeof sp.error === "string" ? sp.error : null;
useEffect(() => {
// If not an OAuth callback, redirect to home with login modal open
if (!callbackUrl && !error) { if (!callbackUrl && !error) {
router.replace("/?login=1"); redirect("/?login=1");
} }
}, [callbackUrl, error, router]);
return ( return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4"> <div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 p-4">
<Dialog defaultOpen> <Dialog defaultOpen>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-center"></DialogTitle> <DialogTitle className="text-center"></DialogTitle>
</DialogHeader> </DialogHeader>
<Suspense fallback={<div>...</div>}> {error && (
<LoginForm hasKeycloak={hasKeycloak} /> <p className="rounded-md border border-destructive/30 bg-destructive/10 p-2 text-center text-xs text-destructive">
,
</p>
)}
<Suspense fallback={<div className="text-sm text-muted-foreground">...</div>}>
<LoginForm hasKeycloak={hasKeycloak} callbackUrl={callbackUrl ?? undefined} />
</Suspense> </Suspense>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
); );
} }
export default function LoginPage() {
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center">...</div>}>
<LoginPageContent />
</Suspense>
);
}

View File

@@ -30,32 +30,16 @@ async function fetchPublicBookmarks(): Promise<Bookmark[]> {
export default async function HomePage() { export default async function HomePage() {
const session = await auth(); const session = await auth();
const isAuthenticated = !!session?.user; const isAuthenticated = !!session?.user;
const isAdmin = (session?.user as any)?.role === "admin"; const role = (session?.user as { role?: string } | undefined)?.role;
const isAdmin = role === "admin";
let healthText = "无法连接到后端服务"; const bookmarks = await fetchPublicBookmarks();
let bookmarks: Bookmark[] = [];
try {
const res = await fetch(`${SERVER_API_URL}/api/health`, {
cache: "no-store",
});
if (res.ok) {
healthText = await res.text();
} else {
healthText = `后端异常: ${res.status}`;
}
} catch {
healthText = "后端连接失败";
}
bookmarks = await fetchPublicBookmarks();
return ( return (
<Suspense> <Suspense>
<HomePageClient <HomePageClient
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
isAdmin={isAdmin} isAdmin={isAdmin}
healthText={healthText}
hasKeycloak={hasKeycloak} hasKeycloak={hasKeycloak}
bookmarks={bookmarks} bookmarks={bookmarks}
/> />

View File

@@ -1,18 +1,5 @@
"use client"; import { redirect } from "next/navigation";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function RegisterPage() { export default function RegisterPage() {
const router = useRouter(); redirect("/");
useEffect(() => {
router.replace("/");
}, [router]);
return (
<div className="flex min-h-screen items-center justify-center">
...
</div>
);
} }

View File

@@ -1,11 +1,21 @@
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "无权访问",
};
export default function UnauthorizedPage() { export default function UnauthorizedPage() {
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center p-4"> <div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-background via-background to-muted/40 p-4 text-center">
<h1 className="text-3xl font-bold">403</h1> <h1 className="text-5xl font-bold tracking-tight">403</h1>
<p className="mt-2 text-gray-500">访</p> <p className="mt-3 text-muted-foreground">访</p>
<a href="/dashboard" className="mt-4 text-blue-600 hover:underline"> <Link
href="/dashboard"
className="mt-6 text-sm font-medium text-primary underline-offset-4 hover:underline"
>
</a> </Link>
</div> </div>
); );
} }

View File

@@ -0,0 +1,26 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "@/lib/use-theme";
export function ThemeToggle({ className }: { className?: string }) {
const { theme, toggle, mounted } = useTheme();
return (
<button
type="button"
onClick={toggle}
aria-label={mounted ? (theme === "dark" ? "切换到浅色" : "切换到深色") : "切换主题"}
className={
className ??
"flex h-full w-full items-center justify-center text-foreground/80 hover:text-foreground"
}
>
{mounted && theme === "dark" ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</button>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import * as React from "react";
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
import { cn } from "@/lib/utils";
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger
data-slot="alert-dialog-trigger"
{...props}
/>
);
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/30 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: AlertDialogPrimitive.Popup.Props) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-3 rounded-xl bg-popover p-5 text-sm text-popover-foreground ring-1 ring-foreground/10 outline-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 sm:max-w-md",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-1.5", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"mt-2 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: AlertDialogPrimitive.Title.Props) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-base font-medium leading-none", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: AlertDialogPrimitive.Description.Props) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
);
}
function AlertDialogClose({ ...props }: AlertDialogPrimitive.Close.Props) {
return (
<AlertDialogPrimitive.Close data-slot="alert-dialog-close" {...props} />
);
}
export {
AlertDialog,
AlertDialogClose,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

View File

@@ -29,7 +29,7 @@ const DEFAULT_DISTANCE = 140
const DEFAULT_DISABLEMAGNIFICATION = false const DEFAULT_DISABLEMAGNIFICATION = false
const dockVariants = cva( const dockVariants = cva(
"supports-backdrop-blur:bg-white/10 supports-backdrop-blur:dark:bg-black/10 mx-auto mt-8 flex h-[58px] w-max items-center justify-center gap-2 rounded-2xl border p-2 backdrop-blur-md" "mx-auto flex h-[58px] w-max items-center justify-center gap-2 rounded-2xl border border-border bg-background/70 p-2 shadow-lg shadow-black/5 backdrop-blur-md supports-backdrop-blur:bg-background/60 dark:shadow-black/40"
) )
const Dock = React.forwardRef<HTMLDivElement, DockProps>( const Dock = React.forwardRef<HTMLDivElement, DockProps>(
@@ -139,8 +139,7 @@ const DockIcon = ({
ref={ref} ref={ref}
style={{ width: scaleSize, height: scaleSize, padding }} style={{ width: scaleSize, height: scaleSize, padding }}
className={cn( className={cn(
"flex aspect-square cursor-pointer items-center justify-center rounded-full", "flex aspect-square cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-muted",
disableMagnification && "hover:bg-muted-foreground transition-colors",
className className
)} )}
{...props} {...props}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };

143
frontend/lib/bookmarks.ts Normal file
View File

@@ -0,0 +1,143 @@
export interface BookmarkLite {
title: string;
url: string;
description?: string;
icon?: string;
category?: string;
sortOrder?: number;
}
export function hostnameOf(raw: string): string {
try {
const u = new URL(raw.includes("://") ? raw : `https://${raw}`);
return u.hostname.replace(/^www\./, "");
} catch {
return "";
}
}
export function faviconFallback(url: string, size = 64): string {
const host = hostnameOf(url);
if (!host) return "";
return `https://www.google.com/s2/favicons?domain=${host}&sz=${size}`;
}
export function normalizeURL(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
export function exportToJSON(bookmarks: BookmarkLite[]): string {
return JSON.stringify(
{
exportedAt: new Date().toISOString(),
bookmarks: bookmarks.map((b) => ({
title: b.title,
url: b.url,
description: b.description ?? "",
icon: b.icon ?? "",
category: b.category ?? "默认",
sortOrder: b.sortOrder ?? 0,
})),
},
null,
2,
);
}
export function downloadFile(filename: string, content: string, mime = "application/json") {
const blob = new Blob([content], { type: `${mime};charset=utf-8` });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
export function parseImport(text: string): BookmarkLite[] {
const trimmed = text.trim();
if (!trimmed) return [];
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
return parseJSON(trimmed);
}
if (trimmed.toLowerCase().includes("<!doctype netscape-bookmark") || trimmed.toLowerCase().includes("<dl")) {
return parseNetscapeHTML(trimmed);
}
return parseJSON(trimmed);
}
function parseJSON(raw: string): BookmarkLite[] {
try {
const parsed = JSON.parse(raw);
const list: unknown[] = Array.isArray(parsed)
? parsed
: Array.isArray((parsed as { bookmarks?: unknown[] })?.bookmarks)
? (parsed as { bookmarks: unknown[] }).bookmarks
: [];
return list
.map((item) => {
const i = item as Record<string, unknown>;
return {
title: String(i.title ?? i.name ?? "").trim(),
url: String(i.url ?? i.href ?? "").trim(),
description: i.description ? String(i.description) : "",
icon: i.icon ? String(i.icon) : "",
category: i.category ? String(i.category) : "默认",
sortOrder: typeof i.sortOrder === "number" ? i.sortOrder : 0,
};
})
.filter((b) => b.title && b.url);
} catch {
return [];
}
}
function parseNetscapeHTML(raw: string): BookmarkLite[] {
if (typeof DOMParser === "undefined") return [];
const doc = new DOMParser().parseFromString(raw, "text/html");
const out: BookmarkLite[] = [];
const walk = (node: Element, currentCategory: string) => {
const children = Array.from(node.children);
for (let i = 0; i < children.length; i++) {
const el = children[i];
const tag = el.tagName.toLowerCase();
if (tag === "dt") {
const inner = el.firstElementChild;
if (!inner) continue;
const innerTag = inner.tagName.toLowerCase();
if (innerTag === "a") {
const a = inner as HTMLAnchorElement;
const href = a.getAttribute("href") || "";
const title = (a.textContent || "").trim() || href;
const icon = a.getAttribute("icon") || "";
if (href && /^https?:\/\//i.test(href)) {
out.push({
title,
url: href,
description: "",
icon,
category: currentCategory || "默认",
sortOrder: 0,
});
}
} else if (innerTag === "h3") {
const folderName = (inner.textContent || "").trim() || currentCategory;
const dl = el.querySelector("dl");
if (dl) walk(dl, folderName);
}
} else if (tag === "dl") {
walk(el, currentCategory);
}
}
};
const root = doc.querySelector("dl");
if (root) walk(root, "默认");
return out;
}

56
frontend/lib/use-theme.ts Normal file
View File

@@ -0,0 +1,56 @@
"use client";
import { useEffect, useState } from "react";
type Theme = "light" | "dark";
function readTheme(): Theme {
if (typeof window === "undefined") return "light";
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
function applyTheme(theme: Theme) {
const root = document.documentElement;
if (theme === "dark") root.classList.add("dark");
else root.classList.remove("dark");
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>("light");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setThemeState(readTheme());
setMounted(true);
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
const onStorage = (e: StorageEvent) => {
if (e.key === "theme" && (e.newValue === "light" || e.newValue === "dark")) {
setThemeState(e.newValue);
applyTheme(e.newValue);
}
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
function setTheme(next: Theme) {
setThemeState(next);
try {
localStorage.setItem("theme", next);
} catch {}
applyTheme(next);
}
function toggle() {
setTheme(theme === "dark" ? "light" : "dark");
}
return { theme, setTheme, toggle, mounted };
}

View File

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

View File

@@ -9,6 +9,9 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
@@ -20,6 +23,7 @@
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"shadcn": "^4.2.0", "shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
@@ -517,6 +521,59 @@
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"peer": true "peer": true
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": { "node_modules/@dotenvx/dotenvx": {
"version": "1.61.0", "version": "1.61.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.61.0.tgz",
@@ -8110,6 +8167,16 @@
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==" "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="
}, },
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",

View File

@@ -10,6 +10,9 @@
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.4.0", "@base-ui/react": "^1.4.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
@@ -21,6 +24,7 @@
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"shadcn": "^4.2.0", "shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },