HuggingClaw-MissionControl / src /components /ui /theme-selector.tsx
nyk
feat(refactor): ready for manual QA after main sync (#274)
b6ecafa unverified
'use client'
import { useTheme } from 'next-themes'
import { useEffect, useState, useRef } from 'react'
import { THEMES } from '@/lib/themes'
import { Button } from '@/components/ui/button'
export function ThemeSelector() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
setMounted(true)
}, [])
// Migrate legacy "dark" theme to "void"
useEffect(() => {
if (mounted && theme === 'dark') {
setTheme('void')
}
}, [mounted, theme, setTheme])
// Close on outside click
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
if (!mounted) {
return <div className="w-8 h-8 rounded-md bg-secondary animate-pulse" />
}
const darkThemes = THEMES.filter(t => t.group === 'dark')
const lightThemes = THEMES.filter(t => t.group === 'light')
return (
<div className="relative" ref={ref}>
<Button
onClick={() => setOpen(!open)}
variant="ghost"
size="icon-sm"
title="Change theme"
>
<PaletteIcon />
</Button>
{open && (
<>
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full mt-1 w-52 rounded-lg bg-card border border-border shadow-lg z-50 py-1 overflow-hidden">
<div className="px-3 py-1.5">
<span className="text-2xs font-semibold text-muted-foreground uppercase tracking-wider">Dark</span>
</div>
{darkThemes.map(t => (
<ThemeRow
key={t.id}
meta={t}
active={theme === t.id}
onSelect={() => { setTheme(t.id); setOpen(false) }}
/>
))}
<div className="my-1 border-t border-border" />
<div className="px-3 py-1.5">
<span className="text-2xs font-semibold text-muted-foreground uppercase tracking-wider">Light</span>
</div>
{lightThemes.map(t => (
<ThemeRow
key={t.id}
meta={t}
active={theme === t.id}
onSelect={() => { setTheme(t.id); setOpen(false) }}
/>
))}
</div>
</>
)}
</div>
)
}
function ThemeRow({ meta, active, onSelect }: { meta: typeof THEMES[number]; active: boolean; onSelect: () => void }) {
return (
<Button
onClick={onSelect}
variant="ghost"
className={`w-full justify-start px-3 py-1.5 h-auto rounded-none text-sm gap-2.5 ${
active
? 'bg-primary/10 text-foreground'
: ''
}`}
>
<span
className="w-3.5 h-3.5 rounded-full border border-border/50 shrink-0"
style={{ backgroundColor: meta.swatch }}
/>
<span className="flex-1 text-xs text-left">{meta.label}</span>
{active && (
<svg className="w-3.5 h-3.5 text-primary shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 8.5l3.5 3.5L13 4" />
</svg>
)}
</Button>
)
}
function PaletteIcon() {
return (
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="8" cy="8" r="6.5" />
<circle cx="6" cy="5.5" r="1" fill="currentColor" stroke="none" />
<circle cx="10" cy="5.5" r="1" fill="currentColor" stroke="none" />
<circle cx="5" cy="8.5" r="1" fill="currentColor" stroke="none" />
<path d="M10 9.5c0 1.1-.9 2-2 2s-2-.9-2-2" />
</svg>
)
}