feat(web): unify reader TOC and bookmarks into one left nav drawer with tabs
This commit is contained in:
@@ -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 };
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type NavTab = "toc" | "bm";
|
||||
|
||||
/** 阅读器左侧导航抽屉:目录/书签选项卡;不传 toc 则退化为纯书签面板. */
|
||||
export function ReaderNav({
|
||||
tab,
|
||||
onTab,
|
||||
onClose,
|
||||
toc,
|
||||
bookmarks,
|
||||
}: {
|
||||
tab: NavTab;
|
||||
onTab?: (t: NavTab) => void;
|
||||
onClose: () => void;
|
||||
toc?: ReactNode;
|
||||
bookmarks: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={onClose} />
|
||||
<nav
|
||||
id="reader-nav"
|
||||
aria-label="导航"
|
||||
className="rd-divider absolute inset-y-0 left-0 z-40 flex w-72 flex-col border-r bg-[var(--rd-bg,#171512)] shadow-2xl"
|
||||
style={{ color: "var(--rd-fg,#c6c0b6)" }}
|
||||
>
|
||||
<div
|
||||
role={toc != null ? "tablist" : undefined}
|
||||
aria-label="导航选项卡"
|
||||
className="rd-divider flex shrink-0 items-center gap-1 border-b p-2"
|
||||
>
|
||||
{toc != null ? (
|
||||
<>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === "toc"}
|
||||
className={"rd-btn" + (tab === "toc" ? " rd-btn-on" : "")}
|
||||
onClick={() => onTab?.("toc")}
|
||||
>
|
||||
目录
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === "bm"}
|
||||
className={"rd-btn" + (tab === "bm" ? " rd-btn-on" : "")}
|
||||
onClick={() => onTab?.("bm")}
|
||||
>
|
||||
书签
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="rd-btn rd-btn-on">书签</span>
|
||||
)}
|
||||
<button className="rd-btn ml-auto" aria-label="关闭导航" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tab === "toc" ? toc : bookmarks}</div>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user