mirror of
https://github.com/nicetry247/offlineacademy.git
synced 2026-07-26 11:58:44 +00:00
131 lines
4.1 KiB
TypeScript
Executable File
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>
|
|
)
|
|
} |