Files
offlineacademy/components/ThemeToggle.tsx
T
2026-06-26 02:51:08 +00:00

131 lines
4.1 KiB
TypeScript
Executable File

'use client'
import * as React from 'react'
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { Sun, Moon, Monitor, ChevronDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
type Theme = 'light' | 'dark' | 'system'
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
resolvedTheme: 'light' | 'dark'
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>('system')
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('dark')
const [mounted, setMounted] = useState(false)
// Initialize from localStorage
useEffect(() => {
const stored = localStorage.getItem('theme') as Theme | null
if (stored) {
setTheme(stored)
}
setMounted(true)
}, [])
// Apply theme to document
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'
} else {
resolved = theme
}
setResolvedTheme(resolved)
root.classList.remove('light', 'dark')
root.classList.add(resolved)
root.style.colorScheme = resolved
localStorage.setItem('theme', theme)
}, [theme, mounted])
// Listen for system theme changes
useEffect(() => {
if (!mounted || theme !== 'system') return
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => {
const resolved = mediaQuery.matches ? 'dark' : 'light'
setResolvedTheme(resolved)
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(resolved)
document.documentElement.style.colorScheme = resolved
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [theme, mounted])
const setThemeCallback = useCallback((newTheme: Theme) => {
setTheme(newTheme)
}, [])
if (!mounted) {
return (
<ThemeContext.Provider value={{ theme: 'system', setTheme: () => {}, resolvedTheme: 'dark' }}>
{children}
</ThemeContext.Provider>
)
}
return (
<ThemeContext.Provider value={{ theme, setTheme: setThemeCallback, resolvedTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme()
const themes: { value: Theme; label: string; icon: React.ReactNode }[] = [
{ value: 'light', label: 'Light', icon: <Sun className="h-4 w-4" /> },
{ value: 'dark', label: 'Dark', icon: <Moon className="h-4 w-4" /> },
{ value: 'system', label: 'System', icon: <Monitor className="h-4 w-4" /> },
]
return (
<div className="relative">
<Button
variant="ghost"
size="icon"
className="relative"
onClick={() => {
const currentIndex = themes.findIndex(t => t.value === theme)
const nextIndex = (currentIndex + 1) % themes.length
setTheme(themes[nextIndex].value)
}}
aria-label={`Current theme: ${theme}. Click to cycle.`}
title={`Theme: ${theme.charAt(0).toUpperCase() + theme.slice(1)}`}
>
{themes.find(t => t.value === resolvedTheme)?.icon || <Monitor className="h-4 w-4" />}
</Button>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 opacity-0 invisible transition-all group-hover:opacity-100 group-hover:visible pointer-events-none">
<div className="bg-muted px-2 py-1 rounded text-xs text-muted-foreground whitespace-nowrap">
Theme: {theme.charAt(0).toUpperCase() + theme.slice(1)}
</div>
</div>
</div>
)
}