refactor(frontend): remove ui.ts/icons.tsx and all rd-* legacy classes (B5)
This commit is contained in:
@@ -8,6 +8,11 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed / 变更
|
||||||
|
|
||||||
|
- Reader UI cleanup: the legacy style helpers (ui.ts / icons.tsx) and all rd-* classes are gone; every reader surface now uses the shared design tokens and components.
|
||||||
|
- 阅读器 UI 清理:旧样式助手(ui.ts / icons.tsx)与全部 rd-* 类删除;所有阅读器表面统一使用共享设计 token 与组件。
|
||||||
|
|
||||||
### Added / 新增
|
### Added / 新增
|
||||||
|
|
||||||
- The shelf page gains a reading stats card (this week's time, streak days, expandable 7-day bar chart); reading time is tracked on-device while a book is open.
|
- The shelf page gains a reading stats card (this week's time, streak days, expandable 7-day bar chart); reading time is tracked on-device while a book is open.
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
|
|||||||
const { user, ready } = useAuth();
|
const { user, ready } = useAuth();
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
|
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
|
||||||
if (!ready || !user) return <div className="grid h-full place-items-center text-stone-500">加载中…</div>;
|
if (!ready || !user) return <div className="text-muted-foreground grid h-full place-items-center">加载中…</div>;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Book } from "../api/types";
|
import type { Book } from "../api/types";
|
||||||
import { fetchObjectUrl } from "../lib/authImage";
|
import { fetchObjectUrl } from "../lib/authImage";
|
||||||
import { btn, formatBadge } from "./ui";
|
import { FormatBadge } from "./format-badge";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
import { TriangleAlert } from "lucide-react";
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
|
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
|
||||||
@@ -44,12 +45,17 @@ export function Cover({ book, className }: { book: Book; className?: string }) {
|
|||||||
<span className="select-none font-serif text-4xl text-muted-foreground">
|
<span className="select-none font-serif text-4xl text-muted-foreground">
|
||||||
{Array.from(book.title)[0] ?? "?"}
|
{Array.from(book.title)[0] ?? "?"}
|
||||||
</span>
|
</span>
|
||||||
<span className={formatBadge(book.format, "absolute bottom-1.5 right-1.5")}>{book.format}</span>
|
<FormatBadge fmt={book.format} className="absolute right-1.5 bottom-1.5" />
|
||||||
{failed && (
|
{failed && (
|
||||||
<button className={btn + " absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"} onClick={retry}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="xs"
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 gap-1"
|
||||||
|
onClick={retry}
|
||||||
|
>
|
||||||
<TriangleAlert className="size-4" />
|
<TriangleAlert className="size-4" />
|
||||||
封面重试
|
封面重试
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Err
|
|||||||
<div className="grid h-full place-items-center p-8 text-center">
|
<div className="grid h-full place-items-center p-8 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-lg text-red-400">页面出错了</p>
|
<p className="mb-2 text-lg text-red-400">页面出错了</p>
|
||||||
<p className="mb-4 max-w-md text-sm break-all text-stone-500">{String(this.state.err)}</p>
|
<p className="mb-4 max-w-md text-sm break-all text-muted-foreground">{String(this.state.err)}</p>
|
||||||
<button
|
<button
|
||||||
className="rounded-md bg-stone-800 px-3 py-1.5 text-sm"
|
className="rounded-md bg-secondary px-3 py-1.5 text-sm"
|
||||||
onClick={() => this.setState({ err: null })}
|
onClick={() => this.setState({ err: null })}
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
function Svg({ children, ...p }: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.7}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
{...p}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IconArrowLeft = (p: SVGProps<SVGSVGElement>) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<path d="M20 12H4m0 0 6-6m-6 6 6 6" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { Format } from "../api/types";
|
|
||||||
|
|
||||||
export const btn =
|
|
||||||
"inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50";
|
|
||||||
export const btnGhost =
|
|
||||||
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-stone-400 transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500";
|
|
||||||
|
|
||||||
// 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义
|
|
||||||
export const FORMAT_BADGE: Record<Format, string> = {
|
|
||||||
cbz: "bg-orange-500/15 text-orange-300 ring-orange-500/25",
|
|
||||||
pdf: "bg-rose-500/15 text-rose-300 ring-rose-500/25",
|
|
||||||
epub: "bg-sky-500/15 text-sky-300 ring-sky-500/25",
|
|
||||||
txt: "bg-emerald-500/15 text-emerald-300 ring-emerald-500/25",
|
|
||||||
md: "bg-teal-500/15 text-teal-300 ring-teal-500/25",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatBadge(fmt: Format, extra = ""): string {
|
|
||||||
return `rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${FORMAT_BADGE[fmt]} ${extra}`;
|
|
||||||
}
|
|
||||||
@@ -149,74 +149,8 @@ body {
|
|||||||
.rd-surface ::selection {
|
.rd-surface ::selection {
|
||||||
background: color-mix(in oklab, var(--rd-link) 30%, transparent);
|
background: color-mix(in oklab, var(--rd-link) 30%, transparent);
|
||||||
}
|
}
|
||||||
/* 主题内的次级控件(章节条/翻页键),跟随当前阅读主题 */
|
|
||||||
.rd-btn {
|
|
||||||
@apply rounded-md px-2.5 py-1 text-sm transition-colors;
|
|
||||||
background: color-mix(in oklab, currentColor 8%, transparent);
|
|
||||||
}
|
|
||||||
.rd-btn:hover {
|
|
||||||
background: color-mix(in oklab, currentColor 16%, transparent);
|
|
||||||
}
|
|
||||||
.rd-btn:disabled {
|
|
||||||
@apply cursor-not-allowed opacity-40;
|
|
||||||
}
|
|
||||||
/* 目录侧边栏行 */
|
|
||||||
.rd-row {
|
|
||||||
@apply block w-full truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors;
|
|
||||||
}
|
|
||||||
.rd-row:hover {
|
|
||||||
background: color-mix(in oklab, currentColor 12%, transparent);
|
|
||||||
}
|
|
||||||
.rd-btn-on {
|
|
||||||
@apply font-semibold;
|
|
||||||
background: color-mix(in oklab, currentColor 20%, transparent);
|
|
||||||
}
|
|
||||||
/* Radix 菜单项高亮(指针/键盘) */
|
|
||||||
.rd-highlight {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.rd-highlight[data-highlighted] {
|
|
||||||
background: color-mix(in oklab, currentColor 16%, transparent);
|
|
||||||
}
|
|
||||||
.rd-divider {
|
|
||||||
border-color: color-mix(in oklab, currentColor 14%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 进度/页码滑条:主题色轨道 + 强调色拇指,触摸友好 */
|
|
||||||
.rd-range {
|
|
||||||
@apply h-1.5 w-full cursor-pointer appearance-none rounded-full;
|
|
||||||
background: color-mix(in oklab, currentColor 18%, transparent);
|
|
||||||
}
|
|
||||||
.rd-range::-webkit-slider-thumb {
|
|
||||||
@apply h-4 w-4 appearance-none rounded-full ring-2 ring-black/10;
|
|
||||||
background: var(--rd-link, #f59e0b);
|
|
||||||
}
|
|
||||||
.rd-range::-moz-range-thumb {
|
|
||||||
@apply h-4 w-4 rounded-full border-0;
|
|
||||||
background: var(--rd-link, #f59e0b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 沉浸态底部工具抽屉的滑入动效 */
|
|
||||||
@keyframes rd-rise {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.rd-sheet {
|
|
||||||
animation: rd-rise 0.2s ease-out;
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.rd-sheet {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
.rd-rise-anim {
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
.fx-rise {
|
.fx-rise {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Cover } from "@/components/Cover";
|
|||||||
import { toast } from "@/components/Toaster";
|
import { toast } from "@/components/Toaster";
|
||||||
import { UploadButton } from "@/components/upload-button";
|
import { UploadButton } from "@/components/upload-button";
|
||||||
import { ReadingStatsCard } from "@/components/reading-stats-card";
|
import { ReadingStatsCard } from "@/components/reading-stats-card";
|
||||||
import { formatBadge } from "@/components/ui";
|
import { FormatBadge } from "@/components/format-badge";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -68,7 +68,7 @@ function BookCard({
|
|||||||
>
|
>
|
||||||
<span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50">
|
<span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50">
|
||||||
<Cover book={book} className="size-full" />
|
<Cover book={book} className="size-full" />
|
||||||
<span className={formatBadge(book.format, "absolute left-1.5 top-1.5")}>{book.format}</span>
|
<FormatBadge fmt={book.format} className="absolute left-1.5 top-1.5" />
|
||||||
{book.state === "error" && (
|
{book.state === "error" && (
|
||||||
<span
|
<span
|
||||||
className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-destructive-foreground"
|
className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-destructive-foreground"
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
|
|||||||
}, [url, nonce]);
|
}, [url, nonce]);
|
||||||
if (failed)
|
if (failed)
|
||||||
return (
|
return (
|
||||||
<div className="grid place-items-center gap-2 bg-stone-900 py-8 text-sm text-stone-500" style={{ width }}>
|
<div className="bg-muted text-muted-foreground grid place-items-center gap-2 py-8 text-sm" style={{ width }}>
|
||||||
本页加载失败
|
本页加载失败
|
||||||
<Button variant="secondary" onClick={() => setNonce((n) => n + 1)}>
|
<Button variant="secondary" onClick={() => setNonce((n) => n + 1)}>
|
||||||
重试
|
重试
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!src) return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
|
if (!src) return <div className="animate-pulse bg-muted" style={{ width, height: width * EST_RATIO }} />;
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
@@ -286,10 +286,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
|
|
||||||
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
|
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
|
||||||
if (countQ.isPending && !!book.pages_url)
|
if (countQ.isPending && !!book.pages_url)
|
||||||
return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
|
return <div className="text-muted-foreground grid h-full place-items-center">页索引加载中…</div>;
|
||||||
if (countQ.isError || !book.page_url_fmt)
|
if (countQ.isError || !book.page_url_fmt)
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
|
<div className="text-muted-foreground grid h-full place-items-center p-8 text-center">
|
||||||
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
|
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -396,7 +396,7 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
style={{ top: ph.offset(w.end) - anchor, height: vh }}
|
style={{ top: ph.offset(w.end) - anchor, height: vh }}
|
||||||
>
|
>
|
||||||
{w.end >= count ? (
|
{w.end >= count ? (
|
||||||
<span className="text-sm text-stone-500">— 全书完 —</span>
|
<span className="text-muted-foreground text-sm">— 全书完 —</span>
|
||||||
) : (
|
) : (
|
||||||
<Button variant="secondary" onClick={() => goPage(chapters![ci + 1].start)}>
|
<Button variant="secondary" onClick={() => goPage(chapters![ci + 1].start)}>
|
||||||
本章完 · 下一章 <ChevronRight size={14} className="inline" />
|
本章完 · 下一章 <ChevronRight size={14} className="inline" />
|
||||||
@@ -544,7 +544,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
<li key={i}>
|
<li key={i}>
|
||||||
<button
|
<button
|
||||||
ref={i === ci ? activeRow : undefined}
|
ref={i === ci ? activeRow : undefined}
|
||||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
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" +
|
||||||
|
(i === ci ? " bg-accent text-accent-foreground" : "")
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
goPage(c.start);
|
goPage(c.start);
|
||||||
setNav(null);
|
setNav(null);
|
||||||
|
|||||||
@@ -411,7 +411,10 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
<li key={i}>
|
<li key={i}>
|
||||||
<button
|
<button
|
||||||
ref={i === ci ? activeRow : undefined}
|
ref={i === ci ? activeRow : undefined}
|
||||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
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" +
|
||||||
|
(i === ci ? " bg-accent text-accent-foreground" : "")
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
goChapter(i);
|
goChapter(i);
|
||||||
setNav(null);
|
setNav(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user