diff --git a/resources/js/components/appearance-tabs.tsx b/resources/js/components/appearance-tabs.tsx index 1a1e271f..bf16c25a 100644 --- a/resources/js/components/appearance-tabs.tsx +++ b/resources/js/components/appearance-tabs.tsx @@ -1,6 +1,5 @@ -import { Appearance, useAppearance } from '@/hooks/use-appearance'; +import { Appearance, AppearanceLabels, AppearanceIcons, useAppearance } from '@/hooks/use-appearance'; import { cn } from '@/lib/utils'; -import { LucideIcon, Monitor, Moon, Sun } from 'lucide-react'; import { HTMLAttributes } from 'react'; export default function AppearanceToggleTab({ @@ -9,35 +8,34 @@ export default function AppearanceToggleTab({ }: HTMLAttributes) { const { appearance, updateAppearance } = useAppearance(); - const tabs: { value: Appearance; icon: LucideIcon; label: string }[] = [ - { value: 'light', icon: Sun, label: 'Light' }, - { value: 'dark', icon: Moon, label: 'Dark' }, - { value: 'system', icon: Monitor, label: 'System' }, - ]; - return (
- {tabs.map(({ value, icon: Icon, label }) => ( - - ))} + {Object.values(Appearance).map((value) => { + const Icon = AppearanceIcons[value]; + const label = AppearanceLabels[value]; + + return ( + + ); + })}
); } diff --git a/resources/js/hooks/use-appearance.tsx b/resources/js/hooks/use-appearance.tsx index 2c6b5682..ad79124f 100644 --- a/resources/js/hooks/use-appearance.tsx +++ b/resources/js/hooks/use-appearance.tsx @@ -1,84 +1,108 @@ import { useCallback, useEffect, useState } from 'react'; +import { Sun, Moon, Monitor } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; + +// --- Define enum for theme modes --- +export enum Appearance { + LIGHT = 'light', + DARK = 'dark', + SYSTEM = 'system', +} -export type Appearance = 'light' | 'dark' | 'system'; - -const prefersDark = () => { - if (typeof window === 'undefined') { - return false; - } +// --- Centralized labels --- +export const AppearanceLabels: Record = { + [Appearance.LIGHT]: 'Light', + [Appearance.DARK]: 'Dark', + [Appearance.SYSTEM]: 'System', +}; - return window.matchMedia('(prefers-color-scheme: dark)').matches; +// --- Centralized icons --- +export const AppearanceIcons: Record = { + [Appearance.LIGHT]: Sun, + [Appearance.DARK]: Moon, + [Appearance.SYSTEM]: Monitor, }; -const setCookie = (name: string, value: string, days = 365) => { - if (typeof document === 'undefined') { - return; - } +// --- Key for localStorage & cookies --- +const APPEARANCE_KEY = 'appearance'; +// Detect OS-level dark mode +const prefersDark = (): boolean => + typeof window !== 'undefined' && + window.matchMedia('(prefers-color-scheme: dark)').matches; + +// Set cookie (SSR-friendly) +const setCookie = (name: string, value: string, days = 365) => { + if (typeof document === 'undefined') return; const maxAge = days * 24 * 60 * 60; document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`; }; +// Apply theme to const applyTheme = (appearance: Appearance) => { const isDark = - appearance === 'dark' || (appearance === 'system' && prefersDark()); - + appearance === Appearance.DARK || + (appearance === Appearance.SYSTEM && prefersDark()); + if (typeof document === 'undefined') return; document.documentElement.classList.toggle('dark', isDark); document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'; }; -const mediaQuery = () => { - if (typeof window === 'undefined') { - return null; - } - - return window.matchMedia('(prefers-color-scheme: dark)'); -}; - -const handleSystemThemeChange = () => { - const currentAppearance = localStorage.getItem('appearance') as Appearance; - applyTheme(currentAppearance || 'system'); -}; +// Listen to system theme changes +const getMediaQuery = (): MediaQueryList | null => + typeof window === 'undefined' + ? null + : window.matchMedia('(prefers-color-scheme: dark)'); export function initializeTheme() { - const savedAppearance = - (localStorage.getItem('appearance') as Appearance) || 'system'; - - applyTheme(savedAppearance); - - // Add the event listener for system theme changes... - mediaQuery()?.addEventListener('change', handleSystemThemeChange); + const saved = + (typeof localStorage !== 'undefined' + ? (localStorage.getItem(APPEARANCE_KEY) as Appearance | null) + : null) || Appearance.SYSTEM; + + applyTheme(saved); + + const mq = getMediaQuery(); + if (mq) { + const listener = () => { + const current = + (localStorage.getItem(APPEARANCE_KEY) as Appearance) || + Appearance.SYSTEM; + applyTheme(current); + }; + mq.addEventListener('change', listener); + } } export function useAppearance() { - const [appearance, setAppearance] = useState('system'); + const getInitial = (): Appearance => { + if (typeof window === 'undefined') return Appearance.SYSTEM; + return ( + (localStorage.getItem(APPEARANCE_KEY) as Appearance) || + Appearance.SYSTEM + ); + }; + + const [appearance, setAppearance] = useState(getInitial); const updateAppearance = useCallback((mode: Appearance) => { setAppearance(mode); - - // Store in localStorage for client-side persistence... - localStorage.setItem('appearance', mode); - - // Store in cookie for SSR... - setCookie('appearance', mode); - + localStorage.setItem(APPEARANCE_KEY, mode); + setCookie(APPEARANCE_KEY, mode); applyTheme(mode); }, []); useEffect(() => { - const savedAppearance = localStorage.getItem( - 'appearance', - ) as Appearance | null; - - // eslint-disable-next-line react-hooks/set-state-in-effect - updateAppearance(savedAppearance || 'system'); - - return () => - mediaQuery()?.removeEventListener( - 'change', - handleSystemThemeChange, - ); - }, [updateAppearance]); + const mq = getMediaQuery(); + const handleChange = () => { + const current = + (localStorage.getItem(APPEARANCE_KEY) as Appearance) || + Appearance.SYSTEM; + applyTheme(current); + }; + mq?.addEventListener('change', handleChange); + return () => mq?.removeEventListener('change', handleChange); + }, []); return { appearance, updateAppearance } as const; }