Skip to content
Closed
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
46 changes: 22 additions & 24 deletions resources/js/components/appearance-tabs.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -9,35 +8,34 @@ export default function AppearanceToggleTab({
}: HTMLAttributes<HTMLDivElement>) {
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 (
<div
className={cn(
'inline-flex gap-1 rounded-lg bg-neutral-100 p-1 dark:bg-neutral-800',
className,
className
)}
{...props}
>
{tabs.map(({ value, icon: Icon, label }) => (
<button
key={value}
onClick={() => updateAppearance(value)}
className={cn(
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
appearance === value
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60',
)}
>
<Icon className="-ml-1 h-4 w-4" />
<span className="ml-1.5 text-sm">{label}</span>
</button>
))}
{Object.values(Appearance).map((value) => {
const Icon = AppearanceIcons[value];
const label = AppearanceLabels[value];

return (
<button
key={value}
onClick={() => updateAppearance(value)}
className={cn(
'flex items-center rounded-md px-3.5 py-1.5 transition-colors',
appearance === value
? 'bg-white shadow-xs dark:bg-neutral-700 dark:text-neutral-100'
: 'text-neutral-500 hover:bg-neutral-200/60 hover:text-black dark:text-neutral-400 dark:hover:bg-neutral-700/60'
)}
>
<Icon className="-ml-1 h-4 w-4" />
<span className="ml-1.5 text-sm">{label}</span>
</button>
);
})}
</div>
);
}
130 changes: 77 additions & 53 deletions resources/js/hooks/use-appearance.tsx
Original file line number Diff line number Diff line change
@@ -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, string> = {
[Appearance.LIGHT]: 'Light',
[Appearance.DARK]: 'Dark',
[Appearance.SYSTEM]: 'System',
};

return window.matchMedia('(prefers-color-scheme: dark)').matches;
// --- Centralized icons ---
export const AppearanceIcons: Record<Appearance, LucideIcon> = {
[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 <html>
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<Appearance>('system');
const getInitial = (): Appearance => {
if (typeof window === 'undefined') return Appearance.SYSTEM;
return (
(localStorage.getItem(APPEARANCE_KEY) as Appearance) ||
Appearance.SYSTEM
);
};

const [appearance, setAppearance] = useState<Appearance>(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;
}