feat(theme): add ThemeToggle and wire into header + admin top bar

- Add reusable client ThemeToggle at src/components/theme/theme-toggle.tsx
- Reads/writes novalon-theme localStorage key and toggles data-theme
- Placeholder on first render to avoid hydration mismatch
- Wired into public Header desktop cluster and AdminLayout top bar
This commit is contained in:
2026-08-31 22:05:06 +08:00
parent 8090ba2b9e
commit 02fec8a67b
3 changed files with 91 additions and 0 deletions
+2
View File
@@ -24,6 +24,7 @@ import {
UserCog, UserCog,
Bell, Bell,
} from 'lucide-react'; } from 'lucide-react';
import { ThemeToggle } from '@/components/theme/theme-toggle';
const menuItems = [ const menuItems = [
{ {
@@ -264,6 +265,7 @@ export function AdminLayout({ children }: { children: React.ReactNode }) {
<div className="flex-1" /> <div className="flex-1" />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<ThemeToggle />
<Link <Link
href="/" href="/"
target="_blank" target="_blank"
+2
View File
@@ -11,6 +11,7 @@ import { useSiteConfig, type NavigationItem } from '@/lib/site-config';
import { MegaDropdown } from '@/components/layout/mega-dropdown'; import { MegaDropdown } from '@/components/layout/mega-dropdown';
import { useFocusTrap } from '@/hooks/use-focus-trap'; import { useFocusTrap } from '@/hooks/use-focus-trap';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { ThemeToggle } from '@/components/theme/theme-toggle';
function HeaderContent() { function HeaderContent() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@@ -153,6 +154,7 @@ function HeaderContent() {
</nav> </nav>
<div className="hidden md:flex items-center gap-3"> <div className="hidden md:flex items-center gap-3">
<ThemeToggle />
<Button <Button
size="sm" size="sm"
asChild asChild
+87
View File
@@ -0,0 +1,87 @@
'use client';
import { useEffect, useState } from 'react';
import { Sun, Moon } from 'lucide-react';
import { cn } from '@/lib/utils';
type Theme = 'light' | 'dark';
const STORAGE_KEY = 'novalon-theme';
/**
* 读取当前应生效的主题:
* 1. 优先用用户显式选择(localStorage,与 layout.tsx FOUC 脚本同一 key
* 2. 回退到 FOUC 脚本已同步写入 <html data-theme> 的值
* 3. 默认浅色
* 这样即便用户刷新,状态也与首帧 FOUC 脚本完全一致,无闪烁。
*/
function resolveTheme(): Theme {
if (typeof window === 'undefined') return 'light';
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'light' || attr === 'dark') return attr as Theme;
return 'light';
}
function applyTheme(theme: Theme) {
document.documentElement.setAttribute('data-theme', theme);
try {
window.localStorage.setItem(STORAGE_KEY, theme);
} catch {
/* 隐私模式等写入失败时忽略:仅本次会话生效 */
}
}
export function ThemeToggle({ className }: { className?: string }) {
// 初始为 null,挂载后再解析真实主题,避免 SSR/CSR 水合不一致
const [theme, setTheme] = useState<Theme | null>(null);
const [mounted, setMounted] = useState(false);
// 必须挂载后读取:FOUC 脚本已在 <head> 同步写入 data-theme
// 此处从 DOM/localStorage 还原真实主题。SSR 阶段 window 不可用,
// 故无法用 useState 惰性初始化(否则首帧与客户端水合不一致)。
useEffect(() => {
// 主题同步必须从挂载后 DOM 读取,此 setState 无法避免
// eslint-disable-next-line react-hooks/set-state-in-effect
setTheme(resolveTheme());
setMounted(true);
}, []);
const isDark = theme === 'dark';
const toggle = () => {
const next: Theme = isDark ? 'light' : 'dark';
setTheme(next);
applyTheme(next);
};
return (
<button
type="button"
onClick={toggle}
aria-label={isDark ? '切换到浅色模式' : '切换到暗黑模式'}
aria-pressed={isDark}
className={cn(
'inline-flex items-center justify-center w-9 h-9 rounded-lg border border-border-primary',
'text-text-tertiary hover:text-text-primary hover:bg-bg-hover',
'transition-colors duration-200 ease-ink',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-border-dark',
className,
)}
>
{/* 挂载前渲染占位,避免与 FOUC 已应用的主题产生水合差异 */}
{mounted ? (
isDark ? (
<Moon className="w-4 h-4" aria-hidden="true" />
) : (
<Sun className="w-4 h-4" aria-hidden="true" />
)
) : (
<span className="block w-4 h-4" aria-hidden="true" />
)}
</button>
);
}
export default ThemeToggle;