feat(theme): add theme toggle dropdown to header
- Create ThemeToggle component with dropdown menu - Shows all 5 themes with descriptions - Persists choice to localStorage - Updates data-theme attribute instantly - Add palette icon to top bar
This commit is contained in:
parent
fa3489b394
commit
a47f21e769
2 changed files with 90 additions and 0 deletions
87
src/components/shared/theme-toggle.tsx
Normal file
87
src/components/shared/theme-toggle.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Palette, Check } from 'lucide-react';
|
||||||
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
|
|
||||||
|
const themes = [
|
||||||
|
{ id: 'aurora', name: 'Aurora', desc: 'Warm charcoal + cyan' },
|
||||||
|
{ id: 'midnight', name: 'Midnight', desc: 'Cool blue-purple' },
|
||||||
|
{ id: 'forest', name: 'Forest', desc: 'Earthy green-brown' },
|
||||||
|
{ id: 'dusk', name: 'Dusk', desc: 'Sunset orange-pink' },
|
||||||
|
{ id: 'contrast', name: 'Contrast', desc: 'High contrast' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [currentTheme, setCurrentTheme] = useState<string>('aurora');
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
// Load saved theme from localStorage
|
||||||
|
const saved = localStorage.getItem('beadboard-theme');
|
||||||
|
if (saved && themes.find(t => t.id === saved)) {
|
||||||
|
setCurrentTheme(saved);
|
||||||
|
document.documentElement.setAttribute('data-theme', saved);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleThemeChange = (themeId: string) => {
|
||||||
|
setCurrentTheme(themeId);
|
||||||
|
document.documentElement.setAttribute('data-theme', themeId);
|
||||||
|
localStorage.setItem('beadboard-theme', themeId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent hydration mismatch
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<button className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-tertiary)]">
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = themes.find(t => t.id === currentTheme) || themes[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--text-tertiary)] transition-colors hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-info)]"
|
||||||
|
aria-label="Change theme"
|
||||||
|
>
|
||||||
|
<Palette className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content
|
||||||
|
className="min-w-[200px] rounded-xl border border-[var(--border-subtle)] bg-[var(--surface-overlay)] p-2 shadow-[var(--shadow-lg)] backdrop-blur-lg z-50"
|
||||||
|
sideOffset={8}
|
||||||
|
align="end"
|
||||||
|
>
|
||||||
|
<div className="px-2 py-1.5 mb-1">
|
||||||
|
<p className="text-xs font-semibold text-[var(--text-primary)]">Theme</p>
|
||||||
|
<p className="text-[10px] text-[var(--text-tertiary)]">{current.desc}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu.Separator className="h-px bg-[var(--border-subtle)] my-1" />
|
||||||
|
|
||||||
|
{themes.map((theme) => (
|
||||||
|
<DropdownMenu.Item
|
||||||
|
key={theme.id}
|
||||||
|
onClick={() => handleThemeChange(theme.id)}
|
||||||
|
className="flex items-center justify-between rounded-lg px-2 py-2 text-xs text-[var(--text-secondary)] hover:bg-[var(--alpha-white-low)] hover:text-[var(--text-primary)] cursor-pointer outline-none transition-colors"
|
||||||
|
>
|
||||||
|
<span>{theme.name}</span>
|
||||||
|
{currentTheme === theme.id && (
|
||||||
|
<Check className="h-3.5 w-3.5 text-[var(--accent-success)]" />
|
||||||
|
)}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
))}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { ReactNode } from 'react';
|
||||||
import { LayoutGrid, Lock, Plus, Sidebar, SidebarClose } from 'lucide-react';
|
import { LayoutGrid, Lock, Plus, Sidebar, SidebarClose } from 'lucide-react';
|
||||||
import { useUrlState } from '../../hooks/use-url-state';
|
import { useUrlState } from '../../hooks/use-url-state';
|
||||||
import { useResponsive } from '../../hooks/use-responsive';
|
import { useResponsive } from '../../hooks/use-responsive';
|
||||||
|
import { ThemeToggle } from './theme-toggle';
|
||||||
|
|
||||||
export interface TopBarProps {
|
export interface TopBarProps {
|
||||||
onCreateTask?: () => Promise<void> | void;
|
onCreateTask?: () => Promise<void> | void;
|
||||||
|
|
@ -127,6 +128,8 @@ export function TopBar({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
{isDesktop ? (
|
{isDesktop ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue