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);
}}
>
删除
);
}