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:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user