diff --git a/backend/app/models.py b/backend/app/models.py index 2f9642c..db094b3 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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"),) diff --git a/backend/app/permissions.py b/backend/app/permissions.py index 677b6d7..8e3bf3f 100644 --- a/backend/app/permissions.py +++ b/backend/app/permissions.py @@ -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"], } diff --git a/backend/app/routers/games.py b/backend/app/routers/games.py index a302233..79a01cd 100644 --- a/backend/app/routers/games.py +++ b/backend/app/routers/games.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 55c6b92..fed2309 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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): diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md new file mode 100644 index 0000000..218d024 --- /dev/null +++ b/docs/UI_DESIGN.md @@ -0,0 +1,123 @@ +# UI: дизайн-система PGames + +Документ для тех, кто добавляет или дорабатывает интерфейс. Прочитайте перед +правкой `frontend/`. Код — источник истины, здесь только правила и карта. + +## Принципы + +1. **«Кинотека»**: спокойный тёмный фон, обложки — главные, один акцент (оранжевый). +2. **Одна система.** Цвета, отступы, радиусы — только через токены (`--accent`, `--space-3`…). + Никаких захардкоженных `#hex` и «магических» px в страницах. +3. **Две темы** (тёмная по умолчанию, светлая, «как в системе»). Всё, что вы + добавите, должно выглядеть нормально в обеих — проверяйте обе. +4. **Все устройства**: десктоп, планшет, телефон, ТВ с геймпадом. Мышь — не единственный сценарий. +5. **Без нативных диалогов.** `alert/confirm/prompt` запрещены (ломаются в + полноэкранном режиме и на геймпаде) — используйте `useUi()`. +6. **Язык интерфейса — русский**, тон короткий и без жаргона. + +## Карта файлов + +| Файл | Что внутри | +|---|---| +| `src/styles.css` | Весь CSS сайта: токены и темы, контролы, поверхности, шапка, страницы, адаптив | +| `src/theme.ts` | `useTheme()` — выбор темы (`system`/`dark`/`light`), хранится в localStorage `pg-theme` | +| `src/ui/Icon.tsx` | Набор иконок (stroke, 24px). Новая иконка — добавить путь в `PATHS` | +| `src/ui/Ui.tsx` | `useUi()` → `confirm`, `prompt`, `toast` (провайдер в `main.tsx`) | +| `src/ui/Bits.tsx` | `PageHeader`, `EmptyState`, `ErrorNote`, `Spinner`, `Cover` | +| `src/ui/nav.ts` | Навигация стрелками и геймпадом (`useSpatialNav`) | +| `src/ui/ThemeButton.tsx` | Кнопка темы для экранов без шапки | +| `src/recent.ts` | «Продолжить игру» (localStorage `pg-recent`) | +| `src/player/player.css` | Стили плеера (отдельная система, см. ниже) | + +## Токены (`:root`) + +- Фоны: `--bg`, `--surface`, `--surface-2`, `--surface-3` (от дальнего к ближнему слою). +- Границы: `--border`, `--border-strong`. Текст: `--text`, `--text-2`, `--muted`. +- Акцент: `--accent`, `--accent-hover`, `--accent-ink` (цвет текста на акценте), `--accent-soft` (фон-подсветка). +- Семантика: `--ok`, `--danger`, `--info` и их `*-soft`. +- Геометрия: `--radius-s` 8 / `--radius` 12 / `--radius-l` 18; `--space-1…6` (4–36px); + `--control-h` — высота контролов (растёт на тач-устройствах). +- Тень: `--shadow-1/2/3`. Оверлей: `--overlay`. + +Светлая тема переопределяет токены в `:root[data-theme="light"]` и в +`@media (prefers-color-scheme: light)` для `:root:not([data-theme])`. **Добавляя +токен, добавьте его во все три блока.** + +## Компоненты (CSS-классы) + +- **Кнопки** `.btn` + модификатор: `.btn-primary` (одна главная на экран), + `.btn-ghost`, `.btn-danger` (контурная), `.btn-danger-solid` (в диалоге удаления), + размеры `.btn-sm`, `.btn-lg`, `.btn-icon`, `.btn-block`. Иконка слева, 16–18px (в `-lg` 20–22). + Кнопка только с иконкой обязана иметь `aria-label` и `title`. +- **Поля**: ``. + Формы — `.form`, ряд полей — `.form-row`. +- **Карточка** `.card`; заметки `.note .note-error|ok|info` (через `ErrorNote`). +- **Статусы** `.badge` + `badge-ok|warn|error|plain`, `busy` — пульсирующая точка для процесса. +- **Чипы** `.chip` (`.on` — выбран), **теги** `.tag`. +- **Таблицы** `.table` в `.table-wrap`. На телефоне таблицу с действиями + заменяйте карточками (пример — `Users.tsx`, `.users-table` / `.user-cards`). +- **Переключатель права** `.switch` (input + ``). +- **Пустое состояние** — `EmptyState`, загрузка списков — `.skeleton`, загрузка действия — `Spinner`. +- **Меню профиля** — `UserMenu` в `App.tsx`; админ-разделы живут там, не в основной навигации. + +## Правила для страниц + +- Обёртка `.page` (`.narrow` 600px / `.medium` 960px для форм и админки). +- Заголовок — `PageHeader` (title, subtitle, actions справа). +- Одна главная кнопка (`btn-primary`) на экран/блок. Опасные действия — `btn-danger` + и **всегда** через `ui.confirm({ danger: true })` с пояснением последствий. +- Результат действия — `ui.toast(text, "ok" | "error")`. Ошибки загрузки страницы — + `ErrorNote` в потоке. Не оставляйте «молчаливых» действий. +- Любой список имеет три состояния: загрузка (скелетон/спиннер), пусто (`EmptyState` с + подсказкой, что делать), данные. +- Ввод имени/значения — `ui.prompt`, а не `window.prompt`. +- Тексты: кнопки — глагол («Создать форк»), подтверждение — конкретное действие, не «Ок». + +## Адаптив и устройства + +- Точка перехода на телефон — **860px**: верхняя навигация скрывается, появляется нижний `.tabbar` + (до 5 пунктов, иконка + подпись), профиль — по аватару. Узкий телефон — **520px** (сетка в 2 колонки). +- `@media (pointer: coarse)`: контролы выше (2.9rem). Минимальная зона касания 44px. +- ТВ: от 1700px увеличивается шрифт, от 2400px ещё больше. Не фиксируйте размеры в px там, где можно `rem`. +- **Фокус всегда виден** (`:focus-visible` — 3px акцентная рамка). Не убирайте `outline`. +- Клавиатура/геймпад: стрелки и D-pad двигают фокус к ближайшему элементу (`nav.ts`), + A — нажать, B — назад. Для этого интерактивные элементы должны быть настоящими + `button`/`a`/`input`, а не `div onClick`. Для карточек используйте ``. +- Уважайте `prefers-reduced-motion` (уже глобально сокращает анимации). + +## Плеер (`src/player`) + +Разделяется на два слоя, их стили не смешиваются: + +1. **Игровой слой** (`.pg-textbox`, `.pg-namebox`, `.pg-text`, `.pg-menu`, `.pg-choice`, NVL, centered) — + должен выглядеть как оригинальная новелла. Цвета и шрифты может переопределять сама игра + (`--pg-accent`, `--pg-text`, шрифты). Не привязывайте его к токенам сайта. +2. **Служебный UI плеера** (`.pg-quick`, `.pg-screen`, `.pg-main-panel`, слоты, настройки, `.pg-dialog`, + `.pg-hud-btn`) — единый стиль «стеклянных» панелей поверх игры, одинаковый для всех игр. + +Нюансы: +- Сцена масштабируется `transform: scale()`, размеры внутри — в `em` от `--pg-font`. + Поэтому **кнопки для касания выносите за сцену** (как `.pg-hud-btn` в `.pg-root`, фиксированные px). +- Подтверждения внутри плеера — `Engine.confirmAsk()`, не `confirm()`. +- Новые экраны открывайте через `openScreen(title, build)`: заголовок, кнопка закрытия и Esc уже есть. +- Кнопки в экранах — обычные `button`, чтобы работала навигация геймпадом (`nav.ts` ограничивается видимым + `.pg-screen` / `.pg-menu`). +- Управление: Esc — меню, Space/Enter — дальше, `A` — авто, `H` — скрыть UI, Ctrl/Tab — пропуск; + геймпад: A/B/X/Y. + +## Как добавить новый экран (чек-лист) + +1. Страница в `src/pages`, маршрут и `RequirePerm` в `App.tsx`; пункт в нижнем `.tabbar` — только если + это основной раздел (иначе в меню профиля). +2. `PageHeader`, состояния загрузки/пусто/ошибка, действия через `useUi`. +3. Только токены и существующие классы; новый повторяющийся паттерн — в `styles.css` (раздел по смыслу), не inline. +4. Проверка: обе темы; ширины 375 / 768 / 1280 / 1920; навигация только клавиатурой (Tab и стрелки); + `npm run build` без ошибок. + +## Чего не делать + +- `alert/confirm/prompt`, `window.open` для служебных окон. +- Цвета и размеры «от руки», `!important` (исключение — уже существующие в плеере). +- Анимации длиннее ~200 мс на интерактивных элементах. +- Информация только цветом: у статуса всегда есть текст. +- Скрытие фокуса, `div` вместо кнопок, иконки без подписи. diff --git a/frontend/index.html b/frontend/index.html index d69f96b..5fce4a7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,8 +2,16 @@
- + +Нет доступа к этой странице.
Нет доступа к этой странице.