Files
leocrm/frontend/src/pages/SettingsTheme.tsx
T

347 lines
13 KiB
TypeScript
Raw Normal View History

2026-07-23 08:42:26 +02:00
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Palette, Type, Moon, Sun, RotateCcw, Check } from 'lucide-react';
import { useSystemSettings, useUpdateSystemSettings } from '@/api/settings';
import { useThemeStore } from '@/store/themeStore';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Badge } from '@/components/ui/Badge';
import { Skeleton } from '@/components/ui/Skeleton';
import { useToast } from '@/components/ui/Toast';
const FONT_OPTIONS = [
{ value: 'Inter', label: 'Inter (Standard)' },
{ value: 'Roboto', label: 'Roboto' },
{ value: 'Open Sans', label: 'Open Sans' },
{ value: 'Lato', label: 'Lato' },
{ value: 'Montserrat', label: 'Montserrat' },
{ value: 'Source Sans Pro', label: 'Source Sans Pro' },
{ value: 'Nunito', label: 'Nunito' },
{ value: 'system-ui', label: 'System UI' },
];
const RADIUS_OPTIONS = [
{ value: '0.25rem', label: 'Sharp (0.25rem)' },
{ value: '0.375rem', label: 'Small (0.375rem)' },
{ value: '0.5rem', label: 'Medium (0.5rem) — Standard' },
{ value: '0.75rem', label: 'Large (0.75rem)' },
{ value: '1rem', label: 'Extra Large (1rem)' },
{ value: '1.5rem', label: 'Round (1.5rem)' },
];
const PRESET_THEMES = [
{ name: 'Classic Blue', primary: '#2563eb', accent: '#d946ef' },
{ name: 'Ocean', primary: '#0ea5e9', accent: '#6366f1' },
{ name: 'Forest', primary: '#16a34a', accent: '#84cc16' },
{ name: 'Sunset', primary: '#ea580c', accent: '#f59e0b' },
{ name: 'Purple', primary: '#7c3aed', accent: '#ec4899' },
{ name: 'Slate', primary: '#475569', accent: '#64748b' },
];
export function SettingsThemePage() {
const { t } = useTranslation();
const toast = useToast();
const { data: settings, isLoading } = useSystemSettings();
const updateMutation = useUpdateSystemSettings();
const themeStore = useThemeStore();
const [primaryColor, setPrimaryColor] = useState('#2563eb');
const [accentColor, setAccentColor] = useState('#d946ef');
const [fontFamily, setFontFamily] = useState('Inter');
const [borderRadius, setBorderRadius] = useState('0.5rem');
const [darkMode, setDarkMode] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Load theme from system settings on mount
useEffect(() => {
if (settings) {
const pc = settings.theme_primary_color || '#2563eb';
const ac = settings.theme_accent_color || '#d946ef';
const ff = settings.theme_font_family || 'Inter';
const br = settings.theme_border_radius || '0.5rem';
setPrimaryColor(pc);
setAccentColor(ac);
setFontFamily(ff);
setBorderRadius(br);
// Apply to store for live preview
themeStore.setTheme({
primaryColor: pc,
accentColor: ac,
fontFamily: ff,
borderRadius: br,
});
}
}, [settings]);
// Load dark mode from localStorage on mount
useEffect(() => {
themeStore.loadFromStorage();
setDarkMode(themeStore.darkMode);
}, []);
// Track changes
useEffect(() => {
if (!settings) return;
const changed =
primaryColor !== (settings.theme_primary_color || '#2563eb') ||
accentColor !== (settings.theme_accent_color || '#d946ef') ||
fontFamily !== (settings.theme_font_family || 'Inter') ||
borderRadius !== (settings.theme_border_radius || '0.5rem');
setHasChanges(changed);
}, [primaryColor, accentColor, fontFamily, borderRadius, settings]);
// Live preview: apply to theme store immediately
const handlePrimaryChange = (val: string) => {
setPrimaryColor(val);
themeStore.setTheme({ primaryColor: val });
};
const handleAccentChange = (val: string) => {
setAccentColor(val);
themeStore.setTheme({ accentColor: val });
};
const handleFontChange = (val: string) => {
setFontFamily(val);
themeStore.setTheme({ fontFamily: val });
};
const handleRadiusChange = (val: string) => {
setBorderRadius(val);
themeStore.setTheme({ borderRadius: val });
};
const handleDarkModeToggle = () => {
const newMode = !darkMode;
setDarkMode(newMode);
themeStore.toggleDarkMode();
};
const handlePreset = (preset: typeof PRESET_THEMES[0]) => {
setPrimaryColor(preset.primary);
setAccentColor(preset.accent);
themeStore.setTheme({
primaryColor: preset.primary,
accentColor: preset.accent,
});
};
const handleSave = async () => {
try {
await updateMutation.mutateAsync({
...settings,
theme_primary_color: primaryColor,
theme_accent_color: accentColor,
theme_font_family: fontFamily,
theme_border_radius: borderRadius,
});
toast.success(t('settings.themeSaved', 'Theme gespeichert'));
setHasChanges(false);
} catch {
toast.error(t('settings.themeSaveError', 'Theme konnte nicht gespeichert werden'));
}
};
const handleReset = () => {
handlePreset(PRESET_THEMES[0]);
setFontFamily('Inter');
setBorderRadius('0.5rem');
themeStore.setTheme({
primaryColor: '#2563eb',
accentColor: '#d946ef',
fontFamily: 'Inter',
borderRadius: '0.5rem',
});
};
if (isLoading) {
return (
<div className="space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-64 w-full" />
</div>
);
}
return (
<div className="space-y-6 max-w-4xl" data-testid="settings-theme-page">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-secondary-900">{t('settings.themeTitle', 'Theme-Anpassung')}</h1>
<p className="text-sm text-secondary-500 mt-1">{t('settings.themeDescription', 'Passen Sie Farben, Schriftart und Erscheinungsbild an.')}</p>
</div>
{hasChanges && (
<Badge variant="warning" dot>{t('settings.unsavedChanges', 'Ungespeicherte Änderungen')}</Badge>
)}
</div>
{/* Preset Themes */}
<Card title={t('settings.presets', 'Vorlagen')} description={t('settings.presetsDescription', 'Schnell eine Farbpalette wählen')}>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
{PRESET_THEMES.map((preset) => (
<button
key={preset.name}
onClick={() => handlePreset(preset)}
className="flex flex-col items-center gap-2 p-3 rounded-lg border border-secondary-200 hover:border-primary-400 hover:bg-primary-50 transition-colors cursor-pointer"
aria-label={preset.name}
>
<div className="flex gap-1">
<div className="w-8 h-8 rounded-full" style={{ backgroundColor: preset.primary }} aria-hidden="true" />
<div className="w-8 h-8 rounded-full" style={{ backgroundColor: preset.accent }} aria-hidden="true" />
</div>
<span className="text-xs font-medium text-secondary-700">{preset.name}</span>
</button>
))}
</div>
</Card>
{/* Color Customization */}
<Card title={t('settings.colors', 'Farben')} description={t('settings.colorsDescription', 'Hauptfarbe und Akzentfarbe anpassen')}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
<Palette className="inline w-4 h-4 mr-1" aria-hidden="true" />
{t('settings.primaryColor', 'Hauptfarbe')}
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={primaryColor}
onChange={(e) => handlePrimaryChange(e.target.value)}
className="w-12 h-12 rounded-md border border-secondary-200 cursor-pointer"
aria-label={t('settings.primaryColor', 'Hauptfarbe')}
/>
<Input
value={primaryColor}
onChange={(e) => handlePrimaryChange(e.target.value)}
className="flex-1"
placeholder="#2563eb"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
<Palette className="inline w-4 h-4 mr-1" aria-hidden="true" />
{t('settings.accentColor', 'Akzentfarbe')}
</label>
<div className="flex items-center gap-3">
<input
type="color"
value={accentColor}
onChange={(e) => handleAccentChange(e.target.value)}
className="w-12 h-12 rounded-md border border-secondary-200 cursor-pointer"
aria-label={t('settings.accentColor', 'Akzentfarbe')}
/>
<Input
value={accentColor}
onChange={(e) => handleAccentChange(e.target.value)}
className="flex-1"
placeholder="#d946ef"
/>
</div>
</div>
</div>
</Card>
{/* Typography & Layout */}
<Card title={t('settings.typography', 'Typografie & Layout')}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
<Type className="inline w-4 h-4 mr-1" aria-hidden="true" />
{t('settings.fontFamily', 'Schriftart')}
</label>
<Select value={fontFamily} onChange={(e) => handleFontChange(e.target.value)} options={FONT_OPTIONS} />
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-2">
{t('settings.borderRadius', 'Border-Radius')}
</label>
<Select value={borderRadius} onChange={(e) => handleRadiusChange(e.target.value)} options={RADIUS_OPTIONS} />
</div>
</div>
</Card>
{/* Dark Mode */}
<Card title={t('settings.darkMode', 'Dark Mode')} description={t('settings.darkModeDescription', 'Zwischen hellem und dunklem Theme wechseln')}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{darkMode ? (
<Moon className="w-5 h-5 text-secondary-600" aria-hidden="true" />
) : (
<Sun className="w-5 h-5 text-secondary-600" aria-hidden="true" />
)}
<span className="text-sm font-medium text-secondary-700">
{darkMode ? t('settings.darkModeOn', 'Dunkles Theme aktiv') : t('settings.darkModeOff', 'Helles Theme aktiv')}
</span>
</div>
<button
onClick={handleDarkModeToggle}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
darkMode ? 'bg-primary-600' : 'bg-secondary-300'
}`}
role="switch"
aria-checked={darkMode}
aria-label={t('settings.darkMode', 'Dark Mode')}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
darkMode ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</Card>
{/* Live Preview */}
<Card title={t('settings.livePreview', 'Live-Vorschau')} description={t('settings.livePreviewDescription', 'So sieht die Anwendung mit dem aktuellen Theme aus')}>
<div className="space-y-4">
{/* Buttons */}
<div className="flex flex-wrap gap-2">
<Button variant="primary" size="sm">Primary Button</Button>
<Button variant="secondary" size="sm">Secondary Button</Button>
<Button variant="danger" size="sm">Danger Button</Button>
<Button variant="ghost" size="sm">Ghost Button</Button>
</div>
{/* Badges */}
<div className="flex flex-wrap gap-2">
<Badge variant="primary">Primary</Badge>
<Badge variant="success" dot>Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="danger">Danger</Badge>
<Badge variant="info">Info</Badge>
</div>
{/* Input preview */}
<div className="max-w-xs">
<Input label="Beispiel-Input" placeholder="Text eingeben..." />
</div>
{/* Card preview */}
<div className="bg-white rounded-lg border border-secondary-200 p-4 shadow-sm">
<p className="text-sm text-secondary-700">Dies ist eine Beispiel-Karte mit dem aktuellen Theme.</p>
</div>
</div>
</Card>
{/* Actions */}
<div className="flex items-center justify-end gap-3">
<Button variant="secondary" onClick={handleReset} icon={<RotateCcw className="w-4 h-4" />}>
{t('settings.resetTheme', 'Zurücksetzen')}
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={!hasChanges}
isLoading={updateMutation.isPending}
icon={<Check className="w-4 h-4" />}
>
{t('settings.saveTheme', 'Theme speichern')}
</Button>
</div>
</div>
);
}