diff --git a/backend/alembic/versions/8f1b4bf29c5d_named_saved_custom_themes_multiple_per_.py b/backend/alembic/versions/8f1b4bf29c5d_named_saved_custom_themes_multiple_per_.py new file mode 100644 index 0000000..deab9b8 --- /dev/null +++ b/backend/alembic/versions/8f1b4bf29c5d_named_saved_custom_themes_multiple_per_.py @@ -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') diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py index 8bc6752..1d591f0 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies.py @@ -3,6 +3,7 @@ import uuid from fastapi import Depends, HTTPException, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.database import get_db from app.models import RoomMembership, RoomRole, User @@ -32,7 +33,13 @@ async def get_current_user( if not user_id: 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: request.session.clear() raise HTTPException(status_code=401, detail="Not authenticated") diff --git a/backend/app/main.py b/backend/app/main.py index 53f2850..1d6ab5d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -11,7 +11,19 @@ from redis.asyncio import Redis from starlette.middleware.sessions import SessionMiddleware 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.chat import router as ws_router from app.ws.connection_manager import ConnectionManager @@ -78,6 +90,7 @@ def create_app() -> FastAPI: app.include_router(rooms.router) app.include_router(users.router) app.include_router(push.router) + app.include_router(custom_themes.router) app.include_router(uploads.router) app.include_router(admin.router) app.include_router(bots.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d58d0c2..d260166 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,6 +1,7 @@ from app.models.admin_audit_log import AdminAuditLog from app.models.api_token import ApiToken from app.models.base import Base +from app.models.custom_theme import CustomTheme from app.models.event_subscription import EventSubscription from app.models.invite import InviteStatus from app.models.membership import RoomMembership, RoomRole @@ -37,4 +38,5 @@ __all__ = [ "ApiToken", "WebhookIncoming", "EventSubscription", + "CustomTheme", ] diff --git a/backend/app/models/custom_theme.py b/backend/app/models/custom_theme.py new file mode 100644 index 0000000..b6b14fa --- /dev/null +++ b/backend/app/models/custom_theme.py @@ -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]) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index fc16fd8..4a85958 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -1,9 +1,8 @@ import uuid from datetime import datetime -from sqlalchemy import Boolean, DateTime, String, func -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import Boolean, DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base @@ -20,10 +19,15 @@ class User(Base): is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) display_name: Mapped[str | None] = mapped_column(String(50)) theme: Mapped[str | None] = mapped_column(String(20)) - # Only meaningful when theme == "custom" -- kept even if the user - # switches to a preset and back, so switching away from custom is never - # destructive. Shape is CustomThemeColors (backend/app/schemas/user.py). - custom_theme_colors: Mapped[dict | None] = mapped_column(JSONB) + # Only meaningful when theme == "custom" -- which of this user's saved + # CustomTheme rows (app/models/custom_theme.py) is currently active. + # Cleared explicitly (not via a DB-level ON DELETE) whenever that theme + # 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_content_type: Mapped[str | None] = mapped_column(String(50)) # Manual override for the presence indicator -- when set, this user @@ -33,3 +37,5 @@ class User(Base): created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False ) + + active_custom_theme = relationship("CustomTheme", foreign_keys=[active_custom_theme_id]) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 76af7a8..58b23c7 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, Response, UploadFile from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.database import get_db from app.dependencies import get_current_user @@ -85,12 +86,16 @@ async def update_profile( current_user.display_name = display_name or None if "theme" in updates: 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: current_user.appear_offline = updates["appear_offline"] 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 # broadcast when something other members would actually see changed. 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_content_type = file.content_type 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: delete_file(previous_filename) @@ -148,7 +157,11 @@ async def remove_avatar( current_user.avatar_filename = None current_user.avatar_content_type = None 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: delete_file(previous_filename) diff --git a/backend/app/routers/custom_themes.py b/backend/app/routers/custom_themes.py new file mode 100644 index 0000000..67f48ed --- /dev/null +++ b/backend/app/routers/custom_themes.py @@ -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)] + ) diff --git a/backend/app/schemas/custom_theme.py b/backend/app/schemas/custom_theme.py new file mode 100644 index 0000000..6b19e94 --- /dev/null +++ b/backend/app/schemas/custom_theme.py @@ -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 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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index f9d6809..f3acb32 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -2,7 +2,9 @@ import uuid from datetime import datetime 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): @@ -11,31 +13,6 @@ class UserCreate(BaseModel): 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 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): model_config = ConfigDict(from_attributes=True) @@ -46,11 +23,26 @@ class UserRead(BaseModel): is_site_admin: bool display_name: 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 appear_offline: bool 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): # Each field is independently optional-and-settable -- the router only @@ -60,8 +52,11 @@ class ProfileUpdate(BaseModel): # their defaults, and vice versa. display_name: str | None = Field(default=None, max_length=50) # Kept in sync with frontend/src/styles/themes.css's theme blocks. - theme: Literal["dark", "light", "midnight", "sunset", "custom"] | None = Field(default=None) - custom_theme_colors: CustomThemeColors | None = Field(default=None) + # "custom" is deliberately not settable here -- becoming custom always + # 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) diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py index 3be156b..cfa2ff8 100644 --- a/backend/app/services/auth_service.py +++ b/backend/app/services/auth_service.py @@ -1,6 +1,7 @@ from sqlalchemy import or_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.models import User from app.schemas.user import UserCreate @@ -40,9 +41,9 @@ async def authenticate_user( db: AsyncSession, username_or_email: str, password: str ) -> User: result = await db.execute( - select(User).where( - or_(User.username == username_or_email, User.email == username_or_email) - ) + select(User) + .where(or_(User.username == username_or_email, User.email == username_or_email)) + .options(selectinload(User.active_custom_theme)) ) user = result.scalar_one_or_none() if user is None or not verify_password(password, user.password_hash): diff --git a/backend/app/services/custom_theme_service.py b/backend/app/services/custom_theme_service.py new file mode 100644 index 0000000..62ef284 --- /dev/null +++ b/backend/app/services/custom_theme_service.py @@ -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() diff --git a/backend/app/services/password_service.py b/backend/app/services/password_service.py index 91608bd..8067dab 100644 --- a/backend/app/services/password_service.py +++ b/backend/app/services/password_service.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload from app.models import PasswordReset, User 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: 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) reset.used = True await db.commit() diff --git a/backend/tests/test_custom_themes.py b/backend/tests/test_custom_themes.py new file mode 100644 index 0000000..b70e356 --- /dev/null +++ b/backend/tests/test_custom_themes.py @@ -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 diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 840c20c..1647d02 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -91,92 +91,17 @@ async def test_updating_display_name_does_not_clobber_theme(client, db_session): assert resp.json()["display_name"] == "Alice A." -def _sample_custom_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_custom_theme_colors_persist(client, db_session): +async def test_theme_custom_rejected_on_generic_profile_update(client, db_session): + # "custom" always means activating one specific saved theme (an id, with + # an ownership check) -- see POST /api/custom-themes/{id}/activate in + # test_custom_themes.py. The generic profile endpoint only ever accepts + # the 4 preset names. 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()} - ) - 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"), - }, - ) + resp = await client.patch("/api/auth/me", json={"theme": "custom"}) 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): await register_and_login(client, db_session, username=_unique("alice")) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index ee0f0af..a33df25 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,5 +1,5 @@ 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 // an operator via the backend CLI (`python -m app.cli create-user`), not @@ -27,14 +27,16 @@ export function updateProfile(displayName: string | null): Promise { }) } -// Deliberately its own call sending only `theme` (and, for 'custom', -// `custom_theme_colors` alongside it) -- the backend only applies fields -// actually present in the request body, so this can't clobber display_name -// (and updateProfile above can't clobber theme). -export function updateTheme(theme: ThemeName, customThemeColors?: CustomThemeColors): Promise { +// Deliberately its own call sending only `theme` -- the backend only +// applies fields actually present in the request body, so this can't +// clobber display_name (and updateProfile above can't clobber theme). +// Presets only ('dark'/'light'/'midnight'/'sunset') -- activating a custom +// 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 { return apiFetch('/api/auth/me', { method: 'PATCH', - body: JSON.stringify({ theme, custom_theme_colors: customThemeColors }), + body: JSON.stringify({ theme }), }) } diff --git a/frontend/src/api/customThemes.ts b/frontend/src/api/customThemes.ts new file mode 100644 index 0000000..cc46c03 --- /dev/null +++ b/frontend/src/api/customThemes.ts @@ -0,0 +1,33 @@ +import { apiFetch } from './client' +import type { CustomTheme, CustomThemeColors, User } from '../types' + +export function listCustomThemes(): Promise { + return apiFetch('/api/custom-themes') +} + +export function createCustomTheme(name: string, colors: CustomThemeColors): Promise { + return apiFetch('/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 { + return apiFetch(`/api/custom-themes/${id}`, { + method: 'PATCH', + body: JSON.stringify(data), + }) +} + +export function deleteCustomTheme(id: string): Promise { + return apiFetch(`/api/custom-themes/${id}`, { method: 'DELETE' }) +} + +export function activateCustomTheme(id: string): Promise { + return apiFetch(`/api/custom-themes/${id}/activate`, { method: 'POST' }) +} diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css index a97260f..c0b7a69 100644 --- a/frontend/src/components/Modal.css +++ b/frontend/src/components/Modal.css @@ -176,6 +176,72 @@ 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 { background: var(--ds-surface-2); border: 1px solid var(--ds-border); diff --git a/frontend/src/components/ProfileModal.tsx b/frontend/src/components/ProfileModal.tsx index 69676ea..c096097 100644 --- a/frontend/src/components/ProfileModal.tsx +++ b/frontend/src/components/ProfileModal.tsx @@ -1,15 +1,22 @@ -import { useRef, useState, type ChangeEvent, type FormEvent } from 'react' -import { changePassword, removeAvatar, updateProfile, updateTheme, uploadAvatar } from '../api/auth' +import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from 'react' +import { changePassword, me, removeAvatar, updateProfile, updateTheme, uploadAvatar } from '../api/auth' import { ApiError } from '../api/client' +import { + activateCustomTheme, + createCustomTheme, + deleteCustomTheme, + listCustomThemes, + updateCustomTheme, +} from '../api/customThemes' import { getUserAvatarUrl } from '../api/users' import { useAuth } from '../context/AuthContext' import { hashIndex } from '../lib/avatar' 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 './Modal.css' -const THEME_OPTIONS: { name: ThemeName; label: string }[] = [ +const THEME_OPTIONS: { name: 'dark' | 'light' | 'midnight' | 'sunset'; label: string }[] = [ { name: 'dark', label: 'Dark' }, { name: 'light', label: 'Light' }, { name: 'midnight', label: 'Midnight' }, @@ -43,11 +50,12 @@ export function ProfileModal({ onClose }: ProfileModalProps) { const [uploadingAvatar, setUploadingAvatar] = useState(false) const fileInputRef = useRef(null) const [themeError, setThemeError] = useState(null) - const [customColors, setCustomColors] = useState( - user?.custom_theme_colors ?? DEFAULT_CUSTOM_COLORS, - ) - const [customColorsDirty, setCustomColorsDirty] = useState(false) - const [savingColors, setSavingColors] = useState(false) + + const [customThemes, setCustomThemes] = useState([]) + const [editingThemeId, setEditingThemeId] = useState(null) + const [editNameDraft, setEditNameDraft] = useState('') + const [editColorsDraft, setEditColorsDraft] = useState(DEFAULT_CUSTOM_COLORS) + const [savingThemeEdit, setSavingThemeEdit] = useState(false) const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') @@ -56,6 +64,15 @@ export function ProfileModal({ onClose }: ProfileModalProps) { const [passwordSuccess, setPasswordSuccess] = 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 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 // apply-immediately pattern rather than requiring a separate Save. - // For 'custom', this always sends the current draft palette alongside - // 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) + applyTheme(theme, null) setThemeError(null) try { - const updated = await updateTheme(theme, theme === 'custom' ? customColors : undefined) + const updated = await updateTheme(theme) updateUser(updated) - setCustomColorsDirty(false) } catch (err) { - // Revert the optimistic DOM change if it didn't actually persist. - applyTheme(user?.theme ?? 'dark', user?.custom_theme_colors ?? null) + applyTheme(user?.theme ?? 'dark', user?.active_custom_theme?.colors ?? null) setThemeError(err instanceof ApiError ? err.message : String(err)) } } - function handleCustomColorChange(key: keyof CustomThemeColors, value: string) { - const next = { ...customColors, [key]: value } - 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) + async function handleActivateCustomTheme(theme: CustomTheme) { + applyTheme('custom', theme.colors) setThemeError(null) try { - const updated = await updateTheme('custom', customColors) + const updated = await activateCustomTheme(theme.id) 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) { setThemeError(err instanceof ApiError ? err.message : String(err)) } 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() { - // Unsaved color edits were only ever a live preview -- revert to - // 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) + if (editingThemeId) closeEditor() onClose() } @@ -173,6 +249,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) { } const avatarUrl = user.avatar_filename ? getUserAvatarUrl(user.id, user.avatar_filename) : null + const editingTheme = customThemes.find((t) => t.id === editingThemeId) ?? null return (
@@ -227,7 +304,7 @@ export function ProfileModal({ onClose }: ProfileModalProps) { className={`theme-swatch theme-swatch-${option.name}${ (user.theme ?? 'dark') === option.name ? ' theme-swatch-selected' : '' }`} - onClick={() => handleSelectTheme(option.name)} + onClick={() => handleSelectPreset(option.name)} aria-pressed={(user.theme ?? 'dark') === option.name} >
{themeError &&

{themeError}

} - {user.theme === 'custom' && ( +
My custom themes
+
+ {customThemes.map((theme) => { + const active = user.theme === 'custom' && user.active_custom_theme?.id === theme.id + return ( +
+ +
+ + +
+
+ ) + })} + +
+ + {editingTheme && (
+ setEditNameDraft(e.target.value)} + placeholder="Theme name" + maxLength={50} + />
{CUSTOM_COLOR_FIELDS.map((field) => ( @@ -273,28 +410,31 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
+
diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index dc963de..0c02d4c 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -23,8 +23,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [offline, setOffline] = useState(false) useEffect(() => { - applyTheme(user?.theme ?? null, user?.custom_theme_colors ?? null) - }, [user?.theme, user?.custom_theme_colors]) + applyTheme(user?.theme ?? null, user?.active_custom_theme?.colors ?? null) + }, [user?.theme, user?.active_custom_theme]) useEffect(() => { authApi diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 13d8861..57e0ee9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -2,7 +2,7 @@ export type ThemeName = 'dark' | 'light' | 'midnight' | 'sunset' | 'custom' // Matches exactly the CSS custom properties frontend/src/styles/themes.css // 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 { void: string void_2: string @@ -19,6 +19,13 @@ export interface CustomThemeColors { color_scheme: 'light' | 'dark' } +export interface CustomTheme { + id: string + name: string + colors: CustomThemeColors + created_at: string +} + export interface User { id: string username: string @@ -27,7 +34,9 @@ export interface User { is_site_admin: boolean display_name: string | 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 appear_offline: boolean created_at: string