diff --git a/eslint.config.js b/eslint.config.js index 7a9fc2ce..81c47509 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,7 +30,7 @@ export default [ }, }, { - ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr', 'tailwind.config.js'], + ignores: ['vendor', 'node_modules', 'public', 'bootstrap/ssr'], }, prettier, // Turn off all rules that might conflict with Prettier ]; diff --git a/resources/css/app.css b/resources/css/app.css index 468baf5b..7015d586 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,6 +1,6 @@ @import 'tailwindcss'; -@import "tw-animate-css"; +@import 'tw-animate-css'; @source '../views'; @source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php'; diff --git a/resources/js/components/two-factor-setup-modal.tsx b/resources/js/components/two-factor-setup-modal.tsx index c0a9d22c..2259e616 100644 --- a/resources/js/components/two-factor-setup-modal.tsx +++ b/resources/js/components/two-factor-setup-modal.tsx @@ -12,6 +12,7 @@ import { InputOTPGroup, InputOTPSlot, } from '@/components/ui/input-otp'; +import { useAppearance } from '@/hooks/use-appearance'; import { useClipboard } from '@/hooks/use-clipboard'; import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth'; import { confirm } from '@/routes/two-factor'; @@ -61,6 +62,7 @@ function TwoFactorSetupStep({ onNextStep: () => void; errors: string[]; }) { + const { resolvedAppearance } = useAppearance(); const [copiedText, copy] = useClipboard(); const IconComponent = copiedText === manualSetupKey ? Check : Copy; @@ -78,6 +80,12 @@ function TwoFactorSetupStep({ dangerouslySetInnerHTML={{ __html: qrCodeSvg, }} + style={{ + filter: + resolvedAppearance === 'dark' + ? 'invert(1) brightness(1.5)' + : undefined, + }} /> ) : ( diff --git a/resources/js/hooks/use-appearance.tsx b/resources/js/hooks/use-appearance.tsx index 2c6b5682..8cbadfad 100644 --- a/resources/js/hooks/use-appearance.tsx +++ b/resources/js/hooks/use-appearance.tsx @@ -1,60 +1,86 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useMemo, useSyncExternalStore } from 'react'; -export type Appearance = 'light' | 'dark' | 'system'; +export type ResolvedAppearance = 'light' | 'dark'; +export type Appearance = ResolvedAppearance | 'system'; -const prefersDark = () => { - if (typeof window === 'undefined') { - return false; - } +const listeners = new Set<() => void>(); +let currentAppearance: Appearance = 'system'; +const prefersDark = (): boolean => { + if (typeof window === 'undefined') return false; return window.matchMedia('(prefers-color-scheme: dark)').matches; }; -const setCookie = (name: string, value: string, days = 365) => { - if (typeof document === 'undefined') { - return; - } - +const setCookie = (name: string, value: string, days = 365): void => { + if (typeof document === 'undefined') return; const maxAge = days * 24 * 60 * 60; document.cookie = `${name}=${value};path=/;max-age=${maxAge};SameSite=Lax`; }; -const applyTheme = (appearance: Appearance) => { - const isDark = - appearance === 'dark' || (appearance === 'system' && prefersDark()); +const getStoredAppearance = (): Appearance => { + if (typeof window === 'undefined') return 'system'; + return (localStorage.getItem('appearance') as Appearance) || 'system'; +}; + +const isDarkMode = (appearance: Appearance): boolean => { + return appearance === 'dark' || (appearance === 'system' && prefersDark()); +}; + +const applyTheme = (appearance: Appearance): void => { + if (typeof document === 'undefined') return; + + const isDark = isDarkMode(appearance); document.documentElement.classList.toggle('dark', isDark); document.documentElement.style.colorScheme = isDark ? 'dark' : 'light'; }; -const mediaQuery = () => { - if (typeof window === 'undefined') { - return null; - } +const subscribe = (callback: () => void) => { + listeners.add(callback); + return () => listeners.delete(callback); +}; + +const notify = (): void => listeners.forEach((listener) => listener()); +const mediaQuery = (): MediaQueryList | null => { + 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'); +const handleSystemThemeChange = (): void => { + applyTheme(currentAppearance); + notify(); }; -export function initializeTheme() { - const savedAppearance = - (localStorage.getItem('appearance') as Appearance) || 'system'; +export function initializeTheme(): void { + if (typeof window === 'undefined') return; + + if (!localStorage.getItem('appearance')) { + localStorage.setItem('appearance', 'system'); + setCookie('appearance', 'system'); + } - applyTheme(savedAppearance); + currentAppearance = getStoredAppearance(); + applyTheme(currentAppearance); - // Add the event listener for system theme changes... + // Set up system theme change listener mediaQuery()?.addEventListener('change', handleSystemThemeChange); } export function useAppearance() { - const [appearance, setAppearance] = useState('system'); + const appearance: Appearance = useSyncExternalStore( + subscribe, + () => currentAppearance, + () => 'system', + ); - const updateAppearance = useCallback((mode: Appearance) => { - setAppearance(mode); + const resolvedAppearance: ResolvedAppearance = useMemo( + () => (isDarkMode(appearance) ? 'dark' : 'light'), + [appearance], + ); + + const updateAppearance = useCallback((mode: Appearance): void => { + currentAppearance = mode; // Store in localStorage for client-side persistence... localStorage.setItem('appearance', mode); @@ -63,22 +89,8 @@ export function useAppearance() { setCookie('appearance', mode); applyTheme(mode); + notify(); }, []); - 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]); - - return { appearance, updateAppearance } as const; + return { appearance, resolvedAppearance, updateAppearance } as const; }