mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-03-19 03:43:08 +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:
@@ -1,5 +1,6 @@
|
||||
import { Loader2, AlertCircle } from 'lucide-react'
|
||||
import { Loader2, AlertCircle, Check, Moon, Sun } from 'lucide-react'
|
||||
import { useSettings, useUpdateSettings, useAvailableModels } from '../hooks/useProjects'
|
||||
import { useTheme, THEMES } from '../hooks/useTheme'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,6 +21,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
const { data: settings, isLoading, isError, refetch } = useSettings()
|
||||
const { data: modelsData } = useAvailableModels()
|
||||
const updateSettings = useUpdateSettings()
|
||||
const { theme, setTheme, darkMode, toggleDarkMode } = useTheme()
|
||||
|
||||
const handleYoloToggle = () => {
|
||||
if (settings && !updateSettings.isPending) {
|
||||
@@ -80,6 +82,77 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
{/* Settings Content */}
|
||||
{settings && !isLoading && (
|
||||
<div className="space-y-6">
|
||||
{/* Theme Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label className="font-medium">Theme</Label>
|
||||
<div className="grid gap-2">
|
||||
{THEMES.map((themeOption) => (
|
||||
<button
|
||||
key={themeOption.id}
|
||||
onClick={() => setTheme(themeOption.id)}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg border-2 transition-colors text-left ${
|
||||
theme === themeOption.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border hover:border-primary/50 hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
{/* Color swatches */}
|
||||
<div className="flex gap-0.5 shrink-0">
|
||||
<div
|
||||
className="w-5 h-5 rounded-sm border border-border/50"
|
||||
style={{ backgroundColor: themeOption.previewColors.background }}
|
||||
/>
|
||||
<div
|
||||
className="w-5 h-5 rounded-sm border border-border/50"
|
||||
style={{ backgroundColor: themeOption.previewColors.primary }}
|
||||
/>
|
||||
<div
|
||||
className="w-5 h-5 rounded-sm border border-border/50"
|
||||
style={{ backgroundColor: themeOption.previewColors.accent }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Theme info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{themeOption.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{themeOption.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkmark */}
|
||||
{theme === themeOption.id && (
|
||||
<Check size={18} className="text-primary shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="dark-mode" className="font-medium">
|
||||
Dark Mode
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Switch between light and dark appearance
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
id="dark-mode"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleDarkMode}
|
||||
className="gap-2"
|
||||
>
|
||||
{darkMode ? <Sun size={16} /> : <Moon size={16} />}
|
||||
{darkMode ? 'Light' : 'Dark'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
{/* YOLO Mode Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
|
||||
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