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)
|
│ │ │ ├── config.py ← All settings via pydantic-settings (reads .env)
|
||||||
│ │ │ ├── security.py ← JWT sign/verify (RS256), bcrypt hash/verify
|
│ │ │ ├── security.py ← JWT sign/verify (RS256), bcrypt hash/verify
|
||||||
│ │ │ ├── sanitize.py ← Input sanitization helpers (see Security Standards)
|
│ │ │ ├── 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/
|
│ │ ├── models/
|
||||||
│ │ │ ├── __init__.py ← Imports all models (required for Alembic autogenerate)
|
│ │ │ ├── __init__.py ← Imports all models (required for Alembic autogenerate)
|
||||||
│ │ │ ├── user.py ← User model (see Database Models)
|
│ │ │ ├── user.py ← User model (see Database Models)
|
||||||
@@ -100,11 +100,11 @@ docker compose up --build -d
|
|||||||
│ │ │ └── group.py ← GroupCreate/Update/Out/DetailOut, GroupMemberOut
|
│ │ │ └── group.py ← GroupCreate/Update/Out/DetailOut, GroupMemberOut
|
||||||
│ │ ├── routers/
|
│ │ ├── routers/
|
||||||
│ │ │ ├── auth.py ← POST /register, POST /login
|
│ │ │ ├── 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)
|
│ │ │ ├── profile.py ← GET+PUT /me (profile)
|
||||||
│ │ │ ├── admin.py ← User admin CRUD (admin-only)
|
│ │ │ ├── admin.py ← User admin CRUD (admin-only)
|
||||||
│ │ │ ├── groups.py ← Group CRUD + member management (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)
|
│ │ │ ├── services.py ← GET /services (health status)
|
||||||
│ │ │ ├── categories_proxy.py ← Transparent proxy → doc-service /categories/*
|
│ │ │ ├── categories_proxy.py ← Transparent proxy → doc-service /categories/*
|
||||||
│ │ │ └── documents_proxy.py ← Transparent proxy → doc-service /documents/*
|
│ │ │ └── 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_active` | Boolean | default=True | soft-delete flag |
|
||||||
| `is_superuser` | Boolean | default=False | admin role; never exposed as-is (serialised as `is_admin`) |
|
| `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 |
|
| `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)
|
Relationship: `profile` (one-to-one, cascade all+delete-orphan)
|
||||||
|
|
||||||
@@ -309,6 +310,7 @@ Unique constraint: `(group_id, user_id)`
|
|||||||
| `676084df61d1` | `add_profiles_table` |
|
| `676084df61d1` | `add_profiles_table` |
|
||||||
| `a3f9c2d14e87` | `add_groups_and_group_memberships` |
|
| `a3f9c2d14e87` | `add_groups_and_group_memberships` |
|
||||||
| `c7e8f9a0b1d2` | `add_dashboard_app_ids_to_users` |
|
| `c7e8f9a0b1d2` | `add_dashboard_app_ids_to_users` |
|
||||||
|
| `dd6ad2f2c211` | `add_color_mode_to_users` |
|
||||||
|
|
||||||
**Doc-service**:
|
**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` | user | Current user info → `UserOut` |
|
||||||
| GET | `/api/users/me/preferences` | user | Dashboard pinned app IDs → `{app_ids}` |
|
| 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/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
|
### Profile (`/api/profile`) — authenticated
|
||||||
|
|
||||||
@@ -375,6 +378,12 @@ Unique constraint: `(group_id, user_id)`
|
|||||||
| PATCH | `/api/settings/documents/limits` | Update max PDF size |
|
| PATCH | `/api/settings/documents/limits` | Update max PDF size |
|
||||||
| GET | `/api/settings/system-prompts` | All editable system prompts |
|
| GET | `/api/settings/system-prompts` | All editable system prompts |
|
||||||
| PATCH | `/api/settings/system-prompts/{service_id}` | Update system prompt |
|
| 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
|
### Services (`/api/services`) — authenticated
|
||||||
|
|
||||||
@@ -436,6 +445,7 @@ Unique constraint: `(group_id, user_id)`
|
|||||||
| `/admin` | `AdminPage` (→ `/admin/users`) | AdminRoute |
|
| `/admin` | `AdminPage` (→ `/admin/users`) | AdminRoute |
|
||||||
| `/admin/users` | `AdminUsersPage` | AdminRoute |
|
| `/admin/users` | `AdminUsersPage` | AdminRoute |
|
||||||
| `/admin/groups` | `AdminGroupsPage` | AdminRoute |
|
| `/admin/groups` | `AdminGroupsPage` | AdminRoute |
|
||||||
|
| `/admin/appearance` | `AdminAppearancePage` | AdminRoute |
|
||||||
| `*` | redirect to `/` | — |
|
| `*` | redirect to `/` | — |
|
||||||
|
|
||||||
`PrivateRoute` — checks `token` from `useAuth`, redirects to `/login` if absent.
|
`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` |
|
| Token localStorage key | `"token"` | `useAuth.ts` |
|
||||||
| Health check interval | 30 s | `service_health.py` |
|
| Health check interval | 30 s | `service_health.py` |
|
||||||
| Service poll (frontend) | 30 s | `AppsPage.tsx`, `DashboardPage.tsx` |
|
| 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` |
|
| Max dashboard pinned apps | 50 | `schemas/user.py` |
|
||||||
| App ID max length | 64 chars | `schemas/user.py` |
|
| App ID max length | 64 chars | `schemas/user.py` |
|
||||||
| App ID allowed chars | `[a-zA-Z0-9_\-]` | `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 copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -211,3 +212,212 @@ def _get_service_prompt_defaults(service_id: str) -> dict:
|
|||||||
d = DocServiceSystemPrompts()
|
d = DocServiceSystemPrompts()
|
||||||
return {"system": d.system, "user_template": d.user_template}
|
return {"system": d.system, "user_template": d.user_template}
|
||||||
return {"system": "", "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 import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.core.app_config import seed_builtin_themes
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.routers import admin, auth, categories_proxy, documents_proxy, groups, profile, services, users
|
from app.routers import admin, auth, categories_proxy, documents_proxy, groups, profile, services, users
|
||||||
from app.routers import settings as settings_router
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
await asyncio.to_thread(seed_builtin_themes)
|
||||||
register_services(
|
register_services(
|
||||||
doc_service_url=settings.DOC_SERVICE_URL,
|
doc_service_url=settings.DOC_SERVICE_URL,
|
||||||
ai_service_url=settings.AI_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)
|
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
# List of service IDs pinned to the user's home dashboard.
|
# List of service IDs pinned to the user's home dashboard.
|
||||||
dashboard_app_ids: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
|
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: Mapped["Profile"] = relationship(
|
||||||
"Profile", back_populates="user", uselist=False, cascade="all, delete-orphan"
|
"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/).
|
Config files live on the shared app_config volume (/config/).
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
@@ -12,18 +13,24 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
from app.core.app_config import (
|
from app.core.app_config import (
|
||||||
SYSTEM_PROMPT_SERVICES,
|
SYSTEM_PROMPT_SERVICES,
|
||||||
|
AppearanceConfig,
|
||||||
_merge_api_key,
|
_merge_api_key,
|
||||||
|
delete_theme,
|
||||||
load_ai_service_config,
|
load_ai_service_config,
|
||||||
load_ai_service_config_masked,
|
load_ai_service_config_masked,
|
||||||
load_all_system_prompts,
|
load_all_system_prompts,
|
||||||
|
load_all_themes,
|
||||||
|
load_appearance_config,
|
||||||
load_doc_service_config,
|
load_doc_service_config,
|
||||||
load_doc_service_config_masked,
|
load_doc_service_config_masked,
|
||||||
save_ai_service_config,
|
save_ai_service_config,
|
||||||
save_doc_service_config,
|
save_doc_service_config,
|
||||||
save_service_system_prompts,
|
save_service_system_prompts,
|
||||||
|
save_theme,
|
||||||
|
validate_theme_tokens,
|
||||||
)
|
)
|
||||||
from app.core.config import settings
|
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
|
from app.models.user import User
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -53,6 +60,36 @@ class SystemPromptUpdate(BaseModel):
|
|||||||
user_template: str
|
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 ────────────────────────────────────────────────────────────────
|
# ── AI settings ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -176,3 +213,108 @@ async def update_system_prompt(
|
|||||||
save_service_system_prompts, service_id, body.system, body.user_template
|
save_service_system_prompts, service_id, body.system, body.user_template
|
||||||
)
|
)
|
||||||
return await asyncio.to_thread(load_all_system_prompts)
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.deps import get_current_user
|
from app.deps import get_current_user
|
||||||
from app.models.user import 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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -29,3 +29,15 @@ async def update_preferences(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(current_user)
|
await db.refresh(current_user)
|
||||||
return DashboardPrefsOut(app_ids=current_user.dashboard_app_ids or [])
|
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
|
# validation_alias reads is_superuser from the ORM object; the JSON key
|
||||||
# in the response is the field name "is_admin" (not the alias).
|
# in the response is the field name "is_admin" (not the alias).
|
||||||
is_admin: bool = Field(validation_alias="is_superuser", default=False)
|
is_admin: bool = Field(validation_alias="is_superuser", default=False)
|
||||||
|
color_mode: str | None = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True, "populate_by_name": True}
|
model_config = {"from_attributes": True, "populate_by_name": True}
|
||||||
|
|
||||||
@@ -104,6 +105,17 @@ class DashboardPrefsOut(BaseModel):
|
|||||||
app_ids: list[str]
|
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):
|
class DashboardPrefsUpdate(BaseModel):
|
||||||
app_ids: list[str] = Field(default_factory=list)
|
app_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import AppsPage from "./pages/AppsPage";
|
|||||||
import AdminPage from "./pages/AdminPage";
|
import AdminPage from "./pages/AdminPage";
|
||||||
import AdminUsersPage from "./pages/AdminUsersPage";
|
import AdminUsersPage from "./pages/AdminUsersPage";
|
||||||
import AdminGroupsPage from "./pages/AdminGroupsPage";
|
import AdminGroupsPage from "./pages/AdminGroupsPage";
|
||||||
|
import AdminAppearancePage from "./pages/AdminAppearancePage";
|
||||||
import DocumentsPage from "./pages/DocumentsPage";
|
import DocumentsPage from "./pages/DocumentsPage";
|
||||||
import DocumentAdminSettingsPage from "./pages/DocumentAdminSettingsPage";
|
import DocumentAdminSettingsPage from "./pages/DocumentAdminSettingsPage";
|
||||||
import AIAdminSettingsPage from "./pages/AIAdminSettingsPage";
|
import AIAdminSettingsPage from "./pages/AIAdminSettingsPage";
|
||||||
@@ -57,6 +58,7 @@ export default function App() {
|
|||||||
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
|
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
|
||||||
<Route path="/admin/users" element={<AdminRoute><AdminUsersPage /></AdminRoute>} />
|
<Route path="/admin/users" element={<AdminRoute><AdminUsersPage /></AdminRoute>} />
|
||||||
<Route path="/admin/groups" element={<AdminRoute><AdminGroupsPage /></AdminRoute>} />
|
<Route path="/admin/groups" element={<AdminRoute><AdminGroupsPage /></AdminRoute>} />
|
||||||
|
<Route path="/admin/appearance" element={<AdminRoute><AdminAppearancePage /></AdminRoute>} />
|
||||||
|
|
||||||
{/* Catch-all */}
|
{/* Catch-all */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface UserData {
|
|||||||
full_name: string | null;
|
full_name: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
|
color_mode: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getMe = () => api.get<UserData>("/users/me").then((r) => r.data);
|
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) =>
|
export const deleteCategory = (id: string) =>
|
||||||
api.delete(`/documents/categories/${id}`);
|
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) ---
|
// --- Settings (admin only) ---
|
||||||
export interface AIProviderUpdate {
|
export interface AIProviderUpdate {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
Users,
|
Users,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
|
Palette,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import ThemeToggle from "@/components/ThemeToggle";
|
import ThemeToggle from "@/components/ThemeToggle";
|
||||||
@@ -265,6 +266,13 @@ export default function Sidebar() {
|
|||||||
<UsersRound className="h-4 w-4 shrink-0" />
|
<UsersRound className="h-4 w-4 shrink-0" />
|
||||||
<span className="whitespace-nowrap">Groups</span>
|
<span className="whitespace-nowrap">Groups</span>
|
||||||
</NavLink>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { useTheme } from "@/hooks/useTheme";
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
|
|
||||||
export default function ThemeToggle() {
|
export default function ThemeToggle() {
|
||||||
const { theme, toggleTheme } = useTheme();
|
const { isDark, toggleTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleTheme}
|
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" />
|
<Sun className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
<Moon 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) {
|
function applyThemeTokens(colors: ThemeColors) {
|
||||||
if (theme === "dark") {
|
const el = document.documentElement;
|
||||||
document.documentElement.classList.add("dark");
|
el.style.setProperty("--color-primary", colors.primary);
|
||||||
} else {
|
el.style.setProperty("--color-primary-hover", colors.primary_hover);
|
||||||
document.documentElement.classList.remove("dark");
|
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() {
|
export function useTheme() {
|
||||||
const [theme, setTheme] = useState<Theme>(() => {
|
const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe });
|
||||||
const stored = localStorage.getItem("theme") as Theme | null;
|
const { data: appearance } = useQuery({
|
||||||
if (stored === "light" || stored === "dark") return stored;
|
queryKey: ["appearance"],
|
||||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
queryFn: getAppearanceSettings,
|
||||||
? "dark"
|
staleTime: 5 * 60 * 1000,
|
||||||
: "light";
|
});
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
applyTheme(theme);
|
const activeId = appearance?.theme ?? localStorage.getItem("themePalette") ?? "default";
|
||||||
localStorage.setItem("theme", theme);
|
const theme =
|
||||||
}, [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(() => {
|
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() {
|
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 (
|
return (
|
||||||
<div className="p-8 max-w-2xl mx-auto">
|
<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" />
|
<Settings className="h-6 w-6 text-muted" />
|
||||||
<h1 className="text-2xl font-semibold text-foreground">Settings</h1>
|
<h1 className="text-2xl font-semibold text-foreground">Settings</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted text-sm">
|
|
||||||
User and application settings will be available here in a future update.
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user