Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
];
2 changes: 1 addition & 1 deletion resources/css/app.css
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
8 changes: 8 additions & 0 deletions resources/js/components/two-factor-setup-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,6 +62,7 @@ function TwoFactorSetupStep({
onNextStep: () => void;
errors: string[];
}) {
const { resolvedAppearance } = useAppearance();
const [copiedText, copy] = useClipboard();
const IconComponent = copiedText === manualSetupKey ? Check : Copy;

Expand All @@ -78,6 +80,12 @@ function TwoFactorSetupStep({
dangerouslySetInnerHTML={{
__html: qrCodeSvg,
}}
style={{
filter:
resolvedAppearance === 'dark'
? 'invert(1) brightness(1.5)'
: undefined,
}}
/>
) : (
<Spinner />
Expand Down
102 changes: 57 additions & 45 deletions resources/js/hooks/use-appearance.tsx
Original file line number Diff line number Diff line change
@@ -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<Appearance>('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);
Expand All @@ -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;
}