refactor(frontend): shared reader chrome to shadcn/radix (header, nav Sheet, bookmarks)

This commit is contained in:
2026-09-16 15:46:07 +08:00
parent 1f9d7e7572
commit cea2414371
8 changed files with 115 additions and 129 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ test("登录 → 书架 → CBZ 阅读翻页 → 书签 → 退出", async ({ pa
await expect(nav).toBeVisible(); await expect(nav).toBeVisible();
await nav.getByRole("button", { name: "加书签" }).click(); await nav.getByRole("button", { name: "加书签" }).click();
await expect(page.getByText("已加书签")).toBeVisible(); await expect(page.getByText("已加书签")).toBeVisible();
await page.getByRole("button", { name: "关闭导航" }).click(); await page.getByRole("button", { name: "关闭" }).click();
// 回书架 → 退出登录 // 回书架 → 退出登录
await page.getByRole("link", { name: "书架" }).click(); await page.getByRole("link", { name: "书架" }).click();
+35 -23
View File
@@ -3,6 +3,8 @@ import { Check, Pencil, Plus, X } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { api } from "../api/client"; import { api } from "../api/client";
import type { Book } from "../api/types"; import type { Book } from "../api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "./Toaster"; import { toast } from "./Toaster";
type Locator = Record<string, unknown>; type Locator = Record<string, unknown>;
@@ -55,8 +57,8 @@ export function BookmarksTab({
return ( return (
<> <>
<div className="mb-2 flex items-center gap-1.5"> <div className="mb-2 flex items-center gap-1.5">
<input <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" className="min-w-0 flex-1"
placeholder="备注(可空)" placeholder="备注(可空)"
maxLength={500} maxLength={500}
value={note} value={note}
@@ -65,26 +67,28 @@ export function BookmarksTab({
if (e.key === "Enter" && !add.isPending) add.mutate(); if (e.key === "Enter" && !add.isPending) add.mutate();
}} }}
/> />
<button <Button
className="rd-btn inline-flex shrink-0 items-center gap-1" variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="加书签" aria-label="加书签"
disabled={add.isPending} disabled={add.isPending}
onClick={() => add.mutate()} onClick={() => add.mutate()}
> >
<Plus size={14} /> 加 <Plus size={14} />
</button> </Button>
</div> </div>
<ul className="space-y-0.5"> <ul className="space-y-0.5">
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>} {q.isPending && <li className="p-2 text-xs text-muted-foreground">加载中…</li>}
{!q.isPending && items.length === 0 && ( {!q.isPending && items.length === 0 && (
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li> <li className="p-2 text-xs text-muted-foreground">还没有书签,读到想记的位置点「加」</li>
)} )}
{items.map((b) => ( {items.map((b) => (
<li key={b.id}> <li key={b.id}>
{editId === b.id ? ( {editId === b.id ? (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<input <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" className="min-w-0 flex-1"
maxLength={500} maxLength={500}
value={editVal} value={editVal}
onChange={(e) => setEditVal(e.target.value)} onChange={(e) => setEditVal(e.target.value)}
@@ -92,34 +96,40 @@ export function BookmarksTab({
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() }); if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
}} }}
/> />
<button <Button
className="rd-btn inline-flex shrink-0 items-center" variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="保存备注" aria-label="保存备注"
disabled={patch.isPending} disabled={patch.isPending}
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })} onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
> >
<Check size={14} /> <Check size={14} />
</button> </Button>
<button <Button
className="rd-btn inline-flex shrink-0 items-center" variant="ghost"
size="icon-xs"
className="shrink-0"
aria-label="取消编辑" aria-label="取消编辑"
onClick={() => setEditId(null)} onClick={() => setEditId(null)}
> >
<X size={14} /> <X size={14} />
</button> </Button>
</div> </div>
) : ( ) : (
<div className="group flex items-center"> <div className="group flex items-center">
<button <button
className="rd-row min-w-0 flex-1 truncate text-left" className="min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
title={b.note || undefined} title={b.note || undefined}
onClick={() => seek(b.locator)} onClick={() => seek(b.locator)}
> >
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "} <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>} {b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
</button> </button>
<button <Button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100" variant="ghost"
size="icon-xs"
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="改备注" aria-label="改备注"
onClick={() => { onClick={() => {
setEditId(b.id); setEditId(b.id);
@@ -127,14 +137,16 @@ export function BookmarksTab({
}} }}
> >
<Pencil size={14} /> <Pencil size={14} />
</button> </Button>
<button <Button
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100" variant="ghost"
size="icon-xs"
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
aria-label="删除书签" aria-label="删除书签"
onClick={() => del.mutate(b.id)} onClick={() => del.mutate(b.id)}
> >
<X size={14} /> <X size={14} />
</button> </Button>
</div> </div>
)} )}
</li> </li>
+19
View File
@@ -0,0 +1,19 @@
import { Badge } from "@/components/ui/badge";
import type { Format } from "@/api/types";
const CLASSES: Record<Format, string> = {
cbz: "bg-orange-500/15 text-orange-600 ring-orange-500/25 dark:text-orange-300",
pdf: "bg-rose-500/15 text-rose-600 ring-rose-500/25 dark:text-rose-300",
epub: "bg-sky-500/15 text-sky-600 ring-sky-500/25 dark:text-sky-300",
txt: "bg-emerald-500/15 text-emerald-600 ring-emerald-500/25 dark:text-emerald-300",
md: "bg-teal-500/15 text-teal-600 ring-teal-600/25 dark:text-teal-300",
};
/** 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义(承接旧 ui.ts formatBadge)。 */
export function FormatBadge({ fmt, className }: { fmt: Format; className?: string }) {
return (
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${CLASSES[fmt]} ${className ?? ""}`}>
{fmt}
</Badge>
);
}
-37
View File
@@ -1,37 +0,0 @@
/** 阅读器主题内的滑条配置项:说明 + 整数档位滑条 + 锚点刻度. */
export function RdSlider({
label,
ariaLabel,
min,
max,
value,
onChange,
}: {
label: string;
ariaLabel: string;
min: number;
max: number;
value: number;
onChange: (v: number) => void;
}) {
return (
<div className="px-2.5 pb-1.5 pt-2">
<div className="mb-1 text-sm">{label}</div>
<input
type="range"
min={min}
max={max}
step={1}
value={value}
aria-label={ariaLabel}
className="rd-range"
onChange={(e) => onChange(Number(e.target.value))}
/>
<div className="flex justify-between px-1 text-[10px] tabular-nums opacity-50" aria-hidden="true">
{Array.from({ length: max - min + 1 }, (_, i) => (
<span key={i}>{min + i}</span>
))}
</div>
</div>
);
}
+31 -46
View File
@@ -1,66 +1,51 @@
import { Bookmark, List, X } from "lucide-react"; import { Bookmark, List, Search } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export type NavTab = "toc" | "bm"; export type NavTab = "toc" | "bm" | "search";
/** 阅读器左侧导航抽屉:目录/书签选项卡;不传 toc 则退化为纯书签面板. */ /** 阅读器导航抽屉(radix Sheet, side=left):目录/书签/搜索选项卡;按传入内容决定可见 tab。 */
export function ReaderNav({ export function ReaderNav({
tab, tab,
onTab, onTab,
onClose, onClose,
toc, toc,
bookmarks, bookmarks,
search,
}: { }: {
tab: NavTab; tab: NavTab;
onTab?: (t: NavTab) => void; onTab?: (t: NavTab) => void;
onClose: () => void; onClose: () => void;
toc?: ReactNode; toc?: ReactNode;
bookmarks: ReactNode; bookmarks: ReactNode;
search?: ReactNode;
}) { }) {
const tabs: { id: NavTab; label: string; icon: typeof List; node: ReactNode }[] = [
...(toc != null ? [{ id: "toc" as const, label: "目录", icon: List, node: toc }] : []),
{ id: "bm" as const, label: "书签", icon: Bookmark, node: bookmarks },
...(search != null ? [{ id: "search" as const, label: "搜索", icon: Search, node: search }] : []),
];
const active = tabs.find((t) => t.id === tab) ?? tabs[0];
return ( return (
<> <Sheet open onOpenChange={(o) => !o && onClose()}>
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={onClose} /> <SheetContent side="left" className="w-72 p-0" aria-describedby={undefined}>
<nav <SheetHeader className="sr-only">
id="reader-nav" <SheetTitle>导航</SheetTitle>
aria-label="导航" </SheetHeader>
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" <div id="reader-nav" className="flex h-full flex-col">
style={{ color: "var(--rd-fg,#c6c0b6)" }} <Tabs value={active.id} onValueChange={(v) => onTab?.(v as NavTab)} className="flex min-h-0 flex-1 flex-col">
> <TabsList className="m-2 shrink-0 self-start">
<div {tabs.map(({ id, label, icon: Icon }) => (
role={toc != null ? "tablist" : undefined} <TabsTrigger key={id} value={id} className="gap-1.5">
aria-label="导航选项卡" <Icon size={14} /> {label}
className="rd-divider flex shrink-0 items-center gap-1 border-b p-2" </TabsTrigger>
> ))}
{toc != null ? ( </TabsList>
<> <div className="min-h-0 flex-1 overflow-y-auto p-2">{active.node}</div>
<button </Tabs>
role="tab"
aria-selected={tab === "toc"}
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "toc" ? " rd-btn-on" : "")}
onClick={() => onTab?.("toc")}
>
<List size={14} /> 目录
</button>
<button
role="tab"
aria-selected={tab === "bm"}
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "bm" ? " rd-btn-on" : "")}
onClick={() => onTab?.("bm")}
>
<Bookmark size={14} /> 书签
</button>
</>
) : (
<span className="rd-btn rd-btn-on inline-flex items-center gap-1.5">
<Bookmark size={14} /> 书签
</span>
)}
<button className="rd-btn ml-auto" aria-label="关闭导航" onClick={onClose}>
<X size={14} />
</button>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tab === "toc" ? toc : bookmarks}</div> </SheetContent>
</nav> </Sheet>
</>
); );
} }
+1 -1
View File
@@ -64,7 +64,7 @@ function SheetContent({
{...props} {...props}
> >
{children} {children}
{showCloseButton && side !== "left" && side !== "bottom" && ( {showCloseButton && side !== "bottom" && (
<SheetPrimitive.Close <SheetPrimitive.Close
data-slot="sheet-close" data-slot="sheet-close"
aria-label="关闭" aria-label="关闭"
+15 -12
View File
@@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react"; import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { api } from "../api/client"; import { api } from "../api/client";
import type { Format } from "../api/types"; import type { Format } from "../api/types";
import { IconArrowLeft } from "../components/icons"; import { FormatBadge } from "../components/format-badge";
import { btn, btnGhost, formatBadge } from "../components/ui"; import { Button } from "../components/ui/button";
import type { ReaderProps } from "../lib/useProgress"; import type { ReaderProps } from "../lib/useProgress";
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = { const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
@@ -51,19 +52,21 @@ export default function Reader() {
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<header <header
className={ className={
"flex shrink-0 items-center gap-3 overflow-hidden border-b border-stone-800/80 bg-stone-950/80 px-4 text-sm backdrop-blur transition-all duration-300 " + "flex shrink-0 items-center gap-3 overflow-hidden border-b border-border bg-background/90 px-4 text-sm text-foreground backdrop-blur transition-all duration-300 " +
(chromeOn ? "h-11 py-2 opacity-100" : "h-0 border-b-0 py-0 opacity-0") (chromeOn ? "h-11 py-2 opacity-100" : "h-0 border-b-0 py-0 opacity-0")
} }
> >
<Link to="/" className={btnGhost + " shrink-0"}> <Button variant="ghost" size="sm" asChild className="shrink-0">
<IconArrowLeft className="size-4" /> <Link to="/">
书架 <ArrowLeft className="size-4" />
</Link> 书架
</Link>
</Button>
<span className="truncate font-medium">{book?.title ?? "…"}</span> <span className="truncate font-medium">{book?.title ?? "…"}</span>
{book && ( {book && (
<span className="hidden shrink-0 items-center gap-2 text-xs text-stone-500 sm:flex"> <span className="hidden shrink-0 items-center gap-2 text-xs text-muted-foreground sm:flex">
{book.library && <span>{book.library}</span>} {book.library && <span>{book.library}</span>}
<span className={formatBadge(book.format)}>{book.format}</span> <FormatBadge fmt={book.format} />
<span className="tabular-nums">{Math.round(book.percent * 100)}%</span> <span className="tabular-nums">{Math.round(book.percent * 100)}%</span>
</span> </span>
)} )}
@@ -77,13 +80,13 @@ export default function Reader() {
function Msg({ text, retry }: { text: string; retry?: () => void }) { function Msg({ text, retry }: { text: string; retry?: () => void }) {
return ( return (
<div className="grid h-full place-items-center p-8 text-center text-stone-500"> <div className="grid h-full place-items-center p-8 text-center text-muted-foreground">
<div> <div>
<p className="mb-3">{text}</p> <p className="mb-3">{text}</p>
{retry && ( {retry && (
<button className={btn} onClick={retry}> <Button variant="secondary" onClick={retry}>
重试 重试
</button> </Button>
)} )}
</div> </div>
</div> </div>
+13 -9
View File
@@ -5,7 +5,6 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
import { api, formatPageUrl } from "../api/client"; import { api, formatPageUrl } from "../api/client";
import { btn } from "../components/ui"; import { btn } from "../components/ui";
import { BookmarksTab } from "../components/Bookmarks"; import { BookmarksTab } from "../components/Bookmarks";
import { RdSlider } from "../components/rd-slider";
import { ReaderNav, type NavTab } from "../components/reader-nav"; import { ReaderNav, type NavTab } from "../components/reader-nav";
import { fetchObjectUrl } from "../lib/authImage"; import { fetchObjectUrl } from "../lib/authImage";
import { useProgressSaver, type ReaderProps } from "../lib/useProgress"; import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
@@ -441,14 +440,19 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
</DropdownMenu.ItemIndicator> </DropdownMenu.ItemIndicator>
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
{locked && ( {locked && (
<RdSlider <div className="px-2.5 pb-1.5 pt-2">
label="预读:锁章末尾时提前挂载其后 N 章" <div className="mb-1 text-sm">预读:锁章末尾时提前挂载其后 N 章</div>
ariaLabel="预读章数" <input
min={0} type="range"
max={3} min={0}
value={prefetch} max={3}
onChange={setPrefetch} step={1}
/> value={prefetch}
aria-label="预读章数"
className="rd-range"
onChange={(e) => setPrefetch(Number(e.target.value))}
/>
</div>
)} )}
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>