feat(web): unify reader TOC and bookmarks into one left nav drawer with tabs

This commit is contained in:
2026-09-08 21:08:42 +08:00
parent 4f4c7e5434
commit 48161ea1ff
7 changed files with 340 additions and 211 deletions
+86 -106
View File
@@ -1,19 +1,22 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { useState } from "react";
import { api } from "../api/client";
import type { Book } from "../api/types";
import { toast } from "./Toaster";
type Locator = Record<string, unknown>;
export function useBookmarks(opts: {
/** 书签面板内容(供 ReaderNav 的「书签」选项卡承载);跳转后由调用方负责关抽屉. */
export function BookmarksTab({
book,
capture,
seek,
}: {
book: Book;
capture: () => { locator: Locator; percent: number };
seek: (locator: Locator) => void;
}): { btn: ReactNode; panel: ReactNode } {
const { book, capture, seek } = opts;
}) {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [note, setNote] = useState("");
const [editId, setEditId] = useState<number | null>(null);
const [editVal, setEditVal] = useState("");
@@ -48,107 +51,84 @@ export function useBookmarks(opts: {
onError: fail,
});
const btn = (
<button
className="rd-btn"
aria-expanded={open}
aria-controls="bm-panel"
onClick={() => setOpen((v) => !v)}
>
书签{items.length ? ` ${items.length}` : ""}
</button>
);
const panel = open ? (
return (
<>
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={() => setOpen(false)} />
<nav
id="bm-panel"
aria-label="书签"
className="rd-divider absolute inset-y-0 right-0 z-40 flex w-72 flex-col border-l bg-[var(--rd-bg)] p-2 shadow-2xl"
>
<div className="mb-2 flex items-center gap-1.5">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
placeholder="备注(可空)"
maxLength={500}
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !add.isPending) add.mutate();
}}
/>
<button className="rd-btn shrink-0" disabled={add.isPending} onClick={() => add.mutate()}>
加
</button>
</div>
<ul className="min-h-0 flex-1 space-y-0.5 overflow-y-auto">
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
{!q.isPending && items.length === 0 && (
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
)}
{items.map((b) => (
<li key={b.id}>
{editId === b.id ? (
<div className="flex items-center gap-1">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100"
maxLength={500}
value={editVal}
onChange={(e) => setEditVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
}}
/>
<button
className="rd-btn shrink-0"
disabled={patch.isPending}
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
>
✓
</button>
<button className="rd-btn shrink-0" onClick={() => setEditId(null)}>
✕
</button>
</div>
) : (
<div className="group flex items-center">
<button
className="rd-row min-w-0 flex-1 truncate text-left"
title={b.note || undefined}
onClick={() => {
seek(b.locator);
setOpen(false);
}}
>
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="改备注"
onClick={() => {
setEditId(b.id);
setEditVal(b.note);
}}
>
✎
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="删除书签"
onClick={() => del.mutate(b.id)}
>
✕
</button>
</div>
)}
</li>
))}
</ul>
</nav>
<div className="mb-2 flex items-center gap-1.5">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
placeholder="备注(可空)"
maxLength={500}
value={note}
onChange={(e) => setNote(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !add.isPending) add.mutate();
}}
/>
<button className="rd-btn shrink-0" disabled={add.isPending} onClick={() => add.mutate()}>
加
</button>
</div>
<ul className="space-y-0.5">
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
{!q.isPending && items.length === 0 && (
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
)}
{items.map((b) => (
<li key={b.id}>
{editId === b.id ? (
<div className="flex items-center gap-1">
<input
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100"
maxLength={500}
value={editVal}
onChange={(e) => setEditVal(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
}}
/>
<button
className="rd-btn shrink-0"
disabled={patch.isPending}
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
>
✓
</button>
<button className="rd-btn shrink-0" onClick={() => setEditId(null)}>
✕
</button>
</div>
) : (
<div className="group flex items-center">
<button
className="rd-row min-w-0 flex-1 truncate text-left"
title={b.note || undefined}
onClick={() => seek(b.locator)}
>
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="改备注"
onClick={() => {
setEditId(b.id);
setEditVal(b.note);
}}
>
✎
</button>
<button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="删除书签"
onClick={() => del.mutate(b.id)}
>
✕
</button>
</div>
)}
</li>
))}
</ul>
</>
) : null;
return { btn, panel };
);
}