mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-01 06:53:36 +00:00
feat(ui): add theme switching system with Twitter, Claude, and Neo Brutalism themes
Add a comprehensive theme system allowing users to switch between three distinct visual themes, each supporting both light and dark modes: - Twitter (default): Clean blue design with soft shadows - Claude: Warm beige/cream tones with orange primary accents - Neo Brutalism: Bold colors, hard shadows, 0px border radius New files: - ui/src/hooks/useTheme.ts: Theme state management hook with localStorage persistence for both theme selection and dark mode preference - ui/src/components/ThemeSelector.tsx: Header dropdown with hover preview and color swatches for quick theme switching Modified files: - ui/src/styles/globals.css: Added CSS custom properties for Claude and Neo Brutalism themes with light/dark variants, shadow variables integrated into @theme inline block - ui/src/App.tsx: Integrated useTheme hook and ThemeSelector component - ui/src/components/SettingsModal.tsx: Added theme selection UI with preview swatches and dark mode toggle - ui/index.html: Added DM Sans and Space Mono fonts for Neo Brutalism Features: - Independent theme and dark mode controls - Smooth CSS transitions when switching themes - Theme-specific shadow styles (soft vs hard) - Theme-specific fonts and border radius - Persisted preferences in localStorage Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
155
ui/src/components/ThemeSelector.tsx
Normal file
155
ui/src/components/ThemeSelector.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Palette, Check } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { ThemeId, ThemeOption } from '../hooks/useTheme'
|
||||
|
||||
interface ThemeSelectorProps {
|
||||
themes: ThemeOption[]
|
||||
currentTheme: ThemeId
|
||||
onThemeChange: (theme: ThemeId) => void
|
||||
}
|
||||
|
||||
export function ThemeSelector({ themes, currentTheme, onThemeChange }: ThemeSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [previewTheme, setPreviewTheme] = useState<ThemeId | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
setPreviewTheme(null)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Apply preview theme temporarily
|
||||
useEffect(() => {
|
||||
if (previewTheme) {
|
||||
const root = document.documentElement
|
||||
root.classList.remove('theme-claude', 'theme-neo-brutalism')
|
||||
if (previewTheme === 'claude') {
|
||||
root.classList.add('theme-claude')
|
||||
} else if (previewTheme === 'neo-brutalism') {
|
||||
root.classList.add('theme-neo-brutalism')
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup: restore current theme when preview ends
|
||||
return () => {
|
||||
if (previewTheme) {
|
||||
const root = document.documentElement
|
||||
root.classList.remove('theme-claude', 'theme-neo-brutalism')
|
||||
if (currentTheme === 'claude') {
|
||||
root.classList.add('theme-claude')
|
||||
} else if (currentTheme === 'neo-brutalism') {
|
||||
root.classList.add('theme-neo-brutalism')
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [previewTheme, currentTheme])
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
setPreviewTheme(null)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const handleThemeHover = (themeId: ThemeId) => {
|
||||
setPreviewTheme(themeId)
|
||||
}
|
||||
|
||||
const handleThemeClick = (themeId: ThemeId) => {
|
||||
onThemeChange(themeId)
|
||||
setPreviewTheme(null)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title="Theme"
|
||||
aria-label="Select theme"
|
||||
aria-expanded={isOpen}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<Palette size={18} />
|
||||
</Button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-2 w-56 bg-popover border-2 border-border rounded-lg shadow-lg z-50 animate-slide-in-down overflow-hidden"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
>
|
||||
<div className="p-2 space-y-1">
|
||||
{themes.map((theme) => (
|
||||
<button
|
||||
key={theme.id}
|
||||
onClick={() => handleThemeClick(theme.id)}
|
||||
onMouseEnter={() => handleThemeHover(theme.id)}
|
||||
onMouseLeave={() => setPreviewTheme(null)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-left transition-colors ${
|
||||
currentTheme === theme.id
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'hover:bg-muted text-foreground'
|
||||
}`}
|
||||
role="menuitem"
|
||||
>
|
||||
{/* Color swatches */}
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm border border-border/50"
|
||||
style={{ backgroundColor: theme.previewColors.background }}
|
||||
/>
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm border border-border/50"
|
||||
style={{ backgroundColor: theme.previewColors.primary }}
|
||||
/>
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm border border-border/50"
|
||||
style={{ backgroundColor: theme.previewColors.accent }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Theme name and description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{theme.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkmark for current theme */}
|
||||
{currentTheme === theme.id && (
|
||||
<Check size={16} className="text-primary shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user