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)
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user