diff --git a/CLAUDE.md b/CLAUDE.md index 93d0e9b..f84e66d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ docker compose up --build -d │ │ │ ├── config.py ← All settings via pydantic-settings (reads .env) │ │ │ ├── security.py ← JWT sign/verify (RS256), bcrypt hash/verify │ │ │ ├── sanitize.py ← Input sanitization helpers (see Security Standards) -│ │ │ └── app_config.py ← Per-service config load/save to /config volume +│ │ │ └── app_config.py ← Per-service config load/save to /config volume; theme files in /config/themes/ │ │ ├── models/ │ │ │ ├── __init__.py ← Imports all models (required for Alembic autogenerate) │ │ │ ├── user.py ← User model (see Database Models) @@ -100,11 +100,11 @@ docker compose up --build -d │ │ │ └── group.py ← GroupCreate/Update/Out/DetailOut, GroupMemberOut │ │ ├── routers/ │ │ │ ├── auth.py ← POST /register, POST /login -│ │ │ ├── users.py ← GET /me, GET+PATCH /me/preferences +│ │ │ ├── users.py ← GET /me, GET+PATCH /me/preferences, PATCH /me/color-mode │ │ │ ├── profile.py ← GET+PUT /me (profile) │ │ │ ├── admin.py ← User admin CRUD (admin-only) │ │ │ ├── groups.py ← Group CRUD + member management (admin-only) -│ │ │ ├── settings.py ← AI, doc limits, system prompts (admin-only) +│ │ │ ├── settings.py ← AI, doc limits, system prompts, appearance, themes (admin-only) │ │ │ ├── services.py ← GET /services (health status) │ │ │ ├── categories_proxy.py ← Transparent proxy → doc-service /categories/* │ │ │ └── documents_proxy.py ← Transparent proxy → doc-service /documents/* @@ -227,6 +227,7 @@ Browser (:5173 dev / :80 prod) | `is_active` | Boolean | default=True | soft-delete flag | | `is_superuser` | Boolean | default=False | admin role; never exposed as-is (serialised as `is_admin`) | | `dashboard_app_ids` | JSON | NOT NULL, default=[] | list of pinned service IDs | +| `color_mode` | String | nullable, default=NULL | user's preferred mode: "light" / "dark" / "system" / NULL (use admin default) | Relationship: `profile` (one-to-one, cascade all+delete-orphan) @@ -309,6 +310,7 @@ Unique constraint: `(group_id, user_id)` | `676084df61d1` | `add_profiles_table` | | `a3f9c2d14e87` | `add_groups_and_group_memberships` | | `c7e8f9a0b1d2` | `add_dashboard_app_ids_to_users` | +| `dd6ad2f2c211` | `add_color_mode_to_users` | **Doc-service**: @@ -335,6 +337,7 @@ Unique constraint: `(group_id, user_id)` | GET | `/api/users/me` | user | Current user info → `UserOut` | | GET | `/api/users/me/preferences` | user | Dashboard pinned app IDs → `{app_ids}` | | PATCH | `/api/users/me/preferences` | user | Save pinned app IDs (max 50, slug-safe) | +| PATCH | `/api/users/me/color-mode` | user | Save colour mode preference ("light"/"dark"/"system") | ### Profile (`/api/profile`) — authenticated @@ -375,6 +378,12 @@ Unique constraint: `(group_id, user_id)` | PATCH | `/api/settings/documents/limits` | Update max PDF size | | GET | `/api/settings/system-prompts` | All editable system prompts | | PATCH | `/api/settings/system-prompts/{service_id}` | Update system prompt | +| GET | `/api/settings/appearance` | Active theme + default mode (auth) | +| PATCH | `/api/settings/appearance` | Update active theme + default mode (admin) | +| GET | `/api/settings/themes` | List all themes — built-in + custom (auth) | +| POST | `/api/settings/themes` | Create custom theme (admin) | +| PATCH | `/api/settings/themes/{id}` | Update custom theme label/colours (admin) | +| DELETE | `/api/settings/themes/{id}` | Delete custom theme (admin, 204) | ### Services (`/api/services`) — authenticated @@ -436,6 +445,7 @@ Unique constraint: `(group_id, user_id)` | `/admin` | `AdminPage` (→ `/admin/users`) | AdminRoute | | `/admin/users` | `AdminUsersPage` | AdminRoute | | `/admin/groups` | `AdminGroupsPage` | AdminRoute | +| `/admin/appearance` | `AdminAppearancePage` | AdminRoute | | `*` | redirect to `/` | — | `PrivateRoute` — checks `token` from `useAuth`, redirects to `/login` if absent. @@ -669,6 +679,7 @@ Use `validation_alias` when the ORM field name differs from the JSON key (e.g., | Token localStorage key | `"token"` | `useAuth.ts` | | Health check interval | 30 s | `service_health.py` | | Service poll (frontend) | 30 s | `AppsPage.tsx`, `DashboardPage.tsx` | +| User `color_mode` default | NULL (falls back to admin default_mode, then system) | `models/user.py` | | Max dashboard pinned apps | 50 | `schemas/user.py` | | App ID max length | 64 chars | `schemas/user.py` | | App ID allowed chars | `[a-zA-Z0-9_\-]` | `schemas/user.py` | diff --git a/backend/alembic/versions/dd6ad2f2c211_add_color_mode_to_users.py b/backend/alembic/versions/dd6ad2f2c211_add_color_mode_to_users.py new file mode 100644 index 0000000..6f118ae --- /dev/null +++ b/backend/alembic/versions/dd6ad2f2c211_add_color_mode_to_users.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') diff --git a/backend/app/core/app_config.py b/backend/app/core/app_config.py index 4802bae..bd7b462 100644 --- a/backend/app/core/app_config.py +++ b/backend/app/core/app_config.py @@ -10,6 +10,7 @@ services never read a partial file. import copy import json import os +import re from pathlib import Path from pydantic import BaseModel @@ -211,3 +212,212 @@ def _get_service_prompt_defaults(service_id: str) -> dict: d = DocServiceSystemPrompts() return {"system": d.system, "user_template": d.user_template} return {"system": "", "user_template": ""} + + +# ── Appearance config ────────────────────────────────────────────────────────── + +class AppearanceConfig(BaseModel): + theme: str = "default" + default_mode: str = "system" + + +def load_appearance_config() -> AppearanceConfig: + path = _CONFIG_DIR / "appearance_config.json" + if not path.exists(): + return AppearanceConfig() + with path.open() as f: + return AppearanceConfig.model_validate(json.load(f)) + + +def save_appearance_config(config: AppearanceConfig) -> None: + path = _CONFIG_DIR / "appearance_config.json" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(config.model_dump(), indent=2)) + os.replace(tmp, path) + + +# ── Theme file management ────────────────────────────────────────────────────── + +_THEMES_DIR = _CONFIG_DIR / "themes" + +# 9 required colour tokens per mode +_REQUIRED_TOKENS = frozenset({ + "primary", "primary_hover", "accent", "accent_hover", + "background", "surface", "border", "text_primary", "text_muted", +}) + +_RGB_RE = re.compile(r"^\d{1,3} \d{1,3} \d{1,3}$") + +_BUILTIN_THEMES: list[dict] = [ + { + "id": "default", + "label": "Default", + "builtin": True, + "light": { + "primary": "37 99 235", + "primary_hover": "29 78 216", + "accent": "234 179 8", + "accent_hover": "202 138 4", + "background": "248 250 252", + "surface": "255 255 255", + "border": "226 232 240", + "text_primary": "15 23 42", + "text_muted": "100 116 139", + }, + "dark": { + "primary": "59 130 246", + "primary_hover": "37 99 235", + "accent": "250 204 21", + "accent_hover": "234 179 8", + "background": "15 23 42", + "surface": "30 41 59", + "border": "51 65 85", + "text_primary": "203 213 225", + "text_muted": "148 163 184", + }, + }, + { + "id": "pastel", + "label": "Pastel", + "builtin": True, + "light": { + "primary": "124 58 237", + "primary_hover": "109 40 217", + "accent": "236 72 153", + "accent_hover": "219 39 119", + "background": "253 244 255", + "surface": "250 245 255", + "border": "233 213 255", + "text_primary": "30 27 75", + "text_muted": "107 114 128", + }, + "dark": { + "primary": "167 139 250", + "primary_hover": "196 181 253", + "accent": "244 114 182", + "accent_hover": "251 164 200", + "background": "30 20 51", + "surface": "45 27 78", + "border": "76 53 117", + "text_primary": "233 213 255", + "text_muted": "196 181 253", + }, + }, + { + "id": "high-contrast", + "label": "High Contrast", + "builtin": True, + "light": { + "primary": "30 58 138", + "primary_hover": "30 64 175", + "accent": "220 38 38", + "accent_hover": "185 28 28", + "background": "255 255 255", + "surface": "255 255 255", + "border": "156 163 175", + "text_primary": "0 0 0", + "text_muted": "75 85 99", + }, + "dark": { + "primary": "96 165 250", + "primary_hover": "147 197 253", + "accent": "248 113 113", + "accent_hover": "252 165 165", + "background": "0 0 0", + "surface": "10 10 10", + "border": "55 65 81", + "text_primary": "255 255 255", + "text_muted": "156 163 175", + }, + }, + { + "id": "ocean", + "label": "Ocean Blue", + "builtin": True, + "light": { + "primary": "29 78 216", + "primary_hover": "30 58 138", + "accent": "8 145 178", + "accent_hover": "14 116 144", + "background": "239 246 255", + "surface": "219 234 254", + "border": "147 197 253", + "text_primary": "30 58 138", + "text_muted": "59 130 246", + }, + "dark": { + "primary": "96 165 250", + "primary_hover": "147 197 253", + "accent": "34 211 238", + "accent_hover": "103 232 249", + "background": "10 22 40", + "surface": "15 36 68", + "border": "29 78 216", + "text_primary": "219 234 254", + "text_muted": "147 197 253", + }, + }, +] + + +def seed_builtin_themes() -> None: + """Create /config/themes/ and write built-in theme files if missing.""" + _THEMES_DIR.mkdir(parents=True, exist_ok=True) + for theme in _BUILTIN_THEMES: + path = _THEMES_DIR / f"{theme['id']}.json" + if not path.exists(): + path.write_text(json.dumps(theme, indent=2)) + + +def load_all_themes() -> list[dict]: + """Return all themes from /config/themes/*.json, built-ins first.""" + if not _THEMES_DIR.exists(): + seed_builtin_themes() + themes = [] + for f in sorted(_THEMES_DIR.glob("*.json")): + try: + themes.append(json.loads(f.read_text())) + except (json.JSONDecodeError, OSError): + pass + # Sort: built-ins first (preserving their original order), then custom by label + builtin_ids = [t["id"] for t in _BUILTIN_THEMES] + def sort_key(t: dict) -> tuple: + tid = t.get("id", "") + try: + return (0, builtin_ids.index(tid)) + except ValueError: + return (1, t.get("label", tid).lower()) + return sorted(themes, key=sort_key) + + +def validate_theme_tokens(colors: dict) -> list[str]: + """Return a list of validation error messages, empty if valid.""" + errors = [] + missing = _REQUIRED_TOKENS - set(colors.keys()) + if missing: + errors.append(f"Missing tokens: {', '.join(sorted(missing))}") + for key, val in colors.items(): + if key in _REQUIRED_TOKENS and not _RGB_RE.match(str(val)): + errors.append(f"Token '{key}' must be an RGB triplet like '37 99 235', got: {val!r}") + return errors + + +def save_theme(theme: dict) -> None: + """Write a theme file atomically.""" + _THEMES_DIR.mkdir(parents=True, exist_ok=True) + path = _THEMES_DIR / f"{theme['id']}.json" + tmp = path.with_suffix(".tmp") + tmp.write_text(json.dumps(theme, indent=2)) + os.replace(tmp, path) + + +def delete_theme(theme_id: str) -> None: + """Delete a custom theme file. Raises ValueError for built-ins, FileNotFoundError if missing.""" + path = _THEMES_DIR / f"{theme_id}.json" + if not path.exists(): + raise FileNotFoundError(theme_id) + data = json.loads(path.read_text()) + if data.get("builtin"): + raise ValueError("Cannot delete a built-in theme") + path.unlink() diff --git a/backend/app/main.py b/backend/app/main.py index 176a1cd..a26426b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app.core.app_config import seed_builtin_themes from app.core.config import settings from app.routers import admin, auth, categories_proxy, documents_proxy, groups, profile, services, users from app.routers import settings as settings_router @@ -12,6 +13,7 @@ from app.services.service_health import check_all, health_check_loop, register_s @asynccontextmanager async def lifespan(app: FastAPI): + await asyncio.to_thread(seed_builtin_themes) register_services( doc_service_url=settings.DOC_SERVICE_URL, ai_service_url=settings.AI_SERVICE_URL, diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 469b61e..72a149d 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -23,6 +23,8 @@ class User(Base): is_superuser: Mapped[bool] = mapped_column(Boolean, default=False) # List of service IDs pinned to the user's home dashboard. dashboard_app_ids: Mapped[list] = mapped_column(JSON, nullable=False, default=list) + # User's preferred colour mode: "light", "dark", "system", or None (use admin default). + color_mode: Mapped[str | None] = mapped_column(String, nullable=True, default=None) profile: Mapped["Profile"] = relationship( "Profile", back_populates="user", uselist=False, cascade="all, delete-orphan" diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index c89e191..027b540 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -5,6 +5,7 @@ All endpoints require the caller to be an admin (Depends(get_current_admin)). Config files live on the shared app_config volume (/config/). """ import asyncio +import json import httpx from fastapi import APIRouter, Depends, HTTPException @@ -12,18 +13,24 @@ from pydantic import BaseModel from app.core.app_config import ( SYSTEM_PROMPT_SERVICES, + AppearanceConfig, _merge_api_key, + delete_theme, load_ai_service_config, load_ai_service_config_masked, load_all_system_prompts, + load_all_themes, + load_appearance_config, load_doc_service_config, load_doc_service_config_masked, save_ai_service_config, save_doc_service_config, save_service_system_prompts, + save_theme, + validate_theme_tokens, ) from app.core.config import settings -from app.deps import get_current_admin +from app.deps import get_current_admin, get_current_user from app.models.user import User router = APIRouter() @@ -53,6 +60,36 @@ class SystemPromptUpdate(BaseModel): user_template: str +class AppearanceUpdate(BaseModel): + theme: str + default_mode: str + + +class ThemeColors(BaseModel): + primary: str + primary_hover: str + accent: str + accent_hover: str + background: str + surface: str + border: str + text_primary: str + text_muted: str + + +class ThemeCreate(BaseModel): + id: str + label: str + light: ThemeColors + dark: ThemeColors + + +class ThemeUpdate(BaseModel): + label: str | None = None + light: ThemeColors | None = None + dark: ThemeColors | None = None + + # ── AI settings ──────────────────────────────────────────────────────────────── @@ -176,3 +213,108 @@ async def update_system_prompt( save_service_system_prompts, service_id, body.system, body.user_template ) return await asyncio.to_thread(load_all_system_prompts) + + +# ── Appearance (global default — auth read, admin write) ─────────────────────── + +import re as _re +_THEME_ID_RE = _re.compile(r"^[a-z0-9_-]{1,64}$") + + +@router.get("/appearance") +async def get_appearance( + _: User = Depends(get_current_user), +) -> dict: + config = await asyncio.to_thread(load_appearance_config) + return config.model_dump() + + +@router.patch("/appearance") +async def update_appearance( + body: AppearanceUpdate, + _: User = Depends(get_current_admin), +) -> dict: + if body.default_mode not in ("light", "dark", "system"): + raise HTTPException(status_code=422, detail="default_mode must be 'light', 'dark', or 'system'") + themes = await asyncio.to_thread(load_all_themes) + theme_ids = {t["id"] for t in themes} + if body.theme not in theme_ids: + raise HTTPException(status_code=422, detail=f"Unknown theme: {body.theme!r}") + config = AppearanceConfig(theme=body.theme, default_mode=body.default_mode) + await asyncio.to_thread(save_appearance_config, config) + return config.model_dump() + + +# ── Theme CRUD ───────────────────────────────────────────────────────────────── + + +@router.get("/themes") +async def list_themes( + _: User = Depends(get_current_user), +) -> list: + return await asyncio.to_thread(load_all_themes) + + +@router.post("/themes", status_code=201) +async def create_theme( + body: ThemeCreate, + _: User = Depends(get_current_admin), +) -> dict: + if not _THEME_ID_RE.match(body.id): + raise HTTPException(status_code=422, detail="Theme ID must match [a-z0-9_-]{1,64}") + existing = {t["id"] for t in await asyncio.to_thread(load_all_themes)} + if body.id in existing: + raise HTTPException(status_code=400, detail=f"Theme ID already in use: {body.id!r}") + light = body.light.model_dump() + dark = body.dark.model_dump() + for mode, colors in (("light", light), ("dark", dark)): + errors = validate_theme_tokens(colors) + if errors: + raise HTTPException(status_code=422, detail=f"{mode}: {'; '.join(errors)}") + theme = {"id": body.id, "label": body.label, "builtin": False, "light": light, "dark": dark} + await asyncio.to_thread(save_theme, theme) + return theme + + +@router.patch("/themes/{theme_id}") +async def update_theme( + theme_id: str, + body: ThemeUpdate, + _: User = Depends(get_current_admin), +) -> dict: + from app.core.app_config import _THEMES_DIR + path = _THEMES_DIR / f"{theme_id}.json" + if not path.exists(): + raise HTTPException(status_code=404, detail="Theme not found") + theme = json.loads(path.read_text()) + if theme.get("builtin"): + raise HTTPException(status_code=400, detail="Cannot edit a built-in theme") + if body.label is not None: + theme["label"] = body.label + if body.light is not None: + light = body.light.model_dump() + errors = validate_theme_tokens(light) + if errors: + raise HTTPException(status_code=422, detail=f"light: {'; '.join(errors)}") + theme["light"] = light + if body.dark is not None: + dark = body.dark.model_dump() + errors = validate_theme_tokens(dark) + if errors: + raise HTTPException(status_code=422, detail=f"dark: {'; '.join(errors)}") + theme["dark"] = dark + await asyncio.to_thread(save_theme, theme) + return theme + + +@router.delete("/themes/{theme_id}", status_code=204) +async def remove_theme( + theme_id: str, + _: User = Depends(get_current_admin), +) -> None: + try: + await asyncio.to_thread(delete_theme, theme_id) + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Theme not found") + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 29679d0..6176c12 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,10 +1,10 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.deps import get_current_user from app.models.user import User -from app.schemas.user import DashboardPrefsOut, DashboardPrefsUpdate, UserOut +from app.schemas.user import ColorModeUpdate, DashboardPrefsOut, DashboardPrefsUpdate, UserOut router = APIRouter() @@ -29,3 +29,15 @@ async def update_preferences( await db.commit() await db.refresh(current_user) return DashboardPrefsOut(app_ids=current_user.dashboard_app_ids or []) + + +@router.patch("/me/color-mode", response_model=UserOut) +async def update_color_mode( + body: ColorModeUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + current_user.color_mode = body.color_mode + await db.commit() + await db.refresh(current_user) + return current_user diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 3c77bc6..a880e48 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -71,6 +71,7 @@ class UserOut(BaseModel): # validation_alias reads is_superuser from the ORM object; the JSON key # in the response is the field name "is_admin" (not the alias). is_admin: bool = Field(validation_alias="is_superuser", default=False) + color_mode: str | None = None model_config = {"from_attributes": True, "populate_by_name": True} @@ -104,6 +105,17 @@ class DashboardPrefsOut(BaseModel): app_ids: list[str] +class ColorModeUpdate(BaseModel): + color_mode: str + + @field_validator("color_mode") + @classmethod + def validate_mode(cls, v: str) -> str: + if v not in ("light", "dark", "system"): + raise ValueError("color_mode must be 'light', 'dark', or 'system'") + return v + + class DashboardPrefsUpdate(BaseModel): app_ids: list[str] = Field(default_factory=list) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8b90b32..5678127 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import AppsPage from "./pages/AppsPage"; import AdminPage from "./pages/AdminPage"; import AdminUsersPage from "./pages/AdminUsersPage"; import AdminGroupsPage from "./pages/AdminGroupsPage"; +import AdminAppearancePage from "./pages/AdminAppearancePage"; import DocumentsPage from "./pages/DocumentsPage"; import DocumentAdminSettingsPage from "./pages/DocumentAdminSettingsPage"; import AIAdminSettingsPage from "./pages/AIAdminSettingsPage"; @@ -57,6 +58,7 @@ export default function App() { } /> } /> } /> + } /> {/* Catch-all */} } /> diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 63045a8..78ed3b6 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -26,6 +26,7 @@ export interface UserData { full_name: string | null; is_active: boolean; is_admin: boolean; + color_mode: string | null; } export const getMe = () => api.get("/users/me").then((r) => r.data); @@ -202,6 +203,56 @@ export const renameCategory = (id: string, name: string) => export const deleteCategory = (id: string) => api.delete(`/documents/categories/${id}`); +// --- Appearance & Themes --- +export interface ThemeColors { + primary: string; + primary_hover: string; + accent: string; + accent_hover: string; + background: string; + surface: string; + border: string; + text_primary: string; + text_muted: string; +} + +export interface ThemeDefinition { + id: string; + label: string; + builtin: boolean; + light: ThemeColors; + dark: ThemeColors; +} + +export interface AppearanceSettings { + theme: string; + default_mode: string; +} + +export const getAppearanceSettings = (): Promise => + api.get("/settings/appearance").then((r) => r.data); + +export const updateAppearanceSettings = (data: AppearanceSettings): Promise => + api.patch("/settings/appearance", data).then((r) => r.data); + +export const getThemes = (): Promise => + api.get("/settings/themes").then((r) => r.data); + +export const createTheme = (data: Omit): Promise => + api.post("/settings/themes", data).then((r) => r.data); + +export const updateTheme = ( + id: string, + data: { label?: string; light?: ThemeColors; dark?: ThemeColors } +): Promise => + api.patch(`/settings/themes/${id}`, data).then((r) => r.data); + +export const deleteTheme = (id: string): Promise => + api.delete(`/settings/themes/${id}`).then((r) => r.data); + +export const updateColorMode = (color_mode: string): Promise => + api.patch("/users/me/color-mode", { color_mode }).then((r) => r.data); + // --- Settings (admin only) --- export interface AIProviderUpdate { provider: string; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 6f1ddfc..7cdb440 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { Folder, Users, UsersRound, + Palette, } from "lucide-react"; import { Button } from "@/components/ui/button"; import ThemeToggle from "@/components/ThemeToggle"; @@ -265,6 +266,13 @@ export default function Sidebar() { Groups + subItemClass(isActive)} + > + + Appearance + )} diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index e5e56dc..96d0de6 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -3,16 +3,16 @@ import { Button } from "@/components/ui/button"; import { useTheme } from "@/hooks/useTheme"; export default function ThemeToggle() { - const { theme, toggleTheme } = useTheme(); + const { isDark, toggleTheme } = useTheme(); return ( + + + )} + + ); +} + +// ── Color editor ─────────────────────────────────────────────────────────────── + +function ColorsEditor({ + label, + colors, + onChange, +}: { + label: string; + colors: ThemeColors; + onChange: (colors: ThemeColors) => void; +}) { + return ( +
+

