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

37
.dockerignore Normal file
View File

@@ -0,0 +1,37 @@
.git
.gitignore
# IDE / local tooling
.vscode
.idea
.claude
# Python
__pycache__/
*.py[cod]
.venv
venv/
backend/venv/
backend/__pycache__/
backend/**/__pycache__/
.pytest_cache/
# Node / Vite
frontend/node_modules/
frontend/dist/
frontend/.vite/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
# Local persistent data (runtime bind-mounted)
config/
*.db
*.sqlite
*.sqlite3
# OS
.DS_Store
Thumbs.db

1
.gitignore vendored
View File

@@ -44,7 +44,6 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
package-lock.json
yarn.lock
pnpm-lock.yaml

View File

@@ -9,7 +9,7 @@ WORKDIR /frontend
COPY frontend/package*.json ./
# Install dependencies
RUN npm ci
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
# Copy frontend source
COPY frontend/ .

View File

@@ -33,5 +33,4 @@ build:
docker-compose build --no-cache
shell:
docker-compose exec app /bin/bash
docker-compose exec app /bin/sh

View File

@@ -12,11 +12,10 @@
## Technology Stack
### Frontend
- React 18 + TypeScript
- React 19 + TypeScript
- Vite (build tool)
- Tailwind CSS + shadcn/ui
- React Query (TanStack Query)
- Zustand (state management)
- React Router
- Axios
### Backend
- FastAPI (Python 3.11+)
@@ -100,4 +99,3 @@
## Contributing
Feel free to fork and customize for your needs.

View File

@@ -15,6 +15,7 @@ from app.config import settings
# Import all models so Alembic can detect them for auto-generating migrations
from app.models.user import User # noqa
from app.models.settings import Settings # noqa
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.

View File

