Support multiple named, saved custom themes per user

Replaces the single custom_theme_colors blob (one palette per user) with
a proper CustomTheme table -- users can now save, name, and switch
between as many custom palettes as they like, not just one.

Data model: users.active_custom_theme_id references whichever saved
CustomTheme (if any) is currently active; theme='custom' + that id
together determine what's rendered. The migration data-migrates any
already-saved single palette into a named CustomTheme row on upgrade,
and best-effort backfills the active one back into the old column shape
on downgrade.

New endpoints under /api/custom-themes: list, create, rename/recolor,
delete (falls back the user to a preset if the deleted theme was
active, so the two theme columns can never disagree), and activate.
UserRead.active_custom_theme is only populated when theme == 'custom'
even though the DB deliberately keeps the id set while a preset is
active, so switching to a preset and back doesn't lose the saved
palette.

ProfileModal now lists saved themes as swatches (click to activate,
pencil to edit -- active or not, trash to delete with a confirm), plus
a "+ New" button that creates, activates, and opens the editor for a
fresh theme immediately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 07:53:05 -06:00
co-authored by Claude Sonnet 5
parent 73020fa39f
commit bd3e621e9f
21 changed files with 972 additions and 203 deletions
@@ -0,0 +1,80 @@
"""named saved custom themes, multiple per user
Revision ID: 8f1b4bf29c5d
Revises: 3c04cf48f4b4
Create Date: 2026-08-17 07:36:18.425662
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '8f1b4bf29c5d'
down_revision: Union[str, Sequence[str], None] = '3c04cf48f4b4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.create_table('custom_themes',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(length=50), nullable=False),
sa.Column('colors', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_custom_themes_user_id'), 'custom_themes', ['user_id'], unique=False)
op.add_column('users', sa.Column('active_custom_theme_id', sa.Uuid(), nullable=True))
# Named explicitly, not left for autogenerate's default -- an unnamed
# constraint here can't be referenced by name in downgrade() below (a
# trap this project has hit before on cross-table FKs).
op.create_foreign_key(
'users_active_custom_theme_id_fkey', 'users', 'custom_themes',
['active_custom_theme_id'], ['id'],
)
# Data migration: anyone who already saved colors under the single-
# theme-per-user shape (#30) gets a real named CustomTheme row instead
# of losing that data outright when the old column is dropped below.
op.execute("""
WITH migrated AS (
INSERT INTO custom_themes (id, user_id, name, colors, created_at)
SELECT gen_random_uuid(), id, 'My Theme', custom_theme_colors, now()
FROM users
WHERE custom_theme_colors IS NOT NULL
RETURNING id, user_id
)
UPDATE users
SET active_custom_theme_id = migrated.id
FROM migrated
WHERE users.id = migrated.user_id
""")
op.drop_column('users', 'custom_theme_colors')
def downgrade() -> None:
"""Downgrade schema."""
op.add_column('users', sa.Column('custom_theme_colors', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=True))
# Best-effort backfill from whichever theme was active, since that's the
# one thing worth preserving going backward -- any *other* saved themes
# are still genuinely lost on downgrade (the old column only ever held
# one palette per user).
op.execute("""
UPDATE users
SET custom_theme_colors = custom_themes.colors
FROM custom_themes
WHERE users.active_custom_theme_id = custom_themes.id
""")
op.drop_constraint('users_active_custom_theme_id_fkey', 'users', type_='foreignkey')
op.drop_column('users', 'active_custom_theme_id')
op.drop_index(op.f('ix_custom_themes_user_id'), table_name='custom_themes')
op.drop_table('custom_themes')
+8 -1
View File
@@ -3,6 +3,7 @@ import uuid
from fastapi import Depends, HTTPException, Request from fastapi import Depends, HTTPException, Request
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db from app.database import get_db
from app.models import RoomMembership, RoomRole, User from app.models import RoomMembership, RoomRole, User
@@ -32,7 +33,13 @@ async def get_current_user(
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
user = await db.get(User, uuid.UUID(user_id)) # Eager-loaded so UserRead.active_custom_theme (app/schemas/user.py) can
# be read without a MissingGreenlet -- selectinload skips the second
# query entirely when active_custom_theme_id is null (the common case),
# so this costs nothing for users who've never set a custom theme.
user = await db.get(
User, uuid.UUID(user_id), options=[selectinload(User.active_custom_theme)]
)
if user is None or not user.is_active: if user is None or not user.is_active:
request.session.clear() request.session.clear()
raise HTTPException(status_code=401, detail="Not authenticated") raise HTTPException(status_code=401, detail="Not authenticated")
+14 -1
View File
@@ -11,7 +11,19 @@ from redis.asyncio import Redis
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from app.config import settings from app.config import settings
from app.routers import admin, auth, bots, health, push, rooms, signup, uploads, users, webhooks from app.routers import (
admin,
auth,
bots,
custom_themes,
health,
push,
rooms,
signup,
uploads,
users,
webhooks,
)
from app.ws.broadcaster import Broadcaster from app.ws.broadcaster import Broadcaster
from app.ws.chat import router as ws_router from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager from app.ws.connection_manager import ConnectionManager
@@ -78,6 +90,7 @@ def create_app() -> FastAPI:
app.include_router(rooms.router) app.include_router(rooms.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(push.router) app.include_router(push.router)
app.include_router(custom_themes.router)
app.include_router(uploads.router) app.include_router(uploads.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(bots.router) app.include_router(bots.router)
+2
View File
@@ -1,6 +1,7 @@
from app.models.admin_audit_log import AdminAuditLog from app.models.admin_audit_log import AdminAuditLog
from app.models.api_token import ApiToken from app.models.api_token import ApiToken
from app.models.base import Base from app.models.base import Base
from app.models.custom_theme import CustomTheme
from app.models.event_subscription import EventSubscription from app.models.event_subscription import EventSubscription
from app.models.invite import InviteStatus from app.models.invite import InviteStatus
from app.models.membership import RoomMembership, RoomRole from app.models.membership import RoomMembership, RoomRole
@@ -37,4 +38,5 @@ __all__ = [
"ApiToken", "ApiToken",
"WebhookIncoming", "WebhookIncoming",
"EventSubscription", "EventSubscription",
"CustomTheme",
] ]
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class CustomTheme(Base):
__tablename__ = "custom_themes"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
name: Mapped[str] = mapped_column(String(50), nullable=False)
# Shape is CustomThemeColors (backend/app/schemas/custom_theme.py) -- the
# same 12 tokens + color_scheme a preset overrides in themes.css.
colors: Mapped[dict] = mapped_column(JSONB, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
user = relationship("User", foreign_keys=[user_id])
+13 -7
View File
@@ -1,9 +1,8 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base from app.models.base import Base
@@ -20,10 +19,15 @@ class User(Base):
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
display_name: Mapped[str | None] = mapped_column(String(50)) display_name: Mapped[str | None] = mapped_column(String(50))
theme: Mapped[str | None] = mapped_column(String(20)) theme: Mapped[str | None] = mapped_column(String(20))
# Only meaningful when theme == "custom" -- kept even if the user # Only meaningful when theme == "custom" -- which of this user's saved
# switches to a preset and back, so switching away from custom is never # CustomTheme rows (app/models/custom_theme.py) is currently active.
# destructive. Shape is CustomThemeColors (backend/app/schemas/user.py). # Cleared explicitly (not via a DB-level ON DELETE) whenever that theme
custom_theme_colors: Mapped[dict | None] = mapped_column(JSONB) # is deleted -- see custom_theme_service.delete_custom_theme, which also
# resets `theme` back to a preset in the same transaction so the two
# columns can't go out of sync.
active_custom_theme_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("custom_themes.id"), nullable=True
)
avatar_filename: Mapped[str | None] = mapped_column(String(64)) avatar_filename: Mapped[str | None] = mapped_column(String(64))
avatar_content_type: Mapped[str | None] = mapped_column(String(50)) avatar_content_type: Mapped[str | None] = mapped_column(String(50))
# Manual override for the presence indicator -- when set, this user # Manual override for the presence indicator -- when set, this user
@@ -33,3 +37,5 @@ class User(Base):
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False DateTime(timezone=True), server_default=func.now(), nullable=False
) )
active_custom_theme = relationship("CustomTheme", foreign_keys=[active_custom_theme_id])
+18 -5
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db from app.database import get_db
from app.dependencies import get_current_user from app.dependencies import get_current_user
@@ -85,12 +86,16 @@ async def update_profile(
current_user.display_name = display_name or None current_user.display_name = display_name or None
if "theme" in updates: if "theme" in updates:
current_user.theme = updates["theme"] current_user.theme = updates["theme"]
if "custom_theme_colors" in updates:
current_user.custom_theme_colors = updates["custom_theme_colors"]
if "appear_offline" in updates: if "appear_offline" in updates:
current_user.appear_offline = updates["appear_offline"] current_user.appear_offline = updates["appear_offline"]
await db.commit() await db.commit()
await db.refresh(current_user) # A plain db.refresh() would expire (and, on next access, lazily
# reload) the active_custom_theme relationship get_current_user
# eager-loaded -- not safe in an async session. Re-fetching with the
# same eager-load instead of refreshing avoids that entirely.
current_user = await db.get(
User, current_user.id, options=[selectinload(User.active_custom_theme)]
)
# theme is private to this user, not shown to anyone else -- only # theme is private to this user, not shown to anyone else -- only
# broadcast when something other members would actually see changed. # broadcast when something other members would actually see changed.
if "display_name" in updates or "appear_offline" in updates: if "display_name" in updates or "appear_offline" in updates:
@@ -129,7 +134,11 @@ async def upload_avatar(
current_user.avatar_filename = storage_filename current_user.avatar_filename = storage_filename
current_user.avatar_content_type = file.content_type current_user.avatar_content_type = file.content_type
await db.commit() await db.commit()
await db.refresh(current_user) # See update_profile's comment -- refresh() would expire the eager-
# loaded active_custom_theme relationship instead of preserving it.
current_user = await db.get(
User, current_user.id, options=[selectinload(User.active_custom_theme)]
)
if previous_filename: if previous_filename:
delete_file(previous_filename) delete_file(previous_filename)
@@ -148,7 +157,11 @@ async def remove_avatar(
current_user.avatar_filename = None current_user.avatar_filename = None
current_user.avatar_content_type = None current_user.avatar_content_type = None
await db.commit() await db.commit()
await db.refresh(current_user) # See update_profile's comment -- refresh() would expire the eager-
# loaded active_custom_theme relationship instead of preserving it.
current_user = await db.get(
User, current_user.id, options=[selectinload(User.active_custom_theme)]
)
if previous_filename: if previous_filename:
delete_file(previous_filename) delete_file(previous_filename)
+86
View File
@@ -0,0 +1,86 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.schemas.custom_theme import CustomThemeCreate, CustomThemeRead, CustomThemeUpdate
from app.schemas.user import UserRead
from app.services.custom_theme_service import (
CustomThemeLimitReachedError,
CustomThemeNotFoundError,
activate_custom_theme,
create_custom_theme,
delete_custom_theme,
list_custom_themes,
update_custom_theme,
)
router = APIRouter(prefix="/api/custom-themes", tags=["custom-themes"])
@router.get("", response_model=list[CustomThemeRead])
async def list_custom_themes_endpoint(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await list_custom_themes(db, current_user.id)
@router.post("", response_model=CustomThemeRead, status_code=201)
async def create_custom_theme_endpoint(
data: CustomThemeCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return await create_custom_theme(db, current_user.id, data.name, data.colors)
except CustomThemeLimitReachedError:
raise HTTPException(status_code=400, detail="Custom theme limit reached")
@router.patch("/{theme_id}", response_model=CustomThemeRead)
async def update_custom_theme_endpoint(
theme_id: uuid.UUID,
data: CustomThemeUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return await update_custom_theme(db, current_user.id, theme_id, data.name, data.colors)
except CustomThemeNotFoundError:
raise HTTPException(status_code=404, detail="Custom theme not found")
@router.delete("/{theme_id}", status_code=204)
async def delete_custom_theme_endpoint(
theme_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
await delete_custom_theme(db, current_user.id, theme_id)
except CustomThemeNotFoundError:
raise HTTPException(status_code=404, detail="Custom theme not found")
return Response(status_code=204)
@router.post("/{theme_id}/activate", response_model=UserRead)
async def activate_custom_theme_endpoint(
theme_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
await activate_custom_theme(db, current_user.id, theme_id)
except CustomThemeNotFoundError:
raise HTTPException(status_code=404, detail="Custom theme not found")
# Re-fetch with the eager-load UserRead.active_custom_theme needs --
# current_user itself is stale (activate_custom_theme mutated a
# different Python object fetched inside the service call).
return await db.get(
User, current_user.id, options=[selectinload(User.active_custom_theme)]
)
+51
View File
@@ -0,0 +1,51 @@
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
# Matches exactly the CSS custom properties frontend/src/styles/themes.css
# overrides per built-in preset -- a saved custom theme is applied the same
# way, just as inline styles on :root instead of a static stylesheet block
# (see frontend/src/lib/theme.ts). Hex-only (`#rrggbb`) since that's exactly
# what a native <input type="color"> always produces -- no alpha, no
# shorthand -- so the pattern constraint can't reject anything the picker UI
# itself sends.
_HEX_COLOR = Field(pattern=r"^#[0-9a-fA-F]{6}$")
class CustomThemeColors(BaseModel):
void: str = _HEX_COLOR
void_2: str = _HEX_COLOR
surface: str = _HEX_COLOR
surface_2: str = _HEX_COLOR
border: str = _HEX_COLOR
text: str = _HEX_COLOR
muted: str = _HEX_COLOR
accent: str = _HEX_COLOR
accent_2: str = _HEX_COLOR
accent_3: str = _HEX_COLOR
highlight: str = _HEX_COLOR
danger: str = _HEX_COLOR
color_scheme: Literal["light", "dark"]
class CustomThemeCreate(BaseModel):
name: str = Field(min_length=1, max_length=50)
colors: CustomThemeColors
class CustomThemeUpdate(BaseModel):
# Each field independently optional-and-settable, same convention as
# ProfileUpdate -- a rename shouldn't require resending all 12 colors.
name: str | None = Field(default=None, min_length=1, max_length=50)
colors: CustomThemeColors | None = Field(default=None)
class CustomThemeRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
colors: CustomThemeColors
created_at: datetime
+24 -29
View File
@@ -2,7 +2,9 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
from app.schemas.custom_theme import CustomThemeRead
class UserCreate(BaseModel): class UserCreate(BaseModel):
@@ -11,31 +13,6 @@ class UserCreate(BaseModel):
password: str = Field(min_length=8, max_length=200) password: str = Field(min_length=8, max_length=200)
# Matches exactly the CSS custom properties frontend/src/styles/themes.css
# overrides per built-in preset -- a "custom" theme is applied the same way,
# just as inline styles on :root instead of a static stylesheet block (see
# frontend/src/lib/theme.ts). Hex-only (`#rrggbb`) since that's exactly what
# a native <input type="color"> always produces -- no alpha, no shorthand --
# so the pattern constraint can't reject anything the picker UI itself sends.
_HEX_COLOR = Field(pattern=r"^#[0-9a-fA-F]{6}$")
class CustomThemeColors(BaseModel):
void: str = _HEX_COLOR
void_2: str = _HEX_COLOR
surface: str = _HEX_COLOR
surface_2: str = _HEX_COLOR
border: str = _HEX_COLOR
text: str = _HEX_COLOR
muted: str = _HEX_COLOR
accent: str = _HEX_COLOR
accent_2: str = _HEX_COLOR
accent_3: str = _HEX_COLOR
highlight: str = _HEX_COLOR
danger: str = _HEX_COLOR
color_scheme: Literal["light", "dark"]
class UserRead(BaseModel): class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@@ -46,11 +23,26 @@ class UserRead(BaseModel):
is_site_admin: bool is_site_admin: bool
display_name: str | None display_name: str | None
theme: str | None theme: str | None
custom_theme_colors: CustomThemeColors | None # Resolved, not just an id -- the frontend needs the actual palette to
# paint on load without a second round trip (see lib/theme.ts).
active_custom_theme: CustomThemeRead | None
avatar_filename: str | None avatar_filename: str | None
appear_offline: bool appear_offline: bool
created_at: datetime created_at: datetime
@model_validator(mode="after")
def _hide_custom_theme_when_not_active(self) -> "UserRead":
# The DB deliberately keeps active_custom_theme_id set even while
# theme is a preset (see custom_theme_service -- switching away from
# custom must not lose the saved palette), so the ORM relationship
# this field is populated from can be non-null even when the user
# isn't actually on the custom theme right now. Enforce "only
# meaningful when theme == 'custom'" here, in one place, rather than
# relying on every router endpoint to remember it.
if self.theme != "custom":
self.active_custom_theme = None
return self
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
# Each field is independently optional-and-settable -- the router only # Each field is independently optional-and-settable -- the router only
@@ -60,8 +52,11 @@ class ProfileUpdate(BaseModel):
# their defaults, and vice versa. # their defaults, and vice versa.
display_name: str | None = Field(default=None, max_length=50) display_name: str | None = Field(default=None, max_length=50)
# Kept in sync with frontend/src/styles/themes.css's theme blocks. # Kept in sync with frontend/src/styles/themes.css's theme blocks.
theme: Literal["dark", "light", "midnight", "sunset", "custom"] | None = Field(default=None) # "custom" is deliberately not settable here -- becoming custom always
custom_theme_colors: CustomThemeColors | None = Field(default=None) # means activating one specific saved theme, which needs an id and an
# ownership check; that's POST /api/custom-themes/{id}/activate, not a
# bare theme name with nothing to point it at.
theme: Literal["dark", "light", "midnight", "sunset"] | None = Field(default=None)
appear_offline: bool | None = Field(default=None) appear_offline: bool | None = Field(default=None)
+4 -3
View File
@@ -1,6 +1,7 @@
from sqlalchemy import or_, select from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import User from app.models import User
from app.schemas.user import UserCreate from app.schemas.user import UserCreate
@@ -40,9 +41,9 @@ async def authenticate_user(
db: AsyncSession, username_or_email: str, password: str db: AsyncSession, username_or_email: str, password: str
) -> User: ) -> User:
result = await db.execute( result = await db.execute(
select(User).where( select(User)
or_(User.username == username_or_email, User.email == username_or_email) .where(or_(User.username == username_or_email, User.email == username_or_email))
) .options(selectinload(User.active_custom_theme))
) )
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash): if user is None or not verify_password(password, user.password_hash):
@@ -0,0 +1,107 @@
import uuid
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import CustomTheme, User
from app.schemas.custom_theme import CustomThemeColors
# Sane cap, not a hard product requirement -- keeps the swatch list from
# growing unbounded and matches this app's generally modest per-user scale
# (self-hosted, small groups) elsewhere.
MAX_CUSTOM_THEMES_PER_USER = 20
class CustomThemeNotFoundError(Exception):
pass
class CustomThemeLimitReachedError(Exception):
pass
async def list_custom_themes(db: AsyncSession, user_id: uuid.UUID) -> list[CustomTheme]:
result = await db.execute(
select(CustomTheme)
.where(CustomTheme.user_id == user_id)
.order_by(CustomTheme.created_at)
)
return list(result.scalars().all())
async def create_custom_theme(
db: AsyncSession, user_id: uuid.UUID, name: str, colors: CustomThemeColors
) -> CustomTheme:
count = await db.scalar(
select(func.count()).select_from(CustomTheme).where(CustomTheme.user_id == user_id)
)
if count >= MAX_CUSTOM_THEMES_PER_USER:
raise CustomThemeLimitReachedError()
theme = CustomTheme(user_id=user_id, name=name, colors=colors.model_dump())
db.add(theme)
await db.commit()
await db.refresh(theme)
return theme
async def _get_owned_theme(db: AsyncSession, user_id: uuid.UUID, theme_id: uuid.UUID) -> CustomTheme:
theme = await db.get(CustomTheme, theme_id)
if theme is None or theme.user_id != user_id:
raise CustomThemeNotFoundError()
return theme
async def update_custom_theme(
db: AsyncSession,
user_id: uuid.UUID,
theme_id: uuid.UUID,
name: str | None,
colors: CustomThemeColors | None,
) -> CustomTheme:
theme = await _get_owned_theme(db, user_id, theme_id)
if name is not None:
theme.name = name
if colors is not None:
theme.colors = colors.model_dump()
await db.commit()
await db.refresh(theme)
return theme
async def delete_custom_theme(db: AsyncSession, user_id: uuid.UUID, theme_id: uuid.UUID) -> None:
theme = await _get_owned_theme(db, user_id, theme_id)
user = await db.get(User, user_id)
# A deleted-but-still-active theme would otherwise leave theme='custom'
# pointing at nothing -- fall back to a preset so the two columns can
# never disagree about what's actually being displayed.
if user.active_custom_theme_id == theme.id:
user.active_custom_theme_id = None
# Keep the relationship attribute in sync too, not just the raw FK
# column -- if `user` is already identity-mapped in this session
# (e.g. a later db.get() in the same request/connection returns the
# same Python object rather than re-querying), only the FK column
# being updated would leave .active_custom_theme still pointing at
# the object we're about to delete below.
user.active_custom_theme = None
user.theme = "dark"
# Flush the FK-clearing UPDATE before the DELETE below -- setting
# the raw *_id column directly (not the relationship attribute)
# doesn't register with SQLAlchemy's automatic flush-order
# dependency detection, so without this the DELETE can be sent
# first and trip the foreign key constraint.
await db.flush()
await db.delete(theme)
await db.commit()
async def activate_custom_theme(db: AsyncSession, user_id: uuid.UUID, theme_id: uuid.UUID) -> None:
theme = await _get_owned_theme(db, user_id, theme_id)
user = await db.get(User, user_id)
user.theme = "custom"
user.active_custom_theme_id = theme_id
# See delete_custom_theme's comment -- keep the relationship attribute
# in sync too, in case `user` is already identity-mapped elsewhere in
# this session.
user.active_custom_theme = theme
await db.commit()
+4 -1
View File
@@ -3,6 +3,7 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models import PasswordReset, User from app.models import PasswordReset, User
from app.security import hash_password, hash_token, verify_password from app.security import hash_password, hash_token, verify_password
@@ -70,7 +71,9 @@ async def validate_reset_token(db: AsyncSession, token: str) -> None:
async def complete_password_reset(db: AsyncSession, token: str, new_password: str) -> User: async def complete_password_reset(db: AsyncSession, token: str, new_password: str) -> User:
reset = await _get_valid_reset(db, token) reset = await _get_valid_reset(db, token)
user = await db.get(User, reset.user_id) user = await db.get(
User, reset.user_id, options=[selectinload(User.active_custom_theme)]
)
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
reset.used = True reset.used = True
await db.commit() await db.commit()
+206
View File
@@ -0,0 +1,206 @@
import uuid
from tests.conftest import register_and_login
def _unique(prefix: str) -> str:
return f"{prefix}-{uuid.uuid4().hex[:8]}"
def _sample_colors(**overrides) -> dict:
colors = {
"void": "#07080f",
"void_2": "#0b0c1a",
"surface": "#101030",
"surface_2": "#181848",
"border": "#242478",
"text": "#fce4fc",
"muted": "#c0ccd8",
"accent": "#60d8fc",
"accent_2": "#6c60fc",
"accent_3": "#7848fc",
"highlight": "#f060fc",
"danger": "#fc6060",
"color_scheme": "dark",
}
colors.update(overrides)
return colors
async def test_create_and_list_custom_themes(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/custom-themes", json={"name": "Sunset Vibes", "colors": _sample_colors()}
)
assert resp.status_code == 201, resp.text
created = resp.json()
assert created["name"] == "Sunset Vibes"
assert created["colors"] == _sample_colors()
resp = await client.get("/api/custom-themes")
assert resp.status_code == 200
themes = resp.json()
assert len(themes) == 1
assert themes[0]["id"] == created["id"]
async def test_create_rejects_bad_hex(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/custom-themes",
json={"name": "Bad", "colors": _sample_colors(accent="not-a-color")},
)
assert resp.status_code == 422
async def test_create_rejects_missing_field(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
colors = _sample_colors()
del colors["danger"]
resp = await client.post("/api/custom-themes", json={"name": "Incomplete", "colors": colors})
assert resp.status_code == 422
async def test_create_rejects_invalid_color_scheme(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.post(
"/api/custom-themes",
json={"name": "Bad scheme", "colors": _sample_colors(color_scheme="sepia")},
)
assert resp.status_code == 422
async def test_activate_sets_theme_and_resolves_colors(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
created = (
await client.post("/api/custom-themes", json={"name": "Mine", "colors": _sample_colors()})
).json()
resp = await client.post(f"/api/custom-themes/{created['id']}/activate")
assert resp.status_code == 200, resp.text
assert resp.json()["theme"] == "custom"
assert resp.json()["active_custom_theme"]["id"] == created["id"]
assert resp.json()["active_custom_theme"]["colors"] == _sample_colors()
me = await client.get("/api/auth/me")
assert me.json()["active_custom_theme"]["name"] == "Mine"
async def test_switching_to_preset_and_back_preserves_saved_theme(client, db_session):
# Switching to a preset and back must not lose a saved custom theme --
# there's no reason picking "Dark" for a moment should force redoing all
# 12 color picks if you switch back later.
await register_and_login(client, db_session, username=_unique("alice"))
created = (
await client.post("/api/custom-themes", json={"name": "Mine", "colors": _sample_colors()})
).json()
await client.post(f"/api/custom-themes/{created['id']}/activate")
resp = await client.patch("/api/auth/me", json={"theme": "dark"})
assert resp.status_code == 200
assert resp.json()["theme"] == "dark"
assert resp.json()["active_custom_theme"] is None
resp = await client.post(f"/api/custom-themes/{created['id']}/activate")
assert resp.status_code == 200
assert resp.json()["active_custom_theme"]["colors"] == _sample_colors()
async def test_update_renames_and_recolors(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
created = (
await client.post("/api/custom-themes", json={"name": "Old name", "colors": _sample_colors()})
).json()
resp = await client.patch(
f"/api/custom-themes/{created['id']}", json={"name": "New name"}
)
assert resp.status_code == 200
assert resp.json()["name"] == "New name"
assert resp.json()["colors"] == _sample_colors()
resp = await client.patch(
f"/api/custom-themes/{created['id']}", json={"colors": _sample_colors(accent="#ff3366")}
)
assert resp.status_code == 200
assert resp.json()["name"] == "New name"
assert resp.json()["colors"]["accent"] == "#ff3366"
async def test_delete_non_active_theme_leaves_active_theme_untouched(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
active = (
await client.post("/api/custom-themes", json={"name": "Active", "colors": _sample_colors()})
).json()
other = (
await client.post(
"/api/custom-themes", json={"name": "Other", "colors": _sample_colors(accent="#ff3366")}
)
).json()
await client.post(f"/api/custom-themes/{active['id']}/activate")
resp = await client.delete(f"/api/custom-themes/{other['id']}")
assert resp.status_code == 204
me = (await client.get("/api/auth/me")).json()
assert me["theme"] == "custom"
assert me["active_custom_theme"]["id"] == active["id"]
async def test_delete_active_theme_falls_back_to_preset(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
created = (
await client.post("/api/custom-themes", json={"name": "Mine", "colors": _sample_colors()})
).json()
await client.post(f"/api/custom-themes/{created['id']}/activate")
resp = await client.delete(f"/api/custom-themes/{created['id']}")
assert resp.status_code == 204
me = (await client.get("/api/auth/me")).json()
assert me["theme"] == "dark"
assert me["active_custom_theme"] is None
assert (await client.get("/api/custom-themes")).json() == []
async def test_ownership_enforced_across_users(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
created = (
await client.post("/api/custom-themes", json={"name": "Alice's", "colors": _sample_colors()})
).json()
await register_and_login(client, db_session, username=_unique("bob"))
resp = await client.patch(f"/api/custom-themes/{created['id']}", json={"name": "Hijacked"})
assert resp.status_code == 404
resp = await client.delete(f"/api/custom-themes/{created['id']}")
assert resp.status_code == 404
resp = await client.post(f"/api/custom-themes/{created['id']}/activate")
assert resp.status_code == 404
# Bob's own list is untouched by alice's theme.
assert (await client.get("/api/custom-themes")).json() == []
async def test_custom_theme_limit(client, db_session, monkeypatch):
monkeypatch.setattr(
"app.services.custom_theme_service.MAX_CUSTOM_THEMES_PER_USER", 2
)
await register_and_login(client, db_session, username=_unique("alice"))
for i in range(2):
resp = await client.post(
"/api/custom-themes", json={"name": f"Theme {i}", "colors": _sample_colors()}
)
assert resp.status_code == 201, resp.text
resp = await client.post(
"/api/custom-themes", json={"name": "One too many", "colors": _sample_colors()}
)
assert resp.status_code == 400
+6 -81
View File
@@ -91,92 +91,17 @@ async def test_updating_display_name_does_not_clobber_theme(client, db_session):
assert resp.json()["display_name"] == "Alice A." assert resp.json()["display_name"] == "Alice A."
def _sample_custom_colors(**overrides) -> dict: async def test_theme_custom_rejected_on_generic_profile_update(client, db_session):
colors = { # "custom" always means activating one specific saved theme (an id, with
"void": "#07080f", # an ownership check) -- see POST /api/custom-themes/{id}/activate in
"void_2": "#0b0c1a", # test_custom_themes.py. The generic profile endpoint only ever accepts
"surface": "#101030", # the 4 preset names.
"surface_2": "#181848",
"border": "#242478",
"text": "#fce4fc",
"muted": "#c0ccd8",
"accent": "#60d8fc",
"accent_2": "#6c60fc",
"accent_3": "#7848fc",
"highlight": "#f060fc",
"danger": "#fc6060",
"color_scheme": "dark",
}
colors.update(overrides)
return colors
async def test_custom_theme_colors_persist(client, db_session):
await register_and_login(client, db_session, username=_unique("alice")) await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch( resp = await client.patch("/api/auth/me", json={"theme": "custom"})
"/api/auth/me", json={"theme": "custom", "custom_theme_colors": _sample_custom_colors()}
)
assert resp.status_code == 200, resp.text
assert resp.json()["theme"] == "custom"
assert resp.json()["custom_theme_colors"] == _sample_custom_colors()
me = await client.get("/api/auth/me")
assert me.json()["custom_theme_colors"] == _sample_custom_colors()
async def test_custom_theme_colors_rejects_bad_hex(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch(
"/api/auth/me",
json={
"theme": "custom",
"custom_theme_colors": _sample_custom_colors(accent="not-a-color"),
},
)
assert resp.status_code == 422 assert resp.status_code == 422
async def test_custom_theme_colors_rejects_missing_field(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
colors = _sample_custom_colors()
del colors["danger"]
resp = await client.patch(
"/api/auth/me", json={"theme": "custom", "custom_theme_colors": colors}
)
assert resp.status_code == 422
async def test_custom_theme_colors_rejects_invalid_color_scheme(client, db_session):
await register_and_login(client, db_session, username=_unique("alice"))
resp = await client.patch(
"/api/auth/me",
json={
"theme": "custom",
"custom_theme_colors": _sample_custom_colors(color_scheme="sepia"),
},
)
assert resp.status_code == 422
async def test_switching_away_from_custom_preserves_saved_colors(client, db_session):
# Switching to a preset and back must not lose previously-saved custom
# colors -- there's no reason picking "Dark" for a moment should force
# you to redo all 12 color picks if you switch back to Custom later.
await register_and_login(client, db_session, username=_unique("alice"))
await client.patch(
"/api/auth/me", json={"theme": "custom", "custom_theme_colors": _sample_custom_colors()}
)
resp = await client.patch("/api/auth/me", json={"theme": "dark"})
assert resp.status_code == 200
assert resp.json()["theme"] == "dark"
assert resp.json()["custom_theme_colors"] == _sample_custom_colors()
async def test_avatar_upload_succeeds_and_persists(client, db_session): async def test_avatar_upload_succeeds_and_persists(client, db_session):
await register_and_login(client, db_session, username=_unique("alice")) await register_and_login(client, db_session, username=_unique("alice"))
+9 -7
View File
@@ -1,5 +1,5 @@
import { apiFetch, ApiError, NetworkError } from './client' import { apiFetch, ApiError, NetworkError } from './client'
import type { CustomThemeColors, ThemeName, User } from '../types' import type { User } from '../types'
// No register() here: this is an invite-only site. Accounts are created by // No register() here: this is an invite-only site. Accounts are created by
// an operator via the backend CLI (`python -m app.cli create-user`), not // an operator via the backend CLI (`python -m app.cli create-user`), not
@@ -27,14 +27,16 @@ export function updateProfile(displayName: string | null): Promise<User> {
}) })
} }
// Deliberately its own call sending only `theme` (and, for 'custom', // Deliberately its own call sending only `theme` -- the backend only
// `custom_theme_colors` alongside it) -- the backend only applies fields // applies fields actually present in the request body, so this can't
// actually present in the request body, so this can't clobber display_name // clobber display_name (and updateProfile above can't clobber theme).
// (and updateProfile above can't clobber theme). // Presets only ('dark'/'light'/'midnight'/'sunset') -- activating a custom
export function updateTheme(theme: ThemeName, customThemeColors?: CustomThemeColors): Promise<User> { // theme is POST /api/custom-themes/{id}/activate (see api/customThemes.ts),
// since that needs an id and an ownership check, not just a bare name.
export function updateTheme(theme: 'dark' | 'light' | 'midnight' | 'sunset'): Promise<User> {
return apiFetch<User>('/api/auth/me', { return apiFetch<User>('/api/auth/me', {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ theme, custom_theme_colors: customThemeColors }), body: JSON.stringify({ theme }),
}) })
} }
+33
View File
@@ -0,0 +1,33 @@
import { apiFetch } from './client'
import type { CustomTheme, CustomThemeColors, User } from '../types'
export function listCustomThemes(): Promise<CustomTheme[]> {
return apiFetch<CustomTheme[]>('/api/custom-themes')
}
export function createCustomTheme(name: string, colors: CustomThemeColors): Promise<CustomTheme> {
return apiFetch<CustomTheme>('/api/custom-themes', {
method: 'POST',
body: JSON.stringify({ name, colors }),
})
}
// Each field independently optional-and-settable, same convention as
// updateProfile -- a rename shouldn't require resending all 12 colors.
export function updateCustomTheme(
id: string,
data: { name?: string; colors?: CustomThemeColors },
): Promise<CustomTheme> {
return apiFetch<CustomTheme>(`/api/custom-themes/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export function deleteCustomTheme(id: string): Promise<void> {
return apiFetch<void>(`/api/custom-themes/${id}`, { method: 'DELETE' })
}
export function activateCustomTheme(id: string): Promise<User> {
return apiFetch<User>(`/api/custom-themes/${id}/activate`, { method: 'POST' })
}
+66
View File
@@ -176,6 +176,72 @@
background: #fca050; background: #fca050;
} }
.custom-theme-swatch {
display: flex;
align-items: stretch;
gap: 4px;
border-radius: var(--radius);
}
.custom-theme-swatch.theme-swatch-selected {
box-shadow: 0 0 0 1px var(--ds-accent);
border-radius: var(--radius);
}
.custom-theme-swatch-select {
flex: 1;
min-width: 0;
}
.custom-theme-swatch-select .theme-swatch-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.custom-theme-swatch-actions {
display: flex;
flex-direction: column;
gap: 2px;
flex: none;
}
.custom-theme-swatch-icon-btn {
flex: 1;
width: 24px;
background: var(--ds-surface-2);
border: 1px solid var(--ds-border);
border-radius: 5px;
color: var(--ds-muted);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
}
.custom-theme-swatch-icon-btn:hover {
color: var(--ds-text);
border-color: var(--ds-accent);
}
.custom-theme-new {
justify-content: center;
color: var(--ds-muted);
}
.theme-swatch-preview-new {
background: transparent;
border-style: dashed;
font-size: 1rem;
line-height: 1;
color: var(--ds-muted);
}
.custom-theme-name-input {
margin-bottom: var(--sp-3) !important;
}
.custom-theme-editor { .custom-theme-editor {
background: var(--ds-surface-2); background: var(--ds-surface-2);
border: 1px solid var(--ds-border); border: 1px solid var(--ds-border);
+196 -56
View File
@@ -1,15 +1,22 @@
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react' import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { changePassword, removeAvatar, updateProfile, updateTheme, uploadAvatar } from '../api/auth' import { changePassword, me, removeAvatar, updateProfile, updateTheme, uploadAvatar } from '../api/auth'
import { ApiError } from '../api/client' import { ApiError } from '../api/client'
import {
activateCustomTheme,
createCustomTheme,
deleteCustomTheme,
listCustomThemes,
updateCustomTheme,
} from '../api/customThemes'
import { getUserAvatarUrl } from '../api/users' import { getUserAvatarUrl } from '../api/users'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { hashIndex } from '../lib/avatar' import { hashIndex } from '../lib/avatar'
import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme' import { applyTheme, DEFAULT_CUSTOM_COLORS } from '../lib/theme'
import type { CustomThemeColors, ThemeName } from '../types' import type { CustomTheme, CustomThemeColors } from '../types'
import { UserAvatar } from './UserAvatar' import { UserAvatar } from './UserAvatar'
import './Modal.css' import './Modal.css'
const THEME_OPTIONS: { name: ThemeName; label: string }[] = [ const THEME_OPTIONS: { name: 'dark' | 'light' | 'midnight' | 'sunset'; label: string }[] = [
{ name: 'dark', label: 'Dark' }, { name: 'dark', label: 'Dark' },
{ name: 'light', label: 'Light' }, { name: 'light', label: 'Light' },
{ name: 'midnight', label: 'Midnight' }, { name: 'midnight', label: 'Midnight' },
@@ -43,11 +50,12 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [uploadingAvatar, setUploadingAvatar] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [themeError, setThemeError] = useState<string | null>(null) const [themeError, setThemeError] = useState<string | null>(null)
const [customColors, setCustomColors] = useState<CustomThemeColors>(
user?.custom_theme_colors ?? DEFAULT_CUSTOM_COLORS, const [customThemes, setCustomThemes] = useState<CustomTheme[]>([])
) const [editingThemeId, setEditingThemeId] = useState<string | null>(null)
const [customColorsDirty, setCustomColorsDirty] = useState(false) const [editNameDraft, setEditNameDraft] = useState('')
const [savingColors, setSavingColors] = useState(false) const [editColorsDraft, setEditColorsDraft] = useState<CustomThemeColors>(DEFAULT_CUSTOM_COLORS)
const [savingThemeEdit, setSavingThemeEdit] = useState(false)
const [currentPassword, setCurrentPassword] = useState('') const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('') const [newPassword, setNewPassword] = useState('')
@@ -56,6 +64,15 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
const [passwordSuccess, setPasswordSuccess] = useState(false) const [passwordSuccess, setPasswordSuccess] = useState(false)
const [savingPassword, setSavingPassword] = useState(false) const [savingPassword, setSavingPassword] = useState(false)
useEffect(() => {
listCustomThemes()
.then(setCustomThemes)
.catch(() => {
// Non-critical -- the saved-themes list just stays empty; presets
// and everything else in this modal still work fine.
})
}, [])
if (!user) return null if (!user) return null
async function handleSaveName(e: FormEvent) { async function handleSaveName(e: FormEvent) {
@@ -98,55 +115,114 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
} }
} }
async function handleSelectTheme(theme: ThemeName) { async function handleSelectPreset(theme: 'dark' | 'light' | 'midnight' | 'sunset') {
// Instant visual feedback, then persist -- mirrors avatar upload's // Instant visual feedback, then persist -- mirrors avatar upload's
// apply-immediately pattern rather than requiring a separate Save. // apply-immediately pattern rather than requiring a separate Save.
// For 'custom', this always sends the current draft palette alongside applyTheme(theme, null)
// the theme name (previously-saved colors if any, else the defaults),
// so selecting Custom never leaves theme='custom' persisted with no
// palette behind it.
applyTheme(theme, theme === 'custom' ? customColors : null)
setThemeError(null) setThemeError(null)
try { try {
const updated = await updateTheme(theme, theme === 'custom' ? customColors : undefined) const updated = await updateTheme(theme)
updateUser(updated) updateUser(updated)
setCustomColorsDirty(false)
} catch (err) { } catch (err) {
// Revert the optimistic DOM change if it didn't actually persist. applyTheme(user?.theme ?? 'dark', user?.active_custom_theme?.colors ?? null)
applyTheme(user?.theme ?? 'dark', user?.custom_theme_colors ?? null)
setThemeError(err instanceof ApiError ? err.message : String(err)) setThemeError(err instanceof ApiError ? err.message : String(err))
} }
} }
function handleCustomColorChange(key: keyof CustomThemeColors, value: string) { async function handleActivateCustomTheme(theme: CustomTheme) {
const next = { ...customColors, [key]: value } applyTheme('custom', theme.colors)
setCustomColors(next)
setCustomColorsDirty(true)
// Live preview only -- deliberately not persisted per keystroke (a
// native color input fires continuously while dragging), see
// handleSaveColors for the actual persist step.
applyTheme('custom', next)
}
async function handleSaveColors() {
setSavingColors(true)
setThemeError(null) setThemeError(null)
try { try {
const updated = await updateTheme('custom', customColors) const updated = await activateCustomTheme(theme.id)
updateUser(updated) updateUser(updated)
setCustomColorsDirty(false) } catch (err) {
applyTheme(user?.theme ?? 'dark', user?.active_custom_theme?.colors ?? null)
setThemeError(err instanceof ApiError ? err.message : String(err))
}
}
async function handleCreateCustomTheme() {
setThemeError(null)
try {
const created = await createCustomTheme('New theme', DEFAULT_CUSTOM_COLORS)
setCustomThemes((prev) => [...prev, created])
await handleActivateCustomTheme(created)
openEditor(created)
} catch (err) {
setThemeError(err instanceof ApiError ? err.message : String(err))
}
}
function openEditor(theme: CustomTheme) {
setEditingThemeId(theme.id)
setEditNameDraft(theme.name)
setEditColorsDraft(theme.colors)
setThemeError(null)
}
function closeEditor() {
// Only ever live-previewed on screen if this theme was already the
// active one (see handleEditColorChange) -- revert that preview back
// to whatever's actually persisted if it was never saved.
if (editingThemeId && user?.active_custom_theme?.id === editingThemeId) {
applyTheme(user.theme, user.active_custom_theme.colors)
}
setEditingThemeId(null)
}
function handleEditColorChange(key: keyof CustomThemeColors, value: string) {
const next = { ...editColorsDraft, [key]: value }
setEditColorsDraft(next)
// Only reflect on the whole page live if the theme being edited is
// already the active one -- editing a theme you're not currently using
// shouldn't hijack what's on screen right now.
if (editingThemeId && user?.active_custom_theme?.id === editingThemeId) {
applyTheme('custom', next)
}
}
async function handleSaveThemeEdit() {
if (!editingThemeId || !user) return
setSavingThemeEdit(true)
setThemeError(null)
try {
const updated = await updateCustomTheme(editingThemeId, {
name: editNameDraft.trim() || 'Untitled',
colors: editColorsDraft,
})
setCustomThemes((prev) => prev.map((t) => (t.id === updated.id ? updated : t)))
if (user.active_custom_theme?.id === updated.id) {
updateUser({ ...user, active_custom_theme: updated })
}
setEditingThemeId(null)
} catch (err) { } catch (err) {
setThemeError(err instanceof ApiError ? err.message : String(err)) setThemeError(err instanceof ApiError ? err.message : String(err))
} finally { } finally {
setSavingColors(false) setSavingThemeEdit(false)
}
}
async function handleDeleteCustomTheme(theme: CustomTheme) {
if (!confirm(`Delete "${theme.name}"? This can't be undone.`) || !user) return
setThemeError(null)
try {
await deleteCustomTheme(theme.id)
setCustomThemes((prev) => prev.filter((t) => t.id !== theme.id))
if (editingThemeId === theme.id) setEditingThemeId(null)
if (user.active_custom_theme?.id === theme.id) {
// The backend fell back to a preset for us -- pick that up rather
// than guessing what it chose.
const refreshed = await me()
updateUser(refreshed)
applyTheme(refreshed.theme, refreshed.active_custom_theme?.colors ?? null)
}
} catch (err) {
setThemeError(err instanceof ApiError ? err.message : String(err))
} }
} }
function handleClose() { function handleClose() {
// Unsaved color edits were only ever a live preview -- revert to if (editingThemeId) closeEditor()
// whatever's actually persisted so closing without saving doesn't leave
// the app visually stuck on a draft.
if (customColorsDirty) applyTheme(user?.theme ?? 'dark', user?.custom_theme_colors ?? null)
onClose() onClose()
} }
@@ -173,6 +249,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
} }
const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null
const editingTheme = customThemes.find((t) => t.id === editingThemeId) ?? null
return ( return (
<div className="modal-scrim" onClick={handleClose}> <div className="modal-scrim" onClick={handleClose}>
@@ -227,7 +304,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
className={`theme-swatch theme-swatch-${option.name}${ className={`theme-swatch theme-swatch-${option.name}${
(user.theme ?? 'dark') === option.name ? ' theme-swatch-selected' : '' (user.theme ?? 'dark') === option.name ? ' theme-swatch-selected' : ''
}`} }`}
onClick={() => handleSelectTheme(option.name)} onClick={() => handleSelectPreset(option.name)}
aria-pressed={(user.theme ?? 'dark') === option.name} aria-pressed={(user.theme ?? 'dark') === option.name}
> >
<span className="theme-swatch-preview" aria-hidden="true"> <span className="theme-swatch-preview" aria-hidden="true">
@@ -236,33 +313,93 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
<span className="theme-swatch-label">{option.label}</span> <span className="theme-swatch-label">{option.label}</span>
</button> </button>
))} ))}
</div>
{themeError && <p className="modal-error">{themeError}</p>}
<div className="modal-field-label">My custom themes</div>
<div className="theme-swatch-grid">
{customThemes.map((theme) => {
const active = user.theme === 'custom' && user.active_custom_theme?.id === theme.id
return (
<div key={theme.id} className={`custom-theme-swatch${active ? ' theme-swatch-selected' : ''}`}>
<button <button
type="button" type="button"
className={`theme-swatch${user.theme === 'custom' ? ' theme-swatch-selected' : ''}`} className="theme-swatch custom-theme-swatch-select"
onClick={() => handleSelectTheme('custom')} onClick={() => handleActivateCustomTheme(theme)}
aria-pressed={user.theme === 'custom'} aria-pressed={active}
> >
<span <span
className="theme-swatch-preview" className="theme-swatch-preview"
aria-hidden="true" aria-hidden="true"
style={{ background: customColors.void }} style={{ background: theme.colors.void }}
> >
<span className="theme-swatch-accent" style={{ background: customColors.accent }} /> <span className="theme-swatch-accent" style={{ background: theme.colors.accent }} />
</span> </span>
<span className="theme-swatch-label">Custom</span> <span className="theme-swatch-label">{theme.name}</span>
</button>
<div className="custom-theme-swatch-actions">
<button
type="button"
className="custom-theme-swatch-icon-btn"
onClick={() => (editingThemeId === theme.id ? closeEditor() : openEditor(theme))}
aria-label={`Edit ${theme.name}`}
title="Edit"
>
<svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M13.5 3.5 16.5 6.5 7 16 3 17 4 13 13.5 3.5Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
</button>
<button
type="button"
className="custom-theme-swatch-icon-btn"
onClick={() => handleDeleteCustomTheme(theme)}
aria-label={`Delete ${theme.name}`}
title="Delete"
>
<svg width="13" height="13" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<path
d="M4 6h12M8 6V4h4v2m-6 0 .7 10.5A1 1 0 0 0 7.7 17h4.6a1 1 0 0 0 1-.95L14 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
)
})}
<button type="button" className="theme-swatch custom-theme-new" onClick={handleCreateCustomTheme}>
<span className="theme-swatch-preview theme-swatch-preview-new" aria-hidden="true">
+
</span>
<span className="theme-swatch-label">New</span>
</button> </button>
</div> </div>
{themeError && <p className="modal-error">{themeError}</p>}
{user.theme === 'custom' && ( {editingTheme && (
<div className="custom-theme-editor"> <div className="custom-theme-editor">
<input
type="text"
className="custom-theme-name-input"
value={editNameDraft}
onChange={(e) => setEditNameDraft(e.target.value)}
placeholder="Theme name"
maxLength={50}
/>
<div className="custom-theme-grid"> <div className="custom-theme-grid">
{CUSTOM_COLOR_FIELDS.map((field) => ( {CUSTOM_COLOR_FIELDS.map((field) => (
<label key={field.key} className="custom-theme-field"> <label key={field.key} className="custom-theme-field">
<input <input
type="color" type="color"
value={customColors[field.key]} value={editColorsDraft[field.key]}
onChange={(e) => handleCustomColorChange(field.key, e.target.value)} onChange={(e) => handleEditColorChange(field.key, e.target.value)}
/> />
<span>{field.label}</span> <span>{field.label}</span>
</label> </label>
@@ -273,28 +410,31 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
<div className="custom-theme-scheme-toggle"> <div className="custom-theme-scheme-toggle">
<button <button
type="button" type="button"
className={`btn-secondary${customColors.color_scheme === 'light' ? ' custom-theme-scheme-active' : ''}`} className={`btn-secondary${editColorsDraft.color_scheme === 'light' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => handleCustomColorChange('color_scheme', 'light')} onClick={() => handleEditColorChange('color_scheme', 'light')}
> >
Light Light
</button> </button>
<button <button
type="button" type="button"
className={`btn-secondary${customColors.color_scheme === 'dark' ? ' custom-theme-scheme-active' : ''}`} className={`btn-secondary${editColorsDraft.color_scheme === 'dark' ? ' custom-theme-scheme-active' : ''}`}
onClick={() => handleCustomColorChange('color_scheme', 'dark')} onClick={() => handleEditColorChange('color_scheme', 'dark')}
> >
Dark Dark
</button> </button>
</div> </div>
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button type="button" className="btn-secondary" onClick={closeEditor}>
Cancel
</button>
<button <button
type="button" type="button"
className="btn-primary" className="btn-primary"
onClick={handleSaveColors} onClick={handleSaveThemeEdit}
disabled={savingColors || !customColorsDirty} disabled={savingThemeEdit}
> >
{savingColors ? 'Saving…' : 'Save colors'} {savingThemeEdit ? 'Saving…' : 'Save'}
</button> </button>
</div> </div>
</div> </div>
+2 -2
View File
@@ -23,8 +23,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [offline, setOffline] = useState(false) const [offline, setOffline] = useState(false)
useEffect(() => { useEffect(() => {
applyTheme(user?.theme ?? null, user?.custom_theme_colors ?? null) applyTheme(user?.theme ?? null, user?.active_custom_theme?.colors ?? null)
}, [user?.theme, user?.custom_theme_colors]) }, [user?.theme, user?.active_custom_theme])
useEffect(() => { useEffect(() => {
authApi authApi
+11 -2
View File
@@ -2,7 +2,7 @@ export type ThemeName = 'dark' | 'light' | 'midnight' | 'sunset' | 'custom'
// Matches exactly the CSS custom properties frontend/src/styles/themes.css // Matches exactly the CSS custom properties frontend/src/styles/themes.css
// overrides per built-in preset -- kept in sync with // overrides per built-in preset -- kept in sync with
// backend/app/schemas/user.py's CustomThemeColors. // backend/app/schemas/custom_theme.py's CustomThemeColors.
export interface CustomThemeColors { export interface CustomThemeColors {
void: string void: string
void_2: string void_2: string
@@ -19,6 +19,13 @@ export interface CustomThemeColors {
color_scheme: 'light' | 'dark' color_scheme: 'light' | 'dark'
} }
export interface CustomTheme {
id: string
name: string
colors: CustomThemeColors
created_at: string
}
export interface User { export interface User {
id: string id: string
username: string username: string
@@ -27,7 +34,9 @@ export interface User {
is_site_admin: boolean is_site_admin: boolean
display_name: string | null display_name: string | null
theme: ThemeName | null theme: ThemeName | null
custom_theme_colors: CustomThemeColors | null // Only non-null when theme === 'custom' -- see UserRead's model_validator
// in backend/app/schemas/user.py.
active_custom_theme: CustomTheme | null
avatar_filename: string | null avatar_filename: string | null
appear_offline: boolean appear_offline: boolean
created_at: string created_at: string