feat(ui): warm library palette (zinc+emerald -> stone+amber) with hover/focus feedback; text reader gains paper/sepia/night themes + persisted font-size (lib/readerPrefs.ts), serif justified measure; PDF drops emoji icons for 首页/末页, CBZ pill page counter, EPUB centered spread, blur reader header with format badge, aria labels on login/search/chapter select
This commit is contained in:
@@ -1,12 +1,47 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { apiRaw } from "../api/client";
|
||||
import { btn, input } from "../components/ui";
|
||||
import { btn } from "../components/ui";
|
||||
import { chapterText, splitChapters } from "../lib/chapters";
|
||||
import { decodeText } from "../lib/encoding";
|
||||
import { FONT_SIZES, RD_THEME_LABEL, useReaderPrefs } from "../lib/readerPrefs";
|
||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||
|
||||
type Prefs = ReturnType<typeof useReaderPrefs>;
|
||||
|
||||
function PrefsBar({ pr }: { pr: Prefs }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-sm" role="group" aria-label="阅读偏好">
|
||||
<button className="rd-btn text-xs" onClick={pr.cycleTheme} aria-label="切换阅读主题">
|
||||
{RD_THEME_LABEL[pr.prefs.theme]}·主题
|
||||
</button>
|
||||
<button className="rd-btn" onClick={() => pr.bump(-1)} disabled={pr.prefs.sizeIdx === 0} aria-label="减小字号">
|
||||
A−
|
||||
</button>
|
||||
<button
|
||||
className="rd-btn"
|
||||
onClick={() => pr.bump(1)}
|
||||
disabled={pr.prefs.sizeIdx === FONT_SIZES.length - 1}
|
||||
aria-label="加大字号"
|
||||
>
|
||||
A+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReaderToolbar({ pr, children }: { pr: Prefs; children?: ReactNode }) {
|
||||
return (
|
||||
<div className="rd-divider flex shrink-0 items-center gap-2 border-b px-4 py-1.5">
|
||||
{children}
|
||||
<div className="ml-auto">
|
||||
<PrefsBar pr={pr} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useFileText(url: string) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
@@ -31,7 +66,7 @@ function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
|
||||
<div className="grid h-full place-items-center p-8 text-center">
|
||||
<div>
|
||||
{onRetry && <p className="mb-3 text-red-400">{text}</p>}
|
||||
{!onRetry && <p className="text-zinc-500">{text}</p>}
|
||||
{!onRetry && <p className="text-stone-500">{text}</p>}
|
||||
{onRetry && (
|
||||
<button className={btn} onClick={onRetry}>
|
||||
重试
|
||||
@@ -50,6 +85,7 @@ function MdView({ book, initialLocator }: ReaderProps) {
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const saver = useProgressSaver(book.id);
|
||||
const restored = useRef(false);
|
||||
const pr = useReaderPrefs();
|
||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||
|
||||
const html = useMemo(() => (text == null ? null : DOMPurify.sanitize(marked.parse(text, { async: false }))), [text]);
|
||||
@@ -75,8 +111,15 @@ function MdView({ book, initialLocator }: ReaderProps) {
|
||||
if (err) return <LoadState text={err} onRetry={retry} />;
|
||||
if (text == null) return <LoadState text="正文加载中…" />;
|
||||
return (
|
||||
<div ref={boxRef} onScroll={onScroll} className="h-full overflow-y-auto p-6">
|
||||
<article className="md-body mx-auto max-w-2xl text-[15px] leading-7" dangerouslySetInnerHTML={{ __html: html ?? "" }} />
|
||||
<div className="rd-surface flex h-full flex-col" data-rd={pr.prefs.theme}>
|
||||
<ReaderToolbar pr={pr} />
|
||||
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
<article
|
||||
className="md-body mx-auto max-w-[46rem] leading-[1.85]"
|
||||
style={{ fontSize: pr.fontSize }}
|
||||
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,6 +132,7 @@ function TxtView({ book, initialLocator }: ReaderProps) {
|
||||
const restoreFrac = useRef<number | null>(null);
|
||||
const targetCi = useRef(0);
|
||||
const [ci, setCi] = useState(0);
|
||||
const pr = useReaderPrefs();
|
||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||
|
||||
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
|
||||
@@ -141,53 +185,65 @@ function TxtView({ book, initialLocator }: ReaderProps) {
|
||||
if (err) return <LoadState text={err} onRetry={retry} />;
|
||||
if (text == null || !chapters) return <LoadState text="正文加载中…" />;
|
||||
|
||||
const chapterOptions = (() => {
|
||||
const groups: { vol: string | undefined; items: number[] }[] = [];
|
||||
chapters.forEach((c, i) => {
|
||||
const last = groups[groups.length - 1];
|
||||
if (!last || last.vol !== c.vol) groups.push({ vol: c.vol, items: [i] });
|
||||
else last.items.push(i);
|
||||
});
|
||||
const opt = (i: number) => (
|
||||
<option key={i} value={i}>
|
||||
{i + 1}. {chapters[i].title}
|
||||
</option>
|
||||
);
|
||||
return groups.length === 1 && !groups[0].vol
|
||||
? chapters.map((_, i) => opt(i))
|
||||
: groups.map((g, gi) =>
|
||||
g.vol ? (
|
||||
<optgroup key={gi} label={g.vol}>
|
||||
{g.items.map(opt)}
|
||||
</optgroup>
|
||||
) : (
|
||||
g.items.map(opt)
|
||||
),
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{chapters.length > 1 && (
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-zinc-800 px-4 py-2 text-sm">
|
||||
<select className={input + " max-w-64"} value={ci} onChange={(e) => goChapter(Number(e.target.value))}>
|
||||
{(() => {
|
||||
const groups: { vol: string | undefined; items: number[] }[] = [];
|
||||
chapters.forEach((c, i) => {
|
||||
const last = groups[groups.length - 1];
|
||||
if (!last || last.vol !== c.vol) groups.push({ vol: c.vol, items: [i] });
|
||||
else last.items.push(i);
|
||||
});
|
||||
const opt = (i: number) => (
|
||||
<option key={i} value={i}>
|
||||
{i + 1}. {chapters[i].title}
|
||||
</option>
|
||||
);
|
||||
return groups.length === 1 && !groups[0].vol
|
||||
? chapters.map((_, i) => opt(i))
|
||||
: groups.map((g, gi) =>
|
||||
g.vol ? (
|
||||
<optgroup key={gi} label={g.vol}>
|
||||
{g.items.map(opt)}
|
||||
</optgroup>
|
||||
) : (
|
||||
g.items.map(opt)
|
||||
),
|
||||
);
|
||||
})()}
|
||||
</select>
|
||||
<span className="ml-auto text-zinc-500">
|
||||
{ci + 1}/{chapters.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto p-6">
|
||||
<pre className="mx-auto max-w-2xl font-sans text-[15px] leading-7 break-words whitespace-pre-wrap">
|
||||
<div className="rd-surface flex h-full flex-col" data-rd={pr.prefs.theme}>
|
||||
<ReaderToolbar pr={pr}>
|
||||
{chapters.length > 1 && (
|
||||
<>
|
||||
<select
|
||||
className="rd-select max-w-72 truncate"
|
||||
value={ci}
|
||||
aria-label="选择章节"
|
||||
onChange={(e) => goChapter(Number(e.target.value))}
|
||||
>
|
||||
{chapterOptions}
|
||||
</select>
|
||||
<span className="text-xs opacity-60 tabular-nums">
|
||||
{ci + 1}/{chapters.length}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</ReaderToolbar>
|
||||
<div ref={boxRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6">
|
||||
<pre
|
||||
className="mx-auto max-w-[34em] break-words whitespace-pre-wrap"
|
||||
style={{ fontSize: pr.fontSize, lineHeight: 1.9, textAlign: "justify" }}
|
||||
>
|
||||
{chapterText(text, chapters, ci)}
|
||||
</pre>
|
||||
</div>
|
||||
{chapters.length > 1 && (
|
||||
<div className="flex shrink-0 items-center justify-center gap-3 border-t border-zinc-800 px-4 py-2 text-sm">
|
||||
<button className={btn} disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
|
||||
上一章
|
||||
<div className="rd-divider flex shrink-0 items-center justify-center gap-3 border-t px-4 py-2 text-sm">
|
||||
<button className="rd-btn" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
|
||||
← 上一章
|
||||
</button>
|
||||
<button className={btn} disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
|
||||
下一章
|
||||
<button className="rd-btn" disabled={ci === chapters.length - 1} onClick={() => goChapter(ci + 1)}>
|
||||
下一章 →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user