{label}

+
+ {TOKEN_LABELS.map(({ key, label: tokenLabel }) => ( +
+ + + onChange({ ...colors, [key]: hexToRgb(e.target.value) }) + } + className="h-8 w-full rounded border border-border cursor-pointer" + title={colors[key]} + /> +
+ ))} +
+
+ ); +} + +// ── 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(initial?.light ?? EMPTY_COLORS); + const [dark, setDark] = useState(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 ( +
+
+

+ {isEditing ? "Edit Theme" : "New Theme"} +

+ +
+ +
+ {!isEditing && ( +
+ + setId(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""))} + placeholder="my-theme" + maxLength={64} + className="w-full" + /> +
+ )} +
+ + setLabel(e.target.value)} + placeholder="My Theme" + className="w-full" + /> +
+
+ +
+ + +
+ + {error &&

{error}

} + +
+ + +
+
+ ); +} + +// ── 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(null); + const [selectedMode, setSelectedMode] = useState(null); + const [showForm, setShowForm] = useState(false); + const [editingTheme, setEditingTheme] = useState(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[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 ( +
+
+ +

Appearance

+
+ + {/* ── Theme selector ── */} +
+

Colour Theme

+

+ Select the colour palette applied site-wide. Custom themes can be created below. +

+ +
+ {themes.map((theme) => ( + setSelectedTheme(theme.id)} + onEdit={() => { setEditingTheme(theme); setShowForm(false); setFormError(""); }} + onDelete={() => { + if (confirm(`Delete "${theme.label}"?`)) deleteMutation.mutate(theme.id); + }} + /> + ))} +
+
+ + {/* ── Default mode ── */} +
+

