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
+134 -30
View File
@@ -1,7 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
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 type { Book } from "@/api/types";
import { useAuth } from "@/auth/AuthContext";
@@ -10,30 +19,60 @@ import { toast } from "@/components/Toaster";
import { UploadButton } from "@/components/upload-button";
import { formatBadge } from "@/components/ui";
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
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 { Skeleton } from "@/components/ui/skeleton";
import { formatSize } from "@/lib/format";
import { sections, SORT_LABEL, type GroupMode, type SortKey } from "@/lib/shelf";
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 pct = Math.round(book.percent * 100);
return (
<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">
<Cover book={book} className="size-full" />
<span className={formatBadge(book.format, "absolute left-1.5 top-1.5")}>{book.format}</span>
{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" /> 损坏
</span>
)}
@@ -45,13 +84,20 @@ function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number
</span>
<span className="mt-2 block truncate text-xs font-medium">{book.title}</span>
<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>
</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">
<DropdownMenu>
<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" />
</Button>
</DropdownMenuTrigger>
@@ -99,18 +145,24 @@ export default function Shelf() {
useEffect(() => {
if (qRaw.trim() === serverQ) return;
const t = setTimeout(() => {
setSp((prev) => {
const next = new URLSearchParams(prev);
if (qRaw.trim()) next.set("q", qRaw.trim());
else next.delete("q");
return next;
}, { replace: true });
setSp(
(prev) => {
const next = new URLSearchParams(prev);
if (qRaw.trim()) next.set("q", qRaw.trim());
else next.delete("q");
return next;
},
{ replace: true },
);
}, 300);
return () => clearTimeout(t);
}, [qRaw, serverQ, sp, setSp]);
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 allQ = useQuery({ queryKey: ["books", LIB0, ""], queryFn: () => api.listBooks() });
@@ -145,14 +197,21 @@ export default function Shelf() {
else next.delete(k);
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 (
<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="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" />
<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>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -164,7 +223,11 @@ export default function Shelf() {
<DropdownMenuContent align="end">
<DropdownMenuLabel>排序</DropdownMenuLabel>
{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]}
</DropdownMenuItem>
))}
@@ -172,7 +235,14 @@ export default function Shelf() {
</DropdownMenu>
<div role="group" aria-label="分组视图" className="flex rounded-md border p-0.5">
{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}
</Button>
))}
@@ -180,13 +250,24 @@ export default function Shelf() {
{isAdmin && lib > LIB0 && (
<>
<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" />
{scan.isPending ? "扫描中…" : "扫描此库"}
</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" />
</Button>
</div>
@@ -208,18 +289,31 @@ export default function Shelf() {
const b = booksByKey.get(`${r.library_id}/${r.path}`);
if (!b)
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="mt-1 text-[10px]">进度已保留</p>
</li>
);
const pct = Math.round(r.percent * 100);
return (
<li 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">
<li
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" />
<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>{r.library}</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 && (
<div className="fx-rise flex flex-col items-center py-20 text-center">
<FolderOpen className="size-10 text-muted-foreground/60" />
<h3 className="mt-3 text-sm font-medium">{searching ? `没有匹配「${serverQ}」的书` : `${libName}暂无书目`}</h3>
<p className="mt-1 text-sm text-muted-foreground">{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}</p>
<h3 className="mt-3 text-sm font-medium">
{searching ? `没有匹配「${serverQ}」的书` : `${libName}暂无书目`}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}
</p>
</div>
)}
@@ -271,7 +369,13 @@ export default function Shelf() {
<SectionHead label={s.label} count={s.books.length} />
<div className={GRID}>
{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>
</section>