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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Globe, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "仪表盘",
|
||||
};
|
||||
|
||||
const SERVER_API_URL = process.env.SERVER_API_URL || "http://backend:8080";
|
||||
|
||||
interface Bookmark {
|
||||
@@ -65,7 +70,7 @@ export default async function DashboardPage() {
|
||||
<CardTitle>最近书签</CardTitle>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/bookmarks"
|
||||
href="/dashboard/bookmarks"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
管理 →
|
||||
@@ -92,6 +97,9 @@ export default async function DashboardPage() {
|
||||
<img
|
||||
src={bm.icon}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
loading="lazy"
|
||||
className="size-4 object-contain"
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { signOut } from "@/auth";
|
||||
import { HomeDock } from "../home-dock";
|
||||
|
||||
export default async function MainLayout({
|
||||
children,
|
||||
@@ -9,38 +7,15 @@ export default async function MainLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const user = session?.user as any;
|
||||
const user = session?.user as { role?: string } | undefined;
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<header className="border-b bg-white">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
|
||||
<Link href="/" className="text-lg font-bold">
|
||||
EvanPage
|
||||
</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 className="min-h-screen bg-gradient-to-br from-background via-background to-muted/40">
|
||||
<main className="mx-auto max-w-6xl px-4 pt-8 pb-32 sm:px-6 sm:pt-12 lg:px-8">
|
||||
{children}
|
||||
</main>
|
||||
<HomeDock isAuthenticated={!!session?.user} isAdmin={isAdmin} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ function BindForm() {
|
||||
<CardTitle className="text-center">绑定本地账号</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
您的 Keycloak 账号({keycloakEmail || keycloakId})尚未绑定本地账户。
|
||||
请输入已有的本地账号密码完成绑定。
|
||||
</p>
|
||||
@@ -76,7 +76,7 @@ function BindForm() {
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
@@ -88,8 +88,8 @@ function BindForm() {
|
||||
|
||||
export default function BindAccountPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
|
||||
<Suspense fallback={<div>加载中...</div>}>
|
||||
<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 className="text-sm text-muted-foreground">加载中...</div>}>
|
||||
<BindForm />
|
||||
</Suspense>
|
||||
</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 {
|
||||
--background: oklch(1 0 0);
|
||||
--background: oklch(0.99 0.002 240);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.546 0.218 263);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.965 0.005 240);
|
||||
--muted-foreground: oklch(0.5 0.02 240);
|
||||
--accent: oklch(0.95 0.03 263);
|
||||
--accent-foreground: oklch(0.35 0.18 263);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--border: oklch(0.92 0.01 240);
|
||||
--input: oklch(0.92 0.01 240);
|
||||
--ring: oklch(0.546 0.218 263);
|
||||
--chart-1: oklch(0.546 0.218 263);
|
||||
--chart-2: oklch(0.65 0.16 200);
|
||||
--chart-3: oklch(0.7 0.18 145);
|
||||
--chart-4: oklch(0.75 0.18 80);
|
||||
--chart-5: oklch(0.65 0.22 25);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.218 263);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.95 0.03 263);
|
||||
--sidebar-accent-foreground: oklch(0.35 0.18 263);
|
||||
--sidebar-border: oklch(0.92 0.01 240);
|
||||
--sidebar-ring: oklch(0.546 0.218 263);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--background: oklch(0.16 0.01 240);
|
||||
--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);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover: oklch(0.21 0.012 240);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--primary: oklch(0.65 0.21 263);
|
||||
--primary-foreground: oklch(0.16 0.01 240);
|
||||
--secondary: oklch(0.27 0.012 240);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.25 0.012 240);
|
||||
--muted-foreground: oklch(0.72 0.015 240);
|
||||
--accent: oklch(0.3 0.05 263);
|
||||
--accent-foreground: oklch(0.85 0.1 263);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--border: oklch(1 0 0 / 14%);
|
||||
--input: oklch(1 0 0 / 18%);
|
||||
--ring: oklch(0.65 0.21 263);
|
||||
--chart-1: oklch(0.65 0.21 263);
|
||||
--chart-2: oklch(0.7 0.16 200);
|
||||
--chart-3: oklch(0.72 0.18 145);
|
||||
--chart-4: oklch(0.78 0.18 80);
|
||||
--chart-5: oklch(0.7 0.22 25);
|
||||
--sidebar: oklch(0.21 0.012 240);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-primary: oklch(0.65 0.21 263);
|
||||
--sidebar-primary-foreground: oklch(0.16 0.01 240);
|
||||
--sidebar-accent: oklch(0.3 0.05 263);
|
||||
--sidebar-accent-foreground: oklch(0.85 0.1 263);
|
||||
--sidebar-border: oklch(1 0 0 / 14%);
|
||||
--sidebar-ring: oklch(0.65 0.21 263);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -126,5 +126,19 @@
|
||||
}
|
||||
html {
|
||||
@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";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Dock, DockIcon } from "@/components/ui/dock";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
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({
|
||||
isAuthenticated,
|
||||
@@ -13,71 +38,168 @@ export function HomeDock({
|
||||
isAdmin: boolean;
|
||||
onLoginClick?: () => void;
|
||||
}) {
|
||||
const isTouch = useIsTouch();
|
||||
const pathname = usePathname() || "/";
|
||||
const isActive = (path: string) =>
|
||||
path === "/" ? pathname === "/" : pathname.startsWith(path);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2">
|
||||
<Dock>
|
||||
<div
|
||||
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>
|
||||
<a href="/" aria-label="首页">
|
||||
<Home className="h-5 w-5 text-slate-700" />
|
||||
</a>
|
||||
<NavLink href="/" label="首页" active={isActive("/")}>
|
||||
<Home className="h-5 w-5" />
|
||||
</NavLink>
|
||||
</DockIcon>
|
||||
<DockIcon>
|
||||
<a
|
||||
href="https://file.liukersun.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="下载网站"
|
||||
>
|
||||
<Download className="h-5 w-5 text-slate-700" />
|
||||
</a>
|
||||
<ExternalNavLink href="https://file.liukersun.com" label="下载网站">
|
||||
<Download className="h-5 w-5" />
|
||||
</ExternalNavLink>
|
||||
</DockIcon>
|
||||
<DockIcon>
|
||||
<a
|
||||
href="https://blog.liukersun.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="博客"
|
||||
>
|
||||
<BookOpen className="h-5 w-5 text-slate-700" />
|
||||
</a>
|
||||
<ExternalNavLink href="https://blog.liukersun.com" label="博客">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
</ExternalNavLink>
|
||||
</DockIcon>
|
||||
{isAuthenticated && (
|
||||
<DockIcon>
|
||||
<a href="/dashboard" aria-label="仪表盘">
|
||||
<LayoutDashboard className="h-5 w-5 text-slate-700" />
|
||||
</a>
|
||||
<NavLink
|
||||
href="/dashboard"
|
||||
label="仪表盘"
|
||||
active={pathname === "/dashboard"}
|
||||
>
|
||||
<LayoutDashboard className="h-5 w-5" />
|
||||
</NavLink>
|
||||
</DockIcon>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DockIcon>
|
||||
<a href="/bookmarks" aria-label="书签">
|
||||
<Bookmark className="h-5 w-5 text-slate-700" />
|
||||
</a>
|
||||
<NavLink
|
||||
href="/dashboard/bookmarks"
|
||||
label="书签管理"
|
||||
active={pathname.startsWith("/dashboard/bookmarks")}
|
||||
>
|
||||
<Bookmark className="h-5 w-5" />
|
||||
</NavLink>
|
||||
</DockIcon>
|
||||
)}
|
||||
<DockIcon>
|
||||
<DockTooltip label="主题">
|
||||
<ThemeToggle />
|
||||
</DockTooltip>
|
||||
</DockIcon>
|
||||
<DockIcon>
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await signOut({ redirect: false });
|
||||
window.location.href = "/";
|
||||
}}
|
||||
aria-label="退出"
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
>
|
||||
<LogOut className="h-5 w-5 text-slate-700" />
|
||||
</button>
|
||||
<DockTooltip label="退出登录">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut({ redirectTo: "/" })}
|
||||
aria-label="退出登录"
|
||||
className="flex h-full w-full items-center justify-center text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</button>
|
||||
</DockTooltip>
|
||||
) : (
|
||||
<button
|
||||
onClick={onLoginClick}
|
||||
aria-label="登录"
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
>
|
||||
<User className="h-5 w-5 text-slate-700" />
|
||||
</button>
|
||||
<DockTooltip label="登录">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoginClick}
|
||||
aria-label="登录"
|
||||
className="flex h-full w-full items-center justify-center text-foreground/80 hover:text-foreground"
|
||||
>
|
||||
<LogIn className="h-5 w-5" />
|
||||
</button>
|
||||
</DockTooltip>
|
||||
)}
|
||||
</DockIcon>
|
||||
</Dock>
|
||||
</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";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { BlurFade } from "@/components/magicui/blur-fade";
|
||||
import { HomeDock } from "./home-dock";
|
||||
@@ -12,8 +12,16 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { LoginForm } from "./login/login-form";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Globe, ExternalLink, Link2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Globe,
|
||||
ExternalLink,
|
||||
Link2,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { hostnameOf, faviconFallback } from "@/lib/bookmarks";
|
||||
|
||||
interface Bookmark {
|
||||
id: number;
|
||||
@@ -24,73 +32,141 @@ interface Bookmark {
|
||||
category: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORY = "默认";
|
||||
const MAX_DELAY = 0.6;
|
||||
|
||||
function clampDelay(value: number) {
|
||||
return Math.min(value, MAX_DELAY);
|
||||
}
|
||||
|
||||
export function HomePageClient({
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
healthText,
|
||||
hasKeycloak,
|
||||
bookmarks,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
isAdmin: boolean;
|
||||
healthText: string;
|
||||
hasKeycloak: boolean;
|
||||
bookmarks: Bookmark[];
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const wantLogin = searchParams.get("login") === "1" && !isAuthenticated;
|
||||
const [prevWantLogin, setPrevWantLogin] = useState(false);
|
||||
if (wantLogin !== prevWantLogin) {
|
||||
setPrevWantLogin(wantLogin);
|
||||
if (wantLogin) setLoginOpen(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("login") === "1" && !isAuthenticated) {
|
||||
setLoginOpen(true);
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
}, [searchParams, isAuthenticated]);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const grouped = bookmarks.reduce<Record<string, Bookmark[]>>((acc, bm) => {
|
||||
const cat = bm.category || "默认";
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(bm);
|
||||
return acc;
|
||||
}, {});
|
||||
const categories = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const b of bookmarks) set.add(b.category || DEFAULT_CATEGORY);
|
||||
return Array.from(set).sort((a, b) => a.localeCompare(b, "zh"));
|
||||
}, [bookmarks]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
for (const b of bookmarks) {
|
||||
const cat = b.category || DEFAULT_CATEGORY;
|
||||
m.set(cat, (m.get(cat) || 0) + 1);
|
||||
}
|
||||
return m;
|
||||
}, [bookmarks]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return bookmarks.filter((b) => {
|
||||
if (
|
||||
activeCategory &&
|
||||
(b.category || DEFAULT_CATEGORY) !== activeCategory
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!q) return true;
|
||||
return (
|
||||
b.title.toLowerCase().includes(q) ||
|
||||
b.url.toLowerCase().includes(q) ||
|
||||
(b.description || "").toLowerCase().includes(q) ||
|
||||
hostnameOf(b.url).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [bookmarks, search, activeCategory]);
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, Bookmark[]>();
|
||||
for (const b of filtered) {
|
||||
const cat = b.category || DEFAULT_CATEGORY;
|
||||
if (!map.has(cat)) map.set(cat, []);
|
||||
map.get(cat)!.push(b);
|
||||
}
|
||||
return Array.from(map.entries()).sort(([a], [b]) =>
|
||||
a.localeCompare(b, "zh"),
|
||||
);
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col items-center bg-gradient-to-br from-slate-50 to-slate-100 p-4 pb-32">
|
||||
<div className="flex flex-1 flex-col items-center justify-center py-12">
|
||||
<div className="relative flex min-h-screen flex-col items-center bg-gradient-to-br from-background via-background to-muted/40 px-4 pb-32 pt-4">
|
||||
<div className="flex flex-col items-center justify-center pb-8 pt-12">
|
||||
<BlurFade inView delay={0.1}>
|
||||
<h1 className="mb-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
|
||||
</h1>
|
||||
</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>
|
||||
|
||||
{bookmarks.length > 0 && (
|
||||
<div className="w-full max-w-4xl space-y-6">
|
||||
<BlurFade inView delay={0.4}>
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-lg font-semibold text-slate-800">
|
||||
我的导航
|
||||
</h2>
|
||||
<div className="w-full max-w-5xl space-y-5">
|
||||
<BlurFade inView delay={0.3}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索书签… 按 / 聚焦"
|
||||
className="bg-background/70 pl-9 backdrop-blur"
|
||||
aria-label="搜索书签"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearch("")}
|
||||
aria-label="清空搜索"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/bookmarks"
|
||||
className="text-sm text-slate-500 hover:text-slate-800 hover:underline"
|
||||
href="/dashboard/bookmarks"
|
||||
className="text-xs text-muted-foreground transition-colors hover:text-primary hover:underline"
|
||||
>
|
||||
管理书签 →
|
||||
</Link>
|
||||
@@ -98,71 +174,63 @@ export function HomePageClient({
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
{Object.entries(grouped).map(([cat, items], catIdx) => (
|
||||
<BlurFade key={cat} inView delay={0.4 + catIdx * 0.1}>
|
||||
<div>
|
||||
<h3 className="mb-3 px-1 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
{cat}
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((bm) => (
|
||||
<Card
|
||||
key={bm.id}
|
||||
className="group transition-shadow hover:shadow-md"
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<a
|
||||
href={bm.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-4"
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-slate-100">
|
||||
{bm.icon ? (
|
||||
<img
|
||||
src={bm.icon}
|
||||
alt=""
|
||||
className="size-5 object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display =
|
||||
"none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Globe className="size-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-slate-800">
|
||||
{bm.title}
|
||||
</p>
|
||||
{bm.description && (
|
||||
<p className="mt-0.5 line-clamp-1 text-xs text-slate-400">
|
||||
{bm.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="size-4 shrink-0 text-slate-300 transition-colors group-hover:text-slate-500" />
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<BlurFade inView delay={0.35}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CategoryChip
|
||||
label="全部"
|
||||
count={bookmarks.length}
|
||||
active={activeCategory === null}
|
||||
onClick={() => setActiveCategory(null)}
|
||||
/>
|
||||
{categories.map((c) => (
|
||||
<CategoryChip
|
||||
key={c}
|
||||
label={c}
|
||||
count={counts.get(c) || 0}
|
||||
active={activeCategory === c}
|
||||
onClick={() => setActiveCategory(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</BlurFade>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<BlurFade inView delay={0.4}>
|
||||
<div className="rounded-xl border border-dashed border-border bg-card/50 py-10 text-center text-sm text-muted-foreground backdrop-blur">
|
||||
没有匹配「{search || activeCategory}」的书签
|
||||
</div>
|
||||
</BlurFade>
|
||||
))}
|
||||
) : (
|
||||
grouped.map(([cat, items], catIdx) => (
|
||||
<BlurFade key={cat} inView delay={clampDelay(0.4 + catIdx * 0.05)}>
|
||||
<div>
|
||||
<h3 className="mb-3 px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{cat}
|
||||
<span className="ml-2 text-muted-foreground/60">
|
||||
{items.length}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((bm) => (
|
||||
<BookmarkCard key={bm.id} bookmark={bm} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</BlurFade>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookmarks.length === 0 && (
|
||||
<BlurFade inView delay={0.4}>
|
||||
<div className="w-full max-w-md rounded-2xl border border-dashed bg-white/60 py-10 text-center backdrop-blur">
|
||||
<Link2 className="mx-auto mb-3 size-8 text-slate-300" />
|
||||
<p className="mb-2 text-slate-500">还没有书签</p>
|
||||
<div className="w-full max-w-md rounded-2xl border border-dashed border-border bg-card/60 py-10 text-center backdrop-blur">
|
||||
<Link2 className="mx-auto mb-3 size-8 text-muted-foreground" />
|
||||
<p className="mb-2 text-muted-foreground">还没有书签</p>
|
||||
{isAdmin && (
|
||||
<Link
|
||||
href="/bookmarks"
|
||||
className="text-sm font-medium text-slate-800 hover:underline"
|
||||
href="/dashboard/bookmarks"
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
添加第一个书签 →
|
||||
</Link>
|
||||
@@ -191,3 +259,107 @@ export function HomePageClient({
|
||||
</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) {
|
||||
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>
|
||||
);
|
||||
@@ -72,13 +72,13 @@ export default function InitPage() {
|
||||
|
||||
if (initialized) {
|
||||
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">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">系统已初始化</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-gray-500">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
系统中已有用户,无法再次初始化。
|
||||
</p>
|
||||
</CardContent>
|
||||
@@ -88,13 +88,13 @@ export default function InitPage() {
|
||||
}
|
||||
|
||||
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">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">系统初始化</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
这是系统首次启动,请创建第一个管理员账号。
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
@@ -139,7 +139,7 @@ export default function InitPage() {
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -13,10 +14,36 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
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({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -24,10 +51,17 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang="zh-CN"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ export function LoginDialog({
|
||||
|
||||
return (
|
||||
<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 ?? "登录"}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
|
||||
@@ -66,7 +66,7 @@ export function LoginForm({
|
||||
required
|
||||
/>
|
||||
</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>
|
||||
@@ -76,14 +76,15 @@ export function LoginForm({
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
<span className="w-full border-t border-border" />
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => signIn("keycloak", { callbackUrl })}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,41 +9,42 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { LoginForm } from "./login-form";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "登录",
|
||||
};
|
||||
|
||||
const hasKeycloak = !!process.env.AUTH_KEYCLOAK_ISSUER;
|
||||
|
||||
function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl");
|
||||
const error = searchParams.get("error");
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) {
|
||||
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) {
|
||||
router.replace("/?login=1");
|
||||
}
|
||||
}, [callbackUrl, error, router]);
|
||||
if (!callbackUrl && !error) {
|
||||
redirect("/?login=1");
|
||||
}
|
||||
|
||||
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>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">登录</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Suspense fallback={<div>加载中...</div>}>
|
||||
<LoginForm hasKeycloak={hasKeycloak} />
|
||||
{error && (
|
||||
<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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</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() {
|
||||
const session = await auth();
|
||||
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 = "无法连接到后端服务";
|
||||
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();
|
||||
const bookmarks = await fetchPublicBookmarks();
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<HomePageClient
|
||||
isAuthenticated={isAuthenticated}
|
||||
isAdmin={isAdmin}
|
||||
healthText={healthText}
|
||||
hasKeycloak={hasKeycloak}
|
||||
bookmarks={bookmarks}
|
||||
/>
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace("/");
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
加载中...
|
||||
</div>
|
||||
);
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "无权访问",
|
||||
};
|
||||
|
||||
export default function UnauthorizedPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center p-4">
|
||||
<h1 className="text-3xl font-bold">403</h1>
|
||||
<p className="mt-2 text-gray-500">您没有权限访问该页面</p>
|
||||
<a href="/dashboard" className="mt-4 text-blue-600 hover:underline">
|
||||
<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-5xl font-bold tracking-tight">403</h1>
|
||||
<p className="mt-3 text-muted-foreground">您没有权限访问该页面</p>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="mt-6 text-sm font-medium text-primary underline-offset-4 hover:underline"
|
||||
>
|
||||
返回仪表盘
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user