Private
Public Access
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:
@@ -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')
|
||||
@@ -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")
|
||||
|
||||
+14
-1
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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])
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
)
|
||||
@@ -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
@@ -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 <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):
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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<User> {
|
||||
})
|
||||
}
|
||||
|
||||
// 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<User> {
|
||||
// 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<User> {
|
||||
return apiFetch<User>('/api/auth/me', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ theme, custom_theme_colors: customThemeColors }),
|
||||
body: JSON.stringify({ theme }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HTMLInputElement>(null)
|
||||
const [themeError, setThemeError] = useState<string | null>(null)
|
||||
const [customColors, setCustomColors] = useState<CustomThemeColors>(
|
||||
user?.custom_theme_colors ?? DEFAULT_CUSTOM_COLORS,
|
||||
)
|
||||
const [customColorsDirty, setCustomColorsDirty] = useState(false)
|
||||
const [savingColors, setSavingColors] = useState(false)
|
||||
|
||||
const [customThemes, setCustomThemes] = useState<CustomTheme[]>([])
|
||||
const [editingThemeId, setEditingThemeId] = useState<string | null>(null)
|
||||
const [editNameDraft, setEditNameDraft] = useState('')
|
||||
const [editColorsDraft, setEditColorsDraft] = useState<CustomThemeColors>(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 (
|
||||
<div className="modal-scrim" onClick={handleClose}>
|
||||
@@ -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}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`theme-swatch${user.theme === 'custom' ? ' theme-swatch-selected' : ''}`}
|
||||
onClick={() => handleSelectTheme('custom')}
|
||||
aria-pressed={user.theme === 'custom'}
|
||||
>
|
||||
<span
|
||||
className="theme-swatch-preview"
|
||||
aria-hidden="true"
|
||||
style={{ background: customColors.void }}
|
||||
>
|
||||
<span className="theme-swatch-accent" style={{ background: customColors.accent }} />
|
||||
</span>
|
||||
<span className="theme-swatch-label">Custom</span>
|
||||
</button>
|
||||
</div>
|
||||
{themeError && <p className="modal-error">{themeError}</p>}
|
||||
|
||||
{user.theme === 'custom' && (
|
||||
<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
|
||||
type="button"
|
||||
className="theme-swatch custom-theme-swatch-select"
|
||||
onClick={() => handleActivateCustomTheme(theme)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<span
|
||||
className="theme-swatch-preview"
|
||||
aria-hidden="true"
|
||||
style={{ background: theme.colors.void }}
|
||||
>
|
||||
<span className="theme-swatch-accent" style={{ background: theme.colors.accent }} />
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{editingTheme && (
|
||||
<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">
|
||||
{CUSTOM_COLOR_FIELDS.map((field) => (
|
||||
<label key={field.key} className="custom-theme-field">
|
||||
<input
|
||||
type="color"
|
||||
value={customColors[field.key]}
|
||||
onChange={(e) => handleCustomColorChange(field.key, e.target.value)}
|
||||
value={editColorsDraft[field.key]}
|
||||
onChange={(e) => handleEditColorChange(field.key, e.target.value)}
|
||||
/>
|
||||
<span>{field.label}</span>
|
||||
</label>
|
||||
@@ -273,28 +410,31 @@ export function ProfileModal({ onClose }: ProfileModalProps) {
|
||||
<div className="custom-theme-scheme-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary${customColors.color_scheme === 'light' ? ' custom-theme-scheme-active' : ''}`}
|
||||
onClick={() => handleCustomColorChange('color_scheme', 'light')}
|
||||
className={`btn-secondary${editColorsDraft.color_scheme === 'light' ? ' custom-theme-scheme-active' : ''}`}
|
||||
onClick={() => handleEditColorChange('color_scheme', 'light')}
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn-secondary${customColors.color_scheme === 'dark' ? ' custom-theme-scheme-active' : ''}`}
|
||||
onClick={() => handleCustomColorChange('color_scheme', 'dark')}
|
||||
className={`btn-secondary${editColorsDraft.color_scheme === 'dark' ? ' custom-theme-scheme-active' : ''}`}
|
||||
onClick={() => handleEditColorChange('color_scheme', 'dark')}
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={closeEditor}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleSaveColors}
|
||||
disabled={savingColors || !customColorsDirty}
|
||||
onClick={handleSaveThemeEdit}
|
||||
disabled={savingThemeEdit}
|
||||
>
|
||||
{savingColors ? 'Saving…' : 'Save colors'}
|
||||
{savingThemeEdit ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
|
||||
+11
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user