From 03bc537308c9a13722dd26c1e30a9c244c9f52bf Mon Sep 17 00:00:00 2001 From: XingfenD Date: Tue, 8 Sep 2026 17:17:44 +0800 Subject: [PATCH] feat(ui): semantic light/dark tokens, ThemeProvider with FOUC guard, token-restyle Toaster/Cover --- frontend/index.html | 11 ++- frontend/src/App.tsx | 89 ++++++++++++------------ frontend/src/components/Cover.tsx | 12 ++-- frontend/src/components/Toaster.tsx | 10 ++- frontend/src/components/theme.tsx | 47 +++++++++++++ frontend/src/index.css | 101 ++++++++++++++++++++++++---- frontend/vite.config.ts | 4 +- 7 files changed, 202 insertions(+), 72 deletions(-) create mode 100644 frontend/src/components/theme.tsx diff --git a/frontend/index.html b/frontend/index.html index 7c3f092..0fdd40d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,8 +3,17 @@ - + + BookLib — 个人书库 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b7224f8..dc4ff58 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider, RequireAdmin, RequireAuth } from "./auth/AuthContext"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { Toaster } from "./components/Toaster"; +import { ThemeProvider } from "./components/theme"; import Login from "./pages/Login"; import Reader from "./pages/Reader"; import Shelf from "./pages/Shelf"; @@ -11,49 +12,51 @@ import AdminUsers from "./pages/admin/Users"; export default function App() { return ( - - - } /> - - - - } - /> - - - - } - /> - - - - - - } - /> - - - - - - } - /> - } /> - - - + + + + } /> + + + + } + /> + + + + } + /> + + + + + + } + /> + + + + + + } + /> + } /> + + + + ); } diff --git a/frontend/src/components/Cover.tsx b/frontend/src/components/Cover.tsx index 81a5cc1..8472df0 100644 --- a/frontend/src/components/Cover.tsx +++ b/frontend/src/components/Cover.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import type { Book } from "../api/types"; import { fetchObjectUrl } from "../lib/authImage"; import { btn, formatBadge } from "./ui"; -import { IconAlert } from "./icons"; +import { TriangleAlert } from "lucide-react"; export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } { const [state, setState] = useState<{ key: string; src: string; failed: boolean }>({ key: "", src: "", failed: false }); @@ -31,26 +31,26 @@ export function Cover({ book, className }: { book: Book; className?: string }) { if (!book.cover_url || failed) { return (
- {Array.from(book.title)[0] ?? "?"} + {Array.from(book.title)[0] ?? "?"} {book.format} {failed && ( )}
); } - if (!src) return
; + if (!src) return
; return ( {`《${book.title}》封面`} ); } diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx index d5e29af..9678fad 100644 --- a/frontend/src/components/Toaster.tsx +++ b/frontend/src/components/Toaster.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, type ReactNode } from "react"; -import { IconAlert, IconCheck } from "./icons"; +import { CheckCircle2, XCircle } from "lucide-react"; type Toast = { id: number; kind: "ok" | "err"; text: ReactNode }; @@ -32,13 +32,11 @@ export function Toaster() {
- {t.kind === "ok" ? : } + {t.kind === "ok" ? : } {t.text}
))} diff --git a/frontend/src/components/theme.tsx b/frontend/src/components/theme.tsx new file mode 100644 index 0000000..343bbdd --- /dev/null +++ b/frontend/src/components/theme.tsx @@ -0,0 +1,47 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; + +export type Theme = "system" | "light" | "dark"; +const KEY = "ui.theme"; +const SYS = "(prefers-color-scheme: dark)"; + +function read(): Theme { + try { + const v = localStorage.getItem(KEY); + return v === "light" || v === "dark" || v === "system" ? v : "system"; + } catch { + return "system"; + } +} + +function apply(t: Theme) { + const dark = t === "dark" || (t === "system" && matchMedia(SYS).matches); + document.documentElement.classList.toggle("dark", dark); +} + +const Ctx = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, set] = useState(read); + useEffect(() => { + apply(theme); + try { + localStorage.setItem(KEY, theme); + } catch { + /* 隐私模式:仅本次会话生效 */ + } + if (theme !== "system") return; + const mq = matchMedia(SYS); + const on = () => apply("system"); + mq.addEventListener("change", on); + return () => mq.removeEventListener("change", on); + }, [theme]); + const setTheme = useCallback((t: Theme) => set(t), []); + const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]); + return {children}; +} + +export function useTheme() { + const v = useContext(Ctx); + if (!v) throw new Error("useTheme outside ThemeProvider"); + return v; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index d5fc3b1..6d2e7c7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,74 @@ @import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.65rem; + --background: oklch(0.985 0.001 80); + --foreground: oklch(0.22 0.01 70); + --card: oklch(0.995 0 0); + --card-foreground: oklch(0.22 0.01 70); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.22 0.01 70); + --primary: oklch(0.25 0.012 70); + --primary-foreground: oklch(0.98 0.002 80); + --secondary: oklch(0.95 0.003 80); + --secondary-foreground: oklch(0.32 0.01 70); + --muted: oklch(0.95 0.003 80); + --muted-foreground: oklch(0.55 0.008 70); + --accent: oklch(0.68 0.16 46); + --accent-foreground: oklch(0.985 0.002 80); + --destructive: oklch(0.58 0.21 27); + --border: oklch(0.91 0.004 80); + --input: oklch(0.91 0.004 80); + --ring: oklch(0.62 0.01 70); +} +.dark { + --background: oklch(0.19 0.006 80); + --foreground: oklch(0.95 0.003 80); + --card: oklch(0.23 0.006 80); + --card-foreground: oklch(0.95 0.003 80); + --popover: oklch(0.23 0.006 80); + --popover-foreground: oklch(0.95 0.003 80); + --primary: oklch(0.95 0.003 80); + --primary-foreground: oklch(0.22 0.01 70); + --secondary: oklch(0.28 0.006 80); + --secondary-foreground: oklch(0.95 0.003 80); + --muted: oklch(0.28 0.006 80); + --muted-foreground: oklch(0.68 0.006 80); + --accent: oklch(0.7 0.16 46); + --accent-foreground: oklch(0.19 0.006 80); + --destructive: oklch(0.65 0.19 25); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 14%); + --ring: oklch(0.6 0.01 70); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); +} html, body, @@ -6,10 +76,23 @@ body, height: 100%; } body { - @apply bg-stone-950 text-stone-100 antialiased; - /* 顶部一抹极淡的琥珀氛围光,避免大面积纯黑的"朴素感" */ - background-image: radial-gradient(60rem 28rem at 50% -8rem, rgb(217 119 6 / 0.07), transparent 70%); - background-repeat: no-repeat; + @apply bg-background text-foreground antialiased; + color-scheme: light; +} +.dark body { + color-scheme: dark; +} + +::-webkit-scrollbar { + width: 10px; + height: 10px; +} +::-webkit-scrollbar-thumb { + @apply rounded-full border-2 border-transparent bg-clip-padding; + background-color: color-mix(in oklab, var(--foreground) 22%, transparent); +} +::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in oklab, var(--foreground) 34%, transparent); } /* 入场:卡片/区块轻微上浮淡入(列表 stagger 用 style.animationDelay) */ @@ -18,16 +101,6 @@ body { to { opacity: 1; transform: none; } } .fx-rise { animation: fx-rise 0.35s cubic-bezier(0.22, 1, 0.36, 1) both; } -::-webkit-scrollbar { - width: 10px; - height: 10px; -} -::-webkit-scrollbar-thumb { - @apply rounded-full border-2 border-transparent bg-clip-padding bg-stone-700/70; -} -::-webkit-scrollbar-thumb:hover { - @apply bg-stone-600; -} /* 阅读页主题:纸/米/夜,文本类阅读器共用 —— 见 lib/readerPrefs.ts */ [data-rd="paper"] { --rd-bg: #faf8f4; --rd-fg: #292524; --rd-link: #9a3412; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5ba74d3..ebf7d01 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -15,8 +15,8 @@ export default defineConfig({ name: "BookLib 个人书库", short_name: "BookLib", description: "个人书库/漫画库在线阅读", - theme_color: "#0c0a09", - background_color: "#0c0a09", + theme_color: "#fafaf9", + background_color: "#fafaf9", display: "standalone", icons: [{ src: "icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }], },