refactor(frontend): shared reader chrome to shadcn/radix (header, nav Sheet, bookmarks)
This commit is contained in:
@@ -3,6 +3,8 @@ import { Check, Pencil, Plus, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { Book } from "../api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { toast } from "./Toaster";
|
||||
|
||||
type Locator = Record<string, unknown>;
|
||||
@@ -55,8 +57,8 @@ export function BookmarksTab({
|
||||
return (
|
||||
<>
|
||||
<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"
|
||||
<Input
|
||||
className="min-w-0 flex-1"
|
||||
placeholder="备注(可空)"
|
||||
maxLength={500}
|
||||
value={note}
|
||||
@@ -65,26 +67,28 @@ export function BookmarksTab({
|
||||
if (e.key === "Enter" && !add.isPending) add.mutate();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="rd-btn inline-flex shrink-0 items-center gap-1"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0"
|
||||
aria-label="加书签"
|
||||
disabled={add.isPending}
|
||||
onClick={() => add.mutate()}
|
||||
>
|
||||
<Plus size={14} /> 加
|
||||
</button>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
<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 && (
|
||||
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
|
||||
<li className="p-2 text-xs text-muted-foreground">还没有书签,读到想记的位置点「加」</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"
|
||||
<Input
|
||||
className="min-w-0 flex-1"
|
||||
maxLength={500}
|
||||
value={editVal}
|
||||
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() });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="rd-btn inline-flex shrink-0 items-center"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0"
|
||||
aria-label="保存备注"
|
||||
disabled={patch.isPending}
|
||||
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="rd-btn inline-flex shrink-0 items-center"
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="shrink-0"
|
||||
aria-label="取消编辑"
|
||||
onClick={() => setEditId(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<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}
|
||||
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"
|
||||
<Button
|
||||
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="改备注"
|
||||
onClick={() => {
|
||||
setEditId(b.id);
|
||||
@@ -127,14 +137,16 @@ export function BookmarksTab({
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||
</Button>
|
||||
<Button
|
||||
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="删除书签"
|
||||
onClick={() => del.mutate(b.id)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +1,51 @@
|
||||
import { Bookmark, List, X } from "lucide-react";
|
||||
import { Bookmark, List, Search } from "lucide-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({
|
||||
tab,
|
||||
onTab,
|
||||
onClose,
|
||||
toc,
|
||||
bookmarks,
|
||||
search,
|
||||
}: {
|
||||
tab: NavTab;
|
||||
onTab?: (t: NavTab) => void;
|
||||
onClose: () => void;
|
||||
toc?: 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 (
|
||||
<>
|
||||
<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 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>
|
||||
<Sheet open onOpenChange={(o) => !o && onClose()}>
|
||||
<SheetContent side="left" className="w-72 p-0" aria-describedby={undefined}>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>导航</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div id="reader-nav" className="flex h-full flex-col">
|
||||
<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">
|
||||
{tabs.map(({ id, label, icon: Icon }) => (
|
||||
<TabsTrigger key={id} value={id} className="gap-1.5">
|
||||
<Icon size={14} /> {label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">{active.node}</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tab === "toc" ? toc : bookmarks}</div>
|
||||
</nav>
|
||||
</>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ function SheetContent({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && side !== "left" && side !== "bottom" && (
|
||||
{showCloseButton && side !== "bottom" && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
aria-label="关闭"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { Format } from "../api/types";
|
||||
import { IconArrowLeft } from "../components/icons";
|
||||
import { btn, btnGhost, formatBadge } from "../components/ui";
|
||||
import { FormatBadge } from "../components/format-badge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import type { ReaderProps } from "../lib/useProgress";
|
||||
|
||||
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
||||
@@ -51,19 +52,21 @@ export default function Reader() {
|
||||
<div className="flex h-full flex-col">
|
||||
<header
|
||||
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")
|
||||
}
|
||||
>
|
||||
<Link to="/" className={btnGhost + " shrink-0"}>
|
||||
<IconArrowLeft className="size-4" />
|
||||
书架
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" asChild className="shrink-0">
|
||||
<Link to="/">
|
||||
<ArrowLeft className="size-4" />
|
||||
书架
|
||||
</Link>
|
||||
</Button>
|
||||
<span className="truncate font-medium">{book?.title ?? "…"}</span>
|
||||
{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>}
|
||||
<span className={formatBadge(book.format)}>{book.format}</span>
|
||||
<FormatBadge fmt={book.format} />
|
||||
<span className="tabular-nums">{Math.round(book.percent * 100)}%</span>
|
||||
</span>
|
||||
)}
|
||||
@@ -77,13 +80,13 @@ export default function Reader() {
|
||||
|
||||
function Msg({ text, retry }: { text: string; retry?: () => void }) {
|
||||
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>
|
||||
<p className="mb-3">{text}</p>
|
||||
{retry && (
|
||||
<button className={btn} onClick={retry}>
|
||||
<Button variant="secondary" onClick={retry}>
|
||||
重试
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||
import { api, formatPageUrl } from "../api/client";
|
||||
import { btn } from "../components/ui";
|
||||
import { BookmarksTab } from "../components/Bookmarks";
|
||||
import { RdSlider } from "../components/rd-slider";
|
||||
import { ReaderNav, type NavTab } from "../components/reader-nav";
|
||||
import { fetchObjectUrl } from "../lib/authImage";
|
||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||
@@ -441,14 +440,19 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
||||
</DropdownMenu.ItemIndicator>
|
||||
</DropdownMenu.CheckboxItem>
|
||||
{locked && (
|
||||
<RdSlider
|
||||
label="预读:锁章末尾时提前挂载其后 N 章"
|
||||
ariaLabel="预读章数"
|
||||
min={0}
|
||||
max={3}
|
||||
value={prefetch}
|
||||
onChange={setPrefetch}
|
||||
/>
|
||||
<div className="px-2.5 pb-1.5 pt-2">
|
||||
<div className="mb-1 text-sm">预读:锁章末尾时提前挂载其后 N 章</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={3}
|
||||
step={1}
|
||||
value={prefetch}
|
||||
aria-label="预读章数"
|
||||
className="rd-range"
|
||||
onChange={(e) => setPrefetch(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
|
||||
Reference in New Issue
Block a user