Files
websitedev/proto/src/components/ThemeProvider.tsx
T
Do Siki d1213f9b3f feat(ZEE-29): implement design system, dark mode, and JSON content management
- 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)
2026-01-24 02:30:27 +01:00

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>
)
}