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:
curo1305
2026-04-18 01:46:17 +02:00
parent da9b911f1e
commit 608b0b7fe8
15 changed files with 1063 additions and 34 deletions
+14 -3
View File
@@ -88,7 +88,7 @@ docker compose up --build -d
│ │ │ ├── config.py ← All settings via pydantic-settings (reads .env)
│ │ │ ├── security.py ← JWT sign/verify (RS256), bcrypt hash/verify
│ │ │ ├── sanitize.py ← Input sanitization helpers (see Security Standards)
│ │ │ └── app_config.py ← Per-service config load/save to /config volume
│ │ │ └── app_config.py ← Per-service config load/save to /config volume; theme files in /config/themes/
│ │ ├── models/
│ │ │ ├── __init__.py ← Imports all models (required for Alembic autogenerate)
│ │ │ ├── user.py ← User model (see Database Models)
@@ -100,11 +100,11 @@ docker compose up --build -d
│ │ │ └── group.py ← GroupCreate/Update/Out/DetailOut, GroupMemberOut
│ │ ├── routers/
│ │ │ ├── auth.py ← POST /register, POST /login
│ │ │ ├── users.py ← GET /me, GET+PATCH /me/preferences
│ │ │ ├── users.py ← GET /me, GET+PATCH /me/preferences, PATCH /me/color-mode
│ │ │ ├── profile.py ← GET+PUT /me (profile)
│ │ │ ├── admin.py ← User admin CRUD (admin-only)
│ │ │ ├── groups.py ← Group CRUD + member management (admin-only)
│ │ │ ├── settings.py ← AI, doc limits, system prompts (admin-only)
│ │ │ ├── settings.py ← AI, doc limits, system prompts, appearance, themes (admin-only)
│ │ │ ├── services.py ← GET /services (health status)
│ │ │ ├── categories_proxy.py ← Transparent proxy → doc-service /categories/*
│ │ │ └── documents_proxy.py ← Transparent proxy → doc-service /documents/*
@@ -227,6 +227,7 @@ Browser (:5173 dev / :80 prod)
| `is_active` | Boolean | default=True | soft-delete flag |
| `is_superuser` | Boolean | default=False | admin role; never exposed as-is (serialised as `is_admin`) |
| `dashboard_app_ids` | JSON | NOT NULL, default=[] | list of pinned service IDs |
| `color_mode` | String | nullable, default=NULL | user's preferred mode: "light" / "dark" / "system" / NULL (use admin default) |
Relationship: `profile` (one-to-one, cascade all+delete-orphan)
@@ -309,6 +310,7 @@ Unique constraint: `(group_id, user_id)`
| `676084df61d1` | `add_profiles_table` |
| `a3f9c2d14e87` | `add_groups_and_group_memberships` |
| `c7e8f9a0b1d2` | `add_dashboard_app_ids_to_users` |
| `dd6ad2f2c211` | `add_color_mode_to_users` |
**Doc-service**:
@@ -335,6 +337,7 @@ Unique constraint: `(group_id, user_id)`
| GET | `/api/users/me` | user | Current user info → `UserOut` |
| GET | `/api/users/me/preferences` | user | Dashboard pinned app IDs → `{app_ids}` |
| PATCH | `/api/users/me/preferences` | user | Save pinned app IDs (max 50, slug-safe) |
| PATCH | `/api/users/me/color-mode` | user | Save colour mode preference ("light"/"dark"/"system") |
### Profile (`/api/profile`) — authenticated
@@ -375,6 +378,12 @@ Unique constraint: `(group_id, user_id)`
| PATCH | `/api/settings/documents/limits` | Update max PDF size |
| GET | `/api/settings/system-prompts` | All editable system prompts |
| PATCH | `/api/settings/system-prompts/{service_id}` | Update system prompt |
| GET | `/api/settings/appearance` | Active theme + default mode (auth) |
| PATCH | `/api/settings/appearance` | Update active theme + default mode (admin) |
| GET | `/api/settings/themes` | List all themes — built-in + custom (auth) |
| POST | `/api/settings/themes` | Create custom theme (admin) |
| PATCH | `/api/settings/themes/{id}` | Update custom theme label/colours (admin) |
| DELETE | `/api/settings/themes/{id}` | Delete custom theme (admin, 204) |
### Services (`/api/services`) — authenticated
@@ -436,6 +445,7 @@ Unique constraint: `(group_id, user_id)`
| `/admin` | `AdminPage` (→ `/admin/users`) | AdminRoute |
| `/admin/users` | `AdminUsersPage` | AdminRoute |
| `/admin/groups` | `AdminGroupsPage` | AdminRoute |
| `/admin/appearance` | `AdminAppearancePage` | AdminRoute |
| `*` | redirect to `/` | — |
`PrivateRoute` — checks `token` from `useAuth`, redirects to `/login` if absent.
@@ -669,6 +679,7 @@ Use `validation_alias` when the ORM field name differs from the JSON key (e.g.,
| Token localStorage key | `"token"` | `useAuth.ts` |
| Health check interval | 30 s | `service_health.py` |
| Service poll (frontend) | 30 s | `AppsPage.tsx`, `DashboardPage.tsx` |
| User `color_mode` default | NULL (falls back to admin default_mode, then system) | `models/user.py` |
| Max dashboard pinned apps | 50 | `schemas/user.py` |
| App ID max length | 64 chars | `schemas/user.py` |
| App ID allowed chars | `[a-zA-Z0-9_\-]` | `schemas/user.py` |
@@ -0,0 +1,25 @@
"""add_color_mode_to_users
Revision ID: dd6ad2f2c211
Revises: c7e8f9a0b1d2
Create Date: 2026-04-17 23:42:58.222958
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = 'dd6ad2f2c211'
down_revision: Union[str, None] = 'c7e8f9a0b1d2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('color_mode', sa.String(), nullable=True))
def downgrade() -> None:
op.drop_column('users', 'color_mode')
+210
View File
@@ -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()
+2
View File
@@ -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,
+2
View File
@@ -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"
+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/).
"""
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))
+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 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
+12
View File
@@ -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)
+2
View File
@@ -10,6 +10,7 @@ import AppsPage from "./pages/AppsPage";
import AdminPage from "./pages/AdminPage";
import AdminUsersPage from "./pages/AdminUsersPage";
import AdminGroupsPage from "./pages/AdminGroupsPage";
import AdminAppearancePage from "./pages/AdminAppearancePage";
import DocumentsPage from "./pages/DocumentsPage";
import DocumentAdminSettingsPage from "./pages/DocumentAdminSettingsPage";
import AIAdminSettingsPage from "./pages/AIAdminSettingsPage";
@@ -57,6 +58,7 @@ export default function App() {
<Route path="/admin" element={<AdminRoute><AdminPage /></AdminRoute>} />
<Route path="/admin/users" element={<AdminRoute><AdminUsersPage /></AdminRoute>} />
<Route path="/admin/groups" element={<AdminRoute><AdminGroupsPage /></AdminRoute>} />
<Route path="/admin/appearance" element={<AdminRoute><AdminAppearancePage /></AdminRoute>} />
{/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} />
+51
View File
@@ -26,6 +26,7 @@ export interface UserData {
full_name: string | null;
is_active: boolean;
is_admin: boolean;
color_mode: string | null;
}
export const getMe = () => api.get<UserData>("/users/me").then((r) => r.data);
@@ -202,6 +203,56 @@ export const renameCategory = (id: string, name: string) =>
export const deleteCategory = (id: string) =>
api.delete(`/documents/categories/${id}`);
// --- Appearance & Themes ---
export interface ThemeColors {
primary: string;
primary_hover: string;
accent: string;
accent_hover: string;
background: string;
surface: string;
border: string;
text_primary: string;
text_muted: string;
}
export interface ThemeDefinition {
id: string;
label: string;
builtin: boolean;
light: ThemeColors;
dark: ThemeColors;
}
export interface AppearanceSettings {
theme: string;
default_mode: string;
}
export const getAppearanceSettings = (): Promise<AppearanceSettings> =>
api.get<AppearanceSettings>("/settings/appearance").then((r) => r.data);
export const updateAppearanceSettings = (data: AppearanceSettings): Promise<AppearanceSettings> =>
api.patch<AppearanceSettings>("/settings/appearance", data).then((r) => r.data);
export const getThemes = (): Promise<ThemeDefinition[]> =>
api.get<ThemeDefinition[]>("/settings/themes").then((r) => r.data);
export const createTheme = (data: Omit<ThemeDefinition, "builtin">): Promise<ThemeDefinition> =>
api.post<ThemeDefinition>("/settings/themes", data).then((r) => r.data);
export const updateTheme = (
id: string,
data: { label?: string; light?: ThemeColors; dark?: ThemeColors }
): Promise<ThemeDefinition> =>
api.patch<ThemeDefinition>(`/settings/themes/${id}`, data).then((r) => r.data);
export const deleteTheme = (id: string): Promise<void> =>
api.delete(`/settings/themes/${id}`).then((r) => r.data);
export const updateColorMode = (color_mode: string): Promise<UserData> =>
api.patch<UserData>("/users/me/color-mode", { color_mode }).then((r) => r.data);
// --- Settings (admin only) ---
export interface AIProviderUpdate {
provider: string;
+8
View File
@@ -15,6 +15,7 @@ import {
Folder,
Users,
UsersRound,
Palette,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import ThemeToggle from "@/components/ThemeToggle";
@@ -265,6 +266,13 @@ export default function Sidebar() {
<UsersRound className="h-4 w-4 shrink-0" />
<span className="whitespace-nowrap">Groups</span>
</NavLink>
<NavLink
to="/admin/appearance"
className={({ isActive }) => subItemClass(isActive)}
>
<Palette className="h-4 w-4 shrink-0" />
<span className="whitespace-nowrap">Appearance</span>
</NavLink>
</div>
)}
</div>
+3 -3
View File
@@ -3,16 +3,16 @@ import { Button } from "@/components/ui/button";
import { useTheme } from "@/hooks/useTheme";
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
const { isDark, toggleTheme } = useTheme();
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? (
{isDark ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
+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) {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
function applyThemeTokens(colors: ThemeColors) {
const el = document.documentElement;
el.style.setProperty("--color-primary", colors.primary);
el.style.setProperty("--color-primary-hover", colors.primary_hover);
el.style.setProperty("--color-accent", colors.accent);
el.style.setProperty("--color-accent-hover", colors.accent_hover);
el.style.setProperty("--color-background", colors.background);
el.style.setProperty("--color-surface", colors.surface);
el.style.setProperty("--color-border", colors.border);
el.style.setProperty("--color-text-primary", colors.text_primary);
el.style.setProperty("--color-text-muted", colors.text_muted);
}
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
const stored = localStorage.getItem("theme") as Theme | null;
if (stored === "light" || stored === "dark") return stored;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe });
const { data: appearance } = useQuery({
queryKey: ["appearance"],
queryFn: getAppearanceSettings,
staleTime: 5 * 60 * 1000,
});
const { data: themes = [] } = useQuery({
queryKey: ["themes"],
queryFn: getThemes,
staleTime: 5 * 60 * 1000,
});
// Priority: user.color_mode → admin default_mode → system preference
const isDark = useMemo(() => {
const mode = me?.color_mode ?? appearance?.default_mode ?? "system";
if (mode === "system") {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
return mode === "dark";
}, [me?.color_mode, appearance?.default_mode]);
// Apply palette + dark class whenever they change
useEffect(() => {
applyTheme(theme);
localStorage.setItem("theme", theme);
}, [theme]);
const activeId = appearance?.theme ?? localStorage.getItem("themePalette") ?? "default";
const theme =
themes.find((t) => t.id === activeId) ?? themes.find((t) => t.id === "default");
if (theme) {
applyThemeTokens(isDark ? theme.dark : theme.light);
}
document.documentElement.classList.toggle("dark", isDark);
localStorage.setItem("themePalette", activeId);
}, [isDark, appearance?.theme, themes]);
const queryClient = useQueryClient();
const setColorMode = useCallback(
async (mode: ColorMode) => {
// Reflect optimistically before the round-trip
document.documentElement.classList.toggle("dark", mode === "dark");
await updateColorMode(mode);
queryClient.invalidateQueries({ queryKey: ["me"] });
},
[queryClient]
);
const toggleTheme = useCallback(() => {
setTheme((t) => (t === "light" ? "dark" : "light"));
}, []);
setColorMode(isDark ? "light" : "dark");
}, [isDark, setColorMode]);
return { theme, toggleTheme };
return { isDark, toggleTheme, setColorMode };
}
+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() {
const queryClient = useQueryClient();
const { data: me } = useQuery({ queryKey: ["me"], queryFn: getMe });
const { data: appearance } = useQuery({
queryKey: ["appearance"],
queryFn: getAppearanceSettings,
staleTime: 5 * 60 * 1000,
});
const currentMode: ColorMode = (me?.color_mode as ColorMode) ?? "system";
const mutation = useMutation({
mutationFn: updateColorMode,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["me"] }),
});
const adminDefault = appearance?.default_mode ?? "system";
const adminDefaultLabel =
adminDefault === "system" ? "system preference" : adminDefault + " mode";
return (
<div className="p-8 max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-2">
<div className="flex items-center gap-3 mb-6">
<Settings className="h-6 w-6 text-muted" />
<h1 className="text-2xl font-semibold text-foreground">Settings</h1>
</div>
<p className="text-muted text-sm">
User and application settings will be available here in a future update.
</p>
<div className="bg-surface border border-border rounded-lg p-6">
<h2 className="text-base font-semibold text-foreground mb-1">Appearance</h2>
<p className="text-sm text-muted mb-4">
Choose your preferred colour mode. Overrides the site-wide default set by your
administrator.
</p>
<div className="flex gap-2">
{MODE_OPTIONS.map(({ value, label, icon }) => (
<button
key={value}
onClick={() => mutation.mutate(value)}
disabled={mutation.isPending}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg border text-sm font-medium transition-colors",
currentMode === value
? "bg-primary/10 border-primary text-primary"
: "border-border text-muted hover:text-foreground hover:bg-muted/20"
)}
>
{icon}
{label}
</button>
))}
</div>
<p className="mt-3 text-xs text-muted">
Site-wide default: <span className="font-medium">{adminDefaultLabel}</span>
</p>
{mutation.isError && (
<p className="mt-2 text-sm text-red-500">Failed to save preference.</p>
)}
</div>
</div>
);
}