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 (
打开 {isAdmin && ( <> 删除 )}
); } function SectionHead({ label, count }: { label: string; count: number }) { if (!label) return null; return (

{label} {count}

); } 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(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(); 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 (
setQRaw(e.target.value)} />
排序 {SORTS.map((k) => ( setParam("sort", k === "added" ? "" : k)} className={k === sort ? "font-medium text-accent-strong" : ""} > {SORT_LABEL[k]} ))}
{GROUPS.map((g) => ( ))}
{isAdmin && lib > LIB0 && ( <> )}
{lib === LIB0 &&

{`当前筛选:${libName}`}

} {(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (

还没有书库。admin 请到「库管理」建库并扫描。

)} {continueRows.length > 0 && !searching && (

继续阅读

{/* eslint-disable-next-line jsx-a11y/no-redundant-roles -- Tailwind preflight 移除了 ul 的 list 样式,Safari/VoiceOver 因此丢列表语义;role="list" 是官方推荐的补偿写法,非冗余 */}
    {continueRows.map((r, i) => { const b = booksByKey.get(`${r.library_id}/${r.path}`); if (!b) return (
  • {r.title || "已删除的书"}

    进度已保留

  • ); const pct = Math.round(r.percent * 100); return (
  • ); })}
)} {booksQ.isError && (
加载失败:{booksQ.error instanceof Error ? booksQ.error.message : "未知错误"}
)} {booksQ.isPending && !booksQ.isError && (
{Array.from({ length: 12 }).map((_, i) => (
))}
)} {!booksQ.isPending && secs.every((s) => s.books.length === 0) && !booksQ.isError && (

{searching ? `没有匹配「${serverQ}」的书` : `${libName}暂无书目`}

{searching ? "换个关键词试试。" : "放入文件后等待扫描,或用侧栏「库管理」上传。"}

)} {secs.map((s) => (
{s.books.map((b, i) => ( nav(`/book/${b.id}`)} onDelete={() => setConfirmDel(b)} /> ))}
))} !o && setConfirmDel(null)}> 删除《{confirmDel?.title}》? 磁盘文件一并删除,阅读进度保留。 取消 { if (confirmDel) del.mutate(confirmDel.id); setConfirmDel(null); }} > 删除
); }