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:
File diff suppressed because it is too large
Load Diff
16
frontend/app/(main)/dashboard/bookmarks/page.tsx
Normal file
16
frontend/app/(main)/dashboard/bookmarks/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 })}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
26
frontend/components/theme-toggle.tsx
Normal file
26
frontend/components/theme-toggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/components/ui/alert-dialog.tsx
Normal file
134
frontend/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -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}
|
||||||
|
|||||||
13
frontend/components/ui/skeleton.tsx
Normal file
13
frontend/components/ui/skeleton.tsx
Normal 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
143
frontend/lib/bookmarks.ts
Normal 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
56
frontend/lib/use-theme.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
67
frontend/package-lock.json
generated
67
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user