colorThemes #1

Merged
curo merged 7 commits from colorThemes into main 2026-04-18 11:05:41 +02:00
15 changed files with 1063 additions and 34 deletions
Showing only changes of commit 608b0b7fe8 - Show all commits
+14 -3
View File
@@ -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')
+210
View File
@@ -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()
+2
View File
@@ -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,
+2
View File
@@ -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"
+143 -1
View File
@@ -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))
+14 -2
View File
@@ -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
+12
View File
@@ -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)
+2
View File
@@ -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 />} />
+51
View File
@@ -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;
+8
View File
@@ -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 -3
View File
@@ -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" />
+65 -20
View File
@@ -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 };
} }
+445
View File
@@ -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>
);
}
+67 -5
View File
@@ -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">
</p> <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> </div>
); );
} }