Add UI settings API and improve context initialization

- Add /settings/ui GET and PUT endpoints for UI settings
- Improve ThemeContext with better initialization and auto accent handling
- Update SidebarContext with expanded state persistence
- Fix context initialization in ModulesContext and ViewModeContext
- Update components to use improved theme/sidebar contexts

🤖 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-14 19:50:30 +01:00
parent 0608217702
commit 41c41adb98
10 changed files with 163 additions and 52 deletions

View File

@@ -10,6 +10,7 @@ from app.models.user import User
from app.core.settings_registry import (
THEME_KEYS,
MODULE_KEYS,
UI_KEYS,
SETTINGS_REGISTRY,
get_default_value,
)
@@ -82,6 +83,47 @@ def get_module_settings(
return result
@router.get("/ui", response_model=dict[str, Any])
def get_ui_settings(
*,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
) -> Any:
"""
Get UI settings (accessible to all authenticated users).
Returns UI/layout settings that apply to all users.
"""
result = {}
for key in UI_KEYS:
setting = crud.settings.get_setting(db, key=key)
if setting:
result[key] = setting.get_value()
else:
result[key] = get_default_value(key)
return result
@router.put("/ui", response_model=dict[str, Any])
def update_ui_settings(
*,
db: Session = Depends(get_db),
ui_data: dict[str, Any],
current_user: User = Depends(get_current_superuser)
) -> Any:
"""
Update UI settings (admin only).
Updates multiple UI/layout settings at once.
"""
result = {}
for key, value in ui_data.items():
if key in UI_KEYS:
setting = crud.settings.update_setting(db, key=key, value=value)
result[key] = setting.get_value()
return result
@router.put("/modules", response_model=dict[str, Any])
def update_module_settings(
*,

View File

@@ -46,7 +46,11 @@ import MainLayout from './components/MainLayout';
// ...
function AppRoutes() {
const { user } = useAuth();
const { user, isLoading } = useAuth();
if (isLoading) {
return <div className="loading">Loading...</div>;
}
return (
<Routes>
@@ -72,9 +76,9 @@ function App() {
return (
<SiteConfigProvider>
<BrowserRouter>
<ThemeProvider>
<LanguageProvider>
<AuthProvider>
<AuthProvider>
<ThemeProvider>
<LanguageProvider>
<ModulesProvider>
<ViewModeProvider>
<SidebarProvider>
@@ -82,9 +86,9 @@ function App() {
</SidebarProvider>
</ViewModeProvider>
</ModulesProvider>
</AuthProvider>
</LanguageProvider>
</ThemeProvider>
</LanguageProvider>
</ThemeProvider>
</AuthProvider>
</BrowserRouter>
</SiteConfigProvider>
);

View File

@@ -14,7 +14,7 @@ import '../styles/Sidebar.css';
export default function Sidebar() {
const { t, language, setLanguage } = useTranslation();
const { config } = useSiteConfig();
const { sidebarStyle, theme, toggleTheme, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle } = useTheme();
const { sidebarStyle, theme, toggleTheme, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle, hasInitializedSettings: themeInitialized } = useTheme();
const {
isCollapsed,
isMobileOpen,
@@ -235,7 +235,7 @@ export default function Sidebar() {
</button>
)}
{showDarkModeToggle && darkModeLocation === 'sidebar' && (
{themeInitialized && showDarkModeToggle && darkModeLocation === 'sidebar' && (
<button
className="view-mode-toggle"
onClick={() => { toggleTheme(); updateTooltipText(theme === 'dark' ? t.theme.lightMode : t.theme.darkMode); }}
@@ -254,7 +254,7 @@ export default function Sidebar() {
</button>
)}
{showLanguageToggle && languageLocation === 'sidebar' && (
{themeInitialized && showLanguageToggle && languageLocation === 'sidebar' && (
<button
className="view-mode-toggle"
onClick={() => { setLanguage(language === 'it' ? 'en' : 'it'); updateTooltipText(language === 'it' ? t.settings.english : t.settings.italian); }}

View File

@@ -9,7 +9,7 @@ import { useViewMode } from '../contexts/ViewModeContext';
export default function UserMenu({ onOpenChange }: { onOpenChange?: (isOpen: boolean) => void }) {
const { user, logout } = useAuth();
const { t, language, setLanguage } = useTranslation();
const { theme, toggleTheme, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle } = useTheme();
const { theme, toggleTheme, darkModeLocation, languageLocation, showDarkModeToggle, showLanguageToggle, hasInitializedSettings: themeInitialized } = useTheme();
const { isCollapsed, isMobileOpen, closeMobileMenu, sidebarMode } = useSidebar();
const { viewMode } = useViewMode();
const [isOpen, setIsOpen] = useState(false);
@@ -112,7 +112,7 @@ export default function UserMenu({ onOpenChange }: { onOpenChange?: (isOpen: boo
<span className="material-symbols-outlined">settings</span>
<span>{t.sidebar.settings}</span>
</NavLink>
{showDarkModeToggle && darkModeLocation === 'user_menu' && (
{themeInitialized && showDarkModeToggle && darkModeLocation === 'user_menu' && (
<button onClick={toggleTheme} className="user-menu-item">
<span className="material-symbols-outlined">
{theme === 'dark' ? 'dark_mode' : 'light_mode'}
@@ -120,7 +120,7 @@ export default function UserMenu({ onOpenChange }: { onOpenChange?: (isOpen: boo
<span>{theme === 'dark' ? 'Dark Mode' : 'Light Mode'}</span>
</button>
)}
{showLanguageToggle && languageLocation === 'user_menu' && (
{themeInitialized && showLanguageToggle && languageLocation === 'user_menu' && (
<button onClick={toggleLanguage} className="user-menu-item">
<span className="material-symbols-outlined">language</span>
<span>{language === 'it' ? 'Italiano' : 'English'}</span>

View File

@@ -1,6 +1,7 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { settingsAPI } from '../api/client';
import { useAuth } from './AuthContext';
import type { UserPermissions } from '../types';
// User-facing modules that can be toggled
@@ -39,6 +40,7 @@ const getDefaultStates = (): Record<ModuleId, ModuleState> => {
};
export function ModulesProvider({ children }: { children: ReactNode }) {
const { token } = useAuth();
const [moduleStates, setModuleStates] = useState<Record<ModuleId, ModuleState>>(getDefaultStates);
const [isLoading, setIsLoading] = useState(true);
const [hasInitialized, setHasInitialized] = useState(false);
@@ -110,16 +112,18 @@ export function ModulesProvider({ children }: { children: ReactNode }) {
}
}, [moduleStates]);
// Load modules on mount when token exists
// Load modules when token becomes available
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
setIsLoading(true);
setHasInitialized(false);
loadModulesFromBackend();
} else {
setModuleStates(getDefaultStates());
setIsLoading(false);
setHasInitialized(true); // No token, mark as initialized
}
}, [loadModulesFromBackend]);
}, [token, loadModulesFromBackend]);
const isModuleEnabled = useCallback((moduleId: string): boolean => {
// Dashboard is always enabled

View File

@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { useAuth } from './AuthContext';
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
@@ -21,6 +22,7 @@ interface SidebarContextType {
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
export function SidebarProvider({ children }: { children: ReactNode }) {
const { token } = useAuth();
const [userCollapsed, setUserCollapsed] = useState(true);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle');
@@ -31,19 +33,29 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
useEffect(() => {
const loadSidebarMode = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
if (!token) {
setSidebarModeState('toggle');
setShowLogo(false);
return;
}
const response = await fetch('/api/v1/settings', {
headers: { 'Authorization': `Bearer ${token} ` }
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;
};
if (data.sidebar_mode && ['collapsed', 'expanded', 'toggle', 'dynamic'].includes(data.sidebar_mode)) {
setSidebarModeState(data.sidebar_mode as SidebarMode);
}
if (data.show_logo !== undefined) {
setShowLogo(data.show_logo === true);
setShowLogo(parseBool(data.show_logo, false));
}
}
} catch (error) {
@@ -51,7 +63,7 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
}
};
loadSidebarMode();
}, []);
}, [token]);
// Compute isCollapsed based on mode
const isCollapsed = sidebarMode === 'collapsed' ? true :
@@ -78,11 +90,11 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
const setSidebarMode = useCallback(async (mode: SidebarMode) => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await fetch('/api/v1/settings/sidebar_mode', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token} `,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: mode }),
@@ -94,7 +106,7 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
} catch (error) {
console.error('Failed to save sidebar mode:', error);
}
}, []);
}, [token]);
return (
<SidebarContext.Provider

View File

@@ -1,6 +1,7 @@
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
import type { ReactNode } from 'react';
import { settingsAPI } from '../api/client';
import { useAuth } from './AuthContext';
type Theme = 'light' | 'dark';
export type AccentColor = 'blue' | 'purple' | 'green' | 'orange' | 'pink' | 'red' | 'teal' | 'amber' | 'indigo' | 'cyan' | 'rose' | 'auto';
@@ -27,6 +28,8 @@ interface ThemeContextType {
showLanguageToggle: boolean;
showDarkModeLogin: boolean;
showLanguageLogin: boolean;
hasInitializedSettings: boolean;
isLoadingSettings: boolean;
toggleTheme: () => void;
setAccentColor: (color: AccentColor) => void;
setBorderRadius: (radius: BorderRadius) => void;
@@ -433,6 +436,7 @@ export const COLOR_PALETTES: Record<ColorPalette, PaletteColors> = {
};
export function ThemeProvider({ children }: { children: ReactNode }) {
const { token } = useAuth();
const [theme, setTheme] = useState<Theme>(() => {
return (localStorage.getItem('theme') as Theme) || 'light';
});
@@ -454,6 +458,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [showLanguageToggle, setShowLanguageToggleState] = useState<boolean>(false);
const [showDarkModeLogin, setShowDarkModeLoginState] = useState<boolean>(true);
const [showLanguageLogin, setShowLanguageLoginState] = useState<boolean>(false);
const [hasInitializedSettings, setHasInitializedSettings] = useState<boolean>(false);
const [isLoadingSettings, setIsLoadingSettings] = useState<boolean>(false);
useEffect(() => {
const root = document.documentElement;
@@ -565,6 +571,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// Load theme from backend when user is authenticated
const loadThemeFromBackend = useCallback(async () => {
setIsLoadingSettings(true);
try {
const themeData = await settingsAPI.getTheme();
if (themeData.theme_accent_color) {
@@ -619,6 +626,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
} catch (error) {
console.error('Failed to load theme from backend:', error);
} finally {
setIsLoadingSettings(false);
setHasInitializedSettings(true);
}
}, []);
@@ -639,21 +649,41 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
showLanguageLogin: boolean;
}>) => {
try {
await settingsAPI.updateTheme({
theme_accent_color: overrides?.accentColor ?? accentColor,
theme_border_radius: overrides?.borderRadius ?? borderRadius,
theme_sidebar_style: overrides?.sidebarStyle ?? sidebarStyle,
theme_density: overrides?.density ?? density,
theme_font_family: overrides?.fontFamily ?? fontFamily,
theme_color_palette: overrides?.colorPalette ?? colorPalette,
theme_custom_colors: JSON.stringify(overrides?.customColors ?? customColors),
theme_dark_mode_location: overrides?.darkModeLocation ?? darkModeLocation,
theme_language_location: overrides?.languageLocation ?? languageLocation,
theme_show_dark_mode_toggle: String(overrides?.showDarkModeToggle ?? showDarkModeToggle),
theme_show_language_toggle: String(overrides?.showLanguageToggle ?? showLanguageToggle),
theme_show_dark_mode_login: String(overrides?.showDarkModeLogin ?? showDarkModeLogin),
theme_show_language_login: String(overrides?.showLanguageLogin ?? showLanguageLogin),
});
const payload: Record<string, string> = {};
if (overrides) {
if (overrides.accentColor !== undefined) payload.theme_accent_color = overrides.accentColor;
if (overrides.borderRadius !== undefined) payload.theme_border_radius = overrides.borderRadius;
if (overrides.sidebarStyle !== undefined) payload.theme_sidebar_style = overrides.sidebarStyle;
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.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);
} else {
payload.theme_accent_color = accentColor;
payload.theme_border_radius = borderRadius;
payload.theme_sidebar_style = sidebarStyle;
payload.theme_density = density;
payload.theme_font_family = fontFamily;
payload.theme_color_palette = colorPalette;
payload.theme_custom_colors = JSON.stringify(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);
}
if (Object.keys(payload).length === 0) return;
await settingsAPI.updateTheme(payload);
} catch (error) {
console.error('Failed to save theme to backend:', error);
throw error;
@@ -662,11 +692,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
// Auto-load theme from backend when token exists
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
setHasInitializedSettings(false);
loadThemeFromBackend();
} else {
setIsLoadingSettings(false);
setHasInitializedSettings(true);
}
}, [loadThemeFromBackend]);
}, [token, loadThemeFromBackend]);
const toggleTheme = () => {
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
@@ -741,6 +774,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
showLanguageToggle,
showDarkModeLogin,
showLanguageLogin,
hasInitializedSettings,
isLoadingSettings,
toggleTheme,
setAccentColor,
setBorderRadius,

View File

@@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect } from 'react';
import type { ReactNode } from 'react';
import { useAuth } from './AuthContext';
type ViewMode = 'admin' | 'user';
@@ -14,6 +15,7 @@ interface ViewModeContextType {
const ViewModeContext = createContext<ViewModeContextType | undefined>(undefined);
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');
@@ -43,7 +45,6 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
// Save to backend (this is a global setting)
try {
const token = localStorage.getItem('token');
if (token) {
await fetch('/api/v1/settings/user_mode_enabled', {
method: 'PUT',
@@ -63,13 +64,15 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
// Sync viewMode (user preference) with localStorage on mount
const savedMode = localStorage.getItem('viewMode');
if (savedMode) setViewModeState(savedMode as ViewMode);
}, []);
// Fetch user_mode_enabled (global setting) from backend
useEffect(() => {
// Fetch user_mode_enabled (global setting) from backend whenever token changes
const fetchUserModeSetting = async () => {
try {
const token = localStorage.getItem('token');
if (!token) {
// No token = use default (false)
setUserModeEnabledState(false);
return;
}
@@ -93,7 +96,7 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
};
fetchUserModeSetting();
}, []);
}, [token]);
const value = {
viewMode,

View File

@@ -13,7 +13,7 @@ export default function Login() {
const [email, setEmail] = useState('');
const [isRegister, setIsRegister] = useState(false);
const [error, setError] = useState('');
const [registrationEnabled, setRegistrationEnabled] = useState(true);
const [registrationEnabled, setRegistrationEnabled] = useState<boolean | null>(null);
const { login, register } = useAuth();
const { t, language, setLanguage } = useTranslation();
const { theme, toggleTheme, showDarkModeLogin, showLanguageLogin, showDarkModeToggle, showLanguageToggle } = useTheme();
@@ -22,23 +22,31 @@ export default function Login() {
// Check if registration is enabled
useEffect(() => {
let isMounted = true;
const checkRegistrationStatus = async () => {
try {
const response = await fetch('/api/v1/auth/registration-status');
if (response.ok) {
const data = await response.json();
setRegistrationEnabled(data.registration_enabled !== false);
const enabled = data.registration_enabled === true;
if (isMounted) {
setRegistrationEnabled(enabled);
if (!enabled) setIsRegister(false);
}
} else {
// Default to enabled if we can't fetch the setting
setRegistrationEnabled(true);
if (isMounted) setRegistrationEnabled(true);
}
} catch (error) {
// Default to enabled if we can't fetch the setting
setRegistrationEnabled(true);
if (isMounted) setRegistrationEnabled(true);
}
};
checkRegistrationStatus();
return () => {
isMounted = false;
};
}, []);
const handleSubmit = async (e: FormEvent) => {
@@ -129,7 +137,7 @@ export default function Login() {
</form>
<div className="login-footer">
{registrationEnabled && (
{registrationEnabled === true && (
<button
onClick={() => {
setIsRegister(!isRegister);

View File

@@ -13,7 +13,7 @@ export default function Features() {
const { user: currentUser } = useAuth();
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
const { moduleStates, setModuleEnabled, saveModulesToBackend, hasInitialized } = useModules();
const { moduleStates, setModuleEnabled, saveModulesToBackend, hasInitialized, isLoading } = useModules();
const [activeTab, setActiveTab] = useState<TabId>('feature1');
const hasUserMadeChanges = useRef(false);
const saveRef = useRef(saveModulesToBackend);
@@ -119,6 +119,9 @@ export default function Features() {
};
const renderTabContent = () => {
if (!hasInitialized || isLoading) {
return <div className="loading">Loading...</div>;
}
switch (activeTab) {
case 'feature1':
return (