Refactor settings system and improve context initialization

Backend:
- Add type validation and coercion for settings API
- Implement SettingStorage and SettingType in registry
- Improve CRUD operations for settings

Frontend:
- Refactor Theme, Language, Sidebar, ViewMode contexts
- Simplify admin components (GeneralTab, SettingsTab, UsersTab)
- Add new settings endpoints to API client
- Improve App initialization flow

Infrastructure:
- Update Dockerfile and docker-compose.yml
- Add .dockerignore
- Update Makefile and README

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 18:14:47 +01:00
parent 04a0fe4b27
commit ba53e0eff0
31 changed files with 4277 additions and 374 deletions

View File

@@ -8,11 +8,11 @@ import '../styles/AdminPanel.css';
type TabId = 'general' | 'users';
export default function AdminPanel() {
export default function AdminPanel({ initialTab = 'general' }: { initialTab?: TabId } = {}) {
const { user: currentUser } = useAuth();
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
const [activeTab, setActiveTab] = useState<TabId>('general');
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
if (!currentUser?.is_superuser) {
return null;

View File

@@ -0,0 +1,36 @@
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import '../styles/AdminPanel.css';
export default function Feature2() {
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
return (
<main className="main-content">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">download</span>
<span className="page-title-text">{t.features.feature2}</span>
</div>
</div>
</div>
<div className="page-content">
<div className="tab-content-placeholder">
<div className="placeholder-icon">
<span className="material-symbols-outlined">download</span>
</div>
<h3>{t.features.feature2}</h3>
<p>{t.features.comingSoon}</p>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,36 @@
import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import '../styles/AdminPanel.css';
export default function Feature3() {
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
return (
<main className="main-content">
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="material-symbols-outlined">cast</span>
<span className="page-title-text">{t.features.feature3}</span>
</div>
</div>
</div>
<div className="page-content">
<div className="tab-content-placeholder">
<div className="placeholder-icon">
<span className="material-symbols-outlined">cast</span>
</div>
<h3>{t.features.feature3}</h3>
<p>{t.features.comingSoon}</p>
</div>
</div>
</main>
);
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { useTranslation } from '../../contexts/LanguageContext';
import { useSidebar } from '../../contexts/SidebarContext';
import Sidebar from '../../components/Sidebar';
import { settingsAPI } from '../../api/client';
import '../../styles/Settings.css';
interface Settings {
@@ -22,17 +23,8 @@ export default function Settings() {
const fetchSettings = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/v1/settings', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
setSettings(data);
}
const data = await settingsAPI.getAllSettings();
setSettings(data as Settings);
} catch (error) {
console.error('Failed to fetch settings:', error);
} finally {
@@ -43,23 +35,11 @@ export default function Settings() {
const updateSetting = async (key: string, value: any) => {
setSaving(true);
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/v1/settings/${key}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value }),
});
if (response.ok) {
const updatedSetting = await response.json();
setSettings(prev => ({
...prev,
[key]: updatedSetting.value
}));
}
const updatedSetting = await settingsAPI.updateSetting(key, value);
setSettings(prev => ({
...prev,
[key]: updatedSetting.value
}));
} catch (error) {
console.error('Failed to update setting:', error);
} finally {

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette } from '../../contexts/ThemeContext';
import { useTranslation } from '../../contexts/LanguageContext';
@@ -10,10 +10,13 @@ import '../../styles/ThemeSettings.css';
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
type ThemeMode = 'light' | 'dark';
type ColorProperty = keyof typeof COLOR_PALETTES.default.light;
type ColorPickerState = {
isOpen: boolean;
theme: 'light' | 'dark';
property: string;
theme: ThemeMode;
property: ColorProperty;
value: string;
} | null;
@@ -32,6 +35,8 @@ export default function ThemeSettings() {
setDensity,
setFontFamily,
setColorPalette,
customColors,
setCustomColors,
saveThemeToBackend
} = useTheme();
const { t } = useTranslation();
@@ -88,32 +93,25 @@ export default function ThemeSettings() {
saveThemeToBackend({ colorPalette: palette }).catch(console.error);
};
// Advanced color states
const [customColors, setCustomColors] = useState({
light: {
bgMain: '#ffffff',
bgCard: '#f9fafb',
bgElevated: '#ffffff',
textPrimary: '#111827',
textSecondary: '#6b7280',
border: '#e5e7eb',
sidebarBg: '#1f2937',
sidebarText: '#f9fafb'
},
dark: {
bgMain: '#0f172a',
bgCard: '#1e293b',
bgElevated: '#334155',
textPrimary: '#f1f5f9',
textSecondary: '#94a3b8',
border: '#334155',
sidebarBg: '#0c1222',
sidebarText: '#f9fafb'
}
});
// Color picker popup state
const [colorPickerState, setColorPickerState] = useState<ColorPickerState>(null);
const hasUserModifiedCustomColors = useRef(false);
const paletteColors = COLOR_PALETTES[colorPalette];
const effectiveColors = {
light: { ...paletteColors.light, ...(customColors.light ?? {}) },
dark: { ...paletteColors.dark, ...(customColors.dark ?? {}) },
};
useEffect(() => {
if (!isAdmin || !hasUserModifiedCustomColors.current) return;
const timeoutId = setTimeout(() => {
saveThemeToBackend({ customColors }).catch(console.error);
}, 300);
return () => clearTimeout(timeoutId);
}, [customColors, isAdmin, saveThemeToBackend]);
const colors: { id: AccentColor; label: string; value: string; description: string }[] = [
{ id: 'auto', label: t.theme.colors.auto, value: '#374151', description: t.theme.colors.autoDesc },
@@ -177,8 +175,8 @@ export default function ThemeSettings() {
// Helper component for color control
const ColorControl = ({ theme, property, label, value }: {
theme: 'light' | 'dark';
property: string;
theme: ThemeMode;
property: ColorProperty;
label: string;
value: string
}) => {
@@ -220,21 +218,35 @@ export default function ThemeSettings() {
};
// Color picker handlers
const handleColorChange = (theme: 'light' | 'dark', property: string, value: string) => {
setCustomColors(prev => ({
...prev,
[theme]: {
...prev[theme],
[property]: value
const handleColorChange = (theme: ThemeMode, property: ColorProperty, value: string) => {
if (!isAdmin) return;
hasUserModifiedCustomColors.current = true;
const baseValue = paletteColors[theme][property];
const normalizedValue = value.trim().toLowerCase();
const normalizedBase = baseValue.trim().toLowerCase();
setCustomColors((prev) => {
const next = { ...prev };
const themeOverrides = { ...(prev[theme] ?? {}) } as Record<string, string>;
if (normalizedValue === normalizedBase) {
delete themeOverrides[property];
} else {
themeOverrides[property] = value;
}
}));
// Apply to CSS variables immediately
const root = document.documentElement;
const varName = `--color-${property.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
root.style.setProperty(varName, value);
if (Object.keys(themeOverrides).length === 0) {
delete next[theme];
} else {
next[theme] = themeOverrides;
}
return next;
});
};
const handleHexInput = (theme: 'light' | 'dark', property: string, value: string) => {
const handleHexInput = (theme: ThemeMode, property: ColorProperty, value: string) => {
// Validate hex color format
const hexRegex = /^#?[0-9A-Fa-f]{6}$/;
const formattedValue = value.startsWith('#') ? value : `#${value}`;
@@ -245,38 +257,9 @@ export default function ThemeSettings() {
};
const resetToDefaults = () => {
setCustomColors({
light: {
bgMain: '#ffffff',
bgCard: '#f9fafb',
bgElevated: '#ffffff',
textPrimary: '#111827',
textSecondary: '#6b7280',
border: '#e5e7eb',
sidebarBg: '#1f2937',
sidebarText: '#f9fafb'
},
dark: {
bgMain: '#0f172a',
bgCard: '#1e293b',
bgElevated: '#334155',
textPrimary: '#f1f5f9',
textSecondary: '#94a3b8',
border: '#334155',
sidebarBg: '#0c1222',
sidebarText: '#f9fafb'
}
});
// Reset CSS variables
const root = document.documentElement;
root.style.removeProperty('--color-bg-main');
root.style.removeProperty('--color-bg-card');
root.style.removeProperty('--color-bg-elevated');
root.style.removeProperty('--color-text-primary');
root.style.removeProperty('--color-text-secondary');
root.style.removeProperty('--color-border');
root.style.removeProperty('--color-sidebar-bg');
root.style.removeProperty('--color-sidebar-text');
if (!isAdmin) return;
hasUserModifiedCustomColors.current = true;
setCustomColors({});
};
return (
@@ -619,49 +602,49 @@ export default function ThemeSettings() {
theme="light"
property="bgMain"
label={t.theme.background}
value={customColors.light.bgMain}
value={effectiveColors.light.bgMain}
/>
<ColorControl
theme="light"
property="bgCard"
label={t.theme.backgroundCard}
value={customColors.light.bgCard}
value={effectiveColors.light.bgCard}
/>
<ColorControl
theme="light"
property="bgElevated"
label={t.theme.backgroundElevated}
value={customColors.light.bgElevated}
value={effectiveColors.light.bgElevated}
/>
<ColorControl
theme="light"
property="textPrimary"
label={t.theme.textPrimary}
value={customColors.light.textPrimary}
value={effectiveColors.light.textPrimary}
/>
<ColorControl
theme="light"
property="textSecondary"
label={t.theme.textSecondary}
value={customColors.light.textSecondary}
value={effectiveColors.light.textSecondary}
/>
<ColorControl
theme="light"
property="border"
label={t.theme.border}
value={customColors.light.border}
value={effectiveColors.light.border}
/>
<ColorControl
theme="light"
property="sidebarBg"
label={t.theme.sidebarBackground}
value={customColors.light.sidebarBg}
value={effectiveColors.light.sidebarBg}
/>
<ColorControl
theme="light"
property="sidebarText"
label={t.theme.sidebarText}
value={customColors.light.sidebarText}
value={effectiveColors.light.sidebarText}
/>
</div>
</div>
@@ -674,49 +657,49 @@ export default function ThemeSettings() {
theme="dark"
property="bgMain"
label={t.theme.background}
value={customColors.dark.bgMain}
value={effectiveColors.dark.bgMain}
/>
<ColorControl
theme="dark"
property="bgCard"
label={t.theme.backgroundCard}
value={customColors.dark.bgCard}
value={effectiveColors.dark.bgCard}
/>
<ColorControl
theme="dark"
property="bgElevated"
label={t.theme.backgroundElevated}
value={customColors.dark.bgElevated}
value={effectiveColors.dark.bgElevated}
/>
<ColorControl
theme="dark"
property="textPrimary"
label={t.theme.textPrimary}
value={customColors.dark.textPrimary}
value={effectiveColors.dark.textPrimary}
/>
<ColorControl
theme="dark"
property="textSecondary"
label={t.theme.textSecondary}
value={customColors.dark.textSecondary}
value={effectiveColors.dark.textSecondary}
/>
<ColorControl
theme="dark"
property="border"
label={t.theme.border}
value={customColors.dark.border}
value={effectiveColors.dark.border}
/>
<ColorControl
theme="dark"
property="sidebarBg"
label={t.theme.sidebarBackground}
value={customColors.dark.sidebarBg}
value={effectiveColors.dark.sidebarBg}
/>
<ColorControl
theme="dark"
property="sidebarText"
label={t.theme.sidebarText}
value={customColors.dark.sidebarText}
value={effectiveColors.dark.sidebarText}
/>
</div>
</div>