Refactor/optimize ui #24
@@ -24,6 +24,7 @@ import {
|
||||
UserCog,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { ThemeToggle } from '@/components/theme/theme-toggle';
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
@@ -264,6 +265,7 @@ export function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href="/"
|
||||
target="_blank"
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useSiteConfig, type NavigationItem } from '@/lib/site-config';
|
||||
import { MegaDropdown } from '@/components/layout/mega-dropdown';
|
||||
import { useFocusTrap } from '@/hooks/use-focus-trap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ThemeToggle } from '@/components/theme/theme-toggle';
|
||||
|
||||
function HeaderContent() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -153,6 +154,7 @@ function HeaderContent() {
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<Button
|
||||
size="sm"
|
||||
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