UI: unified design system, light/dark themes, redesigned pages and player UI

Tokens and components in styles.css, shared Ui (dialogs, toasts), icons,
bottom tab bar on mobile, keyboard/gamepad navigation, library shelf with
filters and conversion badges, hero game page, restyled player screens.
Games list now returns per-game version counts. Adds docs/UI_DESIGN.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 03:43:53 +03:00
1 parent 51a7e77e56
commit 9ee3e1ff6e
27 files changed
+2399 -508

No files matched your search

+19
View File
@@ -57,6 +57,9 @@ class Game(Base):
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
engine: Mapped[str] = mapped_column(String(32), default="renpy")
cover_hash: Mapped[str | None] = mapped_column(String(80))
# the version "Play" opens; translations are published by merging into it
main_version_id: Mapped[int | None] = mapped_column(
ForeignKey("game_versions.id", ondelete="SET NULL", use_alter=True))
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
@@ -74,6 +77,9 @@ class GameVersion(Base):
description: Mapped[str] = mapped_column(Text, default="")
# uploading -> queued -> converting -> ready | failed; importing = app.import_dir run
status: Mapped[str] = mapped_column(String(16), default="uploading", index=True)
# draft: visible to the owner and reviewers only
visibility: Mapped[str] = mapped_column(String(16), default="published")
kind: Mapped[str] = mapped_column(String(16), default="original") # original|translation|mod
progress: Mapped[str] = mapped_column(String(255), default="")
upload_name: Mapped[str | None] = mapped_column(String(255))
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
@@ -84,6 +90,19 @@ class GameVersion(Base):
owner: Mapped[User | None] = relationship(lazy="joined")
class VersionHistory(Base):
"""Previous manifests of a version that was changed in place (e.g. a merged translation)."""
__tablename__ = "version_history"
id: Mapped[int] = mapped_column(primary_key=True)
version_id: Mapped[int] = mapped_column(ForeignKey("game_versions.id", ondelete="CASCADE"),
index=True)
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
note: Mapped[str] = mapped_column(String(255), default="")
manifest: Mapped[dict[str, Any]] = mapped_column(JSONB)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Save(Base):
__tablename__ = "saves"
__table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),)
+4 -1
View File
@@ -7,6 +7,8 @@ PERMISSIONS: dict[str, str] = {
"games.delete": "Удаление игр и любых версий",
"versions.fork": "Создание своих версий (форков)",
"versions.manage_any": "Управление чужими версиями",
"translations.edit": "Перевод игр в своих версиях",
"versions.publish": "Публикация версий и переводов, выбор главной версии",
"users.manage": "Управление пользователями",
"roles.manage": "Управление ролями",
}
@@ -15,6 +17,7 @@ ALL = sorted(PERMISSIONS)
SYSTEM_ROLES: dict[str, list[str]] = {
"admin": ALL,
"moderator": ["games.play", "games.upload", "games.edit", "versions.fork"],
"moderator": ["games.play", "games.upload", "games.edit", "versions.fork",
"translations.edit", "versions.publish"],
"player": ["games.play"],
}
+23 -3
View File
@@ -3,7 +3,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status
from PIL import Image, UnidentifiedImageError
from sqlalchemy import exists, or_, select, true
from sqlalchemy import exists, func, or_, select, true
from starlette.concurrency import run_in_threadpool
from app.blobs import put_bytes
@@ -40,7 +40,7 @@ def can_edit_game(user: User, game: Game) -> bool:
@router.get("", response_model=list[GameOut])
async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[Game]:
async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[dict]:
stmt = select(Game).order_by(Game.title)
if q.strip():
pattern = f"%{q.strip()}%"
@@ -57,7 +57,27 @@ async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[G
exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user))
| (Game.created_by_id == user.id)
)
return list((await db.scalars(stmt.limit(500))).all())
games = list((await db.scalars(stmt.limit(500))).all())
counts: dict[int, dict[str, int]] = {}
if games:
rows = await db.execute(
select(GameVersion.game_id, GameVersion.status, func.count())
.where(GameVersion.game_id.in_([g.id for g in games]), visible_versions_filter(user))
.group_by(GameVersion.game_id, GameVersion.status)
)
for game_id, st, n in rows:
key = "ready" if st == "ready" else "failed" if st == "failed" else "pending"
counts.setdefault(game_id, {}).setdefault(key, 0)
counts[game_id][key] += n
return [
{
**GameOut.model_validate(g).model_dump(),
"ready_versions": counts.get(g.id, {}).get("ready", 0),
"pending_versions": counts.get(g.id, {}).get("pending", 0),
"failed_versions": counts.get(g.id, {}).get("failed", 0),
}
for g in games
]
@router.post("", response_model=GameOut, status_code=201)
+4
View File
@@ -90,6 +90,10 @@ class GameOut(ORM):
engine: str
cover_hash: str | None
created_at: datetime
# version counts visible to the requesting user (filled by the list endpoint)
ready_versions: int = 0
pending_versions: int = 0
failed_versions: int = 0
class GameDetailOut(GameOut):