- Add comprehensive design system with CSS variables, animations, and utility classes - Implement dark mode with ThemeProvider (system preference + manual toggle) - Create JSON-based content management system in src/content/ - Update all pages to use structured JSON content - Add ThemeProvider component with theme toggle button - Update Header with glass effect and animated mobile menu - Update Footer with dark mode support - Update documentation (README.md, CLAUDE.md, knowledge.md, proto/README.md) - Sync with Linear (ZEE-29 completed)
155 lines
4.5 KiB
TypeScript
155 lines
4.5 KiB
TypeScript
'use client'
|
|
|
|
import { createContext, useContext, useEffect, useState, ReactNode } from 'react'
|
|
|
|
type Theme = 'light' | 'dark' | 'system'
|
|
|
|
interface ThemeContextType {
|
|
theme: Theme
|
|
resolvedTheme: 'light' | 'dark'
|
|
setTheme: (theme: Theme) => void
|
|
toggleTheme: () => void
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
|
|
|
export function useTheme() {
|
|
const context = useContext(ThemeContext)
|
|
if (!context) {
|
|
// Return default values when not in provider (SSR/prerendering)
|
|
return {
|
|
theme: 'system' as const,
|
|
resolvedTheme: 'light' as const,
|
|
setTheme: () => {},
|
|
toggleTheme: () => {},
|
|
}
|
|
}
|
|
return context
|
|
}
|
|
|
|
interface ThemeProviderProps {
|
|
children: ReactNode
|
|
defaultTheme?: Theme
|
|
}
|
|
|
|
export function ThemeProvider({ children, defaultTheme = 'system' }: ThemeProviderProps) {
|
|
const [theme, setThemeState] = useState<Theme>(defaultTheme)
|
|
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light')
|
|
const [mounted, setMounted] = useState(false)
|
|
|
|
useEffect(() => {
|
|
setMounted(true)
|
|
const savedTheme = localStorage.getItem('theme') as Theme | null
|
|
if (savedTheme) {
|
|
setThemeState(savedTheme)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!mounted) return
|
|
|
|
const root = document.documentElement
|
|
let resolved: 'light' | 'dark'
|
|
|
|
if (theme === 'system') {
|
|
resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
root.removeAttribute('data-theme')
|
|
} else {
|
|
resolved = theme
|
|
root.setAttribute('data-theme', theme)
|
|
}
|
|
|
|
setResolvedTheme(resolved)
|
|
}, [theme, mounted])
|
|
|
|
useEffect(() => {
|
|
if (!mounted) return
|
|
|
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
|
const handleChange = (e: MediaQueryListEvent) => {
|
|
if (theme === 'system') {
|
|
setResolvedTheme(e.matches ? 'dark' : 'light')
|
|
}
|
|
}
|
|
|
|
mediaQuery.addEventListener('change', handleChange)
|
|
return () => mediaQuery.removeEventListener('change', handleChange)
|
|
}, [theme, mounted])
|
|
|
|
const setTheme = (newTheme: Theme) => {
|
|
setThemeState(newTheme)
|
|
localStorage.setItem('theme', newTheme)
|
|
}
|
|
|
|
const toggleTheme = () => {
|
|
const newTheme = resolvedTheme === 'light' ? 'dark' : 'light'
|
|
setTheme(newTheme)
|
|
}
|
|
|
|
// Prevent hydration mismatch - render children without context until mounted
|
|
if (!mounted) {
|
|
return <>{children}</>
|
|
}
|
|
|
|
return (
|
|
<ThemeContext.Provider value={{ theme, resolvedTheme, setTheme, toggleTheme }}>
|
|
{children}
|
|
</ThemeContext.Provider>
|
|
)
|
|
}
|
|
|
|
// Theme toggle button component
|
|
export function ThemeToggle() {
|
|
const { resolvedTheme, toggleTheme } = useTheme()
|
|
|
|
return (
|
|
<button
|
|
onClick={toggleTheme}
|
|
className="relative p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all duration-200 group"
|
|
style={{
|
|
background: resolvedTheme === 'dark' ? 'var(--color-background-tertiary)' : 'var(--color-background-tertiary)',
|
|
}}
|
|
aria-label={resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
|
|
>
|
|
{/* Sun icon */}
|
|
<svg
|
|
className={`w-5 h-5 transition-all duration-300 ${
|
|
resolvedTheme === 'dark'
|
|
? 'opacity-0 rotate-90 scale-0'
|
|
: 'opacity-100 rotate-0 scale-100'
|
|
}`}
|
|
style={{ color: 'var(--color-foreground)', position: resolvedTheme === 'dark' ? 'absolute' : 'relative' }}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
|
/>
|
|
</svg>
|
|
{/* Moon icon */}
|
|
<svg
|
|
className={`w-5 h-5 transition-all duration-300 ${
|
|
resolvedTheme === 'dark'
|
|
? 'opacity-100 rotate-0 scale-100'
|
|
: 'opacity-0 -rotate-90 scale-0'
|
|
}`}
|
|
style={{ color: 'var(--color-foreground)', position: resolvedTheme === 'light' ? 'absolute' : 'relative' }}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)
|
|
}
|