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

3676
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,12 @@ import { ThemeProvider } from './contexts/ThemeContext';
import { SidebarProvider } from './contexts/SidebarContext';
import { ViewModeProvider } from './contexts/ViewModeContext';
import { ModulesProvider } from './contexts/ModulesContext';
import MainLayout from './components/MainLayout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Feature1 from './pages/Feature1';
import Feature2 from './pages/Feature2';
import Feature3 from './pages/Feature3';
import AdminPanel from './pages/AdminPanel';
import Sources from './pages/admin/Sources';
import Features from './pages/admin/Features';
@@ -41,10 +44,6 @@ function AdminRoute({ children }: { children: ReactElement }) {
return user.is_superuser ? children : <Navigate to="/dashboard" />;
}
import MainLayout from './components/MainLayout';
// ...
function AppRoutes() {
const { user, isLoading } = useAuth();
@@ -59,15 +58,19 @@ function AppRoutes() {
<Route element={<PrivateRoute><MainLayout /></PrivateRoute>}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/feature1" element={<Feature1 />} />
<Route path="/feature2" element={<Feature2 />} />
<Route path="/feature3" element={<Feature3 />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<AdminRoute><AdminPanel /></AdminRoute>} />
<Route path="/admin/users" element={<AdminRoute><AdminPanel initialTab="users" /></AdminRoute>} />
<Route path="/admin/sources" element={<AdminRoute><Sources /></AdminRoute>} />
<Route path="/admin/features" element={<AdminRoute><Features /></AdminRoute>} />
<Route path="/admin/theme" element={<AdminRoute><ThemeSettings /></AdminRoute>} />
</Route>
<Route path="/" element={<Navigate to={user ? '/dashboard' : '/login'} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}

View File

@@ -44,13 +44,18 @@ export const authAPI = {
// Settings endpoints
export const settingsAPI = {
getTheme: async (): Promise<Record<string, string>> => {
const response = await api.get<Record<string, string>>('/settings/theme');
getTheme: async (): Promise<Record<string, unknown>> => {
const response = await api.get<Record<string, unknown>>('/settings/theme');
return response.data;
},
updateTheme: async (data: Record<string, string>): Promise<Record<string, string>> => {
const response = await api.put<Record<string, string>>('/settings/theme', data);
getThemePublic: async (): Promise<Record<string, unknown>> => {
const response = await api.get<Record<string, unknown>>('/settings/theme/public');
return response.data;
},
updateTheme: async (data: Record<string, unknown>): Promise<Record<string, unknown>> => {
const response = await api.put<Record<string, unknown>>('/settings/theme', data);
return response.data;
},
@@ -63,6 +68,37 @@ export const settingsAPI = {
const response = await api.put<Record<string, boolean>>('/settings/modules', data);
return response.data;
},
getUi: async (): Promise<Record<string, unknown>> => {
const response = await api.get<Record<string, unknown>>('/settings/ui');
return response.data;
},
getAllSettings: async (): Promise<Record<string, unknown>> => {
const response = await api.get<Record<string, unknown>>('/settings');
return response.data;
},
updateSetting: async (key: string, value: unknown): Promise<{ key: string; value: unknown; updated_at: string }> => {
const response = await api.put<{ key: string; value: unknown; updated_at: string }>(`/settings/${key}`, { value });
return response.data;
},
getUserModeEnabled: async (): Promise<boolean> => {
const response = await api.get<{ key: string; value: unknown }>('/settings/user_mode_enabled');
const value = response.data?.value;
if (value === true || value === 'true' || value === 'True' || value === 1 || value === '1') return true;
if (value === false || value === 'false' || value === 'False' || value === 0 || value === '0') return false;
return false;
},
updateUserModeEnabled: async (enabled: boolean): Promise<boolean> => {
const response = await api.put<{ key: string; value: unknown }>('/settings/user_mode_enabled', { value: enabled });
const value = response.data?.value;
if (value === true || value === 'true' || value === 'True' || value === 1 || value === '1') return true;
if (value === false || value === 'false' || value === 'False' || value === 0 || value === '0') return false;
return false;
},
};
// Users endpoints

View File

@@ -4,6 +4,7 @@ import { useViewMode } from '../../contexts/ViewModeContext';
import { useTheme } from '../../contexts/ThemeContext';
import { useSidebar } from '../../contexts/SidebarContext';
import { settingsAPI } from '../../api/client';
export default function GeneralTab() {
const { t } = useTranslation();
@@ -32,30 +33,25 @@ export default function GeneralTab() {
useEffect(() => {
const loadSettings = async () => {
try {
const response = await fetch('/api/v1/settings', {
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
});
if (response.ok) {
const data = await response.json();
const data = await settingsAPI.getAllSettings();
// Helper to check for true values (bool, string 'true'/'True', 1)
const isTrue = (val: any) => {
if (val === true) return true;
if (typeof val === 'string' && val.toLowerCase() === 'true') return true;
if (val === 1) return true;
return false;
};
// Helper to check for true values (bool, string 'true'/'True', 1)
const isTrue = (val: any) => {
if (val === true) return true;
if (typeof val === 'string' && val.toLowerCase() === 'true') return true;
if (val === 1) return true;
return false;
};
// Default to true if undefined/null, otherwise check value
const regEnabled = data.registration_enabled;
setRegistrationEnabled(
regEnabled === undefined || regEnabled === null ? true : isTrue(regEnabled)
);
// Default to true if undefined/null, otherwise check value
const regEnabled = data.registration_enabled;
setRegistrationEnabled(
regEnabled === undefined || regEnabled === null ? true : isTrue(regEnabled)
);
// Update sidebar context
if (data.show_logo !== undefined) {
setShowLogo(isTrue(data.show_logo));
}
// Update sidebar context
if (data.show_logo !== undefined) {
setShowLogo(isTrue(data.show_logo));
}
} catch (error) {
console.error('Failed to load settings:', error);
@@ -68,22 +64,9 @@ export default function GeneralTab() {
setRegistrationEnabled(checked); // Optimistic update
setSavingRegistration(true);
try {
const response = await fetch('/api/v1/settings/registration_enabled', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: checked }),
});
if (response.ok) {
const data = await response.json();
setRegistrationEnabled(data.value);
} else {
// Revert on failure
setRegistrationEnabled(!checked);
}
const updated = await settingsAPI.updateSetting('registration_enabled', checked);
const value = updated.value;
setRegistrationEnabled(value === true || value === 'true' || value === 'True' || value === 1 || value === '1');
} catch (error) {
console.error('Failed to update registration setting:', error);
setRegistrationEnabled(!checked);
@@ -96,22 +79,9 @@ export default function GeneralTab() {
setShowLogo(checked); // Optimistic update
setSavingShowLogo(true);
try {
const response = await fetch('/api/v1/settings/show_logo', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: checked }),
});
if (response.ok) {
const data = await response.json();
setShowLogo(data.value);
} else {
// Revert on failure
setShowLogo(!checked);
}
const updated = await settingsAPI.updateSetting('show_logo', checked);
const value = updated.value;
setShowLogo(value === true || value === 'true' || value === 'True' || value === 1 || value === '1');
} catch (error) {
console.error('Failed to update show logo setting:', error);
setShowLogo(!checked);

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useTranslation } from '../../contexts/LanguageContext';
import { settingsAPI } from '../../api/client';
interface Settings {
registration_enabled?: boolean;
@@ -17,17 +18,8 @@ export default function SettingsTab() {
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 {
@@ -38,23 +30,11 @@ export default function SettingsTab() {
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

@@ -200,8 +200,8 @@ export default function UsersTab() {
email: formData.email,
is_active: formData.is_active,
is_superuser: formData.is_superuser,
// Only send permissions if custom permissions are enabled, otherwise send empty object (inherit global)
permissions: formData.hasCustomPermissions ? formData.permissions : {},
// If custom permissions are disabled, clear permissions (inherit from global module settings)
permissions: formData.hasCustomPermissions ? formData.permissions : null,
};
if (formData.password.trim()) {
@@ -760,7 +760,8 @@ export default function UsersTab() {
{formData.hasCustomPermissions ? (
<div className="permissions-grid">
{TOGGLEABLE_MODULES.map((module) => {
const isGloballyDisabled = !moduleStates[module.id];
const state = moduleStates[module.id];
const isGloballyDisabled = !state?.admin || !state?.user;
const isChecked = formData.permissions[module.id] ?? true;
const moduleName = t.sidebar[module.id as keyof typeof t.sidebar] || module.id;

View File

@@ -21,8 +21,17 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(() => {
const saved = localStorage.getItem('language');
if (saved) return saved as Language;
const key = 'user_language';
const saved = localStorage.getItem(key);
if (saved === 'it' || saved === 'en') return saved;
// Backward-compatibility: migrate legacy key
const legacy = localStorage.getItem('language');
if (legacy === 'it' || legacy === 'en') {
localStorage.setItem(key, legacy);
localStorage.removeItem('language');
return legacy;
}
// Try to detect from browser
const browserLang = navigator.language.split('-')[0];
@@ -31,7 +40,7 @@ export function LanguageProvider({ children }: { children: ReactNode }) {
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem('language', lang);
localStorage.setItem('user_language', lang);
};
useEffect(() => {

View File

@@ -1,6 +1,7 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { useAuth } from './AuthContext';
import { settingsAPI } from '../api/client';
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
@@ -23,47 +24,57 @@ const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
export function SidebarProvider({ children }: { children: ReactNode }) {
const { token } = useAuth();
const [userCollapsed, setUserCollapsed] = useState(true);
const [userCollapsed, setUserCollapsed] = useState(() => {
const key = 'user_sidebar_collapsed';
const saved = localStorage.getItem(key);
if (saved === 'true') return true;
if (saved === 'false') return false;
// Backward-compatibility: migrate legacy key
const legacy = localStorage.getItem('sidebarCollapsed');
if (legacy === 'true' || legacy === 'false') {
localStorage.setItem(key, legacy);
localStorage.removeItem('sidebarCollapsed');
return legacy === 'true';
}
return true;
});
const [isMobileOpen, setIsMobileOpen] = useState(false);
const [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle');
const [isHovered, setIsHovered] = useState(false);
const [showLogo, setShowLogo] = useState(false);
// Load sidebar mode from backend
useEffect(() => {
const loadSidebarMode = async () => {
try {
if (!token) {
setSidebarModeState('toggle');
setShowLogo(false);
return;
}
useEffect(() => {
const loadSidebarMode = async () => {
try {
if (!token) {
setSidebarModeState('toggle');
setShowLogo(false);
return;
}
const response = await fetch('/api/v1/settings/ui', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
const parseBool = (val: any, defaultVal: boolean): boolean => {
if (val === undefined || val === null) return defaultVal;
if (val === true || val === 'true' || val === 'True' || val === 1 || val === '1') return true;
if (val === false || val === 'false' || val === 'False' || val === 0 || val === '0') return false;
return defaultVal;
};
const data = await settingsAPI.getUi();
const parseBool = (val: any, defaultVal: boolean): boolean => {
if (val === undefined || val === null) return defaultVal;
if (val === true || val === 'true' || val === 'True' || val === 1 || val === '1') return true;
if (val === false || val === 'false' || val === 'False' || val === 0 || val === '0') return false;
return defaultVal;
};
if (data.sidebar_mode && ['collapsed', 'expanded', 'toggle', 'dynamic'].includes(data.sidebar_mode)) {
setSidebarModeState(data.sidebar_mode as SidebarMode);
}
if (data.show_logo !== undefined) {
setShowLogo(parseBool(data.show_logo, false));
}
}
} catch (error) {
console.error('Failed to load sidebar mode:', error);
}
};
loadSidebarMode();
}, [token]);
if (data.sidebar_mode && ['collapsed', 'expanded', 'toggle', 'dynamic'].includes(data.sidebar_mode as string)) {
setSidebarModeState(data.sidebar_mode as SidebarMode);
}
if (data.show_logo !== undefined) {
setShowLogo(parseBool(data.show_logo, false));
}
} catch (error) {
console.error('Failed to load sidebar mode:', error);
}
};
loadSidebarMode();
}, [token]);
// Compute isCollapsed based on mode
const isCollapsed = sidebarMode === 'collapsed' ? true :
@@ -76,7 +87,11 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
const toggleCollapse = () => {
if (canToggle) {
setUserCollapsed((prev) => !prev);
setUserCollapsed((prev) => {
const next = !prev;
localStorage.setItem('user_sidebar_collapsed', String(next));
return next;
});
}
};
@@ -88,25 +103,15 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
setIsMobileOpen(false);
};
const setSidebarMode = useCallback(async (mode: SidebarMode) => {
try {
if (!token) return;
const response = await fetch('/api/v1/settings/sidebar_mode', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: mode }),
});
if (response.ok) {
setSidebarModeState(mode);
}
} catch (error) {
console.error('Failed to save sidebar mode:', error);
}
}, [token]);
const setSidebarMode = useCallback(async (mode: SidebarMode) => {
try {
if (!token) return;
await settingsAPI.updateSetting('sidebar_mode', mode);
setSidebarModeState(mode);
} catch (error) {
console.error('Failed to save sidebar mode:', error);
}
}, [token]);
return (
<SidebarContext.Provider

View File

@@ -1,5 +1,5 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import type { Dispatch, ReactNode, SetStateAction } from 'react';
import { settingsAPI } from '../api/client';
import { useAuth } from './AuthContext';
@@ -21,7 +21,7 @@ interface ThemeContextType {
density: Density;
fontFamily: FontFamily;
colorPalette: ColorPalette;
customColors: Partial<PaletteColors>;
customColors: CustomColors;
darkModeLocation: ControlLocation;
languageLocation: ControlLocation;
showDarkModeToggle: boolean;
@@ -37,7 +37,7 @@ interface ThemeContextType {
setDensity: (density: Density) => void;
setFontFamily: (font: FontFamily) => void;
setColorPalette: (palette: ColorPalette) => void;
setCustomColors: (colors: Partial<PaletteColors>) => void;
setCustomColors: Dispatch<SetStateAction<CustomColors>>;
setDarkModeLocation: (location: ControlLocation) => void;
setLanguageLocation: (location: ControlLocation) => void;
setShowDarkModeToggle: (show: boolean) => void;
@@ -52,7 +52,7 @@ interface ThemeContextType {
density: Density;
fontFamily: FontFamily;
colorPalette: ColorPalette;
customColors: Partial<PaletteColors>;
customColors: CustomColors;
darkModeLocation: ControlLocation;
languageLocation: ControlLocation;
showDarkModeToggle: boolean;
@@ -162,7 +162,7 @@ const FONTS: Record<FontFamily, string> = {
roboto: '"Roboto", sans-serif',
};
interface PaletteColors {
export interface PaletteColors {
light: {
bgMain: string;
bgCard: string;
@@ -185,6 +185,11 @@ interface PaletteColors {
};
}
export type CustomColors = Partial<{
light: Partial<PaletteColors['light']>;
dark: Partial<PaletteColors['dark']>;
}>;
export const COLOR_PALETTES: Record<ColorPalette, PaletteColors> = {
default: {
light: {
@@ -468,7 +473,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [density, setDensityState] = useState<Density>('compact');
const [fontFamily, setFontFamilyState] = useState<FontFamily>('sans');
const [colorPalette, setColorPaletteState] = useState<ColorPalette>('monochrome');
const [customColors, setCustomColorsState] = useState<Partial<PaletteColors>>({});
const [customColors, setCustomColorsState] = useState<CustomColors>({});
const [darkModeLocation, setDarkModeLocationState] = useState<ControlLocation>('sidebar');
const [languageLocation, setLanguageLocationState] = useState<ControlLocation>('sidebar');
const [showDarkModeToggle, setShowDarkModeToggleState] = useState<boolean>(true);
@@ -593,8 +598,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// Load theme from backend when user is authenticated
const loadThemeFromBackend = useCallback(async () => {
setIsLoadingSettings(true);
setHasInitializedSettings(false);
try {
const themeData = await settingsAPI.getTheme();
const themeData = token
? await settingsAPI.getTheme()
: await settingsAPI.getThemePublic();
if (themeData.theme_accent_color) {
setAccentColorState(themeData.theme_accent_color as AccentColor);
}
@@ -640,7 +648,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const parsed = typeof themeData.theme_custom_colors === 'string'
? JSON.parse(themeData.theme_custom_colors)
: themeData.theme_custom_colors;
setCustomColorsState(parsed as Partial<PaletteColors>);
setCustomColorsState(parsed as CustomColors);
} catch (e) {
console.error('Failed to parse custom colors:', e);
}
@@ -651,7 +659,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setIsLoadingSettings(false);
setHasInitializedSettings(true);
}
}, []);
}, [token]);
// Save theme to backend (admin only) - accepts optional overrides for immediate save
const saveThemeToBackend = useCallback(async (overrides?: Partial<{
@@ -661,7 +669,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
density: Density;
fontFamily: FontFamily;
colorPalette: ColorPalette;
customColors: Partial<PaletteColors>;
customColors: CustomColors;
darkModeLocation: ControlLocation;
languageLocation: ControlLocation;
showDarkModeToggle: boolean;
@@ -670,7 +678,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
showLanguageLogin: boolean;
}>) => {
try {
const payload: Record<string, string> = {};
const payload: Record<string, unknown> = {};
if (overrides) {
if (overrides.accentColor !== undefined) payload.theme_accent_color = overrides.accentColor;
@@ -679,13 +687,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
if (overrides.density !== undefined) payload.theme_density = overrides.density;
if (overrides.fontFamily !== undefined) payload.theme_font_family = overrides.fontFamily;
if (overrides.colorPalette !== undefined) payload.theme_color_palette = overrides.colorPalette;
if (overrides.customColors !== undefined) payload.theme_custom_colors = JSON.stringify(overrides.customColors);
if (overrides.customColors !== undefined) payload.theme_custom_colors = overrides.customColors;
if (overrides.darkModeLocation !== undefined) payload.theme_dark_mode_location = overrides.darkModeLocation;
if (overrides.languageLocation !== undefined) payload.theme_language_location = overrides.languageLocation;
if (overrides.showDarkModeToggle !== undefined) payload.theme_show_dark_mode_toggle = String(overrides.showDarkModeToggle);
if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = String(overrides.showLanguageToggle);
if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = String(overrides.showDarkModeLogin);
if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = String(overrides.showLanguageLogin);
if (overrides.showDarkModeToggle !== undefined) payload.theme_show_dark_mode_toggle = overrides.showDarkModeToggle;
if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = overrides.showLanguageToggle;
if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = overrides.showDarkModeLogin;
if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = overrides.showLanguageLogin;
} else {
payload.theme_accent_color = accentColor;
payload.theme_border_radius = borderRadius;
@@ -693,13 +701,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
payload.theme_density = density;
payload.theme_font_family = fontFamily;
payload.theme_color_palette = colorPalette;
payload.theme_custom_colors = JSON.stringify(customColors);
payload.theme_custom_colors = customColors;
payload.theme_dark_mode_location = darkModeLocation;
payload.theme_language_location = languageLocation;
payload.theme_show_dark_mode_toggle = String(showDarkModeToggle);
payload.theme_show_language_toggle = String(showLanguageToggle);
payload.theme_show_dark_mode_login = String(showDarkModeLogin);
payload.theme_show_language_login = String(showLanguageLogin);
payload.theme_show_dark_mode_toggle = showDarkModeToggle;
payload.theme_show_language_toggle = showLanguageToggle;
payload.theme_show_dark_mode_login = showDarkModeLogin;
payload.theme_show_language_login = showLanguageLogin;
}
if (Object.keys(payload).length === 0) return;
@@ -711,16 +719,10 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
}, [accentColor, borderRadius, sidebarStyle, density, fontFamily, colorPalette, customColors, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle, showDarkModeLogin, showLanguageLogin]);
// Auto-load theme from backend when token exists
// Load theme settings for both authenticated and unauthenticated users
useEffect(() => {
if (token) {
setHasInitializedSettings(false);
loadThemeFromBackend();
} else {
setIsLoadingSettings(false);
setHasInitializedSettings(true);
}
}, [token, loadThemeFromBackend]);
loadThemeFromBackend();
}, [loadThemeFromBackend]);
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
@@ -750,10 +752,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setColorPaletteState(palette);
};
const setCustomColors = (colors: Partial<PaletteColors>) => {
setCustomColorsState(colors);
};
const setDarkModeLocation = (location: ControlLocation) => {
setDarkModeLocationState(location);
};
@@ -804,7 +802,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setDensity,
setFontFamily,
setColorPalette,
setCustomColors,
setDarkModeLocation,
setLanguageLocation,
setShowDarkModeToggle,
@@ -813,6 +810,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
setShowLanguageLogin,
loadThemeFromBackend,
saveThemeToBackend,
setCustomColors: setCustomColorsState,
}}
>
{children}

View File

@@ -1,6 +1,7 @@
import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { useAuth } from './AuthContext';
import { settingsAPI } from '../api/client';
type ViewMode = 'admin' | 'user';
@@ -18,8 +19,19 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
const { token } = useAuth();
// viewMode is user preference - stored in localStorage
const [viewMode, setViewModeState] = useState<ViewMode>(() => {
const saved = localStorage.getItem('viewMode');
return (saved as ViewMode) || 'admin';
const key = 'user_view_mode';
const saved = localStorage.getItem(key);
if (saved === 'admin' || saved === 'user') return saved;
// Backward-compatibility: migrate legacy key
const legacy = localStorage.getItem('viewMode');
if (legacy === 'admin' || legacy === 'user') {
localStorage.setItem(key, legacy);
localStorage.removeItem('viewMode');
return legacy;
}
return 'admin';
});
// isUserModeEnabled is a GLOBAL setting - comes from database, default false
@@ -27,7 +39,7 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
const setViewMode = (mode: ViewMode) => {
setViewModeState(mode);
localStorage.setItem('viewMode', mode);
localStorage.setItem('user_view_mode', mode);
};
const toggleViewMode = () => {
@@ -36,6 +48,7 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
};
const setUserModeEnabled = async (enabled: boolean) => {
const previous = isUserModeEnabled;
setUserModeEnabledState(enabled);
// If disabling, reset to admin view
@@ -45,25 +58,30 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
// Save to backend (this is a global setting)
try {
if (token) {
await fetch('/api/v1/settings/user_mode_enabled', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: enabled }),
});
}
if (!token) return;
const persisted = await settingsAPI.updateUserModeEnabled(enabled);
setUserModeEnabledState(persisted);
} catch (error) {
console.error('Failed to save user mode setting:', error);
setUserModeEnabledState(previous);
}
};
useEffect(() => {
// Sync viewMode (user preference) with localStorage on mount
const savedMode = localStorage.getItem('viewMode');
if (savedMode) setViewModeState(savedMode as ViewMode);
const key = 'user_view_mode';
const savedMode = localStorage.getItem(key);
if (savedMode === 'admin' || savedMode === 'user') {
setViewModeState(savedMode);
return;
}
const legacy = localStorage.getItem('viewMode');
if (legacy === 'admin' || legacy === 'user') {
localStorage.setItem(key, legacy);
localStorage.removeItem('viewMode');
setViewModeState(legacy);
}
}, []);
useEffect(() => {
@@ -76,19 +94,8 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
return;
}
const response = await fetch('/api/v1/settings/user_mode_enabled', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
if (data.value !== undefined) {
const val = data.value;
const isEnabled = val === true || val === 'true' || val === 'True';
setUserModeEnabledState(isEnabled);
}
}
// If 404 or error, keep default (false)
const isEnabled = await settingsAPI.getUserModeEnabled();
setUserModeEnabledState(isEnabled);
} catch (error) {
console.error('Failed to fetch user mode setting:', error);
// Keep default (false)

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>

View File

@@ -38,7 +38,7 @@ export interface UserUpdatePayload {
password?: string;
is_active?: boolean;
is_superuser?: boolean;
permissions?: UserPermissions;
permissions?: UserPermissions | null;
}
export interface Token {