Add theming system: custom palettes, per-user colour mode, admin appearance page
- 4 built-in themes (Default, Pastel, High Contrast, Ocean Blue) seeded as JSON files in /config/themes/ on startup; custom themes can be created, edited, and deleted via the new admin Appearance page - All theme tokens applied via JS inline CSS properties (no hardcoded CSS blocks) - New `color_mode` column on users table (migration dd6ad2f2c211); users can override the admin-set global default in Settings - Backend: GET/PATCH /settings/appearance, full CRUD on /settings/themes - Frontend: AdminAppearancePage with theme grid + colour pickers, SettingsPage replaces placeholder with mode selector, useTheme rewritten to fetch from API Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -88,7 +88,7 @@ docker compose up --build -d
|
||||
│ │ │ ├── config.py ← All settings via pydantic-settings (reads .env)
|
||||
│ │ │ ├── security.py ← JWT sign/verify (RS256), bcrypt hash/verify
|
||||
│ │ │ ├── sanitize.py ← Input sanitization helpers (see Security Standards)
|
||||
│ │ │ └── app_config.py ← Per-service config load/save to /config volume
|
||||
│ │ │ └── app_config.py ← Per-service config load/save to /config volume; theme files in /config/themes/
|
||||
│ │ ├── models/
|
||||
│ │ │ ├── __init__.py ← Imports all models (required for Alembic autogenerate)
|
||||
│ │ │ ├── user.py ← User model (see Database Models)
|
||||
@@ -100,11 +100,11 @@ docker compose up --build -d
|
||||
│ │ │ └── group.py ← GroupCreate/Update/Out/DetailOut, GroupMemberOut
|
||||
│ │ ├── routers/
|
||||
│ │ │ ├── auth.py ← POST /register, POST /login
|
||||
│ │ │ ├── users.py ← GET /me, GET+PATCH /me/preferences
|
||||
│ │ │ ├── users.py ← GET /me, GET+PATCH /me/preferences, PATCH /me/color-mode
|
||||
│ │ │ ├── profile.py ← GET+PUT /me (profile)
|
||||
│ │ │ ├── admin.py ← User admin CRUD (admin-only)
|
||||
│ │ │ ├── groups.py ← Group CRUD + member management (admin-only)
|
||||
│ │ │ ├── settings.py ← AI, doc limits, system prompts (admin-only)
|
||||
│ │ │ ├── settings.py ← AI, doc limits, system prompts, appearance, themes (admin-only)
|
||||
│ │ │ ├── services.py ← GET /services (health status)
|
||||
│ │ │ ├── categories_proxy.py ← Transparent proxy → doc-service /categories/*
|
||||
│ │ │ └── documents_proxy.py ← Transparent proxy → doc-service /documents/*
|
||||
@@ -227,6 +227,7 @@ Browser (:5173 dev / :80 prod)
|
||||
| `is_active` | Boolean | default=True | soft-delete flag |
|
||||
| `is_superuser` | Boolean | default=False | admin role; never exposed as-is (serialised as `is_admin`) |
|
||||
| `dashboard_app_ids` | JSON | NOT NULL, default=[] | list of pinned service IDs |
|
||||
| `color_mode` | String | nullable, default=NULL | user's preferred mode: "light" / "dark" / "system" / NULL (use admin default) |
|
||||
|
||||
Relationship: `profile` (one-to-one, cascade all+delete-orphan)
|
||||
|
||||
@@ -309,6 +310,7 @@ Unique constraint: `(group_id, user_id)`
|
||||
| `676084df61d1` | `add_profiles_table` |
|
||||
| `a3f9c2d14e87` | `add_groups_and_group_memberships` |
|
||||
| `c7e8f9a0b1d2` | `add_dashboard_app_ids_to_users` |
|
||||
| `dd6ad2f2c211` | `add_color_mode_to_users` |
|
||||
|
||||
**Doc-service**:
|
||||
|
||||
@@ -335,6 +337,7 @@ Unique constraint: `(group_id, user_id)`
|
||||
| GET | `/api/users/me` | user | Current user info → `UserOut` |
|
||||
| GET | `/api/users/me/preferences` | user | Dashboard pinned app IDs → `{app_ids}` |
|
||||
| PATCH | `/api/users/me/preferences` | user | Save pinned app IDs (max 50, slug-safe) |
|
||||
| PATCH | `/api/users/me/color-mode` | user | Save colour mode preference ("light"/"dark"/"system") |
|
||||
|
||||
### Profile (`/api/profile`) — authenticated
|
||||
|
||||
@@ -375,6 +378,12 @@ Unique constraint: `(group_id, user_id)`
|
||||
| PATCH | `/api/settings/documents/limits` | Update max PDF size |
|
||||
| GET | `/api/settings/system-prompts` | All editable system prompts |
|
||||
| PATCH | `/api/settings/system-prompts/{service_id}` | Update system prompt |
|
||||
| GET | `/api/settings/appearance` | Active theme + default mode (auth) |
|
||||
| PATCH | `/api/settings/appearance` | Update active theme + default mode (admin) |
|
||||
| GET | `/api/settings/themes` | List all themes — built-in + custom (auth) |
|
||||
| POST | `/api/settings/themes` | Create custom theme (admin) |
|
||||
| PATCH | `/api/settings/themes/{id}` | Update custom theme label/colours (admin) |
|
||||
| DELETE | `/api/settings/themes/{id}` | Delete custom theme (admin, 204) |
|
||||
|
||||
### Services (`/api/services`) — authenticated
|
||||
|
||||
@@ -436,6 +445,7 @@ Unique constraint: `(group_id, user_id)`
|
||||
| `/admin` | `AdminPage` (→ `/admin/users`) | AdminRoute |
|
||||
| `/admin/users` | `AdminUsersPage` | AdminRoute |
|
||||
| `/admin/groups` | `AdminGroupsPage` | AdminRoute |
|
||||
| `/admin/appearance` | `AdminAppearancePage` | AdminRoute |
|
||||
| `*` | redirect to `/` | — |
|
||||
|
||||
`PrivateRoute` — checks `token` from `useAuth`, redirects to `/login` if absent.
|
||||
@@ -669,6 +679,7 @@ Use `validation_alias` when the ORM field name differs from the JSON key (e.g.,
|
||||
| Token localStorage key | `"token"` | `useAuth.ts` |
|
||||
| Health check interval | 30 s | `service_health.py` |
|
||||
| Service poll (frontend) | 30 s | `AppsPage.tsx`, `DashboardPage.tsx` |
|
||||
| User `color_mode` default | NULL (falls back to admin default_mode, then system) | `models/user.py` |
|
||||
| Max dashboard pinned apps | 50 | `schemas/user.py` |
|
||||
| App ID max length | 64 chars | `schemas/user.py` |
|
||||
| App ID allowed chars | `[a-zA-Z0-9_\-]` | `schemas/user.py` |
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""add_color_mode_to_users
|
||||
|
||||
Revision ID: dd6ad2f2c211
|
||||
Revises: c7e8f9a0b1d2
|
||||
Create Date: 2026-04-17 23:42:58.222958
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = 'dd6ad2f2c211'
|
||||
down_revision: Union[str, None] = 'c7e8f9a0b1d2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('users', sa.Column('color_mode', sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('users', 'color_mode')
|
||||
@@ -10,6 +10,7 @@ services never read a partial file.
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -211,3 +212,212 @@ def _get_service_prompt_defaults(service_id: str) -> dict:
|
||||
d = DocServiceSystemPrompts()
|
||||
return {"system": d.system, "user_template": d.user_template}
|
||||
return {"system": "", "user_template": ""}
|
||||
|
||||
|
||||
# ── Appearance config ──────────────────────────────────────────────────────────
|
||||
|
||||
class AppearanceConfig(BaseModel):
|
||||
theme: str = "default"
|
||||
default_mode: str = "system"
|
||||
|
||||
|
||||
def load_appearance_config() -> AppearanceConfig:
|
||||
path = _CONFIG_DIR / "appearance_config.json"
|
||||
if not path.exists():
|
||||
return AppearanceConfig()
|
||||
with path.open() as f:
|
||||
return AppearanceConfig.model_validate(json.load(f))
|
||||
|
||||
|
||||
def save_appearance_config(config: AppearanceConfig) -> None:
|
||||
path = _CONFIG_DIR / "appearance_config.json"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(config.model_dump(), indent=2))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
# ── Theme file management ──────────────────────────────────────────────────────
|
||||
|
||||
_THEMES_DIR = _CONFIG_DIR / "themes"
|
||||
|
||||
# 9 required colour tokens per mode
|
||||
_REQUIRED_TOKENS = frozenset({
|
||||
"primary", "primary_hover", "accent", "accent_hover",
|
||||
"background", "surface", "border", "text_primary", "text_muted",
|
||||
})
|
||||
|
||||
_RGB_RE = re.compile(r"^\d{1,3} \d{1,3} \d{1,3}$")
|
||||
|
||||
_BUILTIN_THEMES: list[dict] = [
|
||||
{
|
||||
"id": "default",
|
||||
"label": "Default",
|
||||
"builtin": True,
|
||||
"light": {
|
||||
"primary": "37 99 235",
|
||||
"primary_hover": "29 78 216",
|
||||
"accent": "234 179 8",
|
||||
"accent_hover": "202 138 4",
|
||||
"background": "248 250 252",
|
||||
"surface": "255 255 255",
|
||||
"border": "226 232 240",
|
||||
"text_primary": "15 23 42",
|
||||
"text_muted": "100 116 139",
|
||||
},
|
||||
"dark": {
|
||||
"primary": "59 130 246",
|
||||
"primary_hover": "37 99 235",
|
||||
"accent": "250 204 21",
|
||||
"accent_hover": "234 179 8",
|
||||
"background": "15 23 42",
|
||||
"surface": "30 41 59",
|
||||
"border": "51 65 85",
|
||||
"text_primary": "203 213 225",
|
||||
"text_muted": "148 163 184",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "pastel",
|
||||
"label": "Pastel",
|
||||
"builtin": True,
|
||||
"light": {
|
||||
"primary": "124 58 237",
|
||||
"primary_hover": "109 40 217",
|
||||
"accent": "236 72 153",
|
||||
"accent_hover": "219 39 119",
|
||||
"background": "253 244 255",
|
||||
"surface": "250 245 255",
|
||||
"border": "233 213 255",
|
||||
"text_primary": "30 27 75",
|
||||
"text_muted": "107 114 128",
|
||||
},
|
||||
"dark": {
|
||||
"primary": "167 139 250",
|
||||
"primary_hover": "196 181 253",
|
||||
"accent": "244 114 182",
|
||||
"accent_hover": "251 164 200",
|
||||
"background": "30 20 51",
|
||||
"surface": "45 27 78",
|
||||
"border": "76 53 117",
|
||||
"text_primary": "233 213 255",
|
||||
"text_muted": "196 181 253",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "high-contrast",
|
||||
"label": "High Contrast",
|
||||
"builtin": True,
|
||||
"light": {
|
||||
"primary": "30 58 138",
|
||||
"primary_hover": "30 64 175",
|
||||
"accent": "220 38 38",
|
||||
"accent_hover": "185 28 28",
|
||||
"background": "255 255 255",
|
||||
"surface": "255 255 255",
|
||||
"border": "156 163 175",
|
||||
"text_primary": "0 0 0",
|
||||
"text_muted": "75 85 99",
|
||||
},
|
||||
"dark": {
|
||||
"primary": "96 165 250",
|
||||
"primary_hover": "147 197 253",
|
||||
"accent": "248 113 113",
|
||||
"accent_hover": "252 165 165",
|
||||
"background": "0 0 0",
|
||||
"surface": "10 10 10",
|
||||
"border": "55 65 81",
|
||||
"text_primary": "255 255 255",
|
||||
"text_muted": "156 163 175",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "ocean",
|
||||
"label": "Ocean Blue",
|
||||
"builtin": True,
|
||||
"light": {
|
||||
"primary": "29 78 216",
|
||||
"primary_hover": "30 58 138",
|
||||
"accent": "8 145 178",
|
||||
"accent_hover": "14 116 144",
|
||||
"background": "239 246 255",
|
||||
"surface": "219 234 254",
|
||||
"border": "147 197 253",
|
||||
"text_primary": "30 58 138",
|
||||
"text_muted": "59 130 246",
|
||||
},
|
||||
"dark": {
|
||||
"primary": "96 165 250",
|
||||
"primary_hover": "147 197 253",
|
||||
"accent": "34 211 238",
|
||||
"accent_hover": "103 232 249",
|
||||
"background": "10 22 40",
|
||||
"surface": "15 36 68",
|
||||
"border": "29 78 216",
|
||||
"text_primary": "219 234 254",
|
||||
"text_muted": "147 197 253",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def seed_builtin_themes() -> None:
|
||||
"""Create /config/themes/ and write built-in theme files if missing."""
|
||||
_THEMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for theme in _BUILTIN_THEMES:
|
||||
path = _THEMES_DIR / f"{theme['id']}.json"
|
||||
if not path.exists():
|
||||
path.write_text(json.dumps(theme, indent=2))
|
||||
|
||||
|
||||
def load_all_themes() -> list[dict]:
|
||||
"""Return all themes from /config/themes/*.json, built-ins first."""
|
||||
if not _THEMES_DIR.exists():
|
||||
seed_builtin_themes()
|
||||
themes = []
|
||||
for f in sorted(_THEMES_DIR.glob("*.json")):
|
||||
try:
|
||||
themes.append(json.loads(f.read_text()))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
# Sort: built-ins first (preserving their original order), then custom by label
|
||||
builtin_ids = [t["id"] for t in _BUILTIN_THEMES]
|
||||
def sort_key(t: dict) -> tuple:
|
||||
tid = t.get("id", "")
|
||||
try:
|
||||
return (0, builtin_ids.index(tid))
|
||||
except ValueError:
|
||||
return (1, t.get("label", tid).lower())
|
||||
return sorted(themes, key=sort_key)
|
||||
|
||||
|
||||
def validate_theme_tokens(colors: dict) -> list[str]:
|
||||
"""Return a list of validation error messages, empty if valid."""
|
||||
errors = []
|
||||
missing = _REQUIRED_TOKENS - set(colors.keys())
|
||||
if missing:
|
||||
errors.append(f"Missing tokens: {', '.join(sorted(missing))}")
|
||||
for key, val in colors.items():
|
||||
if key in _REQUIRED_TOKENS and not _RGB_RE.match(str(val)):
|
||||
errors.append(f"Token '{key}' must be an RGB triplet like '37 99 235', got: {val!r}")
|
||||
return errors
|
||||
|
||||
|
||||
def save_theme(theme: dict) -> None:
|
||||
"""Write a theme file atomically."""
|
||||
_THEMES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = _THEMES_DIR / f"{theme['id']}.json"
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(theme, indent=2))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def delete_theme(theme_id: str) -> None:
|
||||
"""Delete a custom theme file. Raises ValueError for built-ins, FileNotFoundError if missing."""
|
||||
path = _THEMES_DIR / f"{theme_id}.json"
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(theme_id)
|
||||
data = json.loads(path.read_text())
|
||||
if data.get("builtin"):
|
||||
raise ValueError("Cannot delete a built-in theme")
|
||||
path.unlink()
|
||||
|
||||
@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.core.app_config import seed_builtin_themes
|
||||
from app.core.config import settings
|
||||
from app.routers import admin, auth, categories_proxy, documents_proxy, groups, profile, services, users
|
||||
from app.routers import settings as settings_router
|
||||
@@ -12,6 +13,7 @@ from app.services.service_health import check_all, health_check_loop, register_s
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await asyncio.to_thread(seed_builtin_themes)
|
||||
register_services(
|
||||
doc_service_url=settings.DOC_SERVICE_URL,
|
||||
ai_service_url=settings.AI_SERVICE_URL,
|
||||
|
||||
@@ -23,6 +23,8 @@ class User(Base):
|
||||
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
# List of service IDs pinned to the user's home dashboard.
|
||||
dashboard_app_ids: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
||||
# User's preferred colour mode: "light", "dark", "system", or None (use admin default).
|
||||
color_mode: Mapped[str | None] = mapped_column(String, nullable=True, default=None)
|
||||
|
||||
profile: Mapped["Profile"] = relationship(
|
||||
"Profile", back_populates="user", uselist=False, cascade="all, delete-orphan"
|
||||
|
||||
@@ -5,6 +5,7 @@ All endpoints require the caller to be an admin (Depends(get_current_admin)).
|
||||
Config files live on the shared app_config volume (/config/).
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -12,18 +13,24 @@ from pydantic import BaseModel
|
||||
|
||||
from app.core.app_config import (
|
||||
SYSTEM_PROMPT_SERVICES,
|
||||
AppearanceConfig,
|
||||
_merge_api_key,
|
||||
delete_theme,
|
||||
load_ai_service_config,
|
||||
load_ai_service_config_masked,
|
||||
load_all_system_prompts,
|
||||
load_all_themes,
|
||||
load_appearance_config,
|
||||
load_doc_service_config,
|
||||
load_doc_service_config_masked,
|
||||
save_ai_service_config,
|
||||
save_doc_service_config,
|
||||
save_service_system_prompts,
|
||||
save_theme,
|
||||
validate_theme_tokens,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.deps import get_current_admin
|
||||
from app.deps import get_current_admin, get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
@@ -53,6 +60,36 @@ class SystemPromptUpdate(BaseModel):
|
||||
user_template: str
|
||||
|
||||
|
||||
class AppearanceUpdate(BaseModel):
|
||||
theme: str
|
||||
default_mode: str
|
||||
|
||||
|
||||
class ThemeColors(BaseModel):
|
||||
primary: str
|
||||
primary_hover: str
|
||||
accent: str
|
||||
accent_hover: str
|
||||
background: str
|
||||
surface: str
|
||||
border: str
|
||||
text_primary: str
|
||||
text_muted: str
|
||||
|
||||
|
||||
class ThemeCreate(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
light: ThemeColors
|
||||
dark: ThemeColors
|
||||
|
||||
|
||||
class ThemeUpdate(BaseModel):
|
||||
label: str | None = None
|
||||
light: ThemeColors | None = None
|
||||
dark: ThemeColors | None = None
|
||||
|
||||
|
||||
# ── AI settings ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -176,3 +213,108 @@ async def update_system_prompt(
|
||||
save_service_system_prompts, service_id, body.system, body.user_template
|
||||
)
|
||||
return await asyncio.to_thread(load_all_system_prompts)
|
||||
|
||||
|
||||
# ── Appearance (global default — auth read, admin write) ───────────────────────
|
||||
|
||||
import re as _re
|
||||
_THEME_ID_RE = _re.compile(r"^[a-z0-9_-]{1,64}$")
|
||||
|
||||
|
||||
@router.get("/appearance")
|
||||
async def get_appearance(
|
||||
_: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
config = await asyncio.to_thread(load_appearance_config)
|
||||
return config.model_dump()
|
||||
|
||||
|
||||
@router.patch("/appearance")
|
||||
async def update_appearance(
|
||||
body: AppearanceUpdate,
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
if body.default_mode not in ("light", "dark", "system"):
|
||||
raise HTTPException(status_code=422, detail="default_mode must be 'light', 'dark', or 'system'")
|
||||
themes = await asyncio.to_thread(load_all_themes)
|
||||
theme_ids = {t["id"] for t in themes}
|
||||
if body.theme not in theme_ids:
|
||||
raise HTTPException(status_code=422, detail=f"Unknown theme: {body.theme!r}")
|
||||
config = AppearanceConfig(theme=body.theme, default_mode=body.default_mode)
|
||||
await asyncio.to_thread(save_appearance_config, config)
|
||||
return config.model_dump()
|
||||
|
||||
|
||||
# ── Theme CRUD ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/themes")
|
||||
async def list_themes(
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list:
|
||||
return await asyncio.to_thread(load_all_themes)
|
||||
|
||||
|
||||
@router.post("/themes", status_code=201)
|
||||
async def create_theme(
|
||||
body: ThemeCreate,
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
if not _THEME_ID_RE.match(body.id):
|
||||
raise HTTPException(status_code=422, detail="Theme ID must match [a-z0-9_-]{1,64}")
|
||||
existing = {t["id"] for t in await asyncio.to_thread(load_all_themes)}
|
||||
if body.id in existing:
|
||||
raise HTTPException(status_code=400, detail=f"Theme ID already in use: {body.id!r}")
|
||||
light = body.light.model_dump()
|
||||
dark = body.dark.model_dump()
|
||||
for mode, colors in (("light", light), ("dark", dark)):
|
||||
errors = validate_theme_tokens(colors)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail=f"{mode}: {'; '.join(errors)}")
|
||||
theme = {"id": body.id, "label": body.label, "builtin": False, "light": light, "dark": dark}
|
||||
await asyncio.to_thread(save_theme, theme)
|
||||
return theme
|
||||
|
||||
|
||||
@router.patch("/themes/{theme_id}")
|
||||
async def update_theme(
|
||||
theme_id: str,
|
||||
body: ThemeUpdate,
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
from app.core.app_config import _THEMES_DIR
|
||||
path = _THEMES_DIR / f"{theme_id}.json"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
theme = json.loads(path.read_text())
|
||||
if theme.get("builtin"):
|
||||
raise HTTPException(status_code=400, detail="Cannot edit a built-in theme")
|
||||
if body.label is not None:
|
||||
theme["label"] = body.label
|
||||
if body.light is not None:
|
||||
light = body.light.model_dump()
|
||||
errors = validate_theme_tokens(light)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail=f"light: {'; '.join(errors)}")
|
||||
theme["light"] = light
|
||||
if body.dark is not None:
|
||||
dark = body.dark.model_dump()
|
||||
errors = validate_theme_tokens(dark)
|
||||
if errors:
|
||||
raise HTTPException(status_code=422, detail=f"dark: {'; '.join(errors)}")
|
||||
theme["dark"] = dark
|
||||
await asyncio.to_thread(save_theme, theme)
|
||||
return theme
|
||||
|
||||
|
||||
@router.delete("/themes/{theme_id}", status_code=204)
|
||||
async def remove_theme(
|
||||
theme_id: str,
|
||||
_: User = Depends(get_current_admin),
|
||||
) -> None:
|
||||
try:
|
||||
await asyncio.to_thread(delete_theme, theme_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Theme not found")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.user import DashboardPrefsOut, DashboardPrefsUpdate, UserOut
|
||||
from app.schemas.user import ColorModeUpdate, DashboardPrefsOut, DashboardPrefsUpdate, UserOut
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -29,3 +29,15 @@ async def update_preferences(
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
return DashboardPrefsOut(app_ids=current_user.dashboard_app_ids or [])
|
||||
|
||||
|
||||
@router.patch("/me/color-mode", response_model=UserOut)
|
||||
async def update_color_mode(
|
||||
body: ColorModeUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
current_user.color_mode = body.color_mode
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
return current_user
|
||||
|
||||
@@ -71,6 +71,7 @@ class UserOut(BaseModel):
|
||||
# validation_alias reads is_superuser from the ORM object; the JSON key
|
||||
# in the response is the field name "is_admin" (not the alias).
|
||||
is_admin: bool = Field(validation_alias="is_superuser", default=False)
|
||||
color_mode: str | None = None
|
||||
|
||||
model_config = {"from_attributes": True, "populate_by_name": True}
|
||||
|
||||
@@ -104,6 +105,17 @@ class DashboardPrefsOut(BaseModel):
|
||||
app_ids: list[str]
|
||||
|
||||
|
||||
class ColorModeUpdate(BaseModel):
|
||||
color_mode: str
|
||||
|
||||
@field_validator("color_mode")
|
||||
@classmethod
|
||||
def validate_mode(cls, v: str) -> str:
|
||||
if v not in ("light", "dark", "system"):
|
||||
raise ValueError("color_mode must be 'light', 'dark', or 'system'")
|
||||
return v
|
||||
|
||||
|
||||
class DashboardPrefsUpdate(BaseModel):
|
||||
app_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import AppsPage from "./pages/AppsPage";
|
||||
import AdminPage from "./pages/AdminPage";
|
||||
import AdminUsersPage from "./pages/AdminUsersPage";
|
||||
import AdminGroupsPage from "./pages/AdminGroupsPage";
|
||||
import AdminAppearancePage from "./pages/AdminAppearancePage";
|
||||
import DocumentsPage from "./pages/DocumentsPage";
|
||||
import DocumentAdminSettingsPage from "./pages/DocumentAdminSettingsPage";
|
||||
import AIAdminSettingsPage from "./pages/AIAdminSettingsPage";
|
||||
@@ -57,6 +58,7 @@ export default function App() {
|
||||
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
|
||||
<Route path="/admin/users" element={<AdminRoute><AdminUsersPage /></AdminRoute>} />
|
||||
<Route path="/admin/groups" element={<AdminRoute><AdminGroupsPage /></AdminRoute>} />
|
||||
<Route path="/admin/appearance" element={<AdminRoute><AdminAppearancePage /></AdminRoute>} />
|
||||
|
||||
{/* Catch-all */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface UserData {
|
||||
full_name: string | null;
|
||||
is_active: boolean;
|
||||
is_admin: boolean;
|
||||
color_mode: string | null;
|
||||
}
|
||||
|
||||
export const getMe = () => api.get<UserData>("/users/me").then((r) => r.data);
|
||||
@@ -202,6 +203,56 @@ export const renameCategory = (id: string, name: string) =>
|
||||
export const deleteCategory = (id: string) =>
|
||||
api.delete(`/documents/categories/${id}`);
|
||||
|
||||
// --- Appearance & Themes ---
|
||||
export interface ThemeColors {
|
||||
primary: string;
|
||||
primary_hover: string;
|
||||
accent: string;
|
||||
accent_hover: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
border: string;
|
||||
text_primary: string;
|
||||
text_muted: string;
|
||||
}
|
||||
|
||||
export interface ThemeDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
builtin: boolean;
|
||||
light: ThemeColors;
|
||||
dark: ThemeColors;
|
||||
}
|
||||
|
||||
export interface AppearanceSettings {
|
||||
theme: string;
|
||||
default_mode: string;
|
||||
}
|
||||
|
||||
export const getAppearanceSettings = (): Promise<AppearanceSettings> =>
|
||||
api.get<AppearanceSettings>("/settings/appearance").then((r) => r.data);
|
||||
|
||||
export const updateAppearanceSettings = (data: AppearanceSettings): Promise<AppearanceSettings> =>
|
||||
api.patch<AppearanceSettings>("/settings/appearance", data).then((r) => r.data);
|
||||
|
||||
export const getThemes = (): Promise<ThemeDefinition[]> =>
|
||||
api.get<ThemeDefinition[]>("/settings/themes").then((r) => r.data);
|
||||
|
||||
export const createTheme = (data: Omit<ThemeDefinition, "builtin">): Promise<ThemeDefinition> =>
|
||||
api.post<ThemeDefinition>("/settings/themes", data).then((r) => r.data);
|
||||
|
||||
export const updateTheme = (
|
||||
id: string,
|
||||
data: { label?: string; light?: ThemeColors; dark?: ThemeColors }
|
||||
): Promise<ThemeDefinition> =>
|
||||
api.patch<ThemeDefinition>(`/settings/themes/${id}`, data).then((r) => r.data);
|
||||
|
||||
export const deleteTheme = (id: string): Promise<void> =>
|
||||
api.delete(`/settings/themes/${id}`).then((r) => r.data);
|
||||
|
||||
export const updateColorMode = (color_mode: string): Promise<UserData> =>
|
||||
api.patch<UserData>("/users/me/color-mode", { color_mode }).then((r) => r.data);
|
||||
|
||||
// --- Settings (admin only) ---
|
||||
export interface AIProviderUpdate {
|
||||
provider: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Folder,
|
||||
Users,
|
||||
UsersRound,
|
||||
Palette,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
@@ -265,6 +266,13 @@ export default function Sidebar() {
|
||||
<UsersRound className="h-4 w-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Groups</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/admin/appearance"
|
||||
className={({ isActive }) => subItemClass(isActive)}
|
||||
>
|
||||
<Palette className="h-4 w-4 shrink-0" />
|
||||
<span className="whitespace-nowrap">Appearance</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,16 +3,16 @@ import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
{isDark ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
|
||||
@@ -1,32 +1,77 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getAppearanceSettings,
|
||||
getMe,
|
||||
getThemes,
|
||||
updateColorMode,
|
||||
type ThemeColors,
|
||||
} from "@/api/client";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
export type ColorMode = "light" | "dark" | "system";
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
function applyThemeTokens(colors: ThemeColors) {
|
||||
const el = document.documentElement;
|
||||
el.style.setProperty("--color-primary", colors.primary);
|
||||
el.style.setProperty("--color-primary-hover", colors.primary_hover);
|
||||
el.style.setProperty("--color-accent", colors.accent);
|
||||
el.style.setProperty("--color-accent-hover", colors.accent_hover);
|
||||
el.style.setProperty("--color-background", colors.background);
|
||||
el.style.setProperty("--color-surface", colors.surface);
|
||||
el.style.setProperty("--color-border", colors.border);
|
||||
el.style.setProperty("--color-text-primary", colors.text_primary);
|
||||
el.style.setProperty("--color-text-muted", colors.text_muted);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem("theme") as Theme | null;
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
||||
const { data: appearance } = useQuery({
|
||||
queryKey: ["appearance"],
|
||||
queryFn: getAppearanceSettings,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const { data: themes = [] } = useQuery({
|
||||
queryKey: ["themes"],
|
||||
queryFn: getThemes,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Priority: user.color_mode → admin default_mode → system preference
|
||||
const isDark = useMemo(() => {
|
||||
const mode = me?.color_mode ?? appearance?.default_mode ?? "system";
|
||||
if (mode === "system") {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
return mode === "dark";
|
||||
}, [me?.color_mode, appearance?.default_mode]);
|
||||
|
||||
// Apply palette + dark class whenever they change
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}, [theme]);
|
||||
const activeId = appearance?.theme ?? localStorage.getItem("themePalette") ?? "default";
|
||||
const theme =
|
||||
themes.find((t) => t.id === activeId) ?? themes.find((t) => t.id === "default");
|
||||
if (theme) {
|
||||
applyThemeTokens(isDark ? theme.dark : theme.light);
|
||||
}
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
localStorage.setItem("themePalette", activeId);
|
||||
}, [isDark, appearance?.theme, themes]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const setColorMode = useCallback(
|
||||
async (mode: ColorMode) => {
|
||||
// Reflect optimistically before the round-trip
|
||||
document.documentElement.classList.toggle("dark", mode === "dark");
|
||||
await updateColorMode(mode);
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((t) => (t === "light" ? "dark" : "light"));
|
||||
}, []);
|
||||
setColorMode(isDark ? "light" : "dark");
|
||||
}, [isDark, setColorMode]);
|
||||
|
||||
return { theme, toggleTheme };
|
||||
return { isDark, toggleTheme, setColorMode };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { useState } from "react";
|
||||
import { Palette, Monitor, Sun, Moon, Pencil, Trash2, Plus, X } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getAppearanceSettings,
|
||||
getThemes,
|
||||
updateAppearanceSettings,
|
||||
createTheme,
|
||||
updateTheme,
|
||||
deleteTheme,
|
||||
type ThemeDefinition,
|
||||
type ThemeColors,
|
||||
} from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const hexToRgb = (hex: string): string => {
|
||||
const clean = hex.replace("#", "");
|
||||
const r = parseInt(clean.slice(0, 2), 16);
|
||||
const g = parseInt(clean.slice(2, 4), 16);
|
||||
const b = parseInt(clean.slice(4, 6), 16);
|
||||
return `${r} ${g} ${b}`;
|
||||
};
|
||||
|
||||
const rgbToHex = (rgb: string): string => {
|
||||
const parts = rgb.split(" ").map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return "#000000";
|
||||
return "#" + parts.map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
const TOKEN_LABELS: { key: keyof ThemeColors; label: string }[] = [
|
||||
{ key: "background", label: "Background" },
|
||||
{ key: "surface", label: "Surface" },
|
||||
{ key: "primary", label: "Primary" },
|
||||
{ key: "primary_hover", label: "Primary Hover" },
|
||||
{ key: "accent", label: "Accent" },
|
||||
{ key: "accent_hover", label: "Accent Hover" },
|
||||
{ key: "border", label: "Border" },
|
||||
{ key: "text_primary", label: "Text" },
|
||||
{ key: "text_muted", label: "Muted Text" },
|
||||
];
|
||||
|
||||
const EMPTY_COLORS: ThemeColors = {
|
||||
background: "248 250 252",
|
||||
surface: "255 255 255",
|
||||
primary: "37 99 235",
|
||||
primary_hover: "29 78 216",
|
||||
accent: "234 179 8",
|
||||
accent_hover: "202 138 4",
|
||||
border: "226 232 240",
|
||||
text_primary: "15 23 42",
|
||||
text_muted: "100 116 139",
|
||||
};
|
||||
|
||||
type ColorMode = "system" | "light" | "dark";
|
||||
|
||||
const MODE_OPTIONS: { value: ColorMode; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: "system", label: "System", icon: <Monitor className="h-4 w-4" /> },
|
||||
{ value: "light", label: "Light", icon: <Sun className="h-4 w-4" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
// ── Theme card ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function ThemeCard({
|
||||
theme,
|
||||
selected,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
theme: ThemeDefinition;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const swatchKeys: (keyof ThemeColors)[] = [
|
||||
"background",
|
||||
"surface",
|
||||
"primary",
|
||||
"accent",
|
||||
"border",
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"relative cursor-pointer rounded-lg border-2 p-3 transition-all",
|
||||
selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 bg-surface"
|
||||
)}
|
||||
>
|
||||
{/* Swatches */}
|
||||
<div className="flex gap-1 mb-2">
|
||||
{swatchKeys.map((key) => {
|
||||
const rgb = theme.light[key];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="h-5 w-5 rounded-sm border border-black/10"
|
||||
style={{ backgroundColor: `rgb(${rgb.replace(/ /g, ",")})` }}
|
||||
title={key}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-sm font-medium text-foreground">{theme.label}</p>
|
||||
{theme.builtin && (
|
||||
<span className="text-xs text-muted">Built-in</span>
|
||||
)}
|
||||
|
||||
{/* Edit / delete for custom themes */}
|
||||
{!theme.builtin && (
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||
className="p-1 rounded text-muted hover:text-foreground"
|
||||
title="Edit theme"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
className="p-1 rounded text-muted hover:text-red-500"
|
||||
title="Delete theme"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Color editor ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ColorsEditor({
|
||||
label,
|
||||
colors,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
colors: ThemeColors;
|
||||
onChange: (colors: ThemeColors) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-muted uppercase tracking-wide mb-2">{label}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{TOKEN_LABELS.map(({ key, label: tokenLabel }) => (
|
||||
<div key={key} className="flex flex-col gap-0.5">
|
||||
<label className="text-xs text-muted">{tokenLabel}</label>
|
||||
<input
|
||||
type="color"
|
||||
value={rgbToHex(colors[key])}
|
||||
onChange={(e) =>
|
||||
onChange({ ...colors, [key]: hexToRgb(e.target.value) })
|
||||
}
|
||||
className="h-8 w-full rounded border border-border cursor-pointer"
|
||||
title={colors[key]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Theme form (create / edit) ─────────────────────────────────────────────────
|
||||
|
||||
function ThemeForm({
|
||||
initial,
|
||||
onSave,
|
||||
onCancel,
|
||||
isSaving,
|
||||
error,
|
||||
}: {
|
||||
initial?: ThemeDefinition;
|
||||
onSave: (data: { id: string; label: string; light: ThemeColors; dark: ThemeColors }) => void;
|
||||
onCancel: () => void;
|
||||
isSaving: boolean;
|
||||
error?: string;
|
||||
}) {
|
||||
const [id, setId] = useState(initial?.id ?? "");
|
||||
const [label, setLabel] = useState(initial?.label ?? "");
|
||||
const [light, setLight] = useState<ThemeColors>(initial?.light ?? EMPTY_COLORS);
|
||||
const [dark, setDark] = useState<ThemeColors>(initial?.dark ?? { ...EMPTY_COLORS, background: "15 23 42", surface: "30 41 59", text_primary: "203 213 225", text_muted: "148 163 184" });
|
||||
|
||||
const isEditing = !!initial;
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg p-4 bg-surface mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{isEditing ? "Edit Theme" : "New Theme"}
|
||||
</h3>
|
||||
<button onClick={onCancel} className="text-muted hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Theme ID (slug)</label>
|
||||
<input
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))}
|
||||
placeholder="my-theme"
|
||||
maxLength={64}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-xs text-muted block mb-1">Display Name</label>
|
||||
<input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="My Theme"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 mb-4">
|
||||
<ColorsEditor label="Light mode" colors={light} onChange={setLight} />
|
||||
<ColorsEditor label="Dark mode" colors={dark} onChange={setDark} />
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-500 mb-3">{error}</p>}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => onSave({ id, label, light, dark })}
|
||||
disabled={isSaving || (!isEditing && !id) || !label}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Theme"}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminAppearancePage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: appearance } = useQuery({
|
||||
queryKey: ["appearance"],
|
||||
queryFn: getAppearanceSettings,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const { data: themes = [] } = useQuery({
|
||||
queryKey: ["themes"],
|
||||
queryFn: getThemes,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const [selectedTheme, setSelectedTheme] = useState<string | null>(null);
|
||||
const [selectedMode, setSelectedMode] = useState<ColorMode | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingTheme, setEditingTheme] = useState<ThemeDefinition | null>(null);
|
||||
const [formError, setFormError] = useState("");
|
||||
|
||||
const activeTheme = selectedTheme ?? appearance?.theme ?? "default";
|
||||
const activeMode = selectedMode ?? (appearance?.default_mode as ColorMode) ?? "system";
|
||||
|
||||
const saveAppearance = useMutation({
|
||||
mutationFn: updateAppearanceSettings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["appearance"] });
|
||||
setSelectedTheme(null);
|
||||
setSelectedMode(null);
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createTheme,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||
setShowForm(false);
|
||||
setFormError("");
|
||||
},
|
||||
onError: (e: Error) => setFormError(e.message),
|
||||
});
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Parameters<typeof updateTheme>[1] }) =>
|
||||
updateTheme(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||
setEditingTheme(null);
|
||||
setFormError("");
|
||||
},
|
||||
onError: (e: Error) => setFormError(e.message),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteTheme,
|
||||
onSuccess: (_, deletedId) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||
if (activeTheme === deletedId) setSelectedTheme("default");
|
||||
},
|
||||
});
|
||||
|
||||
const isDirty =
|
||||
(selectedTheme !== null && selectedTheme !== appearance?.theme) ||
|
||||
(selectedMode !== null && selectedMode !== appearance?.default_mode);
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Palette className="h-6 w-6 text-muted" />
|
||||
<h1 className="text-2xl font-semibold text-foreground">Appearance</h1>
|
||||
</div>
|
||||
|
||||
{/* ── Theme selector ── */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6 mb-6">
|
||||
<h2 className="text-base font-semibold text-foreground mb-1">Colour Theme</h2>
|
||||
<p className="text-sm text-muted mb-4">
|
||||
Select the colour palette applied site-wide. Custom themes can be created below.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{themes.map((theme) => (
|
||||
<ThemeCard
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
selected={activeTheme === theme.id}
|
||||
onSelect={() => setSelectedTheme(theme.id)}
|
||||
onEdit={() => { setEditingTheme(theme); setShowForm(false); setFormError(""); }}
|
||||
onDelete={() => {
|
||||
if (confirm(`Delete "${theme.label}"?`)) deleteMutation.mutate(theme.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Default mode ── */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6 mb-6">
|
||||
<h2 className="text-base font-semibold text-foreground mb-1">Global Default Mode</h2>
|
||||
<p className="text-sm text-muted mb-4">
|
||||
Users can override this in their personal Settings.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{MODE_OPTIONS.map(({ value, label, icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setSelectedMode(value)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium transition-colors",
|
||||
activeMode === value
|
||||
? "bg-primary/10 border-primary text-primary"
|
||||
: "border-border text-muted hover:text-foreground hover:bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Save button ── */}
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
onClick={() =>
|
||||
saveAppearance.mutate({ theme: activeTheme, default_mode: activeMode })
|
||||
}
|
||||
disabled={saveAppearance.isPending || !isDirty}
|
||||
>
|
||||
{saveAppearance.isPending ? "Saving…" : "Save Appearance"}
|
||||
</Button>
|
||||
{saveAppearance.isError && (
|
||||
<p className="mt-2 text-sm text-red-500">Failed to save.</p>
|
||||
)}
|
||||
{saveAppearance.isSuccess && !isDirty && (
|
||||
<p className="mt-2 text-sm text-green-600">Saved.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Custom themes ── */}
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<h2 className="text-base font-semibold text-foreground">Custom Themes</h2>
|
||||
{!showForm && !editingTheme && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => { setShowForm(true); setEditingTheme(null); setFormError(""); }}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Theme
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted">
|
||||
Create your own colour palettes. Each theme is stored as a file and persists
|
||||
across container restarts.
|
||||
</p>
|
||||
|
||||
{showForm && (
|
||||
<ThemeForm
|
||||
onSave={({ id, label, light, dark }) =>
|
||||
createMutation.mutate({ id, label, light, dark })
|
||||
}
|
||||
onCancel={() => { setShowForm(false); setFormError(""); }}
|
||||
isSaving={createMutation.isPending}
|
||||
error={formError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingTheme && (
|
||||
<ThemeForm
|
||||
initial={editingTheme}
|
||||
onSave={({ label, light, dark }) =>
|
||||
editMutation.mutate({ id: editingTheme.id, data: { label, light, dark } })
|
||||
}
|
||||
onCancel={() => { setEditingTheme(null); setFormError(""); }}
|
||||
isSaving={editMutation.isPending}
|
||||
error={formError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!showForm && !editingTheme && themes.filter((t) => !t.builtin).length === 0 && (
|
||||
<p className="mt-3 text-sm text-muted italic">No custom themes yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,77 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import { Settings, Monitor, Sun, Moon } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getMe, getAppearanceSettings, updateColorMode } from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ColorMode = "system" | "light" | "dark";
|
||||
|
||||
const MODE_OPTIONS: { value: ColorMode; label: string; icon: React.ReactNode }[] = [
|
||||
{ value: "system", label: "System", icon: <Monitor className="h-4 w-4" /> },
|
||||
{ value: "light", label: "Light", icon: <Sun className="h-4 w-4" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
export default function SettingsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
||||
const { data: appearance } = useQuery({
|
||||
queryKey: ["appearance"],
|
||||
queryFn: getAppearanceSettings,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const currentMode: ColorMode = (me?.color_mode as ColorMode) ?? "system";
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: updateColorMode,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["me"] }),
|
||||
});
|
||||
|
||||
const adminDefault = appearance?.default_mode ?? "system";
|
||||
const adminDefaultLabel =
|
||||
adminDefault === "system" ? "system preference" : adminDefault + " mode";
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Settings className="h-6 w-6 text-muted" />
|
||||
<h1 className="text-2xl font-semibold text-foreground">Settings</h1>
|
||||
</div>
|
||||
<p className="text-muted text-sm">
|
||||
User and application settings will be available here in a future update.
|
||||
</p>
|
||||
|
||||
<div className="bg-surface border border-border rounded-lg p-6">
|
||||
<h2 className="text-base font-semibold text-foreground mb-1">Appearance</h2>
|
||||
<p className="text-sm text-muted mb-4">
|
||||
Choose your preferred colour mode. Overrides the site-wide default set by your
|
||||
administrator.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{MODE_OPTIONS.map(({ value, label, icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => mutation.mutate(value)}
|
||||
disabled={mutation.isPending}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium transition-colors",
|
||||
currentMode === value
|
||||
? "bg-primary/10 border-primary text-primary"
|
||||
: "border-border text-muted hover:text-foreground hover:bg-muted/20"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-muted">
|
||||
Site-wide default: <span className="font-medium">{adminDefaultLabel}</span>
|
||||
</p>
|
||||
|
||||
{mutation.isError && (
|
||||
<p className="mt-2 text-sm text-red-500">Failed to save preference.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user