Files
evanpage/frontend/app/home-dock.tsx
root 694b02e848 frontend: rebuild bookmark page with drag-and-drop, search, and theme system
- bookmark management with dnd-kit reordering, bulk edit, search,
  category filter/rename, and meta auto-fetch
- migrate /bookmarks → /dashboard/bookmarks under (main) layout
- homepage redesign with category grid, /-key search, dock tooltips
- theme toggle + use-theme, sonner toasts, alert-dialog/skeleton,
  visual refresh of auth pages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-02 22:53:17 +00:00

206 lines
5.5 KiB
TypeScript

"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,
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,
isAdmin,
onLoginClick,
}: {
isAuthenticated: boolean;
isAdmin: boolean;
onLoginClick?: () => void;
}) {
const isTouch = useIsTouch();
const pathname = usePathname() || "/";
const isActive = (path: string) =>
path === "/" ? pathname === "/" : pathname.startsWith(path);
return (
<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>
<NavLink href="/" label="首页" active={isActive("/")}>
<Home className="h-5 w-5" />
</NavLink>
</DockIcon>
<DockIcon>
<ExternalNavLink href="https://file.liukersun.com" label="下载网站">
<Download className="h-5 w-5" />
</ExternalNavLink>
</DockIcon>
<DockIcon>
<ExternalNavLink href="https://blog.liukersun.com" label="博客">
<BookOpen className="h-5 w-5" />
</ExternalNavLink>
</DockIcon>
{isAuthenticated && (
<DockIcon>
<NavLink
href="/dashboard"
label="仪表盘"
active={pathname === "/dashboard"}
>
<LayoutDashboard className="h-5 w-5" />
</NavLink>
</DockIcon>
)}
{isAdmin && (
<DockIcon>
<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 ? (
<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>
) : (
<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>
);
}