feat(ui): shelf rebuilt — sticky toolbar (search/sort/group in URL), rail, menu cards, alert-dialog delete

This commit is contained in:
2026-09-08 18:58:16 +08:00
parent d9e9e95ece
commit b1c0db6c03
2 changed files with 233 additions and 210 deletions
+8
View File
@@ -0,0 +1,8 @@
import type { GroupMode, SortKey } from "./shelf";
export const SORTS: SortKey[] = ["added", "title", "progress"];
export const GROUPS: { v: GroupMode; label: string }[] = [
{ v: "flat", label: "平铺" },
{ v: "dir", label: "按目录" },
];
export const LIB0 = 0;
+225 -210
View File
@@ -1,88 +1,84 @@
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 } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { api } from "../api/client"; import { ArrowDownUp, Ellipsis, FolderOpen, LibraryBig, RefreshCw, ScanLine, Search, TriangleAlert } from "lucide-react";
import type { Book } from "../api/types"; import { api } from "@/api/client";
import { useAuth } from "../auth/AuthContext"; import type { Book } from "@/api/types";
import { Cover } from "../components/Cover"; import { useAuth } from "@/auth/AuthContext";
import { IconAlert, IconBookOpen, IconClock, IconFolder, IconRefresh, IconScan, IconSearch, IconX } from "../components/icons"; import { Cover } from "@/components/Cover";
import { TopBar } from "../components/TopBar"; import { toast } from "@/components/Toaster";
import { toast } from "../components/Toaster"; import { formatBadge } from "@/components/ui";
import { btn, card, formatBadge, formatSize, input, pill, pillActive } from "../components/ui"; import {
import { groupByDir } from "../lib/group"; 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 { 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";
function BookCard({ 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))]";
book,
index, function BookCard({ book, index, onOpen, onDelete }: { book: Book; index: number; onOpen: () => void; onDelete: () => void }) {
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 <div className="group fx-rise relative flex flex-col" style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }}>
className="group fx-rise relative flex flex-col" <button onClick={onOpen} className="block w-full p-0 text-left focus-visible:outline-none">
style={{ animationDelay: `${Math.min(index, 23) * 25}ms` }} <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">
>
<button onClick={onOpen} className="block w-full text-left focus-visible:outline-none">
<div className="relative aspect-[3/4] w-full overflow-hidden rounded-lg shadow-md ring-1 ring-white/10 transition duration-200 will-change-transform group-hover:-translate-y-1 group-hover:shadow-xl group-hover:shadow-black/40 group-hover:ring-amber-500/40 group-focus-visible:-translate-y-1 group-focus-visible:ring-amber-500">
<Cover book={book} className="size-full" /> <Cover book={book} className="size-full" />
<span className={formatBadge(book.format, "absolute left-1.5 top-1.5 backdrop-blur-sm")}>{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 <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-primary-foreground" title={book.error}>
className="absolute right-1.5 top-1.5 rounded bg-red-950/90 px-1.5 py-0.5 text-[10px] font-semibold text-red-200 ring-1 ring-red-500/40" <TriangleAlert className="size-3" /> 损坏
title={book.error}
>
损坏
</span> </span>
)} )}
<span className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-1 bg-linear-to-t from-black/80 via-black/30 to-transparent p-1.5 pt-6 text-[10px] tabular-nums text-stone-200 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100">
<span>{book.pages > 0 ? `${book.pages} 页` : ""}</span>
<span>{formatSize(book.size)}</span>
</span>
{pct > 0 && ( {pct > 0 && (
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/50" role="presentation"> <span className="absolute inset-x-0 bottom-0 h-1 bg-black/30" role="presentation">
<span className="block h-full bg-amber-500" style={{ width: `${pct}%` }} /> <span className="block h-full bg-accent" style={{ width: `${pct}%` }} />
</span> </span>
)} )}
</div> </span>
<p className="mt-2 truncate text-xs font-medium text-stone-200 transition-colors group-hover:text-amber-300 group-focus-visible:text-amber-300"> <span className="mt-2 block truncate text-xs font-medium">{book.title}</span>
{book.title} <span className="block truncate text-[11px] text-muted-foreground">
</p> {book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : `${book.pages > 0 ? book.pages + " 页 · " : ""}${formatSize(book.size)}`}
<p className="truncate text-[10px] text-stone-500">{book.path.includes("/") ? book.path.slice(0, book.path.lastIndexOf("/")) : "根目录"}</p> </span>
</button> </button>
{isAdmin && ( <div className="absolute right-1 top-1 z-10 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<button <DropdownMenu>
className="absolute right-1.5 top-1.5 z-10 rounded-md bg-stone-950/80 p-1 text-stone-300 opacity-0 ring-1 ring-white/10 backdrop-blur-sm transition hover:bg-red-950 hover:text-red-300 focus-visible:opacity-100 group-hover:opacity-100" <DropdownMenuTrigger asChild>
title="删除(连磁盘文件)" <Button variant="secondary" size="icon" className="size-7 bg-background/80 backdrop-blur" aria-label={`《${book.title}》操作`}>
aria-label={`删除《${book.title}》`} <Ellipsis className="size-4" />
onClick={() => { </Button>
if (confirm(`删除《${book.title}》?磁盘文件一并删除,阅读进度保留`)) onDelete(); </DropdownMenuTrigger>
}} <DropdownMenuContent align="end">
> <DropdownMenuItem onClick={onOpen}>打开</DropdownMenuItem>
<IconX className="size-3.5" /> {isAdmin && (
</button> <>
)} <DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive" onClick={onDelete}>
删除
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div> </div>
); );
} }
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(9rem,1fr))]"; function SectionHead({ label, count }: { label: string; count: number }) {
if (!label) return null;
function Section({ title, icon, children }: { title: string; icon?: React.ReactNode; children: React.ReactNode }) {
return ( return (
<section className="mb-10"> <h2 className="sticky top-0 z-10 mb-3 flex items-center gap-2 bg-background/90 py-1.5 text-sm font-medium backdrop-blur">
<h2 className="mb-3 flex items-center gap-1.5 text-sm font-semibold tracking-wide text-stone-300"> <FolderOpen className="size-4 text-muted-foreground" />
{icon} {label}
{title} <span className="text-xs tabular-nums text-muted-foreground">{count}</span>
</h2> </h2>
{children}
</section>
); );
} }
@@ -90,23 +86,30 @@ export default function Shelf() {
const qc = useQueryClient(); const qc = useQueryClient();
const nav = useNavigate(); const nav = useNavigate();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [lib, setLib] = useState(0); // 0 = 全部库 const [sp, setSp] = useSearchParams();
const [qRaw, setQRaw] = useState(""); const lib = Number(sp.get("lib")) || LIB0;
const [q, setQ] = useState(""); const sort = (SORTS.includes(sp.get("sort") as SortKey) ? (sp.get("sort") as SortKey) : "added") satisfies SortKey;
const group: GroupMode = sp.get("group") === "dir" ? "dir" : "flat";
const serverQ = (sp.get("q") ?? "").trim();
const [qRaw, setQRaw] = useState(serverQ);
const [confirmDel, setConfirmDel] = useState<Book | null>(null);
useEffect(() => setQRaw(serverQ), [serverQ]);
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setQ(qRaw.trim()), 300); // 搜索防抖 if (qRaw.trim() === serverQ) return;
const t = setTimeout(() => {
const next = new URLSearchParams(sp);
if (qRaw.trim()) next.set("q", qRaw.trim());
else next.delete("q");
setSp(next, { replace: true });
}, 300);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [qRaw]); }, [qRaw]); // eslint-disable-line
const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries }); const libsQ = useQuery({ queryKey: ["libraries"], queryFn: api.listLibraries });
const booksQ = useQuery({ const booksQ = useQuery({ queryKey: ["books", lib, serverQ], queryFn: () => api.listBooks({ library: lib || undefined, q: serverQ }) });
queryKey: ["books", lib, q],
queryFn: () => api.listBooks({ library: lib || undefined, q }),
});
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress }); const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
// 继续阅读行 → book id 解析用(键与 booksQ 默认态一致,天然共享缓存) const allQ = useQuery({ queryKey: ["books", LIB0, ""], queryFn: () => api.listBooks() });
const allQ = useQuery({ queryKey: ["books", 0, ""], queryFn: () => api.listBooks() });
const del = useMutation({ const del = useMutation({
mutationFn: (id: number) => api.deleteBook(id), mutationFn: (id: number) => api.deleteBook(id),
@@ -120,162 +123,174 @@ export default function Shelf() {
mutationFn: (id: number) => api.scanLibrary(id), mutationFn: (id: number) => api.scanLibrary(id),
onSuccess: () => { onSuccess: () => {
toast("ok", "扫描已触发"); toast("ok", "扫描已触发");
setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000); // 给周期扫描留出时间 setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000);
}, },
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"), onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
}); });
const groups = useMemo(() => groupByDir(booksQ.data ?? []), [booksQ.data]);
const booksByKey = useMemo(() => { const booksByKey = useMemo(() => {
const m = new Map<string, Book>(); const m = new Map<string, Book>();
for (const b of allQ.data ?? []) m.set(`${b.library_id}/${b.path}`, b); for (const b of allQ.data ?? []) m.set(`${b.library_id}/${b.path}`, b);
return m; return m;
}, [allQ.data]); }, [allQ.data]);
const secs = useMemo(() => sections(booksQ.data ?? [], sort, group), [booksQ.data, sort, group]);
const searching = serverQ.length > 0;
const continueRows = (progQ.data ?? []).slice(0, 12); const continueRows = (progQ.data ?? []).slice(0, 12);
const libs = libsQ.data ?? []; const setParam = (k: string, v: string) => {
const searching = q.length > 0; const next = new URLSearchParams(sp);
if (v) next.set(k, v);
else next.delete(k);
setSp(next, { replace: true });
};
const libName = lib === LIB0 ? "全部" : libsQ.data?.find((l) => l.id === lib)?.name ?? "书库";
return ( return (
<div className="flex h-full flex-col"> <div className="mx-auto flex w-full max-w-6xl flex-col px-4 pb-20 sm:px-6 md:pb-6">
<TopBar <div className="sticky top-0 z-20 -mx-4 mb-6 flex flex-wrap items-center gap-2 border-b bg-background/90 px-4 py-3 backdrop-blur sm:-mx-6 sm:px-6">
right={ <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" />
<div className="relative"> <Input className="pl-8" type="search" aria-label="搜索标题" placeholder="搜索标题…" value={qRaw} onChange={(e) => setQRaw(e.target.value)} />
<IconSearch className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-stone-500" /> </div>
<input <DropdownMenu>
className={input + " w-36 pl-8 sm:w-52"} <DropdownMenuTrigger asChild>
type="search" <Button variant="outline" size="sm" className="gap-1.5">
aria-label="搜索标题" <ArrowDownUp className="size-4" />
placeholder="搜索标题…" {SORT_LABEL[sort]}
value={qRaw} </Button>
onChange={(e) => setQRaw(e.target.value)} </DropdownMenuTrigger>
/> <DropdownMenuContent align="end">
</div> <DropdownMenuLabel>排序</DropdownMenuLabel>
{isAdmin && lib > 0 && ( {SORTS.map((k) => (
<button className={btn} disabled={scan.isPending} onClick={() => scan.mutate(lib)}> <DropdownMenuItem key={k} onClick={() => setParam("sort", k === "added" ? "" : k)} className={k === sort ? "font-medium text-accent" : ""}>
<IconScan className="size-4" /> {SORT_LABEL[k]}
{scan.isPending ? "扫描中…" : "扫描此库"} </DropdownMenuItem>
</button>
)}
<button className={btn} onClick={() => qc.invalidateQueries({ queryKey: ["books"] })} aria-label="刷新书目">
<IconRefresh className="size-4" />
<span className="hidden sm:inline">刷新</span>
</button>
</>
}
/>
<div className="flex-1 overflow-y-auto px-4 py-5 sm:px-6">
{libs.length === 0 && !libsQ.isPending && (
<p className="mb-6 flex items-center gap-2 rounded-lg bg-amber-500/10 px-3 py-2 text-sm text-amber-200 ring-1 ring-amber-500/25">
<IconAlert className="size-4 shrink-0" />
还没有书库。admin 请到「库管理」建库并扫描。
</p>
)}
{(libs.length > 0 || libsQ.isPending) && (
<nav aria-label="按库筛选" className="mb-8 flex w-fit flex-wrap gap-1 rounded-full bg-white/[0.04] p-1 ring-1 ring-white/[0.07]">
<button className={`${pill} ${lib === 0 ? pillActive : ""}`} aria-pressed={lib === 0} onClick={() => setLib(0)}>
全部
</button>
{libs.map((l) => (
<button
key={l.id}
className={`${pill} ${lib === l.id ? pillActive : ""}`}
aria-pressed={lib === l.id}
onClick={() => setLib(l.id)}
>
{l.name}
</button>
))} ))}
</nav> </DropdownMenuContent>
</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)}>
{g.label}
</Button>
))}
</div>
{isAdmin && lib > LIB0 && (
<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"] })}>
<RefreshCw className="size-4" />
</Button>
</div>
{continueRows.length > 0 && !searching && ( {lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
<Section title="继续阅读" icon={<IconClock className="size-4 text-amber-500/80" />}>
<ul className="flex gap-3 overflow-x-auto pb-2" role="list"> {(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
{continueRows.map((r, i) => { <p className="mb-6 flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent">
const b = booksByKey.get(`${r.library_id}/${r.path}`); <LibraryBig className="size-4 shrink-0" />
if (!b) 还没有书库。admin 请到「库管理」建库并扫描。
return ( </p>
<li key={`${r.library_id}/${r.path}`} className={`fx-rise w-44 shrink-0 ${card} p-3`} style={{ animationDelay: `${i * 40}ms` }}> )}
<p className="truncate text-xs text-stone-400">{r.title || "已删除的书"}</p>
<p className="mt-1 text-[10px] text-stone-600">#{r.library_id}/{r.path} · 进度已保留</p> {continueRows.length > 0 && !searching && (
</li> <section className="mb-8">
); <h2 className="mb-3 text-sm font-medium text-muted-foreground">继续阅读</h2>
const pct = Math.round(r.percent * 100); <ul className="flex gap-3 overflow-x-auto pb-2" role="list">
{continueRows.map((r, i) => {
const b = booksByKey.get(`${r.library_id}/${r.path}`);
if (!b)
return ( return (
<li key={`${r.library_id}/${r.path}`} className="fx-rise shrink-0" 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` }}>
<button <p className="truncate">{r.title || "已删除的书"}</p>
className="group flex w-64 items-center gap-3 rounded-xl p-2 text-left ring-1 ring-white/[0.07] transition-colors hover:bg-white/[0.05] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500" <p className="mt-1 text-[10px]">进度已保留</p>
onClick={() => nav(`/book/${b.id}`)}
>
<Cover book={b} className="aspect-[3/4] w-14 shrink-0 rounded-md shadow ring-1 ring-white/10" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-stone-100 group-hover:text-amber-300">{b.title}</span>
<span className="mt-0.5 flex items-center gap-1.5 text-[11px] text-stone-500">
{r.library}
<span className="tabular-nums text-stone-400">{pct}%</span>
</span>
<span className="mt-1.5 block h-1 overflow-hidden rounded-full bg-white/10">
<span className="block h-full rounded-full bg-amber-500" style={{ width: `${pct}%` }} />
</span>
</span>
<IconBookOpen className="size-4 shrink-0 text-stone-600 transition-colors group-hover:text-amber-400" />
</button>
</li> </li>
); );
})} const pct = Math.round(r.percent * 100);
</ul> return (
</Section> <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">{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>
</span>
<span className="mt-1.5 block h-0.5 overflow-hidden rounded-full bg-muted">
<span className="block h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
</span>
</span>
</button>
</li>
);
})}
</ul>
</section>
)}
{booksQ.isError && ( {booksQ.isError && (
<div className={`fx-rise mb-8 flex flex-wrap items-center gap-3 rounded-xl bg-red-950/40 px-4 py-3 text-sm text-red-200 ring-1 ring-red-500/25`}> <div className="fx-rise mb-8 flex flex-wrap items-center gap-3 rounded-md border border-destructive/40 px-4 py-3 text-sm">
<IconAlert className="size-4 shrink-0" /> <TriangleAlert className="size-4 shrink-0 text-destructive" />
<span>加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"}</span> <span>加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"}</span>
<button className={btn} onClick={() => booksQ.refetch()}> <Button variant="outline" size="sm" onClick={() => booksQ.refetch()}>
<IconRefresh className="size-4" /> <RefreshCw className="size-4" /> 重试
重试 </Button>
</button> </div>
</div> )}
)}
{booksQ.isPending && !booksQ.isError && ( {booksQ.isPending && !booksQ.isError && (
<div className={GRID} aria-busy="true" aria-label="加载书目中"> <div className={GRID} aria-busy="true" aria-label="加载书目中">
{Array.from({ length: 12 }).map((_, i) => ( {Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="space-y-2"> <div key={i} className="space-y-2">
<div className="aspect-[3/4] animate-pulse rounded-lg bg-stone-800/70" /> <Skeleton className="aspect-[3/4] w-full rounded-md" />
<div className="h-3 w-3/4 animate-pulse rounded bg-stone-800/70" /> <Skeleton className="h-3 w-3/4" />
<div className="h-2 w-1/2 animate-pulse rounded bg-stone-800/50" /> <Skeleton className="h-2 w-1/2" />
</div> </div>
))}
</div>
)}
{!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>
</div>
)}
{secs.map((s) => (
<section key={s.label || "//"} className="mb-10">
<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)} />
))} ))}
</div> </div>
)} </section>
))}
{!booksQ.isPending && groups.length === 0 && !booksQ.isError && ( <AlertDialog open={confirmDel !== null} onOpenChange={(o) => !o && setConfirmDel(null)}>
<div className="fx-rise flex flex-col items-center py-16 text-center"> <AlertDialogContent>
<IconFolder className="size-10 text-stone-700" /> <AlertDialogHeader>
<h3 className="mt-3 text-sm font-medium text-stone-300">{searching ? `没有匹配「${q}」的书` : "此库暂无书目"}</h3> <AlertDialogTitle>删除《{confirmDel?.title}》?</AlertDialogTitle>
<p className="mt-1 text-sm text-stone-500"> <AlertDialogDescription>磁盘文件一并删除,阅读进度保留。</AlertDialogDescription>
{searching ? "换个关键词试试。" : "放入文件后等待扫描,或点上方「扫描此库」。"} </AlertDialogHeader>
</p> <AlertDialogFooter>
</div> <AlertDialogCancel>取消</AlertDialogCancel>
)} <AlertDialogAction
className="bg-destructive text-primary-foreground hover:bg-destructive/90"
{groups.map((g) => ( onClick={() => {
<Section if (confirmDel) del.mutate(confirmDel.id);
key={g.dir || "//"} setConfirmDel(null);
title={g.dir || "根目录"} }}
icon={<IconFolder className="size-4 text-stone-500" />} >
> 删除
<div className={GRID}> </AlertDialogAction>
{g.books.map((b, i) => ( </AlertDialogFooter>
<BookCard key={b.id} book={b} index={i} onOpen={() => nav(`/book/${b.id}`)} onDelete={() => del.mutate(b.id)} /> </AlertDialogContent>
))} </AlertDialog>
</div>
</Section>
))}
</div>
</div> </div>
); );
} }