417 lines
16 KiB
TypeScript
417 lines
16 KiB
TypeScript
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 { api } from "@/api/client";
|
|
import type { Book } from "@/api/types";
|
|
import { useAuth } from "@/auth/AuthContext";
|
|
import { Cover } from "@/components/Cover";
|
|
import { toast } from "@/components/Toaster";
|
|
import { UploadButton } from "@/components/upload-button";
|
|
import { ReadingStatsCard } from "@/components/reading-stats-card";
|
|
import { FormatBadge } from "@/components/format-badge";
|
|
import {
|
|
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";
|
|
|
|
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;
|
|
}) {
|
|
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"
|
|
>
|
|
<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" />
|
|
<FormatBadge fmt={book.format} className="absolute left-1.5 top-1.5" />
|
|
{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}
|
|
>
|
|
<TriangleAlert className="size-3" /> 损坏
|
|
</span>
|
|
)}
|
|
{pct > 0 && (
|
|
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/30" role="presentation">
|
|
<span className="block h-full bg-accent" style={{ width: `${pct}%` }} />
|
|
</span>
|
|
)}
|
|
</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)}`}
|
|
</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}》操作`}
|
|
>
|
|
<Ellipsis className="size-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onSelect={onOpen}>打开</DropdownMenuItem>
|
|
{isAdmin && (
|
|
<>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
|
|
删除
|
|
</DropdownMenuItem>
|
|
</>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionHead({ label, count }: { label: string; count: number }) {
|
|
if (!label) return null;
|
|
return (
|
|
<h2 className="sticky top-0 z-10 mb-3 flex items-center gap-2 bg-background/85 py-1.5 text-sm font-medium backdrop-blur">
|
|
<FolderOpen className="size-4 text-muted-foreground" />
|
|
{label}
|
|
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
|
|
</h2>
|
|
);
|
|
}
|
|
|
|
export default function Shelf() {
|
|
const qc = useQueryClient();
|
|
const nav = useNavigate();
|
|
const { isAdmin } = useAuth();
|
|
const [sp, setSp] = useSearchParams();
|
|
const lib = Number(sp.get("lib")) || LIB0;
|
|
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);
|
|
|
|
// URL 是筛选词的事实来源:回退/前进/程序化改参时把输入框拉回 URL 值。
|
|
// 用 React 官方推荐的「渲染期按下发值重置本地 state」替代原先 effect 内的同步 setState
|
|
const [prevServerQ, setPrevServerQ] = useState(serverQ);
|
|
if (prevServerQ !== serverQ) {
|
|
setPrevServerQ(serverQ);
|
|
setQRaw(serverQ);
|
|
}
|
|
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 },
|
|
);
|
|
}, 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 progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
|
const allQ = useQuery({ queryKey: ["books", LIB0, ""], queryFn: () => api.listBooks() });
|
|
|
|
const del = useMutation({
|
|
mutationFn: (id: number) => api.deleteBook(id),
|
|
onSuccess: () => {
|
|
toast("ok", "已删除");
|
|
qc.invalidateQueries({ queryKey: ["books"] });
|
|
},
|
|
onError: (e) => toast("err", e instanceof Error ? e.message : "删除失败"),
|
|
});
|
|
const scan = useMutation({
|
|
mutationFn: (id: number) => api.scanLibrary(id),
|
|
onSuccess: () => {
|
|
toast("ok", "扫描已触发");
|
|
setTimeout(() => qc.invalidateQueries({ queryKey: ["books"] }), 3000);
|
|
},
|
|
onError: (e) => toast("err", e instanceof Error ? e.message : "扫描失败"),
|
|
});
|
|
|
|
const booksByKey = useMemo(() => {
|
|
const m = new Map<string, Book>();
|
|
for (const b of allQ.data ?? []) m.set(`${b.library_id}/${b.path}`, b);
|
|
return m;
|
|
}, [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 setParam = (k: string, v: string) => {
|
|
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 (
|
|
<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)}
|
|
/>
|
|
</div>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm" className="gap-1.5">
|
|
<ArrowDownUp className="size-4" />
|
|
{SORT_LABEL[sort]}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<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" : ""}
|
|
>
|
|
{SORT_LABEL[k]}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</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 && (
|
|
<>
|
|
<UploadButton libId={lib} variant="outline" label="上传文件" />
|
|
<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>
|
|
|
|
{lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
|
|
|
|
<ReadingStatsCard />
|
|
|
|
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
|
|
<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-strong">
|
|
<LibraryBig className="size-4 shrink-0" />
|
|
还没有书库。admin 请到「库管理」建库并扫描。
|
|
</p>
|
|
)}
|
|
|
|
{continueRows.length > 0 && !searching && (
|
|
<section className="mb-8">
|
|
<h2 className="mb-3 text-sm font-medium text-muted-foreground">继续阅读</h2>
|
|
{/* eslint-disable-next-line jsx-a11y/no-redundant-roles -- Tailwind preflight 移除了 ul 的 list 样式,Safari/VoiceOver 因此丢列表语义;role="list" 是官方推荐的补偿写法,非冗余 */}
|
|
<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 (
|
|
<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"
|
|
>
|
|
<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="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 && (
|
|
<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">
|
|
<TriangleAlert className="size-4 shrink-0 text-destructive" />
|
|
<span>加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"}</span>
|
|
<Button variant="outline" size="sm" onClick={() => booksQ.refetch()}>
|
|
<RefreshCw className="size-4" /> 重试
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{booksQ.isPending && !booksQ.isError && (
|
|
<div className={GRID} aria-busy="true" aria-label="加载书目中">
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<Skeleton className="aspect-[3/4] w-full rounded-md" />
|
|
<Skeleton className="h-3 w-3/4" />
|
|
<Skeleton className="h-2 w-1/2" />
|
|
</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>
|
|
</section>
|
|
))}
|
|
|
|
<AlertDialog open={confirmDel !== null} onOpenChange={(o) => !o && setConfirmDel(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>删除《{confirmDel?.title}》?</AlertDialogTitle>
|
|
<AlertDialogDescription>磁盘文件一并删除,阅读进度保留。</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
onClick={() => {
|
|
if (confirmDel) del.mutate(confirmDel.id);
|
|
setConfirmDel(null);
|
|
}}
|
|
>
|
|
删除
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|