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 @@ - + + PGames +
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ad9f991..ab8d6b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ -import { Navigate, NavLink, Route, Routes } from "react-router-dom"; +import { useEffect, useRef, useState } from "react"; +import { Navigate, NavLink, Route, Routes, useLocation } from "react-router-dom"; import { useAuth } from "./auth"; import { ChangeCredentials } from "./pages/ChangeCredentials"; import { GamePage } from "./pages/GamePage"; @@ -8,15 +9,78 @@ import { NewGame } from "./pages/NewGame"; import { Play } from "./pages/Play"; import { Roles } from "./pages/Roles"; import { Users } from "./pages/Users"; +import { useTheme } from "./theme"; +import { Icon } from "./ui/Icon"; +import { useSpatialNav } from "./ui/nav"; +import { THEME_ICON, THEME_LABEL } from "./ui/ThemeButton"; function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) { const { can } = useAuth(); - return can(perm) ? children :

Нет доступа к этой странице.

; + return can(perm) ? children : ( +

Нет доступа к этой странице.

+ ); +} + +function UserMenu() { + const { me, logout, can } = useAuth(); + const { choice, cycle } = useTheme(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const { pathname } = useLocation(); + + useEffect(() => setOpen(false), [pathname]); + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => !ref.current?.contains(e.target as Node) && setOpen(false); + const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false); + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [open]); + + if (!me) return null; + return ( +
+ + {open && ( +
+
+ {me.username} +
{me.role.name}
+
+ Аккаунт и пароль + {(can("users.manage") || can("roles.manage")) &&
Администрирование
} + {can("users.manage") && ( + Пользователи + )} + {can("roles.manage") && ( + Роли и права + )} +
+ + +
+ )} +
+ ); } export function App() { - const { me, loading, logout, can } = useAuth(); - if (loading) return
Загрузка…
; + const { me, loading, can } = useAuth(); + useSpatialNav(); + if (loading) return
; if (!me) return ; if (me.must_change_password || me.must_change_username) return ; @@ -28,18 +92,16 @@ export function App() { element={
- PGames -
@@ -52,6 +114,13 @@ export function App() { } />
+
} /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index da30927..eaffa65 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -41,6 +41,9 @@ export interface Game { engine: string; cover_hash: string | null; created_at: string; + ready_versions?: number; + pending_versions?: number; + failed_versions?: number; } export interface GameDetail extends Game { diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a31c752..b9b0a8e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,16 +1,19 @@ -import { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import { BrowserRouter } from "react-router-dom"; -import { App } from "./App"; -import { AuthProvider } from "./auth"; -import "./styles.css"; - -createRoot(document.getElementById("root")!).render( - - - - - - - , -); +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { App } from "./App"; +import { AuthProvider } from "./auth"; +import { UiProvider } from "./ui/Ui"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + + + + + + + , +); diff --git a/frontend/src/pages/ChangeCredentials.tsx b/frontend/src/pages/ChangeCredentials.tsx index db227a3..7d1cf76 100644 --- a/frontend/src/pages/ChangeCredentials.tsx +++ b/frontend/src/pages/ChangeCredentials.tsx @@ -1,16 +1,22 @@ import { useState, type FormEvent } from "react"; import { api, type Me } from "../api"; import { useAuth } from "../auth"; +import { ErrorNote, PageHeader } from "../ui/Bits"; +import { Icon } from "../ui/Icon"; +import { useUi } from "../ui/Ui"; +import { ThemeButton } from "../ui/ThemeButton"; export function ChangeCredentials({ forced = false }: { forced?: boolean }) { const { me, setMe, logout } = useAuth(); + const ui = useUi(); const needUsername = !!me?.must_change_username; const [username, setUsername] = useState(needUsername ? "" : me?.username ?? ""); const [current, setCurrent] = useState(""); const [password, setPassword] = useState(""); const [repeat, setRepeat] = useState(""); const [error, setError] = useState(""); - const [done, setDone] = useState(false); + + const mismatch = repeat.length > 0 && password !== repeat; const submit = async (e: FormEvent) => { e.preventDefault(); @@ -26,7 +32,7 @@ export function ChangeCredentials({ forced = false }: { forced?: boolean }) { new_username: username.trim() || null, }); setMe(updated); - setDone(true); + if (!forced) ui.toast("Данные сохранены", "ok"); setCurrent(""); setPassword(""); setRepeat(""); @@ -36,39 +42,54 @@ export function ChangeCredentials({ forced = false }: { forced?: boolean }) { }; const form = ( -
-

{forced ? "Смена учётных данных" : "Аккаунт"}

- {forced && ( -

- {needUsername - ? "Это первый вход администратора. Задайте собственный логин и пароль." - : "Пароль был выдан администратором. Задайте свой пароль."} -

- )} -