Global Default Mode

+

+ Users can override this in their personal Settings. +

+ +
+ {MODE_OPTIONS.map(({ value, label, icon }) => ( + + ))} +
+
+ + {/* ── Save button ── */} +
+ + {saveAppearance.isError && ( +

Failed to save.

+ )} + {saveAppearance.isSuccess && !isDirty && ( +

Saved.

+ )} +
+ + {/* ── Custom themes ── */} +
+
+

Custom Themes

+ {!showForm && !editingTheme && ( + + )} +
+

+ Create your own colour palettes. Each theme is stored as a file and persists + across container restarts. +

+ + {showForm && ( + + createMutation.mutate({ id, label, light, dark }) + } + onCancel={() => { setShowForm(false); setFormError(""); }} + isSaving={createMutation.isPending} + error={formError} + /> + )} + + {editingTheme && ( + + 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 && ( +

No custom themes yet.

+ )} +
+
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 2a1fde7..e3f9a24 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -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: }, + { value: "light", label: "Light", icon: }, + { value: "dark", label: "Dark", icon: }, +]; 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 (
-
+

Settings

-

- User and application settings will be available here in a future update. -

+ +
+

Appearance

+

+ Choose your preferred colour mode. Overrides the site-wide default set by your + administrator. +

+ +
+ {MODE_OPTIONS.map(({ value, label, icon }) => ( + + ))} +
+ +

+ Site-wide default: {adminDefaultLabel} +

+ + {mutation.isError && ( +

Failed to save preference.

+ )} +
); }