- 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
88 lines
2.9 KiB
TypeScript
88 lines
2.9 KiB
TypeScript
'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;
|