@@ -33,12 +33,17 @@ def register(
# If not the first user, check if registration is enabled
if not is_first_user:
registration_enabled = crud.settings.get_setting_value(
registration_enabled_raw = crud.settings.get_setting_value(
db,
key="registration_enabled",
default=True # Default to enabled if setting doesn't exist
)
if isinstance(registration_enabled_raw, str):
registration_enabled = registration_enabled_raw.strip().lower() in ("true", "1")
else:
registration_enabled = bool(registration_enabled_raw)
if not registration_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
@@ -146,10 +151,15 @@ def get_registration_status(
This is a public endpoint that doesn't require authentication.
Returns the registration_enabled setting value.
"""
registration_enabled = crud.settings.get_setting_value(
registration_enabled_raw = crud.settings.get_setting_value(
db,
key="registration_enabled",
default=True
)
if isinstance(registration_enabled_raw, str):
registration_enabled = registration_enabled_raw.strip().lower() in ("true", "1")
else:
registration_enabled = bool(registration_enabled_raw)
return {"registration_enabled": registration_enabled}

View File

@@ -12,7 +12,7 @@ router = APIRouter()
@router.get("")
async def health_check(db: Session = Depends(get_db)):
def health_check(db: Session = Depends(get_db)):
"""
Health check endpoint that verifies database connectivity.

View File

@@ -1,5 +1,6 @@
"""Settings endpoints."""
import json
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
@@ -12,6 +13,8 @@ from app.core.settings_registry import (
MODULE_KEYS,
UI_KEYS,
SETTINGS_REGISTRY,
SettingStorage,
SettingType,
get_default_value,
)
@@ -19,6 +22,107 @@ from app.core.settings_registry import (
router = APIRouter()
def _require_database_setting(key: str):
setting_def = SETTINGS_REGISTRY.get(key)
if not setting_def:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown setting key '{key}'"
)
if setting_def.storage != SettingStorage.DATABASE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Setting '{key}' is not stored in database"
)
return setting_def
def _coerce_setting_value(key: str, value: Any) -> Any:
setting_def = _require_database_setting(key)
if setting_def.type == SettingType.BOOLEAN:
if isinstance(value, bool):
coerced = value
elif isinstance(value, int) and not isinstance(value, bool):
if value in (0, 1):
coerced = bool(value)
else:
raise HTTPException(status_code=400, detail=f"Invalid boolean value for '{key}'")
elif isinstance(value, str):
lowered = value.strip().lower()
if lowered in ("true", "1"):
coerced = True
elif lowered in ("false", "0"):
coerced = False
else:
raise HTTPException(status_code=400, detail=f"Invalid boolean value for '{key}'")
else:
raise HTTPException(status_code=400, detail=f"Invalid boolean value for '{key}'")
elif setting_def.type == SettingType.INTEGER:
if isinstance(value, bool):
raise HTTPException(status_code=400, detail=f"Invalid integer value for '{key}'")
if isinstance(value, int):
coerced = value
elif isinstance(value, str):
try:
coerced = int(value.strip())
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Invalid integer value for '{key}'") from exc
else:
raise HTTPException(status_code=400, detail=f"Invalid integer value for '{key}'")
elif setting_def.type == SettingType.STRING:
if not isinstance(value, str):
raise HTTPException(status_code=400, detail=f"Invalid string value for '{key}'")
coerced = value
elif setting_def.type == SettingType.JSON:
if isinstance(value, (dict, list)):
coerced = value
elif isinstance(value, str):
try:
coerced = json.loads(value)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"Invalid JSON value for '{key}'") from exc
else:
raise HTTPException(status_code=400, detail=f"Invalid JSON value for '{key}'")
elif setting_def.type == SettingType.LIST:
if isinstance(value, list):
coerced = value
elif isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"Invalid list value for '{key}'") from exc
if not isinstance(parsed, list):
raise HTTPException(status_code=400, detail=f"Invalid list value for '{key}'")
coerced = parsed
else:
raise HTTPException(status_code=400, detail=f"Invalid list value for '{key}'")
else:
coerced = value
if setting_def.choices is not None and coerced not in setting_def.choices:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid value for '{key}'. Allowed: {setting_def.choices}"
)
return coerced
def _validate_bulk_keys(payload: dict[str, Any], allowed_keys: set[str]) -> None:
unknown = [key for key in payload.keys() if key not in allowed_keys]
if unknown:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown/unsupported keys: {sorted(unknown)}"
)
@router.get("/theme", response_model=dict[str, Any])
def get_theme_settings(
*,
@@ -41,6 +145,26 @@ def get_theme_settings(
return result
@router.get("/theme/public", response_model=dict[str, Any])
def get_theme_settings_public(
*,
db: Session = Depends(get_db),
) -> Any:
"""
Get theme settings without authentication.
Intended for login/landing pages to keep UI consistent before auth.
"""
result = {}
for key in THEME_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("/theme", response_model=dict[str, Any])
def update_theme_settings(
*,
@@ -53,10 +177,11 @@ def update_theme_settings(
Updates multiple theme settings at once.
"""
_validate_bulk_keys(theme_data, set(THEME_KEYS))
result = {}
for key, value in theme_data.items():
if key in THEME_KEYS or key.startswith("theme_"):
setting = crud.settings.update_setting(db, key=key, value=value)
coerced = _coerce_setting_value(key, value)
setting = crud.settings.update_setting(db, key=key, value=coerced)
result[key] = setting.get_value()
return result
@@ -116,10 +241,11 @@ def update_ui_settings(
Updates multiple UI/layout settings at once.
"""
_validate_bulk_keys(ui_data, set(UI_KEYS))
result = {}
for key, value in ui_data.items():
if key in UI_KEYS:
setting = crud.settings.update_setting(db, key=key, value=value)
coerced = _coerce_setting_value(key, value)
setting = crud.settings.update_setting(db, key=key, value=coerced)
result[key] = setting.get_value()
return result
@@ -136,10 +262,11 @@ def update_module_settings(
Enable or disable modules for all users.
"""
_validate_bulk_keys(module_data, set(MODULE_KEYS))
result = {}
for key, value in module_data.items():
if key in MODULE_KEYS or key.startswith("module_"):
setting = crud.settings.update_setting(db, key=key, value=value)
coerced = _coerce_setting_value(key, value)
setting = crud.settings.update_setting(db, key=key, value=coerced)
result[key] = setting.get_value()
return result
@@ -171,7 +298,8 @@ def update_user_mode_enabled(
Update user mode enabled setting (admin only).
"""
value = data.get("value", True)
setting = crud.settings.update_setting(db, key="user_mode_enabled", value=value)
coerced = _coerce_setting_value("user_mode_enabled", value)
setting = crud.settings.update_setting(db, key="user_mode_enabled", value=coerced)
return {"key": "user_mode_enabled", "value": setting.get_value()}
@@ -222,5 +350,6 @@ def update_setting(
Creates the setting if it doesn't exist.
"""
setting = crud.settings.update_setting(db, key=key, value=setting_in.value)
coerced = _coerce_setting_value(key, setting_in.value)
setting = crud.settings.update_setting(db, key=key, value=coerced)
return schemas.Setting.from_orm(setting)

View File

@@ -88,10 +88,14 @@ def update_user(
# Only superusers can change is_superuser and is_active
if not current_user.is_superuser:
if user_in.is_superuser is not None or user_in.is_active is not None:
if (
user_in.is_superuser is not None
or user_in.is_active is not None
or user_in.permissions is not None
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can change user status"
detail="Only admins can change user status or permissions"
)
user = crud.user.update(db, db_obj=user, obj_in=user_in)

View File

@@ -328,15 +328,16 @@ register_setting(SettingDefinition(
# =============================================================================
# USER-SPECIFIC SETTINGS (Per-user, Database)
# These settings can be different for each user
# USER-SPECIFIC SETTINGS (Frontend localStorage)
# These settings are managed by the frontend per-browser/user-session.
# If you need cross-device persistence, implement a per-user DB table + API.
# =============================================================================
register_setting(SettingDefinition(
key="user_theme_mode",
type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
storage=SettingStorage.LOCAL_STORAGE,
default="system",
description="User's preferred theme mode (light/dark/system)",
category="user_preferences",
@@ -348,7 +349,7 @@ register_setting(SettingDefinition(
key="user_language",
type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
storage=SettingStorage.LOCAL_STORAGE,
default="en",
description="User's preferred language",
category="user_preferences",
@@ -360,7 +361,7 @@ register_setting(SettingDefinition(
key="user_sidebar_collapsed",
type=SettingType.BOOLEAN,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
storage=SettingStorage.LOCAL_STORAGE,
default=False,
description="User's sidebar collapsed state preference",
category="user_preferences",
@@ -371,7 +372,7 @@ register_setting(SettingDefinition(
key="user_view_mode",
type=SettingType.STRING,
scope=SettingScope.USER_SPECIFIC,
storage=SettingStorage.DATABASE,
storage=SettingStorage.LOCAL_STORAGE,
default="admin",
description="User's current view mode (admin/user)",
category="user_preferences",

View File

@@ -71,7 +71,7 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
def delete(self, db: Session, *, id: Any) -> ModelType:
"""Delete a record."""
obj = db.query(self.model).get(id)
obj = db.get(self.model, id)
db.delete(obj)
db.commit()
return obj

View File

@@ -1,5 +1,6 @@
"""CRUD operations for Settings."""
import json
from typing import Optional
from sqlalchemy.orm import Session
@@ -27,13 +28,16 @@ def update_setting(db: Session, key: str, value: any) -> Settings:
setting = Settings(key=key)
db.add(setting)
# Determine if value is bool or string
# Determine how to store the value (DB has only bool + string columns)
if isinstance(value, bool):
setting.value_bool = value
setting.value_str = None
elif isinstance(value, str) and value.lower() in ('true', 'false'):
setting.value_bool = value.lower() == 'true'
elif isinstance(value, str) and value.lower() in ("true", "false", "1", "0"):
setting.value_bool = value.lower() in ("true", "1")
setting.value_str = None
elif isinstance(value, (dict, list)):
setting.value_str = json.dumps(value)
setting.value_bool = None
else:
setting.value_str = str(value)
setting.value_bool = None

View File

@@ -138,16 +138,17 @@ async def startup_event():
# Seed default settings from registry
from sqlalchemy.orm import Session
from app import crud
from app.core.settings_registry import get_database_defaults
from app.core.settings_registry import SettingScope, get_database_settings
with Session(engine) as db:
# Get all database settings with defaults from registry
default_settings = get_database_defaults()
# Seed only GLOBAL database settings with defaults from registry
for setting_def in get_database_settings():
if setting_def.scope != SettingScope.GLOBAL:
continue
for key, value in default_settings.items():
if crud.settings.get_setting(db, key) is None:
logger.info(f"Seeding default setting: {key}={value}")
crud.settings.update_setting(db, key, value)
if crud.settings.get_setting(db, setting_def.key) is None:
logger.info(f"Seeding default setting: {setting_def.key}={setting_def.default}")
crud.settings.update_setting(db, setting_def.key, setting_def.default)
# Shutdown event

View File

@@ -11,7 +11,7 @@ services:
ports:
- "5174:8000"
healthcheck:
test: [ "CMD-SHELL", "curl -f http://localhost:8000/health || exit 1" ]
test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" ]
interval: 30s
timeout: 10s
start_period: 40s

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,11 +33,7 @@ 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) => {
@@ -56,7 +53,6 @@ export default function GeneralTab() {
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();
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,7 +24,22 @@ 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);
@@ -39,11 +55,7 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
return;
}
const response = await fetch('/api/v1/settings/ui', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const data = await response.json();
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;
@@ -51,13 +63,12 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
return defaultVal;
};
if (data.sidebar_mode && ['collapsed', 'expanded', 'toggle', 'dynamic'].includes(data.sidebar_mode)) {
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);
}
@@ -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;
});
}
};
@@ -91,18 +106,8 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
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) {
await settingsAPI.updateSetting('sidebar_mode', mode);
setSidebarModeState(mode);
}
} catch (error) {
console.error('Failed to save sidebar mode:', error);
}

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]);
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';
const isEnabled = await settingsAPI.getUserModeEnabled();
setUserModeEnabledState(isEnabled);
}
}
// If 404 or error, keep default (false)
} 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();
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 {