feat(ui): AppShell layout route — desktop rail, tablet icon rail, mobile tabs, theme toggle
This commit is contained in:
+7
-23
@@ -1,5 +1,6 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
import { AuthProvider, RequireAdmin, RequireAuth } from "./auth/AuthContext";
|
import { AuthProvider, RequireAdmin, RequireAuth } from "./auth/AuthContext";
|
||||||
|
import { AppShell } from "./components/app-shell";
|
||||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||||
import { Toaster } from "./components/Toaster";
|
import { Toaster } from "./components/Toaster";
|
||||||
import { ThemeProvider } from "./components/theme";
|
import { ThemeProvider } from "./components/theme";
|
||||||
@@ -17,13 +18,16 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<Shelf />
|
<AppShell />
|
||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
>
|
||||||
|
<Route path="/" element={<Shelf />} />
|
||||||
|
<Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} />
|
||||||
|
<Route path="/admin/libraries" element={<RequireAdmin><AdminLibraries /></RequireAdmin>} />
|
||||||
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
path="/book/:id"
|
path="/book/:id"
|
||||||
element={
|
element={
|
||||||
@@ -32,26 +36,6 @@ export default function App() {
|
|||||||
</RequireAuth>
|
</RequireAuth>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="/admin/users"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<RequireAdmin>
|
|
||||||
<AdminUsers />
|
|
||||||
</RequireAdmin>
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/admin/libraries"
|
|
||||||
element={
|
|
||||||
<RequireAuth>
|
|
||||||
<RequireAdmin>
|
|
||||||
<AdminLibraries />
|
|
||||||
</RequireAdmin>
|
|
||||||
</RequireAuth>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { BookMarked, BookOpen, FolderCog, LogOut, Users } from "lucide-react";
|
||||||
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
import { api } from "@/api/client";
|
||||||
|
import { useAuth } from "@/auth/AuthContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
function Brand({ compact }: { compact?: boolean }) {
|
||||||
|
return (
|
||||||
|
<NavLink to="/" className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||||
|
<span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground">
|
||||||
|
<BookMarked className="size-4" />
|
||||||
|
</span>
|
||||||
|
{!compact && <span className="text-[15px] font-semibold tracking-tight">BookLib</span>}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={to + (search ?? "")}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
"flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground",
|
||||||
|
isActive && "bg-secondary font-medium text-foreground",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="hidden lg:inline">{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const { user, isAdmin, logout } = useAuth();
|
||||||
|
const loc = useLocation();
|
||||||
|
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
|
||||||
|
const libs = libsQ.data ?? [];
|
||||||
|
|
||||||
|
const account = (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="gap-2 px-2">
|
||||||
|
<span className="grid size-7 place-items-center rounded-full bg-accent/15 text-xs font-semibold text-accent">
|
||||||
|
{Array.from(user?.username ?? "?")[0]?.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="hidden max-w-28 truncate text-sm lg:inline">{user?.username}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<span className="cursor-pointer" onClick={logout}>
|
||||||
|
<LogOut /> 退出登录
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full">
|
||||||
|
{/* 桌面/平板侧栏;lg 全文标签,md 图标栏 */}
|
||||||
|
<aside className="flex w-14 shrink-0 flex-col gap-1 border-r bg-card/60 p-2 lg:w-60">
|
||||||
|
<Brand compact />
|
||||||
|
<nav aria-label="主导航" className="flex flex-col gap-1">
|
||||||
|
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" />
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<NavItem to="/admin/libraries" icon={<FolderCog className="size-4 shrink-0" />} label="库管理" />
|
||||||
|
<NavItem to="/admin/users" icon={<Users className="size-4 shrink-0" />} label="用户管理" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
{libs.length > 0 && (
|
||||||
|
<div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex">
|
||||||
|
<p className="px-3 pb-1 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">书库</p>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<NavItem to="/" search="?lib=0" icon={<span className="size-1.5 shrink-0 rounded-full bg-muted-foreground/50" />} label="全部" />
|
||||||
|
{libs.map((l) => (
|
||||||
|
<NavItem key={l.id} to="/" search={`?lib=${l.id}`} icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />} label={l.name} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Separator className="my-2 hidden lg:block" />
|
||||||
|
<div className="mt-auto hidden items-center justify-between px-1 lg:flex">
|
||||||
|
<ThemeToggle />
|
||||||
|
{account}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
{/* 移动端顶栏 */}
|
||||||
|
<header className="flex h-12 shrink-0 items-center justify-between border-b bg-card/60 px-3 lg:hidden">
|
||||||
|
<Brand />
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ThemeToggle />
|
||||||
|
{account}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main key={loc.pathname} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
{/* 移动端底 Tab */}
|
||||||
|
<nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-card/90 backdrop-blur md:hidden">
|
||||||
|
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" search="?lib=0" />
|
||||||
|
{isAdmin && <TabItem to="/admin/libraries" icon={<FolderCog className="size-5" />} label="库管理" />}
|
||||||
|
{isAdmin && <TabItem to="/admin/users" icon={<Users className="size-5" />} label="用户" />}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabItem({ to, icon, label, search }: { to: string; icon: ReactNode; label: string; search?: string }) {
|
||||||
|
return (
|
||||||
|
<NavLink to={to + (search ?? "")} className={({ isActive }) => cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent")}>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Check, Monitor, Moon, Sun } from "lucide-react";
|
||||||
|
import { useTheme, type Theme } from "./theme";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [
|
||||||
|
{ v: "light", label: "浅色", icon: Sun },
|
||||||
|
{ v: "dark", label: "深色", icon: Moon },
|
||||||
|
{ v: "system", label: "跟随系统", icon: Monitor },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" aria-label="切换主题" aria-pressed="false">
|
||||||
|
{theme === "dark" ? <Moon className="size-4" /> : theme === "light" ? <Sun className="size-4" /> : <Monitor className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{ITEMS.map(({ v, label, icon: Icon }) => (
|
||||||
|
<DropdownMenuItem key={v} onClick={() => setTheme(v)}>
|
||||||
|
<Icon />
|
||||||
|
{label}
|
||||||
|
{theme === v && <Check className="ml-auto size-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user