style(frontend): prettier baseline format (no logic change)

This commit is contained in:
2026-09-15 11:27:54 +08:00
parent 09a02fa0d7
commit 29167d3cea
38 changed files with 764 additions and 594 deletions
+7 -1
View File
@@ -5,5 +5,11 @@
"tsx": true, "tsx": true,
"tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" },
"iconLibrary": "lucide", "iconLibrary": "lucide",
"aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" } "aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
} }
+2
View File
@@ -39,11 +39,13 @@ export default tseslint.config(
}, },
{ {
files: ["e2e/**/*.ts"], files: ["e2e/**/*.ts"],
// e2e 是 Playwright 脚本,测试工具链的动态类型用法(如 locator/fixture 包装)允许 any
languageOptions: { globals: { process: "readonly", console: "readonly", URL: "readonly" } }, languageOptions: { globals: { process: "readonly", console: "readonly", URL: "readonly" } },
rules: { "@typescript-eslint/no-explicit-any": "off" }, rules: { "@typescript-eslint/no-explicit-any": "off" },
}, },
{ {
files: ["**/*.test.{ts,tsx}", "test/**/*.ts"], files: ["**/*.test.{ts,tsx}", "test/**/*.ts"],
// 测试代码允许 any 以贴合测试惯例(mock 数据、运行时探测)
rules: { "@typescript-eslint/no-explicit-any": "off" }, rules: { "@typescript-eslint/no-explicit-any": "off" },
}, },
prettier, // 必须最后:关闭所有与 prettier 冲突的格式规则 prettier, // 必须最后:关闭所有与 prettier 冲突的格式规则
+16 -2
View File
@@ -25,8 +25,22 @@ export default function App() {
} }
> >
<Route path="/" element={<Shelf />} /> <Route path="/" element={<Shelf />} />
<Route path="/admin/users" element={<RequireAdmin><AdminUsers /></RequireAdmin>} /> <Route
<Route path="/admin/libraries" element={<RequireAdmin><AdminLibraries /></RequireAdmin>} /> path="/admin/users"
element={
<RequireAdmin>
<AdminUsers />
</RequireAdmin>
}
/>
<Route
path="/admin/libraries"
element={
<RequireAdmin>
<AdminLibraries />
</RequireAdmin>
}
/>
</Route> </Route>
<Route <Route
path="/book/:id" path="/book/:id"
+2 -3
View File
@@ -9,7 +9,7 @@ const mem = new Map<string, string>();
const hasLS = typeof globalThis.localStorage?.setItem === "function"; const hasLS = typeof globalThis.localStorage?.setItem === "function";
export function getToken(): string | null { export function getToken(): string | null {
return hasLS ? localStorage.getItem(TOKEN_KEY) : mem.get(TOKEN_KEY) ?? null; return hasLS ? localStorage.getItem(TOKEN_KEY) : (mem.get(TOKEN_KEY) ?? null);
} }
export function setToken(t: string | null): void { export function setToken(t: string | null): void {
@@ -181,8 +181,7 @@ export const api = {
listBookmarks: (id: number) => apiFetch<Bookmark[]>(`/books/${id}/bookmarks`), listBookmarks: (id: number) => apiFetch<Bookmark[]>(`/books/${id}/bookmarks`),
createBookmark: (id: number, locator: Record<string, unknown>, percent: number, note: string) => createBookmark: (id: number, locator: Record<string, unknown>, percent: number, note: string) =>
apiFetch<Bookmark>(`/books/${id}/bookmarks`, { method: "POST", body: { locator, percent, note } }), apiFetch<Bookmark>(`/books/${id}/bookmarks`, { method: "POST", body: { locator, percent, note } }),
patchBookmark: (id: number, note: string) => patchBookmark: (id: number, note: string) => apiFetch<void>(`/bookmarks/${id}`, { method: "PATCH", body: { note } }),
apiFetch<void>(`/bookmarks/${id}`, { method: "PATCH", body: { note } }),
deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: "DELETE" }), deleteBookmark: (id: number) => apiFetch<void>(`/bookmarks/${id}`, { method: "DELETE" }),
listProgress: () => apiFetch<ProgressRow[]>("/progress"), listProgress: () => apiFetch<ProgressRow[]>("/progress"),
}; };
+5 -1
View File
@@ -100,7 +100,11 @@ export function BookmarksTab({
> >
<Check size={14} /> <Check size={14} />
</button> </button>
<button className="rd-btn inline-flex shrink-0 items-center" aria-label="取消编辑" onClick={() => setEditId(null)}> <button
className="rd-btn inline-flex shrink-0 items-center"
aria-label="取消编辑"
onClick={() => setEditId(null)}
>
<X size={14} /> <X size={14} />
</button> </button>
</div> </div>
+9 -5
View File
@@ -5,7 +5,11 @@ import { btn, formatBadge } from "./ui";
import { TriangleAlert } from "lucide-react"; import { TriangleAlert } from "lucide-react";
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } { export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false }); const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({
key: "",
src: "",
failed: false,
});
const [nonce, setNonce] = useState(0); const [nonce, setNonce] = useState(0);
const key = (url ?? "") + "#" + nonce; const key = (url ?? "") + "#" + nonce;
@@ -30,10 +34,10 @@ export function Cover({ book, className }: { book: Book; className?: string }) {
const { src, failed, retry } = useAuthedImage(book.cover_url); const { src, failed, retry } = useAuthedImage(book.cover_url);
if (!book.cover_url || failed) { if (!book.cover_url || failed) {
return ( return (
<div <div className={`relative grid place-items-center overflow-hidden bg-muted ${className ?? ""}`}>
className={`relative grid place-items-center overflow-hidden bg-muted ${className ?? ""}`} <span className="select-none font-serif text-4xl text-muted-foreground">
> {Array.from(book.title)[0] ?? "?"}
<span className="select-none font-serif text-4xl text-muted-foreground">{Array.from(book.title)[0] ?? "?"}</span> </span>
<span className={formatBadge(book.format, "absolute bottom-1.5 right-1.5")}>{book.format}</span> <span className={formatBadge(book.format, "absolute bottom-1.5 right-1.5")}>{book.format}</span>
{failed && ( {failed && (
<button className={btn + " absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"} onClick={retry}> <button className={btn + " absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"} onClick={retry}>
+4 -1
View File
@@ -14,7 +14,10 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Err
<div> <div>
<p className="mb-2 text-lg text-red-400">页面出错了</p> <p className="mb-2 text-lg text-red-400">页面出错了</p>
<p className="mb-4 max-w-md text-sm break-all text-stone-500">{String(this.state.err)}</p> <p className="mb-4 max-w-md text-sm break-all text-stone-500">{String(this.state.err)}</p>
<button className="rounded-md bg-stone-800 px-3 py-1.5 text-sm" onClick={() => this.setState({ err: null })}> <button
className="rounded-md bg-stone-800 px-3 py-1.5 text-sm"
onClick={() => this.setState({ err: null })}
>
重试 重试
</button> </button>
</div> </div>
+5 -1
View File
@@ -36,7 +36,11 @@ export function Toaster() {
t.kind === "ok" ? "border-border" : "border-destructive/40" t.kind === "ok" ? "border-border" : "border-destructive/40"
}`} }`}
> >
{t.kind === "ok" ? <CheckCircle2 className="size-4 shrink-0 text-accent" /> : <XCircle className="size-4 shrink-0 text-destructive" />} {t.kind === "ok" ? (
<CheckCircle2 className="size-4 shrink-0 text-accent" />
) : (
<XCircle className="size-4 shrink-0 text-destructive" />
)}
{t.text} {t.text}
</div> </div>
))} ))}
+44 -7
View File
@@ -4,7 +4,14 @@ import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
import { api } from "@/api/client"; import { api } from "@/api/client";
import { useAuth } from "@/auth/AuthContext"; import { useAuth } from "@/auth/AuthContext";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { ThemeToggle } from "./theme-toggle"; import { ThemeToggle } from "./theme-toggle";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -12,7 +19,11 @@ import type { ReactNode } from "react";
function Brand() { function Brand() {
return ( return (
<Link to="/" aria-label="BookLib 首页" className="flex h-12 shrink-0 items-center gap-2 rounded-md px-3 focus-visible:outline-2 focus-visible:outline-offset-2"> <Link
to="/"
aria-label="BookLib 首页"
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"> <span className="grid size-6 place-items-center rounded bg-accent text-accent-foreground">
<BookMarked className="size-4" /> <BookMarked className="size-4" />
</span> </span>
@@ -57,7 +68,10 @@ export function AppShell() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>{user?.username}{isAdmin ? " · admin" : ""}</DropdownMenuLabel> <DropdownMenuLabel>
{user?.username}
{isAdmin ? " · admin" : ""}
</DropdownMenuLabel>
{isAdmin && ( {isAdmin && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -88,14 +102,25 @@ export function AppShell() {
<aside className="hidden w-14 shrink-0 flex-col gap-1 border-r bg-background/85 p-2 backdrop-blur md:flex lg:w-60"> <aside className="hidden w-14 shrink-0 flex-col gap-1 border-r bg-background/85 p-2 backdrop-blur md:flex lg:w-60">
<Brand /> <Brand />
<nav aria-label="主导航" className="flex flex-col gap-1"> <nav aria-label="主导航" className="flex flex-col gap-1">
<NavItem to="/" icon={<BookOpen className="size-4 shrink-0" />} label="书架" active={loc.pathname === "/" && !libParam} /> <NavItem
to="/"
icon={<BookOpen className="size-4 shrink-0" />}
label="书架"
active={loc.pathname === "/" && !libParam}
/>
</nav> </nav>
{libs.length > 0 && ( {libs.length > 0 && (
<div className="mt-3 hidden min-h-0 flex-1 flex-col lg:flex"> <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> <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"> <div className="min-h-0 flex-1 overflow-y-auto">
{libs.map((l) => ( {libs.map((l) => (
<NavItem key={l.id} to={`/?lib=${l.id}`} icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />} label={l.name} active={loc.pathname === "/" && libParam === String(l.id)} /> <NavItem
key={l.id}
to={`/?lib=${l.id}`}
icon={<span className="size-1.5 shrink-0 rounded-full bg-accent/60" />}
label={l.name}
active={loc.pathname === "/" && libParam === String(l.id)}
/>
))} ))}
</div> </div>
</div> </div>
@@ -120,7 +145,10 @@ export function AppShell() {
<Outlet /> <Outlet />
</main> </main>
{/* 移动端底 Tab */} {/* 移动端底 Tab */}
<nav aria-label="底部导航" className="flex h-14 shrink-0 items-stretch border-t bg-background/85 backdrop-blur md:hidden"> <nav
aria-label="底部导航"
className="flex h-14 shrink-0 items-stretch border-t bg-background/85 backdrop-blur md:hidden"
>
<TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" /> <TabItem to="/" icon={<BookOpen className="size-5" />} label="书架" />
</nav> </nav>
</div> </div>
@@ -130,7 +158,16 @@ export function AppShell() {
function TabItem({ to, icon, label }: { to: string; icon: ReactNode; label: string }) { function TabItem({ to, icon, label }: { to: string; icon: ReactNode; label: string }) {
return ( return (
<NavLink to={to} end={to === "/"} className={({ isActive }) => cn("flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground", isActive && "text-accent-strong")}> <NavLink
to={to}
end={to === "/"}
className={({ isActive }) =>
cn(
"flex flex-col items-center justify-center gap-0.5 text-[11px] text-muted-foreground",
isActive && "text-accent-strong",
)
}
>
{icon} {icon}
{label} {label}
</NavLink> </NavLink>
+10 -1
View File
@@ -2,7 +2,16 @@ import type { SVGProps } from "react";
function Svg({ children, ...p }: SVGProps<SVGSVGElement>) { function Svg({ children, ...p }: SVGProps<SVGSVGElement>) {
return ( return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" {...p}> <svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...p}
>
{children} {children}
</svg> </svg>
); );
+13 -2
View File
@@ -1,6 +1,11 @@
import { Check, Monitor, Moon, Sun } from "lucide-react"; import { Check, Monitor, Moon, Sun } from "lucide-react";
import { useTheme, type Theme } from "./theme"; import { useTheme, type Theme } from "./theme";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [ const ITEMS: { v: Theme; label: string; icon: typeof Sun }[] = [
@@ -15,7 +20,13 @@ export function ThemeToggle() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="切换主题"> <Button variant="ghost" size="icon" aria-label="切换主题">
{theme === "dark" ? <Moon className="size-4" /> : theme === "light" ? <Sun className="size-4" /> : <Monitor className="size-4" />} {theme === "dark" ? (
<Moon className="size-4" />
) : theme === "light" ? (
<Sun className="size-4" />
) : (
<Monitor className="size-4" />
)}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
+35 -68
View File
@@ -1,47 +1,34 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { AlertDialog as AlertDialogPrimitive } from "radix-ui" import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
function AlertDialog({ function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
...props return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
} }
function AlertDialogTrigger({ function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
...props return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
} }
function AlertDialogPortal({ function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
...props return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
} }
function AlertDialogOverlay({ function AlertDialogOverlay({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return ( return (
<AlertDialogPrimitive.Overlay <AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogContent({ function AlertDialogContent({
@@ -49,7 +36,7 @@ function AlertDialogContent({
size = "default", size = "default",
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & { }: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
size?: "default" | "sm" size?: "default" | "sm";
}) { }) {
return ( return (
<AlertDialogPortal> <AlertDialogPortal>
@@ -59,60 +46,51 @@ function AlertDialogContent({
data-size={size} data-size={size}
className={cn( className={cn(
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg", "group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
className className,
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
) );
} }
function AlertDialogHeader({ function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-dialog-header" data-slot="alert-dialog-header"
className={cn( className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]", "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogFooter({ function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-dialog-footer" data-slot="alert-dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogTitle({ function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return ( return (
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
data-slot="alert-dialog-title" data-slot="alert-dialog-title"
className={cn( className={cn(
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2", "text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogDescription({ function AlertDialogDescription({
@@ -125,23 +103,20 @@ function AlertDialogDescription({
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogMedia({ function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) {
className,
...props
}: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="alert-dialog-media" data-slot="alert-dialog-media"
className={cn( className={cn(
"mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8", "mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogAction({ function AlertDialogAction({
@@ -153,13 +128,9 @@ function AlertDialogAction({
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) { Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return ( return (
<Button variant={variant} size={size} asChild> <Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action <AlertDialogPrimitive.Action data-slot="alert-dialog-action" className={cn(className)} {...props} />
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
</Button> </Button>
) );
} }
function AlertDialogCancel({ function AlertDialogCancel({
@@ -171,13 +142,9 @@ function AlertDialogCancel({
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) { Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return ( return (
<Button variant={variant} size={size} asChild> <Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel data-slot="alert-dialog-cancel" className={cn(className)} {...props} />
data-slot="alert-dialog-cancel"
className={cn(className)}
{...props}
/>
</Button> </Button>
) );
} }
export { export {
@@ -193,4 +160,4 @@ export {
AlertDialogPortal, AlertDialogPortal,
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} };
+13 -21
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Slot } from "radix-ui" import { Slot } from "radix-ui";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
@@ -9,12 +9,10 @@ const badgeVariants = cva(
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline: outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline", link: "text-primary underline-offset-4 [a&]:hover:underline",
}, },
@@ -22,26 +20,20 @@ const badgeVariants = cva(
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Badge({ function Badge({
className, className,
variant = "default", variant = "default",
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { const Comp = asChild ? Slot.Root : "span";
const Comp = asChild ? Slot.Root : "span"
return ( return (
<Comp <Comp data-slot="badge" data-variant={variant} className={cn(badgeVariants({ variant }), className)} {...props} />
data-slot="badge" );
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };
+12 -14
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Slot } from "radix-ui" import { Slot } from "radix-ui";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@@ -13,10 +13,8 @@ const buttonVariants = cva(
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40", "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
"bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
@@ -34,8 +32,8 @@ const buttonVariants = cva(
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function Button({ function Button({
className, className,
@@ -45,9 +43,9 @@ function Button({
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean;
}) { }) {
const Comp = asChild ? Slot.Root : "button" const Comp = asChild ? Slot.Root : "button";
return ( return (
<Comp <Comp
@@ -57,7 +55,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };
+14 -50
View File
@@ -1,17 +1,14 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn("flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm", className)}
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -20,72 +17,39 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return <div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props} />;
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return <div data-slot="card-description" className={cn("text-sm text-muted-foreground", className)} {...props} />;
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return <div data-slot="card-content" className={cn("px-6", className)} {...props} />;
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props} />
data-slot="card-footer" );
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
} }
export { export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+28 -48
View File
@@ -1,48 +1,37 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui" import { Dialog as DialogPrimitive } from "radix-ui";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
function Dialog({ function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
...props return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
} }
function DialogTrigger({ function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
...props return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
} }
function DialogPortal({ function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
...props return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
} }
function DialogClose({ function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
...props return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
} }
function DialogOverlay({ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", "fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -51,7 +40,7 @@ function DialogContent({
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal data-slot="dialog-portal"> <DialogPortal data-slot="dialog-portal">
@@ -60,7 +49,7 @@ function DialogContent({
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg", "fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
@@ -76,7 +65,7 @@ function DialogContent({
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -86,7 +75,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ function DialogFooter({
@@ -95,15 +84,12 @@ function DialogFooter({
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props} {...props}
> >
{children} {children}
@@ -113,33 +99,27 @@ function DialogFooter({
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
) );
} }
function DialogTitle({ function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -153,4 +133,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };
+41 -79
View File
@@ -1,33 +1,20 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
function DropdownMenu({ function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
...props return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
} }
function DropdownMenuPortal({ function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
...props return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
} }
function DropdownMenuTrigger({ function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
...props return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
} }
function DropdownMenuContent({ function DropdownMenuContent({
@@ -42,20 +29,16 @@ function DropdownMenuContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", "z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className className,
)} )}
{...props} {...props}
/> />
</DropdownMenuPrimitive.Portal> </DropdownMenuPrimitive.Portal>
) );
} }
function DropdownMenuGroup({ function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
...props return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
} }
function DropdownMenuItem({ function DropdownMenuItem({
@@ -64,8 +47,8 @@ function DropdownMenuItem({
variant = "default", variant = "default",
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean inset?: boolean;
variant?: "default" | "destructive" variant?: "default" | "destructive";
}) { }) {
return ( return (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
@@ -74,11 +57,11 @@ function DropdownMenuItem({
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!", "relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DropdownMenuCheckboxItem({ function DropdownMenuCheckboxItem({
@@ -92,7 +75,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item" data-slot="dropdown-menu-checkbox-item"
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
checked={checked} checked={checked}
{...props} {...props}
@@ -104,18 +87,11 @@ function DropdownMenuCheckboxItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.CheckboxItem> </DropdownMenuPrimitive.CheckboxItem>
) );
} }
function DropdownMenuRadioGroup({ function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
...props return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
} }
function DropdownMenuRadioItem({ function DropdownMenuRadioItem({
@@ -128,7 +104,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item" data-slot="dropdown-menu-radio-item"
className={cn( className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className className,
)} )}
{...props} {...props}
> >
@@ -139,7 +115,7 @@ function DropdownMenuRadioItem({
</span> </span>
{children} {children}
</DropdownMenuPrimitive.RadioItem> </DropdownMenuPrimitive.RadioItem>
) );
} }
function DropdownMenuLabel({ function DropdownMenuLabel({
@@ -147,54 +123,40 @@ function DropdownMenuLabel({
inset, inset,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
className={cn( className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSeparator({ function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return ( return (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator" data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)} className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuShortcut({ function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
className,
...props
}: React.ComponentProps<"span">) {
return ( return (
<span <span
data-slot="dropdown-menu-shortcut" data-slot="dropdown-menu-shortcut"
className={cn( className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props} {...props}
/> />
) );
} }
function DropdownMenuSub({ function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
...props return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
} }
function DropdownMenuSubTrigger({ function DropdownMenuSubTrigger({
@@ -203,7 +165,7 @@ function DropdownMenuSubTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & { }: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean inset?: boolean;
}) { }) {
return ( return (
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
@@ -211,14 +173,14 @@ function DropdownMenuSubTrigger({
data-inset={inset} data-inset={inset}
className={cn( className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", "flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) );
} }
function DropdownMenuSubContent({ function DropdownMenuSubContent({
@@ -230,11 +192,11 @@ function DropdownMenuSubContent({
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
className={cn( className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", "z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -253,4 +215,4 @@ export {
DropdownMenuSub, DropdownMenuSub,
DropdownMenuSubTrigger, DropdownMenuSubTrigger,
DropdownMenuSubContent, DropdownMenuSubContent,
} };
+5 -5
View File
@@ -1,5 +1,5 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return ( return (
@@ -10,11 +10,11 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30", "h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Input } export { Input };
+7 -10
View File
@@ -1,21 +1,18 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Label as LabelPrimitive } from "radix-ui" import { Label as LabelPrimitive } from "radix-ui";
function Label({ function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Label } export { Label };
+30 -58
View File
@@ -1,24 +1,18 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui" import { Select as SelectPrimitive } from "radix-ui";
function Select({ function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
...props return <SelectPrimitive.Root data-slot="select" {...props} />;
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
} }
function SelectGroup({ function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
...props return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
} }
function SelectValue({ function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
...props return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
} }
function SelectTrigger({ function SelectTrigger({
@@ -27,7 +21,7 @@ function SelectTrigger({
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: "sm" | "default";
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
@@ -35,7 +29,7 @@ function SelectTrigger({
data-size={size} data-size={size}
className={cn( className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground", "flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className className,
)} )}
{...props} {...props}
> >
@@ -44,7 +38,7 @@ function SelectTrigger({
<ChevronDownIcon className="size-4 opacity-50" /> <ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) );
} }
function SelectContent({ function SelectContent({
@@ -62,7 +56,7 @@ function SelectContent({
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", "relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" && position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className className,
)} )}
position={position} position={position}
align={align} align={align}
@@ -73,7 +67,7 @@ function SelectContent({
className={cn( className={cn(
"p-1", "p-1",
position === "popper" && position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)} )}
> >
{children} {children}
@@ -81,78 +75,59 @@ function SelectContent({
<SelectScrollDownButton /> <SelectScrollDownButton />
</SelectPrimitive.Content> </SelectPrimitive.Content>
</SelectPrimitive.Portal> </SelectPrimitive.Portal>
) );
} }
function SelectLabel({ function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-label" data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)} className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
function SelectItem({ function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return ( return (
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", "relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className className,
)} )}
{...props} {...props}
> >
<span <span data-slot="select-item-indicator" className="absolute right-2 flex size-3.5 items-center justify-center">
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
) );
} }
function SelectSeparator({ function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)} className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props} {...props}
/> />
) );
} }
function SelectScrollUpButton({ function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return ( return (
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn("flex cursor-default items-center justify-center py-1", className)}
"flex cursor-default items-center justify-center py-1",
className
)}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) );
} }
function SelectScrollDownButton({ function SelectScrollDownButton({
@@ -162,15 +137,12 @@ function SelectScrollDownButton({
return ( return (
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn("flex cursor-default items-center justify-center py-1", className)}
"flex cursor-default items-center justify-center py-1",
className
)}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) );
} }
export { export {
@@ -184,4 +156,4 @@ export {
SelectSeparator, SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} };
+7 -7
View File
@@ -1,8 +1,8 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Separator as SeparatorPrimitive } from "radix-ui" import { Separator as SeparatorPrimitive } from "radix-ui";
function Separator({ function Separator({
className, className,
@@ -17,11 +17,11 @@ function Separator({
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };
+3 -9
View File
@@ -1,13 +1,7 @@
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return <div data-slot="skeleton" className={cn("animate-pulse rounded-md bg-accent", className)} {...props} />;
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
} }
export { Skeleton } export { Skeleton };
+20 -58
View File
@@ -1,54 +1,32 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<"table">) {
return ( return (
<div <div data-slot="table-container" className="relative w-full overflow-x-auto">
data-slot="table-container" <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} />
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div> </div>
) );
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return ( return <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} />;
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return ( return <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return ( return (
<tfoot <tfoot
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props} {...props}
/> />
) );
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
@@ -57,11 +35,11 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted", "border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<"th">) {
@@ -70,11 +48,11 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<"td">) {
@@ -83,33 +61,17 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function TableCaption({ function TableCaption({ className, ...props }: React.ComponentProps<"caption">) {
className,
...props
}: React.ComponentProps<"caption">) {
return ( return (
<caption <caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} />
data-slot="table-caption" );
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
} }
export { export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+12 -25
View File
@@ -1,30 +1,17 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Tooltip as TooltipPrimitive } from "radix-ui" import { Tooltip as TooltipPrimitive } from "radix-ui";
function TooltipProvider({ function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
delayDuration = 0, return <TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />;
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
} }
function Tooltip({ function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
...props return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
} }
function TooltipTrigger({ function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
...props return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
} }
function TooltipContent({ function TooltipContent({
@@ -40,7 +27,7 @@ function TooltipContent({
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className className,
)} )}
{...props} {...props}
> >
@@ -48,7 +35,7 @@ function TooltipContent({
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" /> <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content> </TooltipPrimitive.Content>
</TooltipPrimitive.Portal> </TooltipPrimitive.Portal>
) );
} }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+136 -37
View File
@@ -103,15 +103,35 @@ body {
/* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */ /* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */
@keyframes fx-rise { @keyframes fx-rise {
from { opacity: 0; transform: translateY(8px); } from {
to { opacity: 1; transform: none; } opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
.fx-rise {
animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both;
} }
.fx-rise { animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; }
/* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */ /* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */
[data-rd="paper"] { --rd-bg: #faf8f4; --rd-fg: #292524; --rd-link: #9a3412; } [data-rd="paper"] {
[data-rd="sepia"] { --rd-bg: #f0e6d2; --rd-fg: #433a29; --rd-link: #92400e; } --rd-bg: #faf8f4;
[data-rd="night"] { --rd-bg: #171512; --rd-fg: #c6c0b6; --rd-link: #e0a458; } --rd-fg: #292524;
--rd-link: #9a3412;
}
[data-rd="sepia"] {
--rd-bg: #f0e6d2;
--rd-fg: #433a29;
--rd-link: #92400e;
}
[data-rd="night"] {
--rd-bg: #171512;
--rd-fg: #c6c0b6;
--rd-link: #e0a458;
}
.rd-surface { .rd-surface {
background: var(--rd-bg); background: var(--rd-bg);
color: var(--rd-fg); color: var(--rd-fg);
@@ -125,46 +145,125 @@ body {
@apply rounded-md px-2.5 py-1 text-sm transition-colors; @apply rounded-md px-2.5 py-1 text-sm transition-colors;
background: color-mix(in oklab, currentColor 8%, transparent); background: color-mix(in oklab, currentColor 8%, transparent);
} }
.rd-btn:hover { background: color-mix(in oklab, currentColor 16%, transparent); } .rd-btn:hover {
.rd-btn:disabled { @apply cursor-not-allowed opacity-40; } background: color-mix(in oklab, currentColor 16%, transparent);
}
.rd-btn:disabled {
@apply cursor-not-allowed opacity-40;
}
/* 目录侧边栏行 */ /* 目录侧边栏行 */
.rd-row { @apply block w-full truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors; } .rd-row {
.rd-row:hover { background: color-mix(in oklab, currentColor 12%, transparent); } @apply block w-full truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors;
.rd-btn-on { @apply font-semibold; background: color-mix(in oklab, currentColor 20%, transparent); } }
.rd-row:hover {
background: color-mix(in oklab, currentColor 12%, transparent);
}
.rd-btn-on {
@apply font-semibold;
background: color-mix(in oklab, currentColor 20%, transparent);
}
/* Radix 菜单项高亮(指针/键盘) */ /* Radix 菜单项高亮(指针/键盘) */
.rd-highlight { outline: none; } .rd-highlight {
.rd-highlight[data-highlighted] { background: color-mix(in oklab, currentColor 16%, transparent); } outline: none;
.rd-divider { border-color: color-mix(in oklab, currentColor 14%, transparent); } }
.rd-highlight[data-highlighted] {
background: color-mix(in oklab, currentColor 16%, transparent);
}
.rd-divider {
border-color: color-mix(in oklab, currentColor 14%, transparent);
}
/* 进度/页码滑条:主题色轨道 + 强调色拇指,触摸友好 */ /* 进度/页码滑条:主题色轨道 + 强调色拇指,触摸友好 */
.rd-range { @apply h-1.5 w-full cursor-pointer appearance-none rounded-full; background: color-mix(in oklab, currentColor 18%, transparent); } .rd-range {
@apply h-1.5 w-full cursor-pointer appearance-none rounded-full;
background: color-mix(in oklab, currentColor 18%, transparent);
}
.rd-range::-webkit-slider-thumb { .rd-range::-webkit-slider-thumb {
@apply h-4 w-4 appearance-none rounded-full ring-2 ring-black/10; background: var(--rd-link, #f59e0b); @apply h-4 w-4 appearance-none rounded-full ring-2 ring-black/10;
background: var(--rd-link, #f59e0b);
}
.rd-range::-moz-range-thumb {
@apply h-4 w-4 rounded-full border-0;
background: var(--rd-link, #f59e0b);
} }
.rd-range::-moz-range-thumb { @apply h-4 w-4 rounded-full border-0; background: var(--rd-link, #f59e0b); }
/* 沉浸态底部工具抽屉的滑入动效 */ /* 沉浸态底部工具抽屉的滑入动效 */
@keyframes rd-rise { from { transform: translateY(100%); opacity: 0.4; } to { transform: translateY(0); opacity: 1; } } @keyframes rd-rise {
.rd-sheet { animation: rd-rise 0.2s ease-out; } from {
transform: translateY(100%);
opacity: 0.4;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.rd-sheet {
animation: rd-rise 0.2s ease-out;
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.rd-sheet { animation: none; } .rd-sheet {
.rd-rise-anim { transition: none !important; } animation: none;
.fx-rise { animation: none; } }
.rd-rise-anim {
transition: none !important;
}
.fx-rise {
animation: none;
}
} }
/* markdown 正文(仅 .md-body 作用域) */ /* markdown 正文(仅 .md-body 作用域) */
.md-body h1 { @apply mt-8 mb-3 text-2xl font-semibold; } .md-body h1 {
.md-body h2 { @apply mt-6 mb-2 text-xl font-semibold; } @apply mt-8 mb-3 text-2xl font-semibold;
.md-body h3 { @apply mt-4 mb-2 text-base font-semibold; } }
.md-body p { @apply my-3; } .md-body h2 {
.md-body ul { @apply my-3 list-disc space-y-1 pl-6; } @apply mt-6 mb-2 text-xl font-semibold;
.md-body ol { @apply my-3 list-decimal space-y-1 pl-6; } }
.md-body blockquote { @apply my-3 border-l-2 pl-4; border-color: color-mix(in oklab, currentColor 30%, transparent); opacity: 0.75; } .md-body h3 {
.md-body code { @apply rounded px-1 py-0.5 text-[0.85em]; background: color-mix(in oklab, currentColor 10%, transparent); } @apply mt-4 mb-2 text-base font-semibold;
.md-body pre { @apply my-3 overflow-x-auto rounded-lg p-3; background: color-mix(in oklab, currentColor 6%, transparent); } }
.md-body pre code { @apply bg-transparent p-0; } .md-body p {
.md-body a { color: var(--rd-link, #f59e0b); @apply underline underline-offset-2; } @apply my-3;
.md-body img { @apply my-3 max-w-full rounded; } }
.md-body hr { @apply my-6; border-color: color-mix(in oklab, currentColor 15%, transparent); } .md-body ul {
.md-body table { @apply my-3 w-full border-collapse text-sm; } @apply my-3 list-disc space-y-1 pl-6;
.md-body th, .md-body td { @apply border px-2 py-1 text-left; border-color: color-mix(in oklab, currentColor 22%, transparent); } }
.md-body ol {
@apply my-3 list-decimal space-y-1 pl-6;
}
.md-body blockquote {
@apply my-3 border-l-2 pl-4;
border-color: color-mix(in oklab, currentColor 30%, transparent);
opacity: 0.75;
}
.md-body code {
@apply rounded px-1 py-0.5 text-[0.85em];
background: color-mix(in oklab, currentColor 10%, transparent);
}
.md-body pre {
@apply my-3 overflow-x-auto rounded-lg p-3;
background: color-mix(in oklab, currentColor 6%, transparent);
}
.md-body pre code {
@apply bg-transparent p-0;
}
.md-body a {
color: var(--rd-link, #f59e0b);
@apply underline underline-offset-2;
}
.md-body img {
@apply my-3 max-w-full rounded;
}
.md-body hr {
@apply my-6;
border-color: color-mix(in oklab, currentColor 15%, transparent);
}
.md-body table {
@apply my-3 w-full border-collapse text-sm;
}
.md-body th,
.md-body td {
@apply border px-2 py-1 text-left;
border-color: color-mix(in oklab, currentColor 22%, transparent);
}
+6 -7
View File
@@ -1,8 +1,4 @@
export type ProgressSend = ( export type ProgressSend = (locator: Record<string, unknown>, percent: number, keepalive: boolean) => Promise<unknown>;
locator: Record<string, unknown>,
percent: number,
keepalive: boolean,
) => Promise<unknown>;
export interface ProgressSaver { export interface ProgressSaver {
report: (locator: Record<string, unknown>, percent: number) => void; report: (locator: Record<string, unknown>, percent: number) => void;
@@ -41,11 +37,14 @@ export function createProgressSaver(send: ProgressSend, intervalMs = 5000): Prog
lastAt = t; lastAt = t;
doSend(false); doSend(false);
} else { } else {
timer = setTimeout(() => { timer = setTimeout(
() => {
timer = null; timer = null;
lastAt = Date.now(); lastAt = Date.now();
doSend(false); doSend(false);
}, intervalMs - (t - lastAt)); },
intervalMs - (t - lastAt),
);
} }
}, },
flush() { flush() {
+2 -1
View File
@@ -8,7 +8,8 @@ export type GroupMode = "flat" | "dir";
export function sortBooks(books: Book[], key: SortKey): Book[] { export function sortBooks(books: Book[], key: SortKey): Book[] {
const a = [...books]; const a = [...books];
if (key === "title") return a.sort((x, y) => x.title.localeCompare(y.title, "zh")); if (key === "title") return a.sort((x, y) => x.title.localeCompare(y.title, "zh"));
if (key === "progress") return a.sort((x, y) => y.percent - x.percent || (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0)); if (key === "progress")
return a.sort((x, y) => y.percent - x.percent || (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
return a.sort((x, y) => (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0)); return a.sort((x, y) => (x.added_at < y.added_at ? 1 : x.added_at > y.added_at ? -1 : 0));
} }
+14 -2
View File
@@ -47,11 +47,23 @@ export default function Login() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="login-user">用户名</Label> <Label htmlFor="login-user">用户名</Label>
<Input id="login-user" value={u} onChange={(e) => setU(e.target.value)} autoFocus autoComplete="username" /> <Input
id="login-user"
value={u}
onChange={(e) => setU(e.target.value)}
autoFocus
autoComplete="username"
/>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="login-pass">密码</Label> <Label htmlFor="login-pass">密码</Label>
<Input id="login-pass" type="password" value={p} onChange={(e) => setP(e.target.value)} autoComplete="current-password" /> <Input
id="login-pass"
type="password"
value={p}
onChange={(e) => setP(e.target.value)}
autoComplete="current-password"
/>
</div> </div>
<Button type="submit" className="w-full" disabled={busy || !u || !p}> <Button type="submit" className="w-full" disabled={busy || !u || !p}>
{busy ? "登录中…" : "登录"} {busy ? "登录中…" : "登录"}
+130 -26
View File
@@ -1,7 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { ArrowDownUp, Ellipsis, FolderOpen, LibraryBig, RefreshCw, ScanLine, Search, TriangleAlert } from "lucide-react"; import {
ArrowDownUp,
Ellipsis,
FolderOpen,
LibraryBig,
RefreshCw,
ScanLine,
Search,
TriangleAlert,
} from "lucide-react";
import { api } from "@/api/client"; import { api } from "@/api/client";
import type { Book } from "@/api/types"; import type { Book } from "@/api/types";
import { useAuth } from "@/auth/AuthContext"; import { useAuth } from "@/auth/AuthContext";
@@ -10,30 +19,60 @@ import { toast } from "@/components/Toaster";
import { UploadButton } from "@/components/upload-button"; import { UploadButton } from "@/components/upload-button";
import { formatBadge } from "@/components/ui"; import { formatBadge } from "@/components/ui";
import { import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialog,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { formatSize } from "@/lib/format"; import { formatSize } from "@/lib/format";
import { sections, SORT_LABEL, type GroupMode, type SortKey } from "@/lib/shelf"; import { sections, SORT_LABEL, type GroupMode, type SortKey } from "@/lib/shelf";
import { GROUPS, LIB0, SORTS } from "@/lib/shelfMeta"; import { GROUPS, LIB0, SORTS } from "@/lib/shelfMeta";
const GRID = "grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-x-4 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]"; const GRID =
"grid grid-cols-[repeat(auto-fill,minmax(7.5rem,1fr))] gap-x-4 gap-y-6 sm:grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]";
function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number; onOpen: () => void; onDelete: () => void }) { function BookCard({
book,
index,
onOpen,
onDelete,
}: {
book: Book;
index: number;
onOpen: () => void;
onDelete: () => void;
}) {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const pct = Math.round(book.percent * 100); const pct = Math.round(book.percent * 100);
return ( return (
<div className="group fx-rise relative flex flex-col" style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }}> <div className="group fx-rise relative flex flex-col" style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }}>
<button onClick={onOpen} className="block w-full rounded-md p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"> <button
onClick={onOpen}
className="block w-full rounded-md p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50"> <span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50">
<Cover book={book} className="size-full" /> <Cover book={book} className="size-full" />
<span className={formatBadge(book.format, "absolute left-1.5 top-1.5")}>{book.format}</span> <span className={formatBadge(book.format, "absolute left-1.5 top-1.5")}>{book.format}</span>
{book.state === "error" && ( {book.state === "error" && (
<span className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-destructive-foreground" title={book.error}> <span
className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-destructive-foreground"
title={book.error}
>
<TriangleAlert className="size-3" /> 损坏 <TriangleAlert className="size-3" /> 损坏
</span> </span>
)} )}
@@ -45,13 +84,20 @@ function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number
</span> </span>
<span className="mt-2 block truncate text-xs font-medium">{book.title}</span> <span className="mt-2 block truncate text-xs font-medium">{book.title}</span>
<span className="block truncate text-[11px] text-muted-foreground"> <span className="block truncate text-[11px] text-muted-foreground">
{book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`} {book.path.includes("/")
? book.path.slice(0, book.path.lastIndexOf("/"))
: `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`}
</span> </span>
</button> </button>
<div className="absolute right-1 top-1 z-10 opacity-100 transition-opacity md:opacity-0 md:focus-within:opacity-100 md:group-hover:opacity-100"> <div className="absolute right-1 top-1 z-10 opacity-100 transition-opacity md:opacity-0 md:focus-within:opacity-100 md:group-hover:opacity-100">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="secondary" size="icon" className="size-7 bg-background/80 backdrop-blur" aria-label={`《${book.title}》操作`}> <Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={`《${book.title}》操作`}
>
<Ellipsis className="size-4" /> <Ellipsis className="size-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -99,18 +145,24 @@ export default function Shelf() {
useEffect(() => { useEffect(() => {
if (qRaw.trim() === serverQ) return; if (qRaw.trim() === serverQ) return;
const t = setTimeout(() => { const t = setTimeout(() => {
setSp((prev) => { setSp(
(prev) => {
const next = new URLSearchParams(prev); const next = new URLSearchParams(prev);
if (qRaw.trim()) next.set("q", qRaw.trim()); if (qRaw.trim()) next.set("q", qRaw.trim());
else next.delete("q"); else next.delete("q");
return next; return next;
}, { replace: true }); },
{ replace: true },
);
}, 300); }, 300);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [qRaw, serverQ, sp, setSp]); }, [qRaw, serverQ, sp, setSp]);
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const booksQ = useQuery({ queryKey: ["books", lib, serverQ], queryFn: () => api.listBooks({ library: lib || undefined, q: serverQ }) }); const booksQ = useQuery({
queryKey: ["books", lib, serverQ],
queryFn: () => api.listBooks({ library: lib || undefined, q: serverQ }),
});
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress }); const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
const allQ = useQuery({ queryKey: ["books", LIB0, ""], queryFn: () => api.listBooks() }); const allQ = useQuery({ queryKey: ["books", LIB0, ""], queryFn: () => api.listBooks() });
@@ -145,14 +197,21 @@ export default function Shelf() {
else next.delete(k); else next.delete(k);
setSp(next, { replace: true }); setSp(next, { replace: true });
}; };
const libName = lib === LIB0 ? "全部" : libsQ.data?.find((l) => l.id === lib)?.name ?? "书库"; const libName = lib === LIB0 ? "全部" : (libsQ.data?.find((l) => l.id === lib)?.name ?? "书库");
return ( return (
<div className="mx-auto flex w-full max-w-6xl flex-col px-4 pb-20 sm:px-6 md:pb-6"> <div className="mx-auto flex w-full max-w-6xl flex-col px-4 pb-20 sm:px-6 md:pb-6">
<div className="sticky top-0 z-20 -mx-4 mb-6 flex flex-wrap items-center gap-2 border-b bg-background/85 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6"> <div className="sticky top-0 z-20 -mx-4 mb-6 flex flex-wrap items-center gap-2 border-b bg-background/85 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6">
<div className="relative min-w-0 flex-1 sm:max-w-64"> <div className="relative min-w-0 flex-1 sm:max-w-64">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" /> <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input className="pl-8" type="search" aria-label="搜索标题" placeholder="搜索标题…" value={qRaw} onChange={(e) => setQRaw(e.target.value)} /> <Input
className="pl-8"
type="search"
aria-label="搜索标题"
placeholder="搜索标题…"
value={qRaw}
onChange={(e) => setQRaw(e.target.value)}
/>
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -164,7 +223,11 @@ export default function Shelf() {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuLabel>排序</DropdownMenuLabel> <DropdownMenuLabel>排序</DropdownMenuLabel>
{SORTS.map((k) => ( {SORTS.map((k) => (
<DropdownMenuItem key={k} onSelect={() => setParam("sort", k === "added" ? "" : k)} className={k === sort ? "font-medium text-accent-strong" : ""}> <DropdownMenuItem
key={k}
onSelect={() => setParam("sort", k === "added" ? "" : k)}
className={k === sort ? "font-medium text-accent-strong" : ""}
>
{SORT_LABEL[k]} {SORT_LABEL[k]}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
@@ -172,7 +235,14 @@ export default function Shelf() {
</DropdownMenu> </DropdownMenu>
<div role="group" aria-label="分组视图" className="flex rounded-md border p-0.5"> <div role="group" aria-label="分组视图" className="flex rounded-md border p-0.5">
{GROUPS.map((g) => ( {GROUPS.map((g) => (
<Button key={g.v} size="sm" variant={group === g.v ? "secondary" : "ghost"} aria-pressed={group === g.v} className="px-2.5" onClick={() => setParam("group", g.v === "flat" ? "" : g.v)}> <Button
key={g.v}
size="sm"
variant={group === g.v ? "secondary" : "ghost"}
aria-pressed={group === g.v}
className="px-2.5"
onClick={() => setParam("group", g.v === "flat" ? "" : g.v)}
>
{g.label} {g.label}
</Button> </Button>
))} ))}
@@ -180,13 +250,24 @@ export default function Shelf() {
{isAdmin && lib > LIB0 && ( {isAdmin && lib > LIB0 && (
<> <>
<UploadButton libId={lib} variant="outline" label="上传文件" /> <UploadButton libId={lib} variant="outline" label="上传文件" />
<Button variant="outline" size="sm" disabled={scan.isPending} onClick={() => scan.mutate(lib)} className="gap-1.5"> <Button
variant="outline"
size="sm"
disabled={scan.isPending}
onClick={() => scan.mutate(lib)}
className="gap-1.5"
>
<ScanLine className="size-4" /> <ScanLine className="size-4" />
{scan.isPending ? "扫描中…" : "扫描此库"} {scan.isPending ? "扫描中…" : "扫描此库"}
</Button> </Button>
</> </>
)} )}
<Button variant="ghost" size="icon" aria-label="刷新书目" onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}> <Button
variant="ghost"
size="icon"
aria-label="刷新书目"
onClick={() => qc.invalidateQueries({ queryKey: ["books"] })}
>
<RefreshCw className="size-4" /> <RefreshCw className="size-4" />
</Button> </Button>
</div> </div>
@@ -208,18 +289,31 @@ export default function Shelf() {
const b = booksByKey.get(`${r.library_id}/${r.path}`); const b = booksByKey.get(`${r.library_id}/${r.path}`);
if (!b) if (!b)
return ( return (
<li key={`${r.library_id}/${r.path}`} className="fx-rise w-40 shrink-0 rounded-md border bg-card p-3 text-xs text-muted-foreground" style={{ animationDelay: `${i * 40}ms` }}> <li
key={`${r.library_id}/${r.path}`}
className="fx-rise w-40 shrink-0 rounded-md border bg-card p-3 text-xs text-muted-foreground"
style={{ animationDelay: `${i * 40}ms` }}
>
<p className="truncate">{r.title || "已删除的书"}</p> <p className="truncate">{r.title || "已删除的书"}</p>
<p className="mt-1 text-[10px]">进度已保留</p> <p className="mt-1 text-[10px]">进度已保留</p>
</li> </li>
); );
const pct = Math.round(r.percent * 100); const pct = Math.round(r.percent * 100);
return ( return (
<li key={`${r.library_id}/${r.path}`} className="fx-rise shrink-0" style={{ animationDelay: `${i * 40}ms` }}> <li
<button onClick={() => nav(`/book/${b.id}`)} className="group flex w-56 items-center gap-3 rounded-md border bg-card p-2 text-left transition-colors hover:bg-secondary focus-visible:outline-2 focus-visible:outline-accent"> key={`${r.library_id}/${r.path}`}
className="fx-rise shrink-0"
style={{ animationDelay: `${i * 40}ms` }}
>
<button
onClick={() => nav(`/book/${b.id}`)}
className="group flex w-56 items-center gap-3 rounded-md border bg-card p-2 text-left transition-colors hover:bg-secondary focus-visible:outline-2 focus-visible:outline-accent"
>
<Cover book={b} className="aspect-[3/4] w-11 shrink-0 rounded" /> <Cover book={b} className="aspect-[3/4] w-11 shrink-0 rounded" />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium group-hover:text-accent-strong">{b.title}</span> <span className="block truncate text-sm font-medium group-hover:text-accent-strong">
{b.title}
</span>
<span className="mt-0.5 flex justify-between text-[11px] text-muted-foreground"> <span className="mt-0.5 flex justify-between text-[11px] text-muted-foreground">
<span>{r.library}</span> <span>{r.library}</span>
<span className="tabular-nums">{pct}%</span> <span className="tabular-nums">{pct}%</span>
@@ -261,8 +355,12 @@ export default function Shelf() {
{!booksQ.isPending && secs.every((s) => s.books.length === 0) && !booksQ.isError && ( {!booksQ.isPending && secs.every((s) => s.books.length === 0) && !booksQ.isError && (
<div className="fx-rise flex flex-col items-center py-20 text-center"> <div className="fx-rise flex flex-col items-center py-20 text-center">
<FolderOpen className="size-10 text-muted-foreground/60" /> <FolderOpen className="size-10 text-muted-foreground/60" />
<h3 className="mt-3 text-sm font-medium">{searching ? `没有匹配「${serverQ}」的书` : `${libName}暂无书目`}</h3> <h3 className="mt-3 text-sm font-medium">
<p className="mt-1 text-sm text-muted-foreground">{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}</p> {searching ? `没有匹配「${serverQ}」的书` : `${libName}暂无书目`}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}
</p>
</div> </div>
)} )}
@@ -271,7 +369,13 @@ export default function Shelf() {
<SectionHead label={s.label} count={s.books.length} /> <SectionHead label={s.label} count={s.books.length} />
<div className={GRID}> <div className={GRID}>
{s.books.map((b, i) => ( {s.books.map((b, i) => (
<BookCard key={b.id} book={b} index={i} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => setConfirmDel(b)} /> <BookCard
key={b.id}
book={b}
index={i}
onOpen={() => nav(`/book/${b.id}`)}
onDelete={() => setConfirmDel(b)}
/>
))} ))}
</div> </div>
</section> </section>
+6 -1
View File
@@ -47,7 +47,12 @@ export default function AdminLibraries() {
<div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6"> <div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6">
<h1 className="mb-4 text-lg font-semibold tracking-tight">库管理</h1> <h1 className="mb-4 text-lg font-semibold tracking-tight">库管理</h1>
<form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3"> <form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3">
<Input className="max-w-64 flex-1" placeholder="库名(服务端目录自动落在 BOOKS_DIR/<库名>)" value={name} onChange={(e) => setName(e.target.value)} /> <Input
className="max-w-64 flex-1"
placeholder="库名(服务端目录自动落在 BOOKS_DIR/<库名>)"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<Button type="submit" disabled={create.isPending || !name.trim()}> <Button type="submit" disabled={create.isPending || !name.trim()}>
{create.isPending ? "创建中…" : "建库"} {create.isPending ? "创建中…" : "建库"}
</Button> </Button>
+30 -6
View File
@@ -4,8 +4,15 @@ import { api } from "../../api/client";
import { useAuth } from "../../auth/AuthContext"; import { useAuth } from "../../auth/AuthContext";
import { toast } from "../../components/Toaster"; import { toast } from "../../components/Toaster";
import { import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialog,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -50,9 +57,17 @@ export default function AdminUsers() {
<h1 className="mb-4 text-lg font-semibold tracking-tight">用户管理</h1> <h1 className="mb-4 text-lg font-semibold tracking-tight">用户管理</h1>
<form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3"> <form onSubmit={submit} className="mb-6 flex flex-wrap items-center gap-3">
<Input className="max-w-40" placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} /> <Input className="max-w-40" placeholder="用户名" value={u} onChange={(e) => setU(e.target.value)} />
<Input className="max-w-48" type="password" placeholder="密码(≥8 位)" value={p} onChange={(e) => setP(e.target.value)} /> <Input
className="max-w-48"
type="password"
placeholder="密码(≥8 位)"
value={p}
onChange={(e) => setP(e.target.value)}
/>
<Select value={role} onValueChange={(v) => setRole(v as "member" | "admin")}> <Select value={role} onValueChange={(v) => setRole(v as "member" | "admin")}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger> <SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="member">member</SelectItem> <SelectItem value="member">member</SelectItem>
<SelectItem value="admin">admin</SelectItem> <SelectItem value="admin">admin</SelectItem>
@@ -85,7 +100,13 @@ export default function AdminUsers() {
<TableCell className="text-right"> <TableCell className="text-right">
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" disabled={x.id === me?.id} title={x.id === me?.id ? "不能删除自己" : undefined}> <Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={x.id === me?.id}
title={x.id === me?.id ? "不能删除自己" : undefined}
>
删除 删除
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
@@ -96,7 +117,10 @@ export default function AdminUsers() {
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel> <AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={() => del.mutate(x.id)}> <AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => del.mutate(x.id)}
>
删除 删除
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
+5 -3
View File
@@ -58,8 +58,7 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
</button> </button>
</div> </div>
); );
if (!src) if (!src) return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
return ( return (
<img <img
src={src} src={src}
@@ -408,7 +407,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
)} )}
{chapters && chapters.length >= 2 && ( {chapters && chapters.length >= 2 && (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger className="rd-btn ml-auto inline-flex items-center gap-1.5" aria-label="更多阅读选项"> <DropdownMenu.Trigger
className="rd-btn ml-auto inline-flex items-center gap-1.5"
aria-label="更多阅读选项"
>
<Settings2 size={14} /> 更多 <Settings2 size={14} /> 更多
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Portal> <DropdownMenu.Portal>
+3 -1
View File
@@ -98,7 +98,9 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
<div className="relative grid h-full grid-rows-[1fr_auto]"> <div className="relative grid h-full grid-rows-[1fr_auto]">
<div className="relative mx-auto min-h-0 w-full max-w-4xl bg-white shadow-2xl ring-1 ring-stone-800"> <div className="relative mx-auto min-h-0 w-full max-w-4xl bg-white shadow-2xl ring-1 ring-stone-800">
<div ref={hostRef} className="absolute inset-0" /> <div ref={hostRef} className="absolute inset-0" />
{!ready && !err && <div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>} {!ready && !err && (
<div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>
)}
</div> </div>
<div className="flex items-center justify-center gap-3 border-t border-stone-800 px-4 py-2 text-sm"> <div className="flex items-center justify-center gap-3 border-t border-stone-800 px-4 py-2 text-sm">
<button className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.prev()}> <button className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.prev()}>
+25 -8
View File
@@ -8,10 +8,9 @@ import { btn } from "../components/ui";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
// Vite 原生 worker 语法;pdfjs v5 要求 module worker // Vite 原生 worker 语法;pdfjs v5 要求 module worker
pdfjs.GlobalWorkerOptions.workerPort = new Worker( pdfjs.GlobalWorkerOptions.workerPort = new Worker(new URL("pdfjs-dist/build/pdf.worker.mjs", import.meta.url), {
new URL("pdfjs-dist/build/pdf.worker.mjs", import.meta.url), type: "module",
{ type: "module" }, });
);
type RenderTask = { cancel(): void; promise: Promise<void> }; type RenderTask = { cancel(): void; promise: Promise<void> };
@@ -140,19 +139,37 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
<canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" /> <canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" />
</div> </div>
<div className="flex items-center justify-center gap-2 border-t border-stone-800/80 px-4 py-2 text-sm"> <div className="flex items-center justify-center gap-2 border-t border-stone-800/80 px-4 py-2 text-sm">
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page === 0} onClick={() => setPage(0)} aria-label="第一页"> <button
className={btn + " inline-flex items-center gap-0.5"}
disabled={page === 0}
onClick={() => setPage(0)}
aria-label="第一页"
>
<ChevronsLeft size={14} /> 首页 <ChevronsLeft size={14} /> 首页
</button> </button>
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page === 0} onClick={() => setPage((p) => Math.max(0, p - 1))}> <button
className={btn + " inline-flex items-center gap-0.5"}
disabled={page === 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeft size={14} /> 上一页 <ChevronLeft size={14} /> 上一页
</button> </button>
<span className="min-w-16 text-center tabular-nums text-stone-400"> <span className="min-w-16 text-center tabular-nums text-stone-400">
{page + 1}/{num} {page + 1}/{num}
</span> </span>
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page >= num - 1} onClick={() => setPage((p) => Math.min(num - 1, p + 1))}> <button
className={btn + " inline-flex items-center gap-0.5"}
disabled={page >= num - 1}
onClick={() => setPage((p) => Math.min(num - 1, p + 1))}
>
下一页 <ChevronRight size={14} /> 下一页 <ChevronRight size={14} />
</button> </button>
<button className={btn + " inline-flex items-center gap-0.5"} disabled={page >= num - 1} onClick={() => setPage(num - 1)} aria-label="最后一页"> <button
className={btn + " inline-flex items-center gap-0.5"}
disabled={page >= num - 1}
onClick={() => setPage(num - 1)}
aria-label="最后一页"
>
<ChevronsRight size={14} /> 末页 <ChevronsRight size={14} /> 末页
</button> </button>
<button <button
+8 -11
View File
@@ -8,13 +8,7 @@ import { ReaderNav, type NavTab } from "../components/reader-nav";
import { btn } from "../components/ui"; import { btn } from "../components/ui";
import { chapterText, seekChapter, splitChapters } from "../lib/chapters"; import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
import { decodeText } from "../lib/encoding"; import { decodeText } from "../lib/encoding";
import { import { FONT_SIZES, RD_THEMES, RD_THEME_LABEL, useReaderPrefs, type RdTheme } from "../lib/readerPrefs";
FONT_SIZES,
RD_THEMES,
RD_THEME_LABEL,
useReaderPrefs,
type RdTheme,
} from "../lib/readerPrefs";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
type Prefs = ReturnType<typeof useReaderPrefs>; type Prefs = ReturnType<typeof useReaderPrefs>;
@@ -80,9 +74,7 @@ function ReaderSheet({
className="rd-range" className="rd-range"
aria-label="阅读进度" aria-label="阅读进度"
/> />
<span className="w-10 shrink-0 text-right text-xs tabular-nums opacity-70"> <span className="w-10 shrink-0 text-right text-xs tabular-nums opacity-70">{Math.round(frac * 100)}%</span>
{Math.round(frac * 100)}%
</span>
</div> </div>
<div className="mt-1 flex items-center gap-2"> <div className="mt-1 flex items-center gap-2">
{children} {children}
@@ -105,7 +97,12 @@ function ReaderSheet({
/> />
))} ))}
</div> </div>
<button className="rd-btn" onClick={() => pr.bump(-1)} disabled={pr.prefs.sizeIdx === 0} aria-label="减小字号"> <button
className="rd-btn"
onClick={() => pr.bump(-1)}
disabled={pr.prefs.sizeIdx === 0}
aria-label="减小字号"
>
A− A−
</button> </button>
<button <button
+6 -2
View File
@@ -41,8 +41,12 @@ it("短文无标记 → 单节,chapterText 即全文", () => {
it("卷不感知:只按章切,卷题行留在正文里", () => { it("卷不感知:只按章切,卷题行留在正文里", () => {
const t = const t =
"第一卷 天云世界\n第一章 铜镜\n" + pad(500) + "\n第二章 修炼\n" + pad(500) + "第一卷 天云世界\n第一章 铜镜\n" +
"\n\n\n第二卷 仙界\n第三章 飞升\n" + pad(300); pad(500) +
"\n第二章 修炼\n" +
pad(500) +
"\n\n\n第二卷 仙界\n第三章 飞升\n" +
pad(300);
const chs = splitChapters(t); const chs = splitChapters(t);
expect(chs.map((c) => c.title)).toEqual(["第一章 铜镜", "第二章 修炼", "第三章 飞升"]); expect(chs.map((c) => c.title)).toEqual(["第一章 铜镜", "第二章 修炼", "第三章 飞升"]);
expect(chs[0].start).toBe(t.indexOf("第一章")); expect(chs[0].start).toBe(t.indexOf("第一章"));
+13 -2
View File
@@ -4,8 +4,19 @@ import type { Book } from "../src/api/types";
let seq = 1; let seq = 1;
const mk = (path: string): Book => ({ const mk = (path: string): Book => ({
id: seq++, library_id: 1, path, title: path, format: "cbz", size: 1, mtime: 1, pages: 0, id: seq++,
state: "ready", error: "", added_at: "", percent: 0, cover_url: "/api/books/0/cover", library_id: 1,
path,
title: path,
format: "cbz",
size: 1,
mtime: 1,
pages: 0,
state: "ready",
error: "",
added_at: "",
percent: 0,
cover_url: "/api/books/0/cover",
}); });
it("根目录组为空串且排最前;目录组按字典序;组内保持输入顺序", () => { it("根目录组为空串且排最前;目录组按字典序;组内保持输入顺序", () => {
+29 -4
View File
@@ -3,20 +3,45 @@ import type { Book } from "../src/api/types";
import { sections, sortBooks } from "../src/lib/shelf"; import { sections, sortBooks } from "../src/lib/shelf";
const b = (over: Partial<Book>): Book => const b = (over: Partial<Book>): Book =>
({ id: 1, path: "p", title: "t", format: "cbz", size: 1, pages: 1, percent: 0, added_at: "2026-01-01T00:00:00Z", library_id: 1, state: "ready", cover_url: "", page_url_fmt: "", pages_url: "", error: "", ...over }) as Book; ({
id: 1,
path: "p",
title: "t",
format: "cbz",
size: 1,
pages: 1,
percent: 0,
added_at: "2026-01-01T00:00:00Z",
library_id: 1,
state: "ready",
cover_url: "",
page_url_fmt: "",
pages_url: "",
error: "",
...over,
}) as Book;
it("sortBooks: added 新→旧 / title zh 本地化 / progress 高→低且并列按 added", () => { it("sortBooks: added 新→旧 / title zh 本地化 / progress 高→低且并列按 added", () => {
const old = b({ id: 1, added_at: "2026-01-01T00:00:00Z", title: "b" }); const old = b({ id: 1, added_at: "2026-01-01T00:00:00Z", title: "b" });
const nw = b({ id: 2, added_at: "2026-02-01T00:00:00Z", title: "a" }); const nw = b({ id: 2, added_at: "2026-02-01T00:00:00Z", title: "a" });
expect(sortBooks([old, nw], "added").map((x) => x.id)).toEqual([2, 1]); expect(sortBooks([old, nw], "added").map((x) => x.id)).toEqual([2, 1]);
expect(sortBooks([old, nw], "title").map((x) => x.id)).toEqual([2, 1]); expect(sortBooks([old, nw], "title").map((x) => x.id)).toEqual([2, 1]);
expect(sortBooks([b({ id: 3, percent: 0.2 }), b({ id: 4, percent: 0.9 })], "progress").map((x) => x.id)).toEqual([4, 3]); expect(sortBooks([b({ id: 3, percent: 0.2 }), b({ id: 4, percent: 0.9 })], "progress").map((x) => x.id)).toEqual([
const tie = [b({ id: 5, percent: 0.5, added_at: "2026-01-01T00:00:00Z" }), b({ id: 6, percent: 0.5, added_at: "2026-03-01T00:00:00Z" })]; 4, 3,
]);
const tie = [
b({ id: 5, percent: 0.5, added_at: "2026-01-01T00:00:00Z" }),
b({ id: 6, percent: 0.5, added_at: "2026-03-01T00:00:00Z" }),
];
expect(sortBooks(tie, "progress").map((x) => x.id)).toEqual([6, 5]); expect(sortBooks(tie, "progress").map((x) => x.id)).toEqual([6, 5]);
}); });
it("sections: flat 单段无标题;dir 按目录分组且组内排序、根目录置顶", () => { it("sections: flat 单段无标题;dir 按目录分组且组内排序、根目录置顶", () => {
const books = [b({ id: 1, path: "x/a.cbz" }), b({ id: 2, path: "b.cbz", title: "B" }), b({ id: 3, path: "x/c.cbz", title: "A" })]; const books = [
b({ id: 1, path: "x/a.cbz" }),
b({ id: 2, path: "b.cbz", title: "B" }),
b({ id: 3, path: "x/c.cbz", title: "A" }),
];
// 容器 Intl zh 实测序 A < B < t (localeCompare("zh")),故 title 升序为 id3,id2,id1 // 容器 Intl zh 实测序 A < B < t (localeCompare("zh")),故 title 升序为 id3,id2,id1
expect(sections(books, "title", "flat")).toEqual([{ label: "", books: [books[2], books[1], books[0]] }]); expect(sections(books, "title", "flat")).toEqual([{ label: "", books: [books[2], books[1], books[0]] }]);
const g = sections(books, "title", "dir"); const g = sections(books, "title", "dir");