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:
37
.dockerignore
Normal file
37
.dockerignore
Normal 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
1
.gitignore
vendored
@@ -44,7 +44,6 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
.pnpm-store/
|
.pnpm-store/
|
||||||
package-lock.json
|
|
||||||
yarn.lock
|
yarn.lock
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ WORKDIR /frontend
|
|||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# 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 source
|
||||||
COPY frontend/ .
|
COPY frontend/ .
|
||||||
|
|||||||
3
Makefile
3
Makefile
@@ -33,5 +33,4 @@ build:
|
|||||||
docker-compose build --no-cache
|
docker-compose build --no-cache
|
||||||
|
|
||||||
shell:
|
shell:
|
||||||
docker-compose exec app /bin/bash
|
docker-compose exec app /bin/sh
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,10 @@
|
|||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
- React 18 + TypeScript
|
- React 19 + TypeScript
|
||||||
- Vite (build tool)
|
- Vite (build tool)
|
||||||
- Tailwind CSS + shadcn/ui
|
- React Router
|
||||||
- React Query (TanStack Query)
|
- Axios
|
||||||
- Zustand (state management)
|
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
- FastAPI (Python 3.11+)
|
- FastAPI (Python 3.11+)
|
||||||
@@ -100,4 +99,3 @@
|
|||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Feel free to fork and customize for your needs.
|
Feel free to fork and customize for your needs.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.config import settings
|
|||||||
|
|
||||||
# Import all models so Alembic can detect them for auto-generating migrations
|
# Import all models so Alembic can detect them for auto-generating migrations
|
||||||
from app.models.user import User # noqa
|
from app.models.user import User # noqa
|
||||||
|
from app.models.settings import Settings # noqa
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
|
|||||||
@@ -33,12 +33,17 @@ def register(
|
|||||||
|
|
||||||
# If not the first user, check if registration is enabled
|
# If not the first user, check if registration is enabled
|
||||||
if not is_first_user:
|
if not is_first_user:
|
||||||
registration_enabled = crud.settings.get_setting_value(
|
registration_enabled_raw = crud.settings.get_setting_value(
|
||||||
db,
|
db,
|
||||||
key="registration_enabled",
|
key="registration_enabled",
|
||||||
default=True # Default to enabled if setting doesn't exist
|
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:
|
if not registration_enabled:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
@@ -146,10 +151,15 @@ def get_registration_status(
|
|||||||
This is a public endpoint that doesn't require authentication.
|
This is a public endpoint that doesn't require authentication.
|
||||||
Returns the registration_enabled setting value.
|
Returns the registration_enabled setting value.
|
||||||
"""
|
"""
|
||||||
registration_enabled = crud.settings.get_setting_value(
|
registration_enabled_raw = crud.settings.get_setting_value(
|
||||||
db,
|
db,
|
||||||
key="registration_enabled",
|
key="registration_enabled",
|
||||||
default=True
|
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}
|
return {"registration_enabled": registration_enabled}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@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.
|
Health check endpoint that verifies database connectivity.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Settings endpoints."""
|
"""Settings endpoints."""
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -12,6 +13,8 @@ from app.core.settings_registry import (
|
|||||||
MODULE_KEYS,
|
MODULE_KEYS,
|
||||||
UI_KEYS,
|
UI_KEYS,
|
||||||
SETTINGS_REGISTRY,
|
SETTINGS_REGISTRY,
|
||||||
|
SettingStorage,
|
||||||
|
SettingType,
|
||||||
get_default_value,
|
get_default_value,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +22,107 @@ from app.core.settings_registry import (
|
|||||||
router = APIRouter()
|
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])
|
@router.get("/theme", response_model=dict[str, Any])
|
||||||
def get_theme_settings(
|
def get_theme_settings(
|
||||||
*,
|
*,
|
||||||
@@ -41,6 +145,26 @@ def get_theme_settings(
|
|||||||
return result
|
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])
|
@router.put("/theme", response_model=dict[str, Any])
|
||||||
def update_theme_settings(
|
def update_theme_settings(
|
||||||
*,
|
*,
|
||||||
@@ -53,11 +177,12 @@ def update_theme_settings(
|
|||||||
|
|
||||||
Updates multiple theme settings at once.
|
Updates multiple theme settings at once.
|
||||||
"""
|
"""
|
||||||
|
_validate_bulk_keys(theme_data, set(THEME_KEYS))
|
||||||
result = {}
|
result = {}
|
||||||
for key, value in theme_data.items():
|
for key, value in theme_data.items():
|
||||||
if key in THEME_KEYS or key.startswith("theme_"):
|
coerced = _coerce_setting_value(key, value)
|
||||||
setting = crud.settings.update_setting(db, key=key, value=value)
|
setting = crud.settings.update_setting(db, key=key, value=coerced)
|
||||||
result[key] = setting.get_value()
|
result[key] = setting.get_value()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -116,11 +241,12 @@ def update_ui_settings(
|
|||||||
|
|
||||||
Updates multiple UI/layout settings at once.
|
Updates multiple UI/layout settings at once.
|
||||||
"""
|
"""
|
||||||
|
_validate_bulk_keys(ui_data, set(UI_KEYS))
|
||||||
result = {}
|
result = {}
|
||||||
for key, value in ui_data.items():
|
for key, value in ui_data.items():
|
||||||
if key in UI_KEYS:
|
coerced = _coerce_setting_value(key, value)
|
||||||
setting = crud.settings.update_setting(db, key=key, value=value)
|
setting = crud.settings.update_setting(db, key=key, value=coerced)
|
||||||
result[key] = setting.get_value()
|
result[key] = setting.get_value()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -136,11 +262,12 @@ def update_module_settings(
|
|||||||
|
|
||||||
Enable or disable modules for all users.
|
Enable or disable modules for all users.
|
||||||
"""
|
"""
|
||||||
|
_validate_bulk_keys(module_data, set(MODULE_KEYS))
|
||||||
result = {}
|
result = {}
|
||||||
for key, value in module_data.items():
|
for key, value in module_data.items():
|
||||||
if key in MODULE_KEYS or key.startswith("module_"):
|
coerced = _coerce_setting_value(key, value)
|
||||||
setting = crud.settings.update_setting(db, key=key, value=value)
|
setting = crud.settings.update_setting(db, key=key, value=coerced)
|
||||||
result[key] = setting.get_value()
|
result[key] = setting.get_value()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -171,7 +298,8 @@ def update_user_mode_enabled(
|
|||||||
Update user mode enabled setting (admin only).
|
Update user mode enabled setting (admin only).
|
||||||
"""
|
"""
|
||||||
value = data.get("value", True)
|
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()}
|
return {"key": "user_mode_enabled", "value": setting.get_value()}
|
||||||
|
|
||||||
|
|
||||||
@@ -222,5 +350,6 @@ def update_setting(
|
|||||||
|
|
||||||
Creates the setting if it doesn't exist.
|
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)
|
return schemas.Setting.from_orm(setting)
|
||||||
|
|||||||
@@ -88,10 +88,14 @@ def update_user(
|
|||||||
|
|
||||||
# Only superusers can change is_superuser and is_active
|
# Only superusers can change is_superuser and is_active
|
||||||
if not current_user.is_superuser:
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
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)
|
user = crud.user.update(db, db_obj=user, obj_in=user_in)
|
||||||
|
|||||||
@@ -328,15 +328,16 @@ register_setting(SettingDefinition(
|
|||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# USER-SPECIFIC SETTINGS (Per-user, Database)
|
# USER-SPECIFIC SETTINGS (Frontend localStorage)
|
||||||
# These settings can be different for each user
|
# 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(
|
register_setting(SettingDefinition(
|
||||||
key="user_theme_mode",
|
key="user_theme_mode",
|
||||||
type=SettingType.STRING,
|
type=SettingType.STRING,
|
||||||
scope=SettingScope.USER_SPECIFIC,
|
scope=SettingScope.USER_SPECIFIC,
|
||||||
storage=SettingStorage.DATABASE,
|
storage=SettingStorage.LOCAL_STORAGE,
|
||||||
default="system",
|
default="system",
|
||||||
description="User's preferred theme mode (light/dark/system)",
|
description="User's preferred theme mode (light/dark/system)",
|
||||||
category="user_preferences",
|
category="user_preferences",
|
||||||
@@ -348,7 +349,7 @@ register_setting(SettingDefinition(
|
|||||||
key="user_language",
|
key="user_language",
|
||||||
type=SettingType.STRING,
|
type=SettingType.STRING,
|
||||||
scope=SettingScope.USER_SPECIFIC,
|
scope=SettingScope.USER_SPECIFIC,
|
||||||
storage=SettingStorage.DATABASE,
|
storage=SettingStorage.LOCAL_STORAGE,
|
||||||
default="en",
|
default="en",
|
||||||
description="User's preferred language",
|
description="User's preferred language",
|
||||||
category="user_preferences",
|
category="user_preferences",
|
||||||
@@ -360,7 +361,7 @@ register_setting(SettingDefinition(
|
|||||||
key="user_sidebar_collapsed",
|
key="user_sidebar_collapsed",
|
||||||
type=SettingType.BOOLEAN,
|
type=SettingType.BOOLEAN,
|
||||||
scope=SettingScope.USER_SPECIFIC,
|
scope=SettingScope.USER_SPECIFIC,
|
||||||
storage=SettingStorage.DATABASE,
|
storage=SettingStorage.LOCAL_STORAGE,
|
||||||
default=False,
|
default=False,
|
||||||
description="User's sidebar collapsed state preference",
|
description="User's sidebar collapsed state preference",
|
||||||
category="user_preferences",
|
category="user_preferences",
|
||||||
@@ -371,7 +372,7 @@ register_setting(SettingDefinition(
|
|||||||
key="user_view_mode",
|
key="user_view_mode",
|
||||||
type=SettingType.STRING,
|
type=SettingType.STRING,
|
||||||
scope=SettingScope.USER_SPECIFIC,
|
scope=SettingScope.USER_SPECIFIC,
|
||||||
storage=SettingStorage.DATABASE,
|
storage=SettingStorage.LOCAL_STORAGE,
|
||||||
default="admin",
|
default="admin",
|
||||||
description="User's current view mode (admin/user)",
|
description="User's current view mode (admin/user)",
|
||||||
category="user_preferences",
|
category="user_preferences",
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
|
|||||||
|
|
||||||
def delete(self, db: Session, *, id: Any) -> ModelType:
|
def delete(self, db: Session, *, id: Any) -> ModelType:
|
||||||
"""Delete a record."""
|
"""Delete a record."""
|
||||||
obj = db.query(self.model).get(id)
|
obj = db.get(self.model, id)
|
||||||
db.delete(obj)
|
db.delete(obj)
|
||||||
db.commit()
|
db.commit()
|
||||||
return obj
|
return obj
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""CRUD operations for Settings."""
|
"""CRUD operations for Settings."""
|
||||||
|
|
||||||
|
import json
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -27,13 +28,16 @@ def update_setting(db: Session, key: str, value: any) -> Settings:
|
|||||||
setting = Settings(key=key)
|
setting = Settings(key=key)
|
||||||
db.add(setting)
|
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):
|
if isinstance(value, bool):
|
||||||
setting.value_bool = value
|
setting.value_bool = value
|
||||||
setting.value_str = None
|
setting.value_str = None
|
||||||
elif isinstance(value, str) and value.lower() in ('true', 'false'):
|
elif isinstance(value, str) and value.lower() in ("true", "false", "1", "0"):
|
||||||
setting.value_bool = value.lower() == 'true'
|
setting.value_bool = value.lower() in ("true", "1")
|
||||||
setting.value_str = None
|
setting.value_str = None
|
||||||
|
elif isinstance(value, (dict, list)):
|
||||||
|
setting.value_str = json.dumps(value)
|
||||||
|
setting.value_bool = None
|
||||||
else:
|
else:
|
||||||
setting.value_str = str(value)
|
setting.value_str = str(value)
|
||||||
setting.value_bool = None
|
setting.value_bool = None
|
||||||
|
|||||||
@@ -138,16 +138,17 @@ async def startup_event():
|
|||||||
# Seed default settings from registry
|
# Seed default settings from registry
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from app import crud
|
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:
|
with Session(engine) as db:
|
||||||
# Get all database settings with defaults from registry
|
# Seed only GLOBAL database settings with defaults from registry
|
||||||
default_settings = get_database_defaults()
|
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, setting_def.key) is None:
|
||||||
if crud.settings.get_setting(db, key) is None:
|
logger.info(f"Seeding default setting: {setting_def.key}={setting_def.default}")
|
||||||
logger.info(f"Seeding default setting: {key}={value}")
|
crud.settings.update_setting(db, setting_def.key, setting_def.default)
|
||||||
crud.settings.update_setting(db, key, value)
|
|
||||||
|
|
||||||
|
|
||||||
# Shutdown event
|
# Shutdown event
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "5174:8000"
|
- "5174:8000"
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|||||||
3676
frontend/package-lock.json
generated
Normal file
3676
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,9 +7,12 @@ import { ThemeProvider } from './contexts/ThemeContext';
|
|||||||
import { SidebarProvider } from './contexts/SidebarContext';
|
import { SidebarProvider } from './contexts/SidebarContext';
|
||||||
import { ViewModeProvider } from './contexts/ViewModeContext';
|
import { ViewModeProvider } from './contexts/ViewModeContext';
|
||||||
import { ModulesProvider } from './contexts/ModulesContext';
|
import { ModulesProvider } from './contexts/ModulesContext';
|
||||||
|
import MainLayout from './components/MainLayout';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import Dashboard from './pages/Dashboard';
|
import Dashboard from './pages/Dashboard';
|
||||||
import Feature1 from './pages/Feature1';
|
import Feature1 from './pages/Feature1';
|
||||||
|
import Feature2 from './pages/Feature2';
|
||||||
|
import Feature3 from './pages/Feature3';
|
||||||
import AdminPanel from './pages/AdminPanel';
|
import AdminPanel from './pages/AdminPanel';
|
||||||
import Sources from './pages/admin/Sources';
|
import Sources from './pages/admin/Sources';
|
||||||
import Features from './pages/admin/Features';
|
import Features from './pages/admin/Features';
|
||||||
@@ -41,10 +44,6 @@ function AdminRoute({ children }: { children: ReactElement }) {
|
|||||||
return user.is_superuser ? children : <Navigate to="/dashboard" />;
|
return user.is_superuser ? children : <Navigate to="/dashboard" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
import MainLayout from './components/MainLayout';
|
|
||||||
|
|
||||||
// ...
|
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { user, isLoading } = useAuth();
|
const { user, isLoading } = useAuth();
|
||||||
|
|
||||||
@@ -59,15 +58,19 @@ function AppRoutes() {
|
|||||||
<Route element={<PrivateRoute><MainLayout /></PrivateRoute>}>
|
<Route element={<PrivateRoute><MainLayout /></PrivateRoute>}>
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/feature1" element={<Feature1 />} />
|
<Route path="/feature1" element={<Feature1 />} />
|
||||||
|
<Route path="/feature2" element={<Feature2 />} />
|
||||||
|
<Route path="/feature3" element={<Feature3 />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
|
||||||
<Route path="/admin" element={<AdminRoute><AdminPanel /></AdminRoute>} />
|
<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/sources" element={<AdminRoute><Sources /></AdminRoute>} />
|
||||||
<Route path="/admin/features" element={<AdminRoute><Features /></AdminRoute>} />
|
<Route path="/admin/features" element={<AdminRoute><Features /></AdminRoute>} />
|
||||||
<Route path="/admin/theme" element={<AdminRoute><ThemeSettings /></AdminRoute>} />
|
<Route path="/admin/theme" element={<AdminRoute><ThemeSettings /></AdminRoute>} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/" element={<Navigate to={user ? '/dashboard' : '/login'} />} />
|
<Route path="/" element={<Navigate to={user ? '/dashboard' : '/login'} />} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,13 +44,18 @@ export const authAPI = {
|
|||||||
|
|
||||||
// Settings endpoints
|
// Settings endpoints
|
||||||
export const settingsAPI = {
|
export const settingsAPI = {
|
||||||
getTheme: async (): Promise<Record<string, string>> => {
|
getTheme: async (): Promise<Record<string, unknown>> => {
|
||||||
const response = await api.get<Record<string, string>>('/settings/theme');
|
const response = await api.get<Record<string, unknown>>('/settings/theme');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateTheme: async (data: Record<string, string>): Promise<Record<string, string>> => {
|
getThemePublic: async (): Promise<Record<string, unknown>> => {
|
||||||
const response = await api.put<Record<string, string>>('/settings/theme', data);
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -63,6 +68,37 @@ export const settingsAPI = {
|
|||||||
const response = await api.put<Record<string, boolean>>('/settings/modules', data);
|
const response = await api.put<Record<string, boolean>>('/settings/modules', data);
|
||||||
return response.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
|
// Users endpoints
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useViewMode } from '../../contexts/ViewModeContext';
|
|||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
|
||||||
import { useSidebar } from '../../contexts/SidebarContext';
|
import { useSidebar } from '../../contexts/SidebarContext';
|
||||||
|
import { settingsAPI } from '../../api/client';
|
||||||
|
|
||||||
export default function GeneralTab() {
|
export default function GeneralTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -32,30 +33,25 @@ export default function GeneralTab() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/settings', {
|
const data = await settingsAPI.getAllSettings();
|
||||||
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Helper to check for true values (bool, string 'true'/'True', 1)
|
// Helper to check for true values (bool, string 'true'/'True', 1)
|
||||||
const isTrue = (val: any) => {
|
const isTrue = (val: any) => {
|
||||||
if (val === true) return true;
|
if (val === true) return true;
|
||||||
if (typeof val === 'string' && val.toLowerCase() === 'true') return true;
|
if (typeof val === 'string' && val.toLowerCase() === 'true') return true;
|
||||||
if (val === 1) return true;
|
if (val === 1) return true;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Default to true if undefined/null, otherwise check value
|
// Default to true if undefined/null, otherwise check value
|
||||||
const regEnabled = data.registration_enabled;
|
const regEnabled = data.registration_enabled;
|
||||||
setRegistrationEnabled(
|
setRegistrationEnabled(
|
||||||
regEnabled === undefined || regEnabled === null ? true : isTrue(regEnabled)
|
regEnabled === undefined || regEnabled === null ? true : isTrue(regEnabled)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update sidebar context
|
// Update sidebar context
|
||||||
if (data.show_logo !== undefined) {
|
if (data.show_logo !== undefined) {
|
||||||
setShowLogo(isTrue(data.show_logo));
|
setShowLogo(isTrue(data.show_logo));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings:', error);
|
console.error('Failed to load settings:', error);
|
||||||
@@ -68,22 +64,9 @@ export default function GeneralTab() {
|
|||||||
setRegistrationEnabled(checked); // Optimistic update
|
setRegistrationEnabled(checked); // Optimistic update
|
||||||
setSavingRegistration(true);
|
setSavingRegistration(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/settings/registration_enabled', {
|
const updated = await settingsAPI.updateSetting('registration_enabled', checked);
|
||||||
method: 'PUT',
|
const value = updated.value;
|
||||||
headers: {
|
setRegistrationEnabled(value === true || value === 'true' || value === 'True' || value === 1 || value === '1');
|
||||||
'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);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update registration setting:', error);
|
console.error('Failed to update registration setting:', error);
|
||||||
setRegistrationEnabled(!checked);
|
setRegistrationEnabled(!checked);
|
||||||
@@ -96,22 +79,9 @@ export default function GeneralTab() {
|
|||||||
setShowLogo(checked); // Optimistic update
|
setShowLogo(checked); // Optimistic update
|
||||||
setSavingShowLogo(true);
|
setSavingShowLogo(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/settings/show_logo', {
|
const updated = await settingsAPI.updateSetting('show_logo', checked);
|
||||||
method: 'PUT',
|
const value = updated.value;
|
||||||
headers: {
|
setShowLogo(value === true || value === 'true' || value === 'True' || value === 1 || value === '1');
|
||||||
'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);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update show logo setting:', error);
|
console.error('Failed to update show logo setting:', error);
|
||||||
setShowLogo(!checked);
|
setShowLogo(!checked);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
|
import { settingsAPI } from '../../api/client';
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
registration_enabled?: boolean;
|
registration_enabled?: boolean;
|
||||||
@@ -17,17 +18,8 @@ export default function SettingsTab() {
|
|||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const data = await settingsAPI.getAllSettings();
|
||||||
const response = await fetch('/api/v1/settings', {
|
setSettings(data as Settings);
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setSettings(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch settings:', error);
|
console.error('Failed to fetch settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,23 +30,11 @@ export default function SettingsTab() {
|
|||||||
const updateSetting = async (key: string, value: any) => {
|
const updateSetting = async (key: string, value: any) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const updatedSetting = await settingsAPI.updateSetting(key, value);
|
||||||
const response = await fetch(`/api/v1/settings/${key}`, {
|
setSettings(prev => ({
|
||||||
method: 'PUT',
|
...prev,
|
||||||
headers: {
|
[key]: updatedSetting.value
|
||||||
'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
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update setting:', error);
|
console.error('Failed to update setting:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -200,8 +200,8 @@ export default function UsersTab() {
|
|||||||
email: formData.email,
|
email: formData.email,
|
||||||
is_active: formData.is_active,
|
is_active: formData.is_active,
|
||||||
is_superuser: formData.is_superuser,
|
is_superuser: formData.is_superuser,
|
||||||
// Only send permissions if custom permissions are enabled, otherwise send empty object (inherit global)
|
// If custom permissions are disabled, clear permissions (inherit from global module settings)
|
||||||
permissions: formData.hasCustomPermissions ? formData.permissions : {},
|
permissions: formData.hasCustomPermissions ? formData.permissions : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (formData.password.trim()) {
|
if (formData.password.trim()) {
|
||||||
@@ -760,7 +760,8 @@ export default function UsersTab() {
|
|||||||
{formData.hasCustomPermissions ? (
|
{formData.hasCustomPermissions ? (
|
||||||
<div className="permissions-grid">
|
<div className="permissions-grid">
|
||||||
{TOGGLEABLE_MODULES.map((module) => {
|
{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 isChecked = formData.permissions[module.id] ?? true;
|
||||||
const moduleName = t.sidebar[module.id as keyof typeof t.sidebar] || module.id;
|
const moduleName = t.sidebar[module.id as keyof typeof t.sidebar] || module.id;
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,17 @@ const LanguageContext = createContext<LanguageContextType | undefined>(undefined
|
|||||||
|
|
||||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||||
const [language, setLanguageState] = useState<Language>(() => {
|
const [language, setLanguageState] = useState<Language>(() => {
|
||||||
const saved = localStorage.getItem('language');
|
const key = 'user_language';
|
||||||
if (saved) return saved as 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
|
// Try to detect from browser
|
||||||
const browserLang = navigator.language.split('-')[0];
|
const browserLang = navigator.language.split('-')[0];
|
||||||
@@ -31,7 +40,7 @@ export function LanguageProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const setLanguage = (lang: Language) => {
|
const setLanguage = (lang: Language) => {
|
||||||
setLanguageState(lang);
|
setLanguageState(lang);
|
||||||
localStorage.setItem('language', lang);
|
localStorage.setItem('user_language', lang);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
|
import { settingsAPI } from '../api/client';
|
||||||
|
|
||||||
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
|
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
|
||||||
|
|
||||||
@@ -23,47 +24,57 @@ const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
|||||||
|
|
||||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||||
const { token } = useAuth();
|
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 [isMobileOpen, setIsMobileOpen] = useState(false);
|
||||||
const [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle');
|
const [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle');
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [showLogo, setShowLogo] = useState(false);
|
const [showLogo, setShowLogo] = useState(false);
|
||||||
|
|
||||||
// Load sidebar mode from backend
|
// Load sidebar mode from backend
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSidebarMode = async () => {
|
const loadSidebarMode = async () => {
|
||||||
try {
|
try {
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setSidebarModeState('toggle');
|
setSidebarModeState('toggle');
|
||||||
setShowLogo(false);
|
setShowLogo(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/v1/settings/ui', {
|
const data = await settingsAPI.getUi();
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
const parseBool = (val: any, defaultVal: boolean): boolean => {
|
||||||
});
|
if (val === undefined || val === null) return defaultVal;
|
||||||
if (response.ok) {
|
if (val === true || val === 'true' || val === 'True' || val === 1 || val === '1') return true;
|
||||||
const data = await response.json();
|
if (val === false || val === 'false' || val === 'False' || val === 0 || val === '0') return false;
|
||||||
const parseBool = (val: any, defaultVal: boolean): boolean => {
|
return defaultVal;
|
||||||
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)) {
|
if (data.sidebar_mode && ['collapsed', 'expanded', 'toggle', 'dynamic'].includes(data.sidebar_mode as string)) {
|
||||||
setSidebarModeState(data.sidebar_mode as SidebarMode);
|
setSidebarModeState(data.sidebar_mode as SidebarMode);
|
||||||
}
|
}
|
||||||
if (data.show_logo !== undefined) {
|
if (data.show_logo !== undefined) {
|
||||||
setShowLogo(parseBool(data.show_logo, false));
|
setShowLogo(parseBool(data.show_logo, false));
|
||||||
}
|
}
|
||||||
}
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('Failed to load sidebar mode:', error);
|
||||||
console.error('Failed to load sidebar mode:', error);
|
}
|
||||||
}
|
};
|
||||||
};
|
loadSidebarMode();
|
||||||
loadSidebarMode();
|
}, [token]);
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
// Compute isCollapsed based on mode
|
// Compute isCollapsed based on mode
|
||||||
const isCollapsed = sidebarMode === 'collapsed' ? true :
|
const isCollapsed = sidebarMode === 'collapsed' ? true :
|
||||||
@@ -76,7 +87,11 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const toggleCollapse = () => {
|
const toggleCollapse = () => {
|
||||||
if (canToggle) {
|
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);
|
setIsMobileOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setSidebarMode = useCallback(async (mode: SidebarMode) => {
|
const setSidebarMode = useCallback(async (mode: SidebarMode) => {
|
||||||
try {
|
try {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
const response = await fetch('/api/v1/settings/sidebar_mode', {
|
await settingsAPI.updateSetting('sidebar_mode', mode);
|
||||||
method: 'PUT',
|
setSidebarModeState(mode);
|
||||||
headers: {
|
} catch (error) {
|
||||||
'Authorization': `Bearer ${token}`,
|
console.error('Failed to save sidebar mode:', error);
|
||||||
'Content-Type': 'application/json',
|
}
|
||||||
},
|
}, [token]);
|
||||||
body: JSON.stringify({ value: mode }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setSidebarModeState(mode);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save sidebar mode:', error);
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarContext.Provider
|
<SidebarContext.Provider
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
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 { settingsAPI } from '../api/client';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ interface ThemeContextType {
|
|||||||
density: Density;
|
density: Density;
|
||||||
fontFamily: FontFamily;
|
fontFamily: FontFamily;
|
||||||
colorPalette: ColorPalette;
|
colorPalette: ColorPalette;
|
||||||
customColors: Partial<PaletteColors>;
|
customColors: CustomColors;
|
||||||
darkModeLocation: ControlLocation;
|
darkModeLocation: ControlLocation;
|
||||||
languageLocation: ControlLocation;
|
languageLocation: ControlLocation;
|
||||||
showDarkModeToggle: boolean;
|
showDarkModeToggle: boolean;
|
||||||
@@ -37,7 +37,7 @@ interface ThemeContextType {
|
|||||||
setDensity: (density: Density) => void;
|
setDensity: (density: Density) => void;
|
||||||
setFontFamily: (font: FontFamily) => void;
|
setFontFamily: (font: FontFamily) => void;
|
||||||
setColorPalette: (palette: ColorPalette) => void;
|
setColorPalette: (palette: ColorPalette) => void;
|
||||||
setCustomColors: (colors: Partial<PaletteColors>) => void;
|
setCustomColors: Dispatch<SetStateAction<CustomColors>>;
|
||||||
setDarkModeLocation: (location: ControlLocation) => void;
|
setDarkModeLocation: (location: ControlLocation) => void;
|
||||||
setLanguageLocation: (location: ControlLocation) => void;
|
setLanguageLocation: (location: ControlLocation) => void;
|
||||||
setShowDarkModeToggle: (show: boolean) => void;
|
setShowDarkModeToggle: (show: boolean) => void;
|
||||||
@@ -52,7 +52,7 @@ interface ThemeContextType {
|
|||||||
density: Density;
|
density: Density;
|
||||||
fontFamily: FontFamily;
|
fontFamily: FontFamily;
|
||||||
colorPalette: ColorPalette;
|
colorPalette: ColorPalette;
|
||||||
customColors: Partial<PaletteColors>;
|
customColors: CustomColors;
|
||||||
darkModeLocation: ControlLocation;
|
darkModeLocation: ControlLocation;
|
||||||
languageLocation: ControlLocation;
|
languageLocation: ControlLocation;
|
||||||
showDarkModeToggle: boolean;
|
showDarkModeToggle: boolean;
|
||||||
@@ -162,7 +162,7 @@ const FONTS: Record<FontFamily, string> = {
|
|||||||
roboto: '"Roboto", sans-serif',
|
roboto: '"Roboto", sans-serif',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PaletteColors {
|
export interface PaletteColors {
|
||||||
light: {
|
light: {
|
||||||
bgMain: string;
|
bgMain: string;
|
||||||
bgCard: 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> = {
|
export const COLOR_PALETTES: Record<ColorPalette, PaletteColors> = {
|
||||||
default: {
|
default: {
|
||||||
light: {
|
light: {
|
||||||
@@ -468,7 +473,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
const [density, setDensityState] = useState<Density>('compact');
|
const [density, setDensityState] = useState<Density>('compact');
|
||||||
const [fontFamily, setFontFamilyState] = useState<FontFamily>('sans');
|
const [fontFamily, setFontFamilyState] = useState<FontFamily>('sans');
|
||||||
const [colorPalette, setColorPaletteState] = useState<ColorPalette>('monochrome');
|
const [colorPalette, setColorPaletteState] = useState<ColorPalette>('monochrome');
|
||||||
const [customColors, setCustomColorsState] = useState<Partial<PaletteColors>>({});
|
const [customColors, setCustomColorsState] = useState<CustomColors>({});
|
||||||
const [darkModeLocation, setDarkModeLocationState] = useState<ControlLocation>('sidebar');
|
const [darkModeLocation, setDarkModeLocationState] = useState<ControlLocation>('sidebar');
|
||||||
const [languageLocation, setLanguageLocationState] = useState<ControlLocation>('sidebar');
|
const [languageLocation, setLanguageLocationState] = useState<ControlLocation>('sidebar');
|
||||||
const [showDarkModeToggle, setShowDarkModeToggleState] = useState<boolean>(true);
|
const [showDarkModeToggle, setShowDarkModeToggleState] = useState<boolean>(true);
|
||||||
@@ -593,8 +598,11 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
// Load theme from backend when user is authenticated
|
// Load theme from backend when user is authenticated
|
||||||
const loadThemeFromBackend = useCallback(async () => {
|
const loadThemeFromBackend = useCallback(async () => {
|
||||||
setIsLoadingSettings(true);
|
setIsLoadingSettings(true);
|
||||||
|
setHasInitializedSettings(false);
|
||||||
try {
|
try {
|
||||||
const themeData = await settingsAPI.getTheme();
|
const themeData = token
|
||||||
|
? await settingsAPI.getTheme()
|
||||||
|
: await settingsAPI.getThemePublic();
|
||||||
if (themeData.theme_accent_color) {
|
if (themeData.theme_accent_color) {
|
||||||
setAccentColorState(themeData.theme_accent_color as AccentColor);
|
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'
|
const parsed = typeof themeData.theme_custom_colors === 'string'
|
||||||
? JSON.parse(themeData.theme_custom_colors)
|
? JSON.parse(themeData.theme_custom_colors)
|
||||||
: themeData.theme_custom_colors;
|
: themeData.theme_custom_colors;
|
||||||
setCustomColorsState(parsed as Partial<PaletteColors>);
|
setCustomColorsState(parsed as CustomColors);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse custom colors:', e);
|
console.error('Failed to parse custom colors:', e);
|
||||||
}
|
}
|
||||||
@@ -651,7 +659,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsLoadingSettings(false);
|
setIsLoadingSettings(false);
|
||||||
setHasInitializedSettings(true);
|
setHasInitializedSettings(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [token]);
|
||||||
|
|
||||||
// Save theme to backend (admin only) - accepts optional overrides for immediate save
|
// Save theme to backend (admin only) - accepts optional overrides for immediate save
|
||||||
const saveThemeToBackend = useCallback(async (overrides?: Partial<{
|
const saveThemeToBackend = useCallback(async (overrides?: Partial<{
|
||||||
@@ -661,7 +669,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
density: Density;
|
density: Density;
|
||||||
fontFamily: FontFamily;
|
fontFamily: FontFamily;
|
||||||
colorPalette: ColorPalette;
|
colorPalette: ColorPalette;
|
||||||
customColors: Partial<PaletteColors>;
|
customColors: CustomColors;
|
||||||
darkModeLocation: ControlLocation;
|
darkModeLocation: ControlLocation;
|
||||||
languageLocation: ControlLocation;
|
languageLocation: ControlLocation;
|
||||||
showDarkModeToggle: boolean;
|
showDarkModeToggle: boolean;
|
||||||
@@ -670,7 +678,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
showLanguageLogin: boolean;
|
showLanguageLogin: boolean;
|
||||||
}>) => {
|
}>) => {
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, string> = {};
|
const payload: Record<string, unknown> = {};
|
||||||
|
|
||||||
if (overrides) {
|
if (overrides) {
|
||||||
if (overrides.accentColor !== undefined) payload.theme_accent_color = overrides.accentColor;
|
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.density !== undefined) payload.theme_density = overrides.density;
|
||||||
if (overrides.fontFamily !== undefined) payload.theme_font_family = overrides.fontFamily;
|
if (overrides.fontFamily !== undefined) payload.theme_font_family = overrides.fontFamily;
|
||||||
if (overrides.colorPalette !== undefined) payload.theme_color_palette = overrides.colorPalette;
|
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.darkModeLocation !== undefined) payload.theme_dark_mode_location = overrides.darkModeLocation;
|
||||||
if (overrides.languageLocation !== undefined) payload.theme_language_location = overrides.languageLocation;
|
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.showDarkModeToggle !== undefined) payload.theme_show_dark_mode_toggle = overrides.showDarkModeToggle;
|
||||||
if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = String(overrides.showLanguageToggle);
|
if (overrides.showLanguageToggle !== undefined) payload.theme_show_language_toggle = overrides.showLanguageToggle;
|
||||||
if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = String(overrides.showDarkModeLogin);
|
if (overrides.showDarkModeLogin !== undefined) payload.theme_show_dark_mode_login = overrides.showDarkModeLogin;
|
||||||
if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = String(overrides.showLanguageLogin);
|
if (overrides.showLanguageLogin !== undefined) payload.theme_show_language_login = overrides.showLanguageLogin;
|
||||||
} else {
|
} else {
|
||||||
payload.theme_accent_color = accentColor;
|
payload.theme_accent_color = accentColor;
|
||||||
payload.theme_border_radius = borderRadius;
|
payload.theme_border_radius = borderRadius;
|
||||||
@@ -693,13 +701,13 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
payload.theme_density = density;
|
payload.theme_density = density;
|
||||||
payload.theme_font_family = fontFamily;
|
payload.theme_font_family = fontFamily;
|
||||||
payload.theme_color_palette = colorPalette;
|
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_dark_mode_location = darkModeLocation;
|
||||||
payload.theme_language_location = languageLocation;
|
payload.theme_language_location = languageLocation;
|
||||||
payload.theme_show_dark_mode_toggle = String(showDarkModeToggle);
|
payload.theme_show_dark_mode_toggle = showDarkModeToggle;
|
||||||
payload.theme_show_language_toggle = String(showLanguageToggle);
|
payload.theme_show_language_toggle = showLanguageToggle;
|
||||||
payload.theme_show_dark_mode_login = String(showDarkModeLogin);
|
payload.theme_show_dark_mode_login = showDarkModeLogin;
|
||||||
payload.theme_show_language_login = String(showLanguageLogin);
|
payload.theme_show_language_login = showLanguageLogin;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) return;
|
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]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
loadThemeFromBackend();
|
||||||
setHasInitializedSettings(false);
|
}, [loadThemeFromBackend]);
|
||||||
loadThemeFromBackend();
|
|
||||||
} else {
|
|
||||||
setIsLoadingSettings(false);
|
|
||||||
setHasInitializedSettings(true);
|
|
||||||
}
|
|
||||||
}, [token, loadThemeFromBackend]);
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
const toggleTheme = () => {
|
||||||
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
setTheme((prev) => (prev === 'light' ? 'dark' : 'light'));
|
||||||
@@ -750,10 +752,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
setColorPaletteState(palette);
|
setColorPaletteState(palette);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setCustomColors = (colors: Partial<PaletteColors>) => {
|
|
||||||
setCustomColorsState(colors);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setDarkModeLocation = (location: ControlLocation) => {
|
const setDarkModeLocation = (location: ControlLocation) => {
|
||||||
setDarkModeLocationState(location);
|
setDarkModeLocationState(location);
|
||||||
};
|
};
|
||||||
@@ -804,7 +802,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
setDensity,
|
setDensity,
|
||||||
setFontFamily,
|
setFontFamily,
|
||||||
setColorPalette,
|
setColorPalette,
|
||||||
setCustomColors,
|
|
||||||
setDarkModeLocation,
|
setDarkModeLocation,
|
||||||
setLanguageLocation,
|
setLanguageLocation,
|
||||||
setShowDarkModeToggle,
|
setShowDarkModeToggle,
|
||||||
@@ -813,6 +810,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
setShowLanguageLogin,
|
setShowLanguageLogin,
|
||||||
loadThemeFromBackend,
|
loadThemeFromBackend,
|
||||||
saveThemeToBackend,
|
saveThemeToBackend,
|
||||||
|
setCustomColors: setCustomColorsState,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createContext, useContext, useState, useEffect } from 'react';
|
import { createContext, useContext, useState, useEffect } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
|
import { settingsAPI } from '../api/client';
|
||||||
|
|
||||||
type ViewMode = 'admin' | 'user';
|
type ViewMode = 'admin' | 'user';
|
||||||
|
|
||||||
@@ -18,8 +19,19 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
|
|||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
// viewMode is user preference - stored in localStorage
|
// viewMode is user preference - stored in localStorage
|
||||||
const [viewMode, setViewModeState] = useState<ViewMode>(() => {
|
const [viewMode, setViewModeState] = useState<ViewMode>(() => {
|
||||||
const saved = localStorage.getItem('viewMode');
|
const key = 'user_view_mode';
|
||||||
return (saved as ViewMode) || 'admin';
|
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
|
// isUserModeEnabled is a GLOBAL setting - comes from database, default false
|
||||||
@@ -27,7 +39,7 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const setViewMode = (mode: ViewMode) => {
|
const setViewMode = (mode: ViewMode) => {
|
||||||
setViewModeState(mode);
|
setViewModeState(mode);
|
||||||
localStorage.setItem('viewMode', mode);
|
localStorage.setItem('user_view_mode', mode);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleViewMode = () => {
|
const toggleViewMode = () => {
|
||||||
@@ -36,6 +48,7 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setUserModeEnabled = async (enabled: boolean) => {
|
const setUserModeEnabled = async (enabled: boolean) => {
|
||||||
|
const previous = isUserModeEnabled;
|
||||||
setUserModeEnabledState(enabled);
|
setUserModeEnabledState(enabled);
|
||||||
|
|
||||||
// If disabling, reset to admin view
|
// If disabling, reset to admin view
|
||||||
@@ -45,25 +58,30 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// Save to backend (this is a global setting)
|
// Save to backend (this is a global setting)
|
||||||
try {
|
try {
|
||||||
if (token) {
|
if (!token) return;
|
||||||
await fetch('/api/v1/settings/user_mode_enabled', {
|
const persisted = await settingsAPI.updateUserModeEnabled(enabled);
|
||||||
method: 'PUT',
|
setUserModeEnabledState(persisted);
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ value: enabled }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save user mode setting:', error);
|
console.error('Failed to save user mode setting:', error);
|
||||||
|
setUserModeEnabledState(previous);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Sync viewMode (user preference) with localStorage on mount
|
// Sync viewMode (user preference) with localStorage on mount
|
||||||
const savedMode = localStorage.getItem('viewMode');
|
const key = 'user_view_mode';
|
||||||
if (savedMode) setViewModeState(savedMode as ViewMode);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -76,19 +94,8 @@ export function ViewModeProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/v1/settings/user_mode_enabled', {
|
const isEnabled = await settingsAPI.getUserModeEnabled();
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
setUserModeEnabledState(isEnabled);
|
||||||
});
|
|
||||||
|
|
||||||
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)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch user mode setting:', error);
|
console.error('Failed to fetch user mode setting:', error);
|
||||||
// Keep default (false)
|
// Keep default (false)
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import '../styles/AdminPanel.css';
|
|||||||
|
|
||||||
type TabId = 'general' | 'users';
|
type TabId = 'general' | 'users';
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel({ initialTab = 'general' }: { initialTab?: TabId } = {}) {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toggleMobileMenu } = useSidebar();
|
const { toggleMobileMenu } = useSidebar();
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('general');
|
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
||||||
|
|
||||||
if (!currentUser?.is_superuser) {
|
if (!currentUser?.is_superuser) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
36
frontend/src/pages/Feature2.tsx
Normal file
36
frontend/src/pages/Feature2.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
36
frontend/src/pages/Feature3.tsx
Normal file
36
frontend/src/pages/Feature3.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
import { useSidebar } from '../../contexts/SidebarContext';
|
import { useSidebar } from '../../contexts/SidebarContext';
|
||||||
import Sidebar from '../../components/Sidebar';
|
import Sidebar from '../../components/Sidebar';
|
||||||
|
import { settingsAPI } from '../../api/client';
|
||||||
import '../../styles/Settings.css';
|
import '../../styles/Settings.css';
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
@@ -22,17 +23,8 @@ export default function Settings() {
|
|||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const data = await settingsAPI.getAllSettings();
|
||||||
const response = await fetch('/api/v1/settings', {
|
setSettings(data as Settings);
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setSettings(data);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch settings:', error);
|
console.error('Failed to fetch settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -43,23 +35,11 @@ export default function Settings() {
|
|||||||
const updateSetting = async (key: string, value: any) => {
|
const updateSetting = async (key: string, value: any) => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem('token');
|
const updatedSetting = await settingsAPI.updateSetting(key, value);
|
||||||
const response = await fetch(`/api/v1/settings/${key}`, {
|
setSettings(prev => ({
|
||||||
method: 'PUT',
|
...prev,
|
||||||
headers: {
|
[key]: updatedSetting.value
|
||||||
'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
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update setting:', error);
|
console.error('Failed to update setting:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
|
import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext';
|
||||||
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette } from '../../contexts/ThemeContext';
|
import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette } from '../../contexts/ThemeContext';
|
||||||
import { useTranslation } from '../../contexts/LanguageContext';
|
import { useTranslation } from '../../contexts/LanguageContext';
|
||||||
@@ -10,10 +10,13 @@ import '../../styles/ThemeSettings.css';
|
|||||||
|
|
||||||
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
|
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
|
||||||
|
|
||||||
|
type ThemeMode = 'light' | 'dark';
|
||||||
|
type ColorProperty = keyof typeof COLOR_PALETTES.default.light;
|
||||||
|
|
||||||
type ColorPickerState = {
|
type ColorPickerState = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
theme: 'light' | 'dark';
|
theme: ThemeMode;
|
||||||
property: string;
|
property: ColorProperty;
|
||||||
value: string;
|
value: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
@@ -32,6 +35,8 @@ export default function ThemeSettings() {
|
|||||||
setDensity,
|
setDensity,
|
||||||
setFontFamily,
|
setFontFamily,
|
||||||
setColorPalette,
|
setColorPalette,
|
||||||
|
customColors,
|
||||||
|
setCustomColors,
|
||||||
saveThemeToBackend
|
saveThemeToBackend
|
||||||
} = useTheme();
|
} = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -88,32 +93,25 @@ export default function ThemeSettings() {
|
|||||||
saveThemeToBackend({ colorPalette: palette }).catch(console.error);
|
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
|
// Color picker popup state
|
||||||
const [colorPickerState, setColorPickerState] = useState<ColorPickerState>(null);
|
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 }[] = [
|
const colors: { id: AccentColor; label: string; value: string; description: string }[] = [
|
||||||
{ id: 'auto', label: t.theme.colors.auto, value: '#374151', description: t.theme.colors.autoDesc },
|
{ 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
|
// Helper component for color control
|
||||||
const ColorControl = ({ theme, property, label, value }: {
|
const ColorControl = ({ theme, property, label, value }: {
|
||||||
theme: 'light' | 'dark';
|
theme: ThemeMode;
|
||||||
property: string;
|
property: ColorProperty;
|
||||||
label: string;
|
label: string;
|
||||||
value: string
|
value: string
|
||||||
}) => {
|
}) => {
|
||||||
@@ -220,21 +218,35 @@ export default function ThemeSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Color picker handlers
|
// Color picker handlers
|
||||||
const handleColorChange = (theme: 'light' | 'dark', property: string, value: string) => {
|
const handleColorChange = (theme: ThemeMode, property: ColorProperty, value: string) => {
|
||||||
setCustomColors(prev => ({
|
if (!isAdmin) return;
|
||||||
...prev,
|
|
||||||
[theme]: {
|
hasUserModifiedCustomColors.current = true;
|
||||||
...prev[theme],
|
const baseValue = paletteColors[theme][property];
|
||||||
[property]: value
|
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
|
if (Object.keys(themeOverrides).length === 0) {
|
||||||
const root = document.documentElement;
|
delete next[theme];
|
||||||
const varName = `--color-${property.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
} else {
|
||||||
root.style.setProperty(varName, value);
|
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
|
// Validate hex color format
|
||||||
const hexRegex = /^#?[0-9A-Fa-f]{6}$/;
|
const hexRegex = /^#?[0-9A-Fa-f]{6}$/;
|
||||||
const formattedValue = value.startsWith('#') ? value : `#${value}`;
|
const formattedValue = value.startsWith('#') ? value : `#${value}`;
|
||||||
@@ -245,38 +257,9 @@ export default function ThemeSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resetToDefaults = () => {
|
const resetToDefaults = () => {
|
||||||
setCustomColors({
|
if (!isAdmin) return;
|
||||||
light: {
|
hasUserModifiedCustomColors.current = true;
|
||||||
bgMain: '#ffffff',
|
setCustomColors({});
|
||||||
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');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -619,49 +602,49 @@ export default function ThemeSettings() {
|
|||||||
theme="light"
|
theme="light"
|
||||||
property="bgMain"
|
property="bgMain"
|
||||||
label={t.theme.background}
|
label={t.theme.background}
|
||||||
value={customColors.light.bgMain}
|
value={effectiveColors.light.bgMain}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="light"
|
theme="light"
|
||||||
property="bgCard"
|
property="bgCard"
|
||||||
label={t.theme.backgroundCard}
|
label={t.theme.backgroundCard}
|
||||||
value={customColors.light.bgCard}
|
value={effectiveColors.light.bgCard}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="light"
|
theme="light"
|
||||||
property="bgElevated"
|
property="bgElevated"
|
||||||
label={t.theme.backgroundElevated}
|
label={t.theme.backgroundElevated}
|
||||||
value={customColors.light.bgElevated}
|
value={effectiveColors.light.bgElevated}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="light"
|
theme="light"
|
||||||
property="textPrimary"
|
property="textPrimary"
|
||||||
label={t.theme.textPrimary}
|
label={t.theme.textPrimary}
|
||||||
value={customColors.light.textPrimary}
|
value={effectiveColors.light.textPrimary}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="light"
|
theme="light"
|
||||||
property="textSecondary"
|
property="textSecondary"
|
||||||
label={t.theme.textSecondary}
|
label={t.theme.textSecondary}
|
||||||
value={customColors.light.textSecondary}
|
value={effectiveColors.light.textSecondary}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="light"
|
theme="light"
|
||||||
property="border"
|
property="border"
|
||||||
label={t.theme.border}
|
label={t.theme.border}
|
||||||
value={customColors.light.border}
|
value={effectiveColors.light.border}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="light"
|
theme="light"
|
||||||
property="sidebarBg"
|
property="sidebarBg"
|
||||||
label={t.theme.sidebarBackground}
|
label={t.theme.sidebarBackground}
|
||||||
value={customColors.light.sidebarBg}
|
value={effectiveColors.light.sidebarBg}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="light"
|
theme="light"
|
||||||
property="sidebarText"
|
property="sidebarText"
|
||||||
label={t.theme.sidebarText}
|
label={t.theme.sidebarText}
|
||||||
value={customColors.light.sidebarText}
|
value={effectiveColors.light.sidebarText}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -674,49 +657,49 @@ export default function ThemeSettings() {
|
|||||||
theme="dark"
|
theme="dark"
|
||||||
property="bgMain"
|
property="bgMain"
|
||||||
label={t.theme.background}
|
label={t.theme.background}
|
||||||
value={customColors.dark.bgMain}
|
value={effectiveColors.dark.bgMain}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="dark"
|
theme="dark"
|
||||||
property="bgCard"
|
property="bgCard"
|
||||||
label={t.theme.backgroundCard}
|
label={t.theme.backgroundCard}
|
||||||
value={customColors.dark.bgCard}
|
value={effectiveColors.dark.bgCard}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="dark"
|
theme="dark"
|
||||||
property="bgElevated"
|
property="bgElevated"
|
||||||
label={t.theme.backgroundElevated}
|
label={t.theme.backgroundElevated}
|
||||||
value={customColors.dark.bgElevated}
|
value={effectiveColors.dark.bgElevated}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="dark"
|
theme="dark"
|
||||||
property="textPrimary"
|
property="textPrimary"
|
||||||
label={t.theme.textPrimary}
|
label={t.theme.textPrimary}
|
||||||
value={customColors.dark.textPrimary}
|
value={effectiveColors.dark.textPrimary}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="dark"
|
theme="dark"
|
||||||
property="textSecondary"
|
property="textSecondary"
|
||||||
label={t.theme.textSecondary}
|
label={t.theme.textSecondary}
|
||||||
value={customColors.dark.textSecondary}
|
value={effectiveColors.dark.textSecondary}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="dark"
|
theme="dark"
|
||||||
property="border"
|
property="border"
|
||||||
label={t.theme.border}
|
label={t.theme.border}
|
||||||
value={customColors.dark.border}
|
value={effectiveColors.dark.border}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="dark"
|
theme="dark"
|
||||||
property="sidebarBg"
|
property="sidebarBg"
|
||||||
label={t.theme.sidebarBackground}
|
label={t.theme.sidebarBackground}
|
||||||
value={customColors.dark.sidebarBg}
|
value={effectiveColors.dark.sidebarBg}
|
||||||
/>
|
/>
|
||||||
<ColorControl
|
<ColorControl
|
||||||
theme="dark"
|
theme="dark"
|
||||||
property="sidebarText"
|
property="sidebarText"
|
||||||
label={t.theme.sidebarText}
|
label={t.theme.sidebarText}
|
||||||
value={customColors.dark.sidebarText}
|
value={effectiveColors.dark.sidebarText}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export interface UserUpdatePayload {
|
|||||||
password?: string;
|
password?: string;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
is_superuser?: boolean;
|
is_superuser?: boolean;
|
||||||
permissions?: UserPermissions;
|
permissions?: UserPermissions | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Token {
|
export interface Token {
|
||||||
|
|||||||
Reference in New Issue
Block a user