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
+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)]
)