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) tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
engine: Mapped[str] = mapped_column(String(32), default="renpy") engine: Mapped[str] = mapped_column(String(32), default="renpy")
cover_hash: Mapped[str | None] = mapped_column(String(80)) 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_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()) 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="") description: Mapped[str] = mapped_column(Text, default="")
# uploading -> queued -> converting -> ready | failed; importing = app.import_dir run # uploading -> queued -> converting -> ready | failed; importing = app.import_dir run
status: Mapped[str] = mapped_column(String(16), default="uploading", index=True) 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="") progress: Mapped[str] = mapped_column(String(255), default="")
upload_name: Mapped[str | None] = mapped_column(String(255)) upload_name: Mapped[str | None] = mapped_column(String(255))
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB) manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
@@ -84,6 +90,19 @@ class GameVersion(Base):
owner: Mapped[User | None] = relationship(lazy="joined") 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): class Save(Base):
__tablename__ = "saves" __tablename__ = "saves"
__table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),) __table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),)
+4 -1
View File
@@ -7,6 +7,8 @@ PERMISSIONS: dict[str, str] = {
"games.delete": "Удаление игр и любых версий", "games.delete": "Удаление игр и любых версий",
"versions.fork": "Создание своих версий (форков)", "versions.fork": "Создание своих версий (форков)",
"versions.manage_any": "Управление чужими версиями", "versions.manage_any": "Управление чужими версиями",
"translations.edit": "Перевод игр в своих версиях",
"versions.publish": "Публикация версий и переводов, выбор главной версии",
"users.manage": "Управление пользователями", "users.manage": "Управление пользователями",
"roles.manage": "Управление ролями", "roles.manage": "Управление ролями",
} }
@@ -15,6 +17,7 @@ ALL = sorted(PERMISSIONS)
SYSTEM_ROLES: dict[str, list[str]] = { SYSTEM_ROLES: dict[str, list[str]] = {
"admin": ALL, "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"], "player": ["games.play"],
} }
+23 -3
View File
@@ -3,7 +3,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from PIL import Image, UnidentifiedImageError 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 starlette.concurrency import run_in_threadpool
from app.blobs import put_bytes 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]) @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) stmt = select(Game).order_by(Game.title)
if q.strip(): if q.strip():
pattern = f"%{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)) exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user))
| (Game.created_by_id == user.id) | (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) @router.post("", response_model=GameOut, status_code=201)
+4
View File
@@ -90,6 +90,10 @@ class GameOut(ORM):
engine: str engine: str
cover_hash: str | None cover_hash: str | None
created_at: datetime 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): class GameDetailOut(GameOut):
+123
View File
@@ -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`.
- **Поля**: `<label className="field"><span>Подпись</span><input/> <span className="hint">…</span></label>`.
Формы — `.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 + `<i/>`).
- **Пустое состояние** — `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`. Для карточек используйте `<Link>`.
- Уважайте `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` вместо кнопок, иконки без подписи.
+9 -1
View File
@@ -2,8 +2,16 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0e1016" />
<title>PGames</title> <title>PGames</title>
<script>
// apply the saved theme before first paint to avoid a flash
try {
var t = localStorage.getItem("pg-theme");
if (t === "light" || t === "dark") document.documentElement.dataset.theme = t;
} catch (e) {}
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+84 -15
View File
@@ -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 { useAuth } from "./auth";
import { ChangeCredentials } from "./pages/ChangeCredentials"; import { ChangeCredentials } from "./pages/ChangeCredentials";
import { GamePage } from "./pages/GamePage"; import { GamePage } from "./pages/GamePage";
@@ -8,15 +9,78 @@ import { NewGame } from "./pages/NewGame";
import { Play } from "./pages/Play"; import { Play } from "./pages/Play";
import { Roles } from "./pages/Roles"; import { Roles } from "./pages/Roles";
import { Users } from "./pages/Users"; 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 }) { function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) {
const { can } = useAuth(); const { can } = useAuth();
return can(perm) ? children : <div className="page"><p className="muted">Нет доступа к этой странице.</p></div>; return can(perm) ? children : (
<div className="page"><p className="muted">Нет доступа к этой странице.</p></div>
);
}
function UserMenu() {
const { me, logout, can } = useAuth();
const { choice, cycle } = useTheme();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(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 (
<div className="menu" ref={ref}>
<button className="btn btn-ghost menu-trigger" onClick={() => setOpen((o) => !o)}
aria-haspopup="menu" aria-expanded={open} aria-label="Меню профиля">
<span className="avatar">{me.username[0]?.toUpperCase()}</span>
<span className="menu-label">{me.username}</span>
<Icon name="chevron" size={14} className="menu-chevron" style={{ transform: "rotate(90deg)" }} />
</button>
{open && (
<div className="menu-panel" role="menu">
<div className="menu-head">
<b>{me.username}</b>
<div className="muted small">{me.role.name}</div>
</div>
<NavLink to="/account" className="menu-item" role="menuitem"><Icon name="key" size={18} />Аккаунт и пароль</NavLink>
{(can("users.manage") || can("roles.manage")) && <div className="menu-section">Администрирование</div>}
{can("users.manage") && (
<NavLink to="/admin/users" className="menu-item" role="menuitem"><Icon name="users" size={18} />Пользователи</NavLink>
)}
{can("roles.manage") && (
<NavLink to="/admin/roles" className="menu-item" role="menuitem"><Icon name="shield" size={18} />Роли и права</NavLink>
)}
<div className="menu-sep" />
<button className="menu-item" role="menuitem" onClick={cycle}>
<Icon name={THEME_ICON[choice]} size={18} />{THEME_LABEL[choice]}
</button>
<button className="menu-item danger" role="menuitem" onClick={() => void logout()}>
<Icon name="logout" size={18} />Выйти
</button>
</div>
)}
</div>
);
} }
export function App() { export function App() {
const { me, loading, logout, can } = useAuth(); const { me, loading, can } = useAuth();
if (loading) return <div className="center muted">Загрузка…</div>; useSpatialNav();
if (loading) return <div className="center"><span className="spinner" /></div>;
if (!me) return <Login />; if (!me) return <Login />;
if (me.must_change_password || me.must_change_username) return <ChangeCredentials forced />; if (me.must_change_password || me.must_change_username) return <ChangeCredentials forced />;
@@ -28,18 +92,16 @@ export function App() {
element={ element={
<div className="layout"> <div className="layout">
<header className="topbar"> <header className="topbar">
<NavLink to="/" className="brand">PGames</NavLink> <NavLink to="/" className="brand">
<nav> <span className="brand-mark"><Icon name="play" size={16} /></span>PGames
<NavLink to="/" end>Библиотека</NavLink> </NavLink>
{can("games.upload") && <NavLink to="/games/new">Загрузить игру</NavLink>} <nav aria-label="Основная навигация">
{can("users.manage") && <NavLink to="/admin/users">Пользователи</NavLink>} <NavLink to="/" end className="nav-link"><Icon name="library" size={18} />Библиотека</NavLink>
{can("roles.manage") && <NavLink to="/admin/roles">Роли</NavLink>} {can("games.upload") && (
<NavLink to="/games/new" className="nav-link"><Icon name="upload" size={18} />Загрузить игру</NavLink>
)}
</nav> </nav>
<div className="user"> <UserMenu />
<NavLink to="/account">{me.username}</NavLink>
<span className="muted"> · {me.role.name}</span>
<button className="link" onClick={() => void logout()}>Выйти</button>
</div>
</header> </header>
<main> <main>
<Routes> <Routes>
@@ -52,6 +114,13 @@ export function App() {
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</main> </main>
<nav className="tabbar" aria-label="Нижняя навигация">
<NavLink to="/" end><Icon name="library" />Библиотека</NavLink>
{can("games.upload") && <NavLink to="/games/new"><Icon name="upload" />Загрузить</NavLink>}
{can("users.manage") && <NavLink to="/admin/users"><Icon name="users" />Люди</NavLink>}
{can("roles.manage") && <NavLink to="/admin/roles"><Icon name="shield" />Роли</NavLink>}
<NavLink to="/account"><Icon name="user" />Профиль</NavLink>
</nav>
</div> </div>
} }
/> />
+3
View File
@@ -41,6 +41,9 @@ export interface Game {
engine: string; engine: string;
cover_hash: string | null; cover_hash: string | null;
created_at: string; created_at: string;
ready_versions?: number;
pending_versions?: number;
failed_versions?: number;
} }
export interface GameDetail extends Game { export interface GameDetail extends Game {
+19 -16
View File
@@ -1,16 +1,19 @@
import { StrictMode } from "react"; import { StrictMode } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { App } from "./App"; import { App } from "./App";
import { AuthProvider } from "./auth"; import { AuthProvider } from "./auth";
import "./styles.css"; import { UiProvider } from "./ui/Ui";
import "./styles.css";
createRoot(document.getElementById("root")!).render(
<StrictMode> createRoot(document.getElementById("root")!).render(
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}> <StrictMode>
<AuthProvider> <BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<App /> <UiProvider>
</AuthProvider> <AuthProvider>
</BrowserRouter> <App />
</StrictMode>, </AuthProvider>
); </UiProvider>
</BrowserRouter>
</StrictMode>,
);
+51 -30
View File
@@ -1,16 +1,22 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { api, type Me } from "../api"; import { api, type Me } from "../api";
import { useAuth } from "../auth"; 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 }) { export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
const { me, setMe, logout } = useAuth(); const { me, setMe, logout } = useAuth();
const ui = useUi();
const needUsername = !!me?.must_change_username; const needUsername = !!me?.must_change_username;
const [username, setUsername] = useState(needUsername ? "" : me?.username ?? ""); const [username, setUsername] = useState(needUsername ? "" : me?.username ?? "");
const [current, setCurrent] = useState(""); const [current, setCurrent] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState(""); const [repeat, setRepeat] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [done, setDone] = useState(false);
const mismatch = repeat.length > 0 && password !== repeat;
const submit = async (e: FormEvent) => { const submit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -26,7 +32,7 @@ export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
new_username: username.trim() || null, new_username: username.trim() || null,
}); });
setMe(updated); setMe(updated);
setDone(true); if (!forced) ui.toast("Данные сохранены", "ok");
setCurrent(""); setCurrent("");
setPassword(""); setPassword("");
setRepeat(""); setRepeat("");
@@ -36,39 +42,54 @@ export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
}; };
const form = ( const form = (
<form className="card auth-card" onSubmit={submit}> <form className={forced ? "card auth-card" : "card form"} onSubmit={submit}>
<h1>{forced ? "Смена учётных данных" : "Аккаунт"}</h1> {forced ? (
{forced && ( <div>
<p className="muted"> <div className="brand"><span className="brand-mark"><Icon name="key" size={20} /></span>Смена данных</div>
{needUsername <p className="muted" style={{ marginTop: "0.6rem" }}>
? "Это первый вход администратора. Задайте собственный логин и пароль." {needUsername
: "Пароль был выдан администратором. Задайте свой пароль."} ? "Это первый вход администратора. Задайте собственный логин и пароль."
</p> : "Пароль был выдан администратором. Задайте свой пароль."}
)} </p>
<label> </div>
Логин{needUsername && " (новый)"} ) : null}
<label className="field">
<span>Логин{needUsername && " (новый)"}</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" /> <input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label> </label>
<label> <label className="field">
Текущий пароль <span>Текущий пароль</span>
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} <input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" />
autoComplete="current-password" />
</label> </label>
<label> <label className="field">
Новый пароль (от 8 символов) <span>Новый пароль</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
autoComplete="new-password" /> <span className="hint">Минимум 8 символов</span>
</label> </label>
<label> <label className="field">
Повторите пароль <span>Повторите пароль</span>
<input type="password" value={repeat} onChange={(e) => setRepeat(e.target.value)} <input type="password" value={repeat} onChange={(e) => setRepeat(e.target.value)} autoComplete="new-password"
autoComplete="new-password" /> aria-invalid={mismatch} style={mismatch ? { borderColor: "var(--danger)" } : undefined} />
{mismatch && <span className="hint" style={{ color: "var(--danger)" }}>Пароли не совпадают</span>}
</label> </label>
{error && <div className="error">{error}</div>} {error && <ErrorNote>{error}</ErrorNote>}
{done && !forced && <div className="ok">Сохранено</div>} <button className="btn btn-primary btn-lg" disabled={!current || password.length < 8 || mismatch}>Сохранить</button>
<button className="primary" disabled={!current || password.length < 8}>Сохранить</button> {forced && <button type="button" className="btn btn-ghost" onClick={() => void logout()}>Выйти</button>}
{forced && <button type="button" className="link" onClick={() => void logout()}>Выйти</button>}
</form> </form>
); );
return forced ? <div className="center">{form}</div> : <div className="page narrow">{form}</div>;
if (forced) {
return (
<div className="auth-wrap">
<ThemeButton className="auth-theme" />
{form}
</div>
);
}
return (
<div className="page narrow">
<PageHeader title="Аккаунт" subtitle={<>Вы вошли как <b>{me?.username}</b> · {me?.role.name}</>} />
{form}
</div>
);
} }
+161 -86
View File
@@ -2,19 +2,30 @@ import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../api"; import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import { getRecent, pushRecent } from "../recent";
import { Cover, ErrorNote, Spinner } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
const STATUS: Record<Version["status"], string> = { const STATUS: Record<Version["status"], string> = {
uploading: "загружается", uploading: "Загружается",
queued: "в очереди", queued: "В очереди",
converting: "конвертируется", converting: "Конвертируется",
importing: "импортируется", importing: "Импортируется",
ready: "готова", ready: "Готова",
failed: "ошибка", failed: "Ошибка",
}; };
const BUSY: Version["status"][] = ["uploading", "queued", "converting", "importing"];
function StatusBadge({ status }: { status: Version["status"] }) {
const cls = status === "ready" ? "badge-ok" : status === "failed" ? "badge-error" : "badge-warn busy";
return <span className={`badge ${cls}`}>{STATUS[status]}</span>;
}
export function GamePage() { export function GamePage() {
const { gameId } = useParams(); const { gameId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const ui = useUi();
const { me, can } = useAuth(); const { me, can } = useAuth();
const [game, setGame] = useState<GameDetail | null>(null); const [game, setGame] = useState<GameDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -33,43 +44,75 @@ export function GamePage() {
}, [load]); }, [load]);
// poll while something is being converted // poll while something is being converted
const busy = game?.versions.some((v) => ["uploading", "queued", "converting", "importing"].includes(v.status)); const busy = game?.versions.some((v) => BUSY.includes(v.status));
useEffect(() => { useEffect(() => {
if (!busy) return; if (!busy) return;
const t = setInterval(() => void load(), 2000); const t = setInterval(() => void load(), 2000);
return () => clearInterval(t); return () => clearInterval(t);
}, [busy, load]); }, [busy, load]);
if (error) return <div className="page"><div className="error">{error}</div></div>; if (error && !game) return <div className="page"><ErrorNote>{error}</ErrorNote></div>;
if (!game) return <div className="page muted">Загрузка…</div>; if (!game) return <div className="page"><Spinner /></div>;
// the backend also lets uploaders edit only games they created // the backend also lets uploaders edit only games they created
const canEdit = can("games.edit") || can("games.upload"); const canEdit = can("games.edit") || can("games.upload");
const run = async (fn: () => Promise<unknown>) => { const run = async (fn: () => Promise<unknown>, okText?: string) => {
setError("");
try { try {
await fn(); await fn();
if (okText) ui.toast(okText, "ok");
await load(); await load();
} catch (e) { } catch (e) {
setError((e as Error).message); ui.toast((e as Error).message, "error");
} }
}; };
const fork = (v: Version) => { const play = (v: Version) => {
const name = prompt("Название новой версии", `${v.name} (${me?.username})`); pushRecent({ gameId: game.id, versionId: v.id, title: game.title, coverHash: game.cover_hash });
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name })); navigate(`/play/${v.id}`);
};
const fork = async (v: Version) => {
const name = await ui.prompt({
title: "Новая версия (форк)",
text: "Форк делит с оригиналом все ассеты и почти не занимает места.",
label: "Название версии", initial: `${v.name} (${me?.username})`, confirmLabel: "Создать форк",
});
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }), "Форк создан");
}; };
const uploadCover = (file: File) => const uploadCover = (file: File) =>
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {})); run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}), "Обложка обновлена");
const uploadVersion = (file: File) => { const uploadVersion = async (file: File) => {
const name = prompt("Название версии", file.name); const name = await ui.prompt({
title: "Загрузить новую версию", label: "Название версии", initial: file.name.replace(/\.[^.]+$/, ""),
confirmLabel: "Загрузить",
});
if (name) if (name)
void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`, void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`,
file, () => {})); file, () => {}), "Архив загружен, началась конвертация");
}; };
const removeVersion = async (v: Version) => {
if (await ui.confirm({ title: `Удалить версию «${v.name}»?`, text: "Сохранения игроков этой версии тоже будут удалены.", danger: true }))
void run(() => api.del(`/api/versions/${v.id}`), "Версия удалена");
};
const removeGame = async () => {
if (!(await ui.confirm({ title: `Удалить «${game.title}»?`, text: "Игра будет удалена вместе со всеми версиями и сохранениями.", danger: true }))) return;
try {
await api.del(`/api/games/${game.id}`);
ui.toast("Игра удалена", "ok");
navigate("/");
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const ready = game.versions.filter((v) => v.status === "ready");
const lastId = getRecent().find((r) => r.gameId === game.id)?.versionId;
const playVersion = ready.find((v) => v.id === lastId) ?? ready.find((v) => v.parent_id === null) ?? ready[0];
const byParent = new Map<number | null, Version[]>(); const byParent = new Map<number | null, Version[]>();
for (const v of game.versions) { for (const v of game.versions) {
const parent = game.versions.some((p) => p.id === v.parent_id) ? v.parent_id : null; const parent = game.versions.some((p) => p.id === v.parent_id) ? v.parent_id : null;
@@ -77,60 +120,76 @@ export function GamePage() {
} }
const renderTree = (parent: number | null, depth: number): JSX.Element[] => const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [ (byParent.get(parent) ?? []).flatMap((v) => [
<VersionRow key={v.id} v={v} depth={depth} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")} <VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id}
canFork={can("versions.fork")} onPlay={() => navigate(`/play/${v.id}`)} onFork={() => fork(v)} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canFork={can("versions.fork")} onPlay={() => play(v)} onFork={() => void fork(v)}
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))} onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
onDelete={() => confirm(`Удалить версию «${v.name}»?`) && void run(() => api.del(`/api/versions/${v.id}`))} />, onDelete={() => void removeVersion(v)} />,
...renderTree(v.id, depth + 1), ...renderTree(v.id, depth + 1),
]); ]);
return ( return (
<div className="page"> <div className="page">
<Link to="/" className="muted">← Библиотека</Link> <div className="hero">
<div className="game-head"> {game.cover_hash && <div className="hero-bg" style={{ backgroundImage: `url(/blobs/${game.cover_hash})` }} />}
<div className="cover big"> <Link to="/" className="back-link"><Icon name="back" size={18} />Библиотека</Link>
{game.cover_hash ? <img src={`/blobs/${game.cover_hash}`} alt="" /> : <span>{game.title[0]}</span>} <div className="hero-inner">
</div> <Cover hash={game.cover_hash} title={game.title} className="big" />
<div className="game-info"> <div className="hero-info">
{editing ? ( {editing ? (
<GameEditor game={game} onDone={() => { setEditing(false); void load(); }} /> <GameEditor game={game} onDone={(changed) => { setEditing(false); if (changed) { ui.toast("Сохранено", "ok"); void load(); } }} />
) : ( ) : (
<> <>
<h1>{game.title}</h1> <h1>{game.title}</h1>
<div className="tags">{game.tags.map((t) => <span key={t} className="tag">{t}</span>)}</div> {game.tags.length > 0 && <div className="tags">{game.tags.map((t) => <Link key={t} to={`/?tag=${encodeURIComponent(t)}`} className="tag">{t}</Link>)}</div>}
<p className="description">{game.description || <span className="muted">Нет описания</span>}</p> <p className="description">{game.description || <span className="muted">Нет описания</span>}</p>
<div className="actions"> <div className="hero-actions">
{canEdit && <button onClick={() => setEditing(true)}>Редактировать</button>} {playVersion ? (
{canEdit && ( <button className="btn btn-primary btn-lg" onClick={() => play(playVersion)}>
<label className="button"> <Icon name="play" size={22} />Играть
Обложка </button>
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} /> ) : (
</label> <span className="badge badge-warn busy">{busy ? "Идёт конвертация…" : "Нет готовых версий"}</span>
)} )}
{can("games.upload") && ( {playVersion && game.versions.length > 1 && <span className="muted small">версия «{playVersion.name}»</span>}
<label className="button"> </div>
Загрузить ещё архив <div className="actions">
<input type="file" hidden onChange={(e) => e.target.files?.[0] && uploadVersion(e.target.files[0])} /> {canEdit && <button className="btn btn-sm" onClick={() => setEditing(true)}><Icon name="edit" size={16} />Редактировать</button>}
</label> {canEdit && (
)} <label className="btn btn-sm">
{can("games.delete") && ( <Icon name="image" size={16} />Обложка
<button className="danger" onClick={() => confirm("Удалить игру со всеми версиями?") && <input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
void api.del(`/api/games/${game.id}`).then(() => navigate("/"))}>Удалить игру</button> </label>
)} )}
</div> {can("games.delete") && (
</> <button className="btn btn-sm btn-danger" onClick={() => void removeGame()}><Icon name="trash" size={16} />Удалить игру</button>
)} )}
</div>
</>
)}
</div>
</div> </div>
</div> </div>
{error && <div className="error">{error}</div>}
<h2>Версии</h2> <div className="section-title" style={{ marginTop: "var(--space-5)" }}>
<h2>Версии</h2>
<span className="count">{game.versions.length}</span>
<span className="grow" />
{can("games.upload") && (
<label className="btn btn-sm">
<Icon name="upload" size={16} />Загрузить ещё архив
<input type="file" hidden onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ""; if (f) void uploadVersion(f); }} />
</label>
)}
</div>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="versions">{renderTree(null, 0)}</div> <div className="versions">{renderTree(null, 0)}</div>
</div> </div>
); );
} }
function VersionRow(props: { function VersionRow(props: {
v: Version; depth: number; canManage: boolean; canFork: boolean; v: Version; depth: number; current: boolean; canManage: boolean; canFork: boolean;
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void; onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
}) { }) {
const { v } = props; const { v } = props;
@@ -139,25 +198,38 @@ function VersionRow(props: {
if (report !== undefined) setReport(undefined); if (report !== undefined) setReport(undefined);
else setReport((await api.get<Version>(`/api/versions/${v.id}`)).report ?? null); else setReport((await api.get<Version>(`/api/versions/${v.id}`)).report ?? null);
}; };
const inProgress = BUSY.includes(v.status);
return ( return (
<div className="version" style={{ marginLeft: props.depth * 24 }}> <div className={`version ${props.current ? "main" : ""}`} style={{ marginLeft: Math.min(props.depth, 4) * 22 }}>
<div className="version-main"> <div className="version-main">
<div> <div>
<b>{props.depth > 0 && "↳ "}{v.name}</b> <div className="version-title">
<span className={`status status-${v.status}`}>{STATUS[v.status]}</span> {props.depth > 0 && <span className="fork-mark" title="Форк"><Icon name="fork" size={16} /></span>}
<div className="muted small"> <b>{v.name}</b>
<StatusBadge status={v.status} />
</div>
<div className="muted small version-meta">
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)} {v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
{v.progress && ` · ${v.progress}`} {v.progress && ` · ${v.progress}`}
</div> </div>
</div> </div>
<div className="actions"> <div className="actions">
{v.status === "ready" && <button className="primary" onClick={props.onPlay}>Играть</button>} {v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>}
{v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>} {v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
{(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>} {(v.status === "ready" || v.status === "failed") && (
{v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>} <button className="btn btn-sm btn-ghost" onClick={() => void toggleReport()} aria-expanded={report !== undefined}>
{props.canManage && !["converting", "importing"].includes(v.status) && <button className="danger" onClick={props.onDelete}>Удалить</button>} <Icon name="file" size={16} />Отчёт
</button>
)}
{v.status === "failed" && props.canManage && <button className="btn btn-sm" onClick={props.onRetry}><Icon name="retry" size={16} />Повторить</button>}
{props.canManage && !["converting", "importing"].includes(v.status) && (
<button className="btn btn-sm btn-ghost btn-icon btn-danger" onClick={props.onDelete} title="Удалить версию" aria-label="Удалить версию">
<Icon name="trash" size={16} />
</button>
)}
</div> </div>
</div> </div>
{inProgress && <div className="version-progress progress"><div style={{ width: "100%", opacity: 0.5 }} /></div>}
{report !== undefined && <Report report={report} />} {report !== undefined && <Report report={report} />}
</div> </div>
); );
@@ -165,17 +237,20 @@ function VersionRow(props: {
function Report({ report }: { report: Record<string, any> | null }) { function Report({ report }: { report: Record<string, any> | null }) {
if (!report) return <div className="report muted">Отчёта нет</div>; if (!report) return <div className="report muted">Отчёта нет</div>;
if (report.error) return <div className="report error">{report.error}</div>; if (report.error) return <div className="report"><ErrorNote>{report.error}</ErrorNote></div>;
const stat = (value: string | number, label: string) => (
<div className="stat" key={label}><b>{value}</b><span>{label}</span></div>
);
return ( return (
<div className="report"> <div className="report">
<div className="report-stats"> <div className="report-stats">
<span>Скриптов: {report.scripts} (декомпилировано {report.decompiled})</span> {stat(`${report.scripts} / ${report.decompiled}`, "скриптов / декомпилировано")}
<span>Меток: {report.labels}</span> {stat(report.labels, "меток")}
<span>Реплик: {report.stats?.say ?? 0}</span> {stat(report.stats?.say ?? 0, "реплик")}
<span>Меню: {report.stats?.menu ?? 0}</span> {stat(report.stats?.menu ?? 0, "меню выбора")}
<span>Картинок: {report.media?.image}, аудио: {report.media?.audio}, видео: {report.media?.video}</span> {stat(`${report.media?.image ?? 0} · ${report.media?.audio ?? 0} · ${report.media?.video ?? 0}`, "картинок · аудио · видео")}
<span>Размер: {formatSize(report.sourceSize)} → {formatSize(report.outputSize)}</span> {stat(`${formatSize(report.sourceSize)} → ${formatSize(report.outputSize)}`, "размер")}
<span>Время: {report.durationSec} с</span> {stat(`${report.durationSec} с`, "время конвертации")}
</div> </div>
{report.missingFiles?.length > 0 && ( {report.missingFiles?.length > 0 && (
<details> <details>
@@ -203,7 +278,7 @@ function Report({ report }: { report: Record<string, any> | null }) {
); );
} }
function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void }) { function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: boolean) => void }) {
const [title, setTitle] = useState(game.title); const [title, setTitle] = useState(game.title);
const [description, setDescription] = useState(game.description); const [description, setDescription] = useState(game.description);
const [tags, setTags] = useState(game.tags.join(", ")); const [tags, setTags] = useState(game.tags.join(", "));
@@ -213,21 +288,21 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void })
await api.patch(`/api/games/${game.id}`, { await api.patch(`/api/games/${game.id}`, {
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean), title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
}); });
onDone(); onDone(true);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
} }
}; };
return ( return (
<div className="editor"> <form className="editor" onSubmit={(e) => { e.preventDefault(); void save(); }}>
<input value={title} onChange={(e) => setTitle(e.target.value)} /> <label className="field"><span>Название</span><input value={title} onChange={(e) => setTitle(e.target.value)} /></label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} /> <label className="field"><span>Описание</span><textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} /></label>
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="теги через запятую" /> <label className="field"><span>Теги</span><input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="через запятую: романтика, драма" /></label>
{error && <div className="error">{error}</div>} {error && <ErrorNote>{error}</ErrorNote>}
<div className="actions"> <div className="actions">
<button className="primary" onClick={() => void save()}>Сохранить</button> <button className="btn btn-primary" disabled={!title.trim()}>Сохранить</button>
<button onClick={onDone}>Отмена</button> <button type="button" className="btn" onClick={() => onDone(false)}>Отмена</button>
</div> </div>
</div> </form>
); );
} }
+158 -22
View File
@@ -1,52 +1,180 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom"; import { Link, useSearchParams } from "react-router-dom";
import { api, type Game } from "../api"; import { api, type Game } from "../api";
import { useAuth } from "../auth";
import { dropRecent, getRecent, type RecentPlay } from "../recent";
import { Cover, EmptyState, ErrorNote, PageHeader } from "../ui/Bits";
import { Icon } from "../ui/Icon";
type Sort = "new" | "title" | "recent";
const SORTS: [Sort, string][] = [["new", "Новые"], ["title", "По названию"], ["recent", "Недавно играл"]];
function StatusBadge({ g }: { g: Game }) {
if (g.pending_versions) return <span className="badge badge-warn busy">Конвертируется</span>;
if (g.failed_versions && !g.ready_versions) return <span className="badge badge-error">Ошибка</span>;
return null;
}
export function Library() { export function Library() {
const { can } = useAuth();
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const q = params.get("q") ?? ""; const q = params.get("q") ?? "";
const tag = params.get("tag") ?? "";
const [query, setQuery] = useState(q); const [query, setQuery] = useState(q);
const [sort, setSort] = useState<Sort>(() => (localStorage.getItem("pg-sort") as Sort) || "new");
const [games, setGames] = useState<Game[] | null>(null); const [games, setGames] = useState<Game[] | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [recent, setRecent] = useState<RecentPlay[]>(getRecent);
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setParams(query ? { q: query } : {}, { replace: true }), 250); const t = setTimeout(() => {
const next = new URLSearchParams(params);
if (query) next.set("q", query);
else next.delete("q");
setParams(next, { replace: true });
}, 250);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [query, setParams]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const hasPending = games?.some((g) => g.pending_versions) ?? false;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
api.get<Game[]>(`/api/games?q=${encodeURIComponent(q)}`) const load = () =>
.then((g) => !cancelled && setGames(g)) api.get<Game[]>(`/api/games?q=${encodeURIComponent(q)}`)
.catch((e) => !cancelled && setError(e.message)); .then((g) => !cancelled && (setGames(g), setError("")))
.catch((e) => !cancelled && setError(e.message));
void load();
// keep conversion badges fresh while something is being converted
const timer = hasPending ? setInterval(() => void load(), 4000) : undefined;
return () => { return () => {
cancelled = true; cancelled = true;
if (timer) clearInterval(timer);
}; };
}, [q]); }, [q, hasPending]);
const changeSort = (s: Sort) => {
setSort(s);
try {
localStorage.setItem("pg-sort", s);
} catch {
/* storage unavailable */
}
};
const setTag = (t: string) => {
const next = new URLSearchParams(params);
if (t && t !== tag) next.set("tag", t);
else next.delete("tag");
setParams(next, { replace: true });
};
const allTags = useMemo(() => {
const counts = new Map<string, number>();
for (const g of games ?? []) for (const t of g.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 24);
}, [games]);
const shown = useMemo(() => {
const list = (games ?? []).filter((g) => !tag || g.tags.includes(tag));
const playedAt = new Map(recent.map((r) => [r.gameId, r.at]));
return [...list].sort((a, b) => {
if (sort === "title") return a.title.localeCompare(b.title, "ru");
if (sort === "recent") return (playedAt.get(b.id) ?? 0) - (playedAt.get(a.id) ?? 0) || a.title.localeCompare(b.title, "ru");
return b.created_at.localeCompare(a.created_at);
});
}, [games, tag, sort, recent]);
// only show "continue" for games that still exist in the library
const known = new Set((games ?? []).map((g) => g.id));
const shelf = games ? recent.filter((r) => known.has(r.gameId)).slice(0, 8) : [];
const filtering = !!(q || tag);
return ( return (
<div className="page"> <div className="page">
<div className="search"> <PageHeader
<input placeholder="Поиск по названию, описанию или тегу…" value={query} title="Библиотека"
onChange={(e) => setQuery(e.target.value)} autoFocus /> subtitle={games ? `${games.length} ${plural(games.length, "игра", "игры", "игр")}` : undefined}
actions={can("games.upload") && (
<Link to="/games/new" className="btn btn-primary"><Icon name="plus" size={18} />Загрузить игру</Link>
)}
/>
{shelf.length > 0 && !filtering && (
<section aria-label="Продолжить игру">
<div className="section-title"><Icon name="clock" size={20} /><h2>Продолжить</h2></div>
<div className="shelf">
{shelf.map((r) => (
<div key={r.gameId} className="recent-card">
<Link to={`/play/${r.versionId}`} aria-label={`Продолжить: ${r.title}`}>
<Cover hash={r.coverHash} title={r.title} />
<div className="recent-body">
<b>{r.title}</b>
<span className="recent-play"><Icon name="play" size={16} /></span>
</div>
</Link>
<button className="recent-x" title="Убрать из списка" aria-label="Убрать из списка"
onClick={() => { dropRecent(r.gameId); setRecent(getRecent()); }}>
<Icon name="x" size={14} />
</button>
</div>
))}
</div>
</section>
)}
<div className="toolbar" style={{ marginTop: shelf.length && !filtering ? "var(--space-5)" : 0 }}>
<div className="search">
<Icon name="search" size={20} />
<input type="search" placeholder="Поиск по названию, описанию или тегу…" value={query}
onChange={(e) => setQuery(e.target.value)} aria-label="Поиск" />
</div>
<select value={sort} onChange={(e) => changeSort(e.target.value as Sort)} aria-label="Сортировка"
style={{ height: "2.9rem" }}>
{SORTS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div> </div>
{error && <div className="error">{error}</div>}
{games === null ? ( {allTags.length > 0 && (
<p className="muted">Загрузка…</p> <div className="chips" style={{ marginBottom: "var(--space-4)" }} role="group" aria-label="Теги">
) : games.length === 0 ? ( <button className={`chip ${!tag ? "on" : ""}`} onClick={() => setTag("")}>Все</button>
<p className="muted">{q ? "Ничего не найдено." : "В библиотеке пока нет игр."}</p> {allTags.map(([t, n]) => (
<button key={t} className={`chip ${tag === t ? "on" : ""}`} onClick={() => setTag(t)}>
{t} <span className="muted small">{n}</span>
</button>
))}
</div>
)}
{error && <ErrorNote>{error}</ErrorNote>}
{games === null && !error ? (
<div className="grid" aria-busy="true">
{Array.from({ length: 8 }, (_, i) => <div key={i} className="skeleton" style={{ aspectRatio: "16 / 13" }} />)}
</div>
) : shown.length === 0 && games ? (
filtering ? (
<EmptyState icon="search" title="Ничего не найдено" text="Попробуйте изменить запрос или сбросить фильтр по тегу."
action={<button className="btn" onClick={() => { setQuery(""); setParams({}, { replace: true }); }}>Сбросить</button>} />
) : (
<EmptyState icon="library" title="В библиотеке пока нет игр"
text={can("games.upload") ? "Загрузите архив с игрой на Ren'Py — сервер сконвертирует её в формат PGVN." : "Попросите администратора добавить игры."}
action={can("games.upload") ? <Link to="/games/new" className="btn btn-primary"><Icon name="upload" size={18} />Загрузить игру</Link> : undefined} />
)
) : ( ) : (
<div className="grid"> <div className="grid">
{games.map((g) => ( {shown.map((g) => (
<Link key={g.id} to={`/games/${g.id}`} className="game-card"> <Link key={g.id} to={`/games/${g.id}`} className="game-card">
<div className="cover"> <div className="game-card-media">
{g.cover_hash ? <img src={`/blobs/${g.cover_hash}`} alt="" loading="lazy" /> : <span>{g.title[0]}</span>} <Cover hash={g.cover_hash} title={g.title} />
<StatusBadge g={g} />
</div> </div>
<div className="game-card-body"> <div className="game-card-body">
<b>{g.title}</b> <span className="game-card-title">{g.title}</span>
<div className="tags"> {g.tags.length > 0 && (
{g.tags.slice(0, 4).map((t) => <span key={t} className="tag">{t}</span>)} <div className="tags">{g.tags.slice(0, 3).map((t) => <span key={t} className="tag">{t}</span>)}</div>
</div> )}
{!!g.ready_versions && g.ready_versions > 1 && (
<span className="game-card-meta">{g.ready_versions} {plural(g.ready_versions, "версия", "версии", "версий")}</span>
)}
</div> </div>
</Link> </Link>
))} ))}
@@ -55,3 +183,11 @@ export function Library() {
</div> </div>
); );
} }
export function plural(n: number, one: string, few: string, many: string): string {
const m10 = n % 10;
const m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return few;
return many;
}
+17 -9
View File
@@ -1,6 +1,9 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { api, type Me } from "../api"; import { api, type Me } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import { ErrorNote } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { ThemeButton } from "../ui/ThemeButton";
export function Login() { export function Login() {
const { setMe } = useAuth(); const { setMe } = useAuth();
@@ -23,21 +26,26 @@ export function Login() {
}; };
return ( return (
<div className="center"> <div className="auth-wrap">
<ThemeButton className="auth-theme" />
<form className="card auth-card" onSubmit={submit}> <form className="card auth-card" onSubmit={submit}>
<h1>PGames</h1> <div>
<p className="muted">Локальная библиотека визуальных новелл</p> <div className="brand"><span className="brand-mark"><Icon name="play" size={20} /></span>PGames</div>
<label> <p className="muted">Локальная библиотека визуальных новелл</p>
Логин </div>
<label className="field">
<span>Логин</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus autoComplete="username" /> <input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus autoComplete="username" />
</label> </label>
<label> <label className="field">
Пароль <span>Пароль</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} <input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" /> autoComplete="current-password" />
</label> </label>
{error && <div className="error">{error}</div>} {error && <ErrorNote>{error}</ErrorNote>}
<button className="primary" disabled={busy || !username || !password}>Войти</button> <button className="btn btn-primary btn-lg btn-block" disabled={busy || !username || !password}>
{busy ? "Входим…" : "Войти"}
</button>
</form> </form>
</div> </div>
); );
+74 -29
View File
@@ -1,6 +1,10 @@
import { useState, type FormEvent } from "react"; import { useState, type DragEvent, type FormEvent } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { api, uploadRaw, type Game } from "../api"; import { api, formatSize, uploadRaw, type Game } from "../api";
import { ErrorNote, PageHeader } from "../ui/Bits";
import { Icon } from "../ui/Icon";
const ARCHIVE = /\.(zip|7z|rar|tar|gz|tgz|bz2|xz)$/i;
export function NewGame() { export function NewGame() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -8,9 +12,25 @@ export function NewGame() {
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [tags, setTags] = useState(""); const [tags, setTags] = useState("");
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [over, setOver] = useState(false);
const [progress, setProgress] = useState<number | null>(null); const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const pick = (f: File | null | undefined) => {
if (!f) return;
if (!ARCHIVE.test(f.name)) {
setError("Нужен архив: zip, 7z, rar или tar");
return;
}
setError("");
setFile(f);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setOver(false);
pick(e.dataTransfer.files[0]);
};
const submit = async (e: FormEvent) => { const submit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!file) return; if (!file) return;
@@ -34,38 +54,63 @@ export function NewGame() {
} }
}; };
const uploading = progress !== null;
const pct = Math.round((progress ?? 0) * 100);
return ( return (
<div className="page narrow"> <div className="page medium">
<form className="card" onSubmit={submit}> <PageHeader title="Новая игра"
<h1>Новая игра</h1> subtitle="Загрузите архив с игрой на Ren'Py. Сервер распакует его, сконвертирует скрипты и ассеты в формат PGVN и удалит исходники." />
<p className="muted"> <form className="card form" onSubmit={submit}>
Загрузите архив с игрой Ren'Py (zip, 7z, rar, tar). После загрузки сервер распакует его, <label className={`dropzone ${over ? "over" : ""} ${file ? "has-file" : ""}`}
сконвертирует скрипты и ассеты в формат PGVN и удалит исходники. onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={onDrop}>
</p> <input type="file" accept=".zip,.7z,.rar,.tar,.gz,.tgz,.bz2,.xz" disabled={uploading}
<label> onChange={(e) => pick(e.target.files?.[0])} />
Архив <Icon name={file ? "file" : "upload"} size={34} className="dz-icon" />
<input type="file" accept=".zip,.7z,.rar,.tar,.gz,.tgz,.bz2,.xz" {file ? (
onChange={(e) => setFile(e.target.files?.[0] ?? null)} /> <>
<b>{file.name}</b>
<span className="muted">{formatSize(file.size)} · нажмите, чтобы выбрать другой</span>
</>
) : (
<>
<b>Перетащите архив сюда или нажмите, чтобы выбрать</b>
<span className="muted small">zip, 7z, rar, tar</span>
</>
)}
</label> </label>
<label>
Название <div className="form-row">
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="По имени архива" /> <label className="field">
</label> <span>Название</span>
<label> <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="По имени архива" />
Описание </label>
<label className="field">
<span>Теги</span>
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="романтика, драма" />
<span className="hint">Через запятую</span>
</label>
</div>
<label className="field">
<span>Описание</span>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={4} /> <textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={4} />
</label> </label>
<label>
Теги через запятую {error && <ErrorNote>{error}</ErrorNote>}
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="романтика, драма" /> {uploading && (
</label> <div>
{error && <div className="error">{error}</div>} <div className="progress"><div style={{ width: `${pct}%` }} /></div>
{progress !== null && ( <p className="muted small" style={{ marginTop: "0.4rem" }}>
<div className="progress"><div style={{ width: `${Math.round(progress * 100)}%` }} /></div> {pct < 100 ? `Загрузка архива… ${pct}%` : "Архив загружен, передаём на конвертацию…"}
</p>
</div>
)} )}
<button className="primary" disabled={!file || progress !== null}> <div className="actions">
{progress !== null ? `Загрузка ${Math.round(progress * 100)}%` : "Загрузить"} <button className="btn btn-primary btn-lg" disabled={!file || uploading}>
</button> <Icon name="upload" size={20} />{uploading ? `Загрузка ${pct}%` : "Загрузить"}
</button>
{!uploading && <button type="button" className="btn btn-ghost" onClick={() => navigate(-1)}>Отмена</button>}
</div>
</form> </form>
</div> </div>
); );
+13 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import type { Engine } from "../player/engine"; import type { Engine } from "../player/engine";
import { Icon } from "../ui/Icon";
export function Play() { export function Play() {
const { versionId } = useParams(); const { versionId } = useParams();
@@ -33,8 +34,18 @@ export function Play() {
<div ref={host} className="play-host" /> <div ref={host} className="play-host" />
{(status || error) && ( {(status || error) && (
<div className="play-status"> <div className="play-status">
{error ? <span className="error">{error}</span> : status} {error ? (
{error && <button onClick={() => navigate(-1)}>Назад</button>} <>
<Icon name="alert" size={36} />
<span className="error-text">{error}</span>
<button className="btn" onClick={() => navigate(-1)}><Icon name="back" size={18} />Назад</button>
</>
) : (
<>
<span className="spinner" />
{status}
</>
)}
</div> </div>
)} )}
</div> </div>
+66 -40
View File
@@ -1,8 +1,12 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api, type Role } from "../api"; import { api, type Role } from "../api";
import { ErrorNote, PageHeader, Spinner } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
export function Roles() { export function Roles() {
const [roles, setRoles] = useState<Role[]>([]); const ui = useUi();
const [roles, setRoles] = useState<Role[] | null>(null);
const [perms, setPerms] = useState<Record<string, string>>({}); const [perms, setPerms] = useState<Record<string, string>>({});
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -18,13 +22,13 @@ export function Roles() {
load().catch((e) => setError(e.message)); load().catch((e) => setError(e.message));
}, [load]); }, [load]);
const run = async (fn: () => Promise<unknown>) => { const run = async (fn: () => Promise<unknown>, okText?: string) => {
setError("");
try { try {
await fn(); await fn();
if (okText) ui.toast(okText, "ok");
await load(); await load();
} catch (e) { } catch (e) {
setError((e as Error).message); ui.toast((e as Error).message, "error");
} }
}; };
@@ -35,48 +39,70 @@ export function Roles() {
void run(() => api.put(`/api/roles/${role.id}`, { name: role.name, permissions: next })); void run(() => api.put(`/api/roles/${role.id}`, { name: role.name, permissions: next }));
}; };
const remove = async (r: Role) => {
if (await ui.confirm({ title: `Удалить роль «${r.name}»?`, danger: true }))
void run(() => api.del(`/api/roles/${r.id}`), "Роль удалена");
};
return ( return (
<div className="page"> <div className="page medium">
<h1>Роли и права</h1> <PageHeader title="Роли и права" subtitle="Роль — набор прав. Роль admin всегда имеет все права. Изменения применяются сразу." />
<p className="muted">Роль — набор прав. Роль admin всегда имеет все права.</p> {error && <ErrorNote>{error}</ErrorNote>}
{error && <div className="error">{error}</div>} {roles === null ? <Spinner /> : (
<div className="table-wrap"> <div className="table-wrap">
<table className="table roles"> <table className="table roles">
<thead> <thead>
<tr> <tr>
<th>Право</th> <th>Право</th>
{roles.map((r) => (
<th key={r.id}>
{r.name}
{!r.is_system && (
<button className="link danger" title="Удалить роль"
onClick={() => confirm(`Удалить роль ${r.name}?`) && void run(() => api.del(`/api/roles/${r.id}`))}>✕</button>
)}
</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(perms).map(([code, label]) => (
<tr key={code}>
<td>{label} <span className="muted small">{code}</span></td>
{roles.map((r) => ( {roles.map((r) => (
<td key={r.id} className="center-cell"> <th key={r.id}>
<input type="checkbox" checked={r.permissions.includes(code)} disabled={r.name === "admin"} <span className="roles-th">
onChange={() => toggle(r, code)} /> {r.name}
</td> {!r.is_system && (
<button className="btn btn-sm btn-ghost btn-icon btn-danger" title={`Удалить роль ${r.name}`}
aria-label={`Удалить роль ${r.name}`} onClick={() => void remove(r)}>
<Icon name="x" size={14} />
</button>
)}
</span>
</th>
))} ))}
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {Object.entries(perms).map(([code, label]) => (
</div> <tr key={code}>
<form className="card inline-form" onSubmit={(e) => { <td>{label}<small>{code}</small></td>
{roles.map((r) => (
<td key={r.id}>
<label className="switch" title={`${r.name}: ${label}`}>
<input type="checkbox" checked={r.permissions.includes(code) || r.name === "admin"}
disabled={r.name === "admin"} onChange={() => toggle(r, code)}
aria-label={`${r.name}: ${label}`} />
<i />
</label>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
<form className="card form" style={{ marginTop: "var(--space-5)" }} onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
void run(() => api.post("/api/roles", { name: newName.trim(), permissions: ["games.play"] })).then(() => setNewName("")); void run(() => api.post("/api/roles", { name: newName.trim(), permissions: ["games.play"] }), "Роль добавлена")
.then(() => setNewName(""));
}}> }}>
<input placeholder="Название новой роли" value={newName} onChange={(e) => setNewName(e.target.value)} /> <h2>Новая роль</h2>
<button className="primary" disabled={!newName.trim()}>Добавить роль</button> <div className="form-row">
<label className="field">
<span>Название</span>
<input value={newName} onChange={(e) => setNewName(e.target.value)} />
<span className="hint">Сначала у роли есть только право играть, остальное включите в таблице выше.</span>
</label>
<button className="btn btn-primary" disabled={!newName.trim()}><Icon name="plus" size={18} />Добавить роль</button>
</div>
</form> </form>
</div> </div>
); );
+115 -59
View File
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState, type FormEvent } from "react"; import { useCallback, useEffect, useState, type FormEvent } from "react";
import { api, type Role, type User } from "../api"; import { api, type Role, type User } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import { ErrorNote, PageHeader, Spinner } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
interface Issued { interface Issued {
user: User; user: User;
@@ -9,7 +12,8 @@ interface Issued {
export function Users() { export function Users() {
const { me } = useAuth(); const { me } = useAuth();
const [users, setUsers] = useState<User[]>([]); const ui = useUi();
const [users, setUsers] = useState<User[] | null>(null);
const [roles, setRoles] = useState<Role[]>([]); const [roles, setRoles] = useState<Role[]>([]);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [roleId, setRoleId] = useState<number | "">(""); const [roleId, setRoleId] = useState<number | "">("");
@@ -27,13 +31,13 @@ export function Users() {
load().catch((e) => setError(e.message)); load().catch((e) => setError(e.message));
}, [load]); }, [load]);
const run = async (fn: () => Promise<unknown>) => { const run = async (fn: () => Promise<unknown>, okText?: string) => {
setError("");
try { try {
await fn(); await fn();
if (okText) ui.toast(okText, "ok");
await load(); await load();
} catch (e) { } catch (e) {
setError((e as Error).message); ui.toast((e as Error).message, "error");
} }
}; };
@@ -45,71 +49,123 @@ export function Users() {
}); });
}; };
const copy = async () => {
if (!issued) return;
try {
await navigator.clipboard.writeText(`${issued.user.username} / ${issued.password}`);
ui.toast("Логин и пароль скопированы", "ok");
} catch {
ui.toast("Не удалось скопировать — выделите пароль вручную", "error");
}
};
const reset = async (u: User) => {
if (await ui.confirm({ title: `Сбросить пароль для ${u.username}?`, text: "Будет выдан новый пароль, текущий перестанет работать.", confirmLabel: "Сбросить" }))
void run(async () => setIssued(await api.post<Issued>(`/api/users/${u.id}/reset-password`)));
};
const toggle = (u: User) =>
void run(() => api.patch(`/api/users/${u.id}`, { is_active: !u.is_active }), u.is_active ? "Пользователь отключён" : "Пользователь включён");
const remove = async (u: User) => {
if (await ui.confirm({ title: `Удалить ${u.username}?`, text: "Учётная запись и сохранения пользователя будут удалены.", danger: true }))
void run(() => api.del(`/api/users/${u.id}`), "Пользователь удалён");
};
const setRole = (u: User, id: number) => void run(() => api.patch(`/api/users/${u.id}`, { role_id: id }), "Роль изменена");
const statusOf = (u: User) => (
<>
<span className={`badge ${u.is_active ? "badge-ok" : "badge-plain"}`}>{u.is_active ? "Активен" : "Отключён"}</span>
{u.must_change_password && <span className="muted small"> ждёт смены пароля</span>}
</>
);
const roleSelect = (u: User) => (
<select value={u.role.id} disabled={u.id === me?.id} aria-label={`Роль ${u.username}`}
onChange={(e) => setRole(u, Number(e.target.value))}>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
);
const actions = (u: User) =>
u.id !== me?.id && (
<div className="row-actions">
<button className="btn btn-sm" onClick={() => void reset(u)}><Icon name="key" size={15} />Сбросить пароль</button>
<button className="btn btn-sm" onClick={() => toggle(u)}>{u.is_active ? "Отключить" : "Включить"}</button>
<button className="btn btn-sm btn-ghost btn-icon btn-danger" onClick={() => void remove(u)} title="Удалить" aria-label={`Удалить ${u.username}`}>
<Icon name="trash" size={16} />
</button>
</div>
);
return ( return (
<div className="page"> <div className="page medium">
<h1>Пользователи</h1> <PageHeader title="Пользователи" subtitle="Регистрации нет — пользователей создаёт администратор." />
<form className="card inline-form" onSubmit={create}>
<input placeholder="Логин нового пользователя" value={username} onChange={(e) => setUsername(e.target.value)} /> <form className="card form" onSubmit={create}>
<select value={roleId} onChange={(e) => setRoleId(Number(e.target.value))}> <h2>Новый пользователь</h2>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)} <div className="form-row">
</select> <label className="field">
<button className="primary" disabled={username.trim().length < 3}>Создать</button> <span>Логин</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="off" />
<span className="hint">От 3 символов. Пароль сервер сгенерирует сам.</span>
</label>
<label className="field" style={{ flex: "0 0 12rem" }}>
<span>Роль</span>
<select value={roleId} onChange={(e) => setRoleId(Number(e.target.value))} style={{ width: "100%" }}>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</label>
<button className="btn btn-primary" disabled={username.trim().length < 3}><Icon name="plus" size={18} />Создать</button>
</div>
</form> </form>
{issued && ( {issued && (
<div className="card issued"> <div className="card issued" style={{ marginTop: "var(--space-4)" }}>
<p> <p>
Пароль для <b>{issued.user.username}</b> (показывается один раз, передайте его пользователю — Пароль для <b>{issued.user.username}</b> показывается один раз. Передайте его пользователю —
при первом входе он задаст свой): при первом входе он задаст свой.
</p> </p>
<div className="password-box"> <div className="password-box">
<code>{issued.password}</code> <code>{issued.password}</code>
<button onClick={() => void navigator.clipboard?.writeText(`${issued.user.username} / ${issued.password}`)}> <button className="btn" onClick={() => void copy()}><Icon name="copy" size={16} />Копировать</button>
Копировать <button className="btn btn-ghost" onClick={() => setIssued(null)}>Скрыть</button>
</button>
<button onClick={() => setIssued(null)}>Скрыть</button>
</div> </div>
</div> </div>
)} )}
{error && <div className="error">{error}</div>}
<table className="table"> {error && <ErrorNote>{error}</ErrorNote>}
<thead> <div className="section-title"><h2>Все пользователи</h2>{users && <span className="count">{users.length}</span>}</div>
<tr><th>Логин</th><th>Роль</th><th>Статус</th><th>Создан</th><th /></tr> {users === null ? <Spinner /> : (
</thead> <>
<tbody> <div className="table-wrap users-table">
{users.map((u) => ( <table className="table">
<tr key={u.id} className={u.is_active ? "" : "inactive"}> <thead>
<td> <tr><th>Логин</th><th>Роль</th><th>Статус</th><th>Создан</th><th /></tr>
{u.username} </thead>
{u.id === me?.id && <span className="muted"> (вы)</span>} <tbody>
</td> {users.map((u) => (
<td> <tr key={u.id} className={u.is_active ? "" : "inactive"}>
<select value={u.role.id} disabled={u.id === me?.id} <td><b>{u.username}</b>{u.id === me?.id && <span className="muted"> (вы)</span>}</td>
onChange={(e) => void run(() => api.patch(`/api/users/${u.id}`, { role_id: Number(e.target.value) }))}> <td>{roleSelect(u)}</td>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)} <td>{statusOf(u)}</td>
</select> <td className="muted">{new Date(u.created_at).toLocaleDateString()}</td>
</td> <td>{actions(u)}</td>
<td> </tr>
{u.is_active ? "активен" : "отключён"} ))}
{u.must_change_password && <span className="muted"> · ждёт смены пароля</span>} </tbody>
</td> </table>
<td className="muted">{new Date(u.created_at).toLocaleDateString()}</td> </div>
<td className="actions"> <div className="user-cards">
{u.id !== me?.id && ( {users.map((u) => (
<> <div key={u.id} className={`user-card ${u.is_active ? "" : "inactive"}`}>
<button onClick={() => void run(async () => <div className="user-card-head">
setIssued(await api.post<Issued>(`/api/users/${u.id}/reset-password`)))}>Сбросить пароль</button> <b>{u.username}{u.id === me?.id && <span className="muted"> (вы)</span>}</b>
<button onClick={() => void run(() => api.patch(`/api/users/${u.id}`, { is_active: !u.is_active }))}> {roleSelect(u)}
{u.is_active ? "Отключить" : "Включить"} </div>
</button> <div>{statusOf(u)}</div>
<button className="danger" onClick={() => confirm(`Удалить ${u.username}?`) && {actions(u)}
void run(() => api.del(`/api/users/${u.id}`))}>Удалить</button> </div>
</> ))}
)} </div>
</td> </>
</tr> )}
))}
</tbody>
</table>
</div> </div>
); );
} }
+52 -2
View File
@@ -1149,6 +1149,18 @@ export class Engine {
this.notifyEl = el("div", "pg-notify pg-hidden"); this.notifyEl = el("div", "pg-notify pg-hidden");
stage.append(this.nvlEl, this.textbox, this.centeredEl, this.menuEl, this.screen, this.notifyEl); stage.append(this.nvlEl, this.textbox, this.centeredEl, this.menuEl, this.screen, this.notifyEl);
// touch-friendly menu button outside the scaled stage
const hud = el("button", "pg-hud-btn", "☰");
hud.title = "Меню";
hud.setAttribute("aria-label", "Меню");
hud.onclick = (ev) => {
ev.stopPropagation();
if (this.screen.classList.contains("pg-main")) return;
if (this.screen.classList.contains("pg-hidden")) this.openGameMenu();
else this.dismissScreen();
};
this.container.append(hud);
stage.addEventListener("click", (ev) => { stage.addEventListener("click", (ev) => {
if (!this.screen.classList.contains("pg-hidden")) return; if (!this.screen.classList.contains("pg-hidden")) return;
if ((ev.target as HTMLElement).closest(".pg-menu, .pg-quick")) return; if ((ev.target as HTMLElement).closest(".pg-menu, .pg-quick")) return;
@@ -1169,6 +1181,37 @@ export class Engine {
}).catch(() => {}); }).catch(() => {});
} }
/** In-player confirmation (the native confirm() breaks fullscreen and gamepads). */
private confirmAsk(text: string, okLabel = "Ок"): Promise<boolean> {
return new Promise((resolve) => {
const back = el("div", "pg-dialog-back");
const box = el("div", "pg-dialog");
box.setAttribute("role", "dialog");
box.append(el("p", "", text));
const row = el("div", "pg-dialog-row");
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopImmediatePropagation();
done(false);
};
const done = (v: boolean) => {
window.removeEventListener("keydown", onKey, true);
back.remove();
resolve(v);
};
const no = el("button", "", "Отмена");
no.onclick = () => done(false);
const yes = el("button", "pg-primary", okLabel);
yes.onclick = () => done(true);
window.addEventListener("keydown", onKey, true);
row.append(no, yes);
box.append(row);
back.append(box);
this.container.append(back);
yes.focus();
});
}
private advanceInput() { private advanceInput() {
if (this.skip) { if (this.skip) {
this.setSkip(false); this.setSkip(false);
@@ -1212,6 +1255,7 @@ export class Engine {
private openScreen(title: string, build: (body: HTMLElement) => void, onClose?: () => void) { private openScreen(title: string, build: (body: HTMLElement) => void, onClose?: () => void) {
this.screen.textContent = ""; this.screen.textContent = "";
this.screen.classList.remove("pg-main"); this.screen.classList.remove("pg-main");
this.container.classList.remove("pg-in-main");
this.screen.style.backgroundImage = ""; this.screen.style.backgroundImage = "";
this.screenOnClose = onClose ?? null; this.screenOnClose = onClose ?? null;
const head = el("div", "pg-screen-head"); const head = el("div", "pg-screen-head");
@@ -1235,6 +1279,7 @@ export class Engine {
this.screenOnClose = null; this.screenOnClose = null;
this.screen.classList.add("pg-hidden"); this.screen.classList.add("pg-hidden");
this.screen.classList.remove("pg-main"); this.screen.classList.remove("pg-main");
this.container.classList.remove("pg-in-main");
this.screen.style.backgroundImage = ""; this.screen.style.backgroundImage = "";
} }
@@ -1248,6 +1293,7 @@ export class Engine {
this.clearNvl(); this.clearNvl();
this.screen.textContent = ""; this.screen.textContent = "";
this.screen.classList.add("pg-main"); this.screen.classList.add("pg-main");
this.container.classList.add("pg-in-main");
const bg = this.game.ui.mainMenu ? this.fileUrl(this.game.ui.mainMenu) : null; const bg = this.game.ui.mainMenu ? this.fileUrl(this.game.ui.mainMenu) : null;
if (bg) this.screen.style.backgroundImage = `url("${bg}")`; if (bg) this.screen.style.backgroundImage = `url("${bg}")`;
const panel = el("div", "pg-main-panel"); const panel = el("div", "pg-main-panel");
@@ -1302,11 +1348,15 @@ export class Engine {
list.append(b); list.append(b);
}; };
add("Вернуться", () => this.closeScreen()); add("Вернуться", () => this.closeScreen());
add("История", () => this.openHistory());
add(this.skip ? "Выключить пропуск" : "Пропуск", () => { this.closeScreen(); this.setSkip(!this.skip); });
add(this.auto ? "Выключить авто" : "Автопрочтение", () => { this.closeScreen(); this.setAuto(!this.auto); });
add("Сохранить", () => this.openSaves("save")); add("Сохранить", () => this.openSaves("save"));
add("Загрузить", () => this.openSaves("load")); add("Загрузить", () => this.openSaves("load"));
add("Настройки", () => this.openSettings()); add("Настройки", () => this.openSettings());
add("Главное меню", () => { add("Главное меню", () => {
if (confirm("Выйти в главное меню? Несохранённый прогресс будет потерян.")) this.showMainMenu(); void this.confirmAsk("Выйти в главное меню? Несохранённый прогресс будет потерян.", "Выйти")
.then((yes) => yes && this.showMainMenu());
}); });
add("В библиотеку", () => this.onExit()); add("В библиотеку", () => this.onExit());
body.append(list); body.append(list);
@@ -1346,7 +1396,7 @@ export class Engine {
card.disabled = mode === "load" && !meta; card.disabled = mode === "load" && !meta;
card.onclick = async () => { card.onclick = async () => {
if (mode === "save") { if (mode === "save") {
if (meta && !confirm("Перезаписать сохранение?")) return; if (meta && !(await this.confirmAsk("Перезаписать сохранение?", "Перезаписать"))) return;
await this.save(slot); await this.save(slot);
render(await this.saves.list()); render(await this.saves.list());
} else { } else {
+113 -40
View File
@@ -69,22 +69,33 @@
.pg-quick { .pg-quick {
position: absolute; position: absolute;
bottom: 0.4em; bottom: 0.35em;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
display: flex; display: flex;
gap: 0.2em; gap: 0.15em;
padding: 0.1em 0.3em;
border-radius: 99em;
background: rgba(8, 10, 20, 0.45);
backdrop-filter: blur(6px);
opacity: 0.55;
transition: opacity 0.2s;
} }
.pg-quick:hover, .pg-quick:focus-within { opacity: 1; }
.pg-quick button { .pg-quick button {
background: none; background: none;
border: 0; border: 0;
color: rgba(255, 255, 255, 0.55); border-radius: 99em;
color: rgba(255, 255, 255, 0.8);
font: inherit; font: inherit;
font-size: calc(var(--pg-font) * 0.55); font-size: calc(var(--pg-font) * 0.55);
padding: 0.2em 0.6em; padding: 0.25em 0.75em;
cursor: pointer; cursor: pointer;
transition: background 0.15s, color 0.15s;
} }
.pg-quick button:hover, .pg-quick button.pg-on { color: var(--pg-accent); } .pg-quick button:hover { background: rgba(255, 255, 255, 0.12); color: #fff; }
.pg-quick button.pg-on { color: #1a1205; background: var(--pg-accent); }
.pg-quick button:focus-visible { outline: 2px solid var(--pg-accent); }
.pg-menu { .pg-menu {
position: absolute; position: absolute;
@@ -147,37 +158,50 @@
.pg-screen { .pg-screen {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: rgba(8, 10, 20, 0.94) center / cover; background: rgba(8, 10, 20, 0.88) center / cover;
backdrop-filter: blur(10px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 3% 6%; padding: 3% 6%;
font-size: calc(var(--pg-font) * 0.85); font-size: calc(var(--pg-font) * 0.85);
z-index: 10; z-index: 10;
animation: pg-fade 0.15s ease-out;
} }
.pg-screen-head { display: flex; justify-content: space-between; align-items: center; } @keyframes pg-fade { from { opacity: 0; } }
.pg-screen-head h2 { margin: 0 0 0.6em; color: var(--pg-accent); font-weight: 600; } .pg-screen-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4em; }
.pg-screen button { .pg-screen-head h2 { margin: 0 0 0.3em; color: #fff; font-weight: 650; font-size: 1.3em; letter-spacing: -0.01em; }
.pg-screen-head h2::before { content: ""; display: inline-block; width: 0.2em; height: 0.9em; margin-right: 0.5em; border-radius: 99em; background: var(--pg-accent); vertical-align: -0.1em; }
.pg-screen button, .pg-dialog button, .pg-screen select {
font: inherit; font: inherit;
color: #fff; color: #fff;
background: rgba(255, 255, 255, 0.07); background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 0.3em; border-radius: 0.5em;
padding: 0.4em 1em; padding: 0.5em 1.1em;
cursor: pointer; cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
} }
.pg-screen button:hover:not(:disabled) { border-color: var(--pg-accent); color: var(--pg-accent); } .pg-screen button:hover:not(:disabled), .pg-dialog button:hover { background: rgba(255, 255, 255, 0.15); border-color: var(--pg-accent); }
.pg-screen button:disabled { opacity: 0.4; cursor: default; } .pg-screen button:focus-visible, .pg-dialog button:focus-visible, .pg-screen select:focus-visible, .pg-hud-btn:focus-visible {
.pg-close { background: none !important; border: 0 !important; font-size: 1.3em !important; } outline: 3px solid var(--pg-accent);
.pg-screen-body { flex: 1; overflow: auto; } outline-offset: 2px;
.pg-menu-list { display: flex; flex-direction: column; gap: 0.5em; max-width: 30%; } }
.pg-history { display: flex; flex-direction: column; gap: 0.6em; max-height: 100%; overflow: auto; } .pg-screen button:disabled { opacity: 0.35; cursor: default; }
.pg-screen option { color: #111; }
.pg-close { background: rgba(255, 255, 255, 0.08) !important; width: 2.2em; height: 2.2em; padding: 0 !important; border-radius: 50% !important; font-size: 1em !important; }
.pg-screen-body { flex: 1; overflow: auto; padding: 0.2em; }
.pg-menu-list { display: flex; flex-direction: column; gap: 0.5em; max-width: min(24em, 100%); }
.pg-menu-list button { text-align: left; padding: 0.7em 1.2em; }
.pg-history { display: flex; flex-direction: column; gap: 0.8em; max-height: 100%; overflow: auto; max-width: 52em; }
.pg-history-row { padding: 0.5em 0.8em; border-left: 3px solid rgba(255, 255, 255, 0.12); }
.pg-history-row b { color: var(--pg-accent); margin-right: 0.6em; } .pg-history-row b { color: var(--pg-accent); margin-right: 0.6em; }
.pg-slots { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.6em; } .pg-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(11em, 1fr)); gap: 0.7em; }
.pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 5em; } .pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 6em; }
.pg-slot-title { font-size: 0.8em; opacity: 0.85; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .pg-slot b { color: var(--pg-accent); font-weight: 600; }
.pg-slot small { opacity: 0.5; font-size: 0.65em; } .pg-slot-title { font-size: 0.85em; opacity: 0.9; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.pg-setting { display: grid; grid-template-columns: 30% 1fr 9em; align-items: center; gap: 1em; margin-bottom: 0.7em; } .pg-slot small { opacity: 0.55; font-size: 0.7em; margin-top: auto; }
.pg-setting input { width: 100%; accent-color: var(--pg-accent); } .pg-setting { display: grid; grid-template-columns: minmax(8em, 30%) 1fr 9em; align-items: center; gap: 1em; margin-bottom: 0.8em; }
.pg-setting input { width: 100%; accent-color: var(--pg-accent); min-height: 1.6em; }
.pg-setting-val { opacity: 0.7; } .pg-setting-val { opacity: 0.7; }
.pg-wide { margin-top: 0.8em; margin-right: 0.6em; } .pg-wide { margin-top: 0.8em; margin-right: 0.6em; }
@@ -185,22 +209,71 @@
.pg-main-panel { .pg-main-panel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5em; gap: 0.55em;
width: 32%; width: min(26em, 100%);
padding: 2em; padding: 2em;
background: rgba(0, 0, 0, 0.6); background: rgba(8, 10, 20, 0.72);
border-radius: 0.5em; backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 1em;
box-shadow: 0 1em 3em rgba(0, 0, 0, 0.5);
} }
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: var(--pg-accent); } .pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: #fff; letter-spacing: -0.01em; }
.pg-main-panel button { text-align: left; } .pg-main-panel button { text-align: left; padding: 0.7em 1.2em; }
.pg-select { .pg-main-panel button:first-of-type { background: var(--pg-accent); border-color: var(--pg-accent); color: #1a1205; font-weight: 650; }
font: inherit; .pg-main-panel button:first-of-type:hover { filter: brightness(1.1); }
font-size: 0.85em;
/* menu button outside the scaled stage: stays tappable on phones */
.pg-hud-btn {
position: absolute;
top: max(10px, env(safe-area-inset-top));
right: max(10px, env(safe-area-inset-right));
z-index: 20;
width: 44px;
height: 44px;
display: grid;
place-items: center;
font-size: 22px;
line-height: 1;
color: #fff; color: #fff;
background: rgba(255, 255, 255, 0.07); background: rgba(8, 10, 20, 0.55);
border: 1px solid rgba(255, 255, 255, 0.15); backdrop-filter: blur(6px);
border-radius: 0.3em; border: 1px solid rgba(255, 255, 255, 0.18);
padding: 0.3em 0.6em; border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.15s;
} }
.pg-select option { background: #151827; } .pg-root:hover .pg-hud-btn, .pg-hud-btn:focus-visible { opacity: 0.8; }
.pg-lang { margin-top: 0.6em; } .pg-hud-btn:hover { opacity: 1 !important; background: rgba(8, 10, 20, 0.85); }
.pg-root.pg-in-main .pg-hud-btn { display: none; }
@media (pointer: coarse) {
.pg-hud-btn { opacity: 0.6; }
}
/* in-player confirmation */
.pg-dialog-back {
position: absolute;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 16px;
background: rgba(5, 7, 12, 0.7);
backdrop-filter: blur(4px);
font-family: "Segoe UI", system-ui, sans-serif;
}
.pg-dialog {
width: min(420px, 100%);
padding: 22px;
color: #eceef5;
background: #171b25;
border: 1px solid #2a3042;
border-radius: 14px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.55);
font-size: 16px;
}
.pg-dialog p { margin: 0 0 18px; line-height: 1.45; }
.pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; }
.pg-dialog button { font-size: 15px; padding: 9px 18px; }
.pg-dialog .pg-primary { background: #ff9f43; border-color: #ff9f43; color: #1a1205; font-weight: 650; }
+37
View File
@@ -0,0 +1,37 @@
// "Continue playing": remembered per browser, the server stores only saves.
export interface RecentPlay {
gameId: number;
versionId: number;
title: string;
coverHash: string | null;
at: number;
}
const KEY = "pg-recent";
const MAX = 12;
export function getRecent(): RecentPlay[] {
try {
const list = JSON.parse(localStorage.getItem(KEY) ?? "[]");
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
export function pushRecent(item: Omit<RecentPlay, "at">) {
try {
const list = getRecent().filter((r) => r.gameId !== item.gameId);
localStorage.setItem(KEY, JSON.stringify([{ ...item, at: Date.now() }, ...list].slice(0, MAX)));
} catch {
/* storage unavailable */
}
}
export function dropRecent(gameId: number) {
try {
localStorage.setItem(KEY, JSON.stringify(getRecent().filter((r) => r.gameId !== gameId)));
} catch {
/* storage unavailable */
}
}
+828 -153
View File
@@ -1,153 +1,828 @@
:root { /* ==========================================================================
--bg: #0f1117; PGames design system
--panel: #171a23; 1. tokens + themes 2. base 3. controls 4. surfaces 5. shell/nav
--panel-2: #1f2330; 6. library 7. game page 8. admin 9. feedback 10. responsive
--border: #2a2f3e; ========================================================================== */
--text: #e6e8ef;
--muted: #8b90a0; /* ---- 1. tokens ---------------------------------------------------------- */
--accent: #ff9f43; :root {
--danger: #ef5a5a; /* dark (default) */
--ok: #4cc38a; --bg: #0e1016;
color-scheme: dark; --bg-2: #12151d;
} --surface: #171b25;
--surface-2: #1e2330;
* { box-sizing: border-box; } --surface-3: #272d3d;
html, body, #root { height: 100%; margin: 0; } --border: #2a3042;
body { --border-strong: #3a4258;
background: var(--bg); --text: #eceef5;
color: var(--text); --text-2: #b4b9ca;
font: 15px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif; --muted: #8087a0;
} --accent: #ff9f43;
a { color: inherit; text-decoration: none; } --accent-hover: #ffb066;
h1 { font-size: 1.6rem; margin: 0 0 0.75rem; } --accent-ink: #1a1205;
h2 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; } --accent-soft: rgba(255, 159, 67, 0.14);
--danger: #f2686b;
input, select, textarea, button, .button { --danger-soft: rgba(242, 104, 107, 0.14);
font: inherit; --ok: #4cc38a;
color: var(--text); --ok-soft: rgba(76, 195, 138, 0.14);
background: var(--panel-2); --info: #6aa8ff;
border: 1px solid var(--border); --info-soft: rgba(106, 168, 255, 0.14);
border-radius: 6px; --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35);
padding: 0.45rem 0.7rem; --shadow-2: 0 8px 24px rgba(0, 0, 0, 0.4);
} --shadow-3: 0 18px 48px rgba(0, 0, 0, 0.55);
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); } --overlay: rgba(5, 7, 12, 0.7);
button, .button { cursor: pointer; white-space: nowrap; } --topbar-bg: rgba(14, 16, 22, 0.82);
button:hover:not(:disabled), .button:hover { border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: default; } --radius-s: 8px;
button.primary { background: var(--accent); border-color: var(--accent); color: #1a1205; font-weight: 600; } --radius: 12px;
button.danger { color: var(--danger); } --radius-l: 18px;
button.link { background: none; border: 0; padding: 0 0.3rem; color: var(--muted); } --space-1: 0.25rem;
button.link:hover { color: var(--text); } --space-2: 0.5rem;
--space-3: 0.75rem;
.muted { color: var(--muted); } --space-4: 1rem;
.small { font-size: 0.85em; } --space-5: 1.5rem;
.error { color: var(--danger); margin: 0.5rem 0; } --space-6: 2.25rem;
.ok { color: var(--ok); } --control-h: 2.5rem;
.center { min-height: 100%; display: flex; align-items: center; justify-content: center; padding: 1rem; } --nav-h: 3.75rem;
--tabbar-h: 3.6rem;
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; } --font: "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
.auth-card { width: min(380px, 100%); display: flex; flex-direction: column; gap: 0.8rem; } color-scheme: dark;
.card label, .auth-card label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.9rem; color: var(--muted); } }
.card form, form.card { display: flex; flex-direction: column; gap: 0.8rem; }
.inline-form { flex-direction: row !important; flex-wrap: wrap; align-items: center; margin: 1rem 0; } :root[data-theme="light"] {
.inline-form input { flex: 1; min-width: 200px; } --bg: #f4f1ec;
--bg-2: #ece8e1;
.layout { min-height: 100%; display: flex; flex-direction: column; } --surface: #ffffff;
.topbar { --surface-2: #f6f3ee;
display: flex; --surface-3: #ebe6de;
align-items: center; --border: #e0dad0;
gap: 1.5rem; --border-strong: #cbc3b5;
padding: 0.7rem 1.5rem; --text: #1c1d25;
background: var(--panel); --text-2: #464a5c;
border-bottom: 1px solid var(--border); --muted: #6c7188;
flex-wrap: wrap; --accent: #e8710a;
} --accent-hover: #cf6406;
.brand { font-weight: 700; color: var(--accent); font-size: 1.15rem; } --accent-ink: #ffffff;
.topbar nav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; } --accent-soft: rgba(232, 113, 10, 0.12);
.topbar nav a { color: var(--muted); } --danger: #d6393c;
.topbar nav a.active, .topbar nav a:hover { color: var(--text); } --danger-soft: rgba(214, 57, 60, 0.1);
.user { display: flex; align-items: center; gap: 0.3rem; } --ok: #1d8f5c;
--ok-soft: rgba(29, 143, 92, 0.12);
.page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; width: 100%; } --info: #2a6fd6;
.page.narrow { max-width: 560px; } --info-soft: rgba(42, 111, 214, 0.1);
--shadow-1: 0 1px 2px rgba(60, 45, 20, 0.08);
.search input { width: 100%; font-size: 1.05rem; padding: 0.7rem 1rem; margin-bottom: 1.25rem; } --shadow-2: 0 8px 24px rgba(60, 45, 20, 0.12);
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; } --shadow-3: 0 18px 48px rgba(60, 45, 20, 0.2);
.game-card { --overlay: rgba(30, 24, 14, 0.45);
background: var(--panel); --topbar-bg: rgba(244, 241, 236, 0.85);
border: 1px solid var(--border); color-scheme: light;
border-radius: 10px; }
overflow: hidden;
transition: transform 0.15s, border-color 0.15s; @media (prefers-color-scheme: light) {
} :root:not([data-theme]) {
.game-card:hover { transform: translateY(-3px); border-color: var(--accent); } --bg: #f4f1ec;
.game-card-body { padding: 0.6rem 0.8rem; display: flex; flex-direction: column; gap: 0.35rem; } --bg-2: #ece8e1;
.cover { --surface: #ffffff;
aspect-ratio: 16 / 10; --surface-2: #f6f3ee;
background: linear-gradient(135deg, #262b3b, #1a1d29); --surface-3: #ebe6de;
display: flex; --border: #e0dad0;
align-items: center; --border-strong: #cbc3b5;
justify-content: center; --text: #1c1d25;
font-size: 2.5rem; --text-2: #464a5c;
color: var(--muted); --muted: #6c7188;
overflow: hidden; --accent: #e8710a;
} --accent-hover: #cf6406;
.cover img { width: 100%; height: 100%; object-fit: cover; } --accent-ink: #ffffff;
.cover.big { width: 340px; max-width: 100%; border-radius: 10px; flex-shrink: 0; } --accent-soft: rgba(232, 113, 10, 0.12);
.tags { display: flex; gap: 0.3rem; flex-wrap: wrap; } --danger: #d6393c;
.tag { font-size: 0.75rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); color: var(--muted); } --danger-soft: rgba(214, 57, 60, 0.1);
--ok: #1d8f5c;
.game-head { display: flex; gap: 1.5rem; margin-top: 1rem; flex-wrap: wrap; } --ok-soft: rgba(29, 143, 92, 0.12);
.game-info { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 0.6rem; } --info: #2a6fd6;
.description { white-space: pre-wrap; margin: 0; } --info-soft: rgba(42, 111, 214, 0.1);
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } --shadow-1: 0 1px 2px rgba(60, 45, 20, 0.08);
.editor { display: flex; flex-direction: column; gap: 0.6rem; } --shadow-2: 0 8px 24px rgba(60, 45, 20, 0.12);
--shadow-3: 0 18px 48px rgba(60, 45, 20, 0.2);
.versions { display: flex; flex-direction: column; gap: 0.6rem; } --overlay: rgba(30, 24, 14, 0.45);
.version { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; } --topbar-bg: rgba(244, 241, 236, 0.85);
.version-main { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: center; } color-scheme: light;
.status { margin-left: 0.6rem; font-size: 0.8rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); } }
.status-ready { color: var(--ok); } }
.status-failed { color: var(--danger); }
.status-queued, .status-converting, .status-importing, .status-uploading { color: var(--accent); } /* ---- 2. base ------------------------------------------------------------ */
.report { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); font-size: 0.9rem; } * { box-sizing: border-box; }
.report-stats { display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem; margin-bottom: 0.5rem; } html, body, #root { min-height: 100%; margin: 0; }
.report table { border-collapse: collapse; margin-top: 0.4rem; } html { -webkit-text-size-adjust: 100%; }
.report td { padding: 0.15rem 0.6rem 0.15rem 0; vertical-align: top; } body {
.report summary { cursor: pointer; margin: 0.3rem 0; } background: var(--bg);
color: var(--text);
.progress { height: 8px; background: var(--panel-2); border-radius: 99px; overflow: hidden; } font: 15px/1.5 var(--font);
.progress > div { height: 100%; background: var(--accent); transition: width 0.2s; } -webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
.table-wrap { overflow-x: auto; } }
.table { width: 100%; border-collapse: collapse; margin-top: 1rem; } a { color: inherit; text-decoration: none; }
.table th, .table td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--border); } h1, h2, h3 { margin: 0; line-height: 1.2; letter-spacing: -0.01em; }
.table th { color: var(--muted); font-weight: 500; } h1 { font-size: 1.75rem; font-weight: 700; }
.table tr.inactive td { opacity: 0.55; } h2 { font-size: 1.2rem; font-weight: 650; }
.center-cell { text-align: center !important; } h3 { font-size: 1.02rem; font-weight: 650; }
.roles th:not(:first-child) { text-align: center; } p { margin: 0; }
code { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; }
.issued { border-color: var(--accent); margin-bottom: 1rem; } ::selection { background: var(--accent-soft); }
.password-box { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.password-box code { font-size: 1.2rem; padding: 0.3rem 0.8rem; background: var(--panel-2); border-radius: 6px; user-select: all; } :focus { outline: none; }
:focus-visible {
.play { position: fixed; inset: 0; background: #000; } outline: 3px solid var(--accent);
.play-host { position: absolute; inset: 0; } outline-offset: 2px;
.play-status { border-radius: var(--radius-s);
position: absolute; }
inset: 0;
display: flex; .muted { color: var(--muted); }
flex-direction: column; .small { font-size: 0.85em; }
gap: 1rem; .sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
align-items: center; .grow { flex: 1; }
justify-content: center; .center { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: var(--space-4); }
color: var(--muted);
pointer-events: none; /* ---- 3. controls -------------------------------------------------------- */
} .btn, .field input, .field select, .field textarea, input, select, textarea {
.play-status button { pointer-events: auto; } font: inherit;
color: var(--text);
@media (max-width: 640px) { }
.page { padding: 1rem; } .btn {
.topbar { padding: 0.6rem 1rem; gap: 0.8rem; } display: inline-flex;
.grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } align-items: center;
} justify-content: center;
gap: 0.5rem;
min-height: var(--control-h);
padding: 0 1rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-s);
cursor: pointer;
white-space: nowrap;
font-weight: 550;
transition: background 0.15s, border-color 0.15s, transform 0.1s;
user-select: none;
}
.btn:hover:not(:disabled) { border-color: var(--border-strong); background: var(--surface-3); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: 0.5; cursor: default; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); font-weight: 650; }
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--text-2); }
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); border-color: transparent; color: var(--text); }
.btn-danger { color: var(--danger); }
.btn-danger:hover:not(:disabled) { background: var(--danger-soft); border-color: var(--danger); }
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #fff; font-weight: 650; }
.btn-danger-solid:hover:not(:disabled) { filter: brightness(1.08); background: var(--danger); }
.btn-lg { min-height: 3.2rem; padding: 0 1.6rem; font-size: 1.1rem; border-radius: var(--radius); }
.btn-sm { min-height: 2rem; padding: 0 0.7rem; font-size: 0.9rem; }
.btn-icon { padding: 0; width: var(--control-h); }
.btn-sm.btn-icon { width: 2rem; }
.btn-block { width: 100%; }
input, select, textarea {
min-height: var(--control-h);
padding: 0.5rem 0.8rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-s);
width: 100%;
transition: border-color 0.15s, background 0.15s;
}
textarea { resize: vertical; line-height: 1.5; }
input::placeholder, textarea::placeholder { color: var(--muted); }
input:hover, select:hover, textarea:hover { border-color: var(--border-strong); }
input:focus-visible, select:focus-visible, textarea:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
select { cursor: pointer; width: auto; }
input[type="checkbox"] { width: 1.15rem; min-height: 0; height: 1.15rem; accent-color: var(--accent); cursor: pointer; }
input[type="file"] { padding: 0.4rem; }
.field { display: flex; flex-direction: column; gap: 0.35rem; }
.field > span { font-size: 0.88rem; font-weight: 550; color: var(--text-2); }
.field .hint { font-size: 0.82rem; color: var(--muted); font-weight: 400; }
.form { display: flex; flex-direction: column; gap: var(--space-4); }
.form-row { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: flex-end; }
.form-row > .field { flex: 1; min-width: 180px; }
/* file drop zone */
.dropzone {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
text-align: center;
padding: 2rem 1rem;
border: 2px dashed var(--border-strong);
border-radius: var(--radius);
background: var(--surface-2);
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.dropzone:hover, .dropzone.over, .dropzone:focus-within { border-color: var(--accent); background: var(--accent-soft); }
.dropzone input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.dropzone .dz-icon { color: var(--accent); }
.dropzone.has-file { border-style: solid; border-color: var(--accent); }
/* segmented / chips */
.chips { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 2.1rem;
padding: 0 0.85rem;
border-radius: 99px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-2);
font: inherit;
font-size: 0.9rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.chip:hover { border-color: var(--border-strong); color: var(--text); }
.chip.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }
.tags { display: flex; gap: 0.35rem; flex-wrap: wrap; }
.tag {
font-size: 0.74rem;
padding: 0.05rem 0.55rem;
border-radius: 99px;
background: var(--surface-3);
color: var(--text-2);
}
/* status badges */
.badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.78rem;
font-weight: 600;
padding: 0.1rem 0.6rem;
border-radius: 99px;
background: var(--surface-3);
color: var(--text-2);
white-space: nowrap;
}
.badge::before { content: ""; width: 0.45rem; height: 0.45rem; border-radius: 50%; background: currentColor; }
.badge-ok { background: var(--ok-soft); color: var(--ok); }
.badge-warn { background: var(--accent-soft); color: var(--accent); }
.badge-warn.busy::before { animation: pulse 1.2s ease-in-out infinite; }
.badge-error { background: var(--danger-soft); color: var(--danger); }
.badge-plain::before { display: none; }
.progress { height: 6px; background: var(--surface-3); border-radius: 99px; overflow: hidden; }
.progress > div { height: 100%; background: var(--accent); border-radius: 99px; transition: width 0.2s; }
/* ---- 4. surfaces -------------------------------------------------------- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-5);
box-shadow: var(--shadow-1);
}
.card-flat { box-shadow: none; }
.card h2 { margin-bottom: var(--space-3); }
.note {
display: flex;
gap: 0.6rem;
align-items: flex-start;
padding: 0.7rem 0.9rem;
border-radius: var(--radius-s);
border: 1px solid var(--border);
background: var(--surface-2);
font-size: 0.93rem;
}
.note svg { flex-shrink: 0; margin-top: 0.15rem; }
.note-error { background: var(--danger-soft); border-color: var(--danger); color: var(--danger); }
.note-ok { background: var(--ok-soft); border-color: var(--ok); color: var(--ok); }
.note-info { background: var(--info-soft); border-color: var(--info); color: var(--info); }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: var(--space-5);
}
.page-header p { margin-top: 0.25rem; }
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.6rem;
text-align: center;
padding: 3.5rem 1rem;
border: 1px dashed var(--border-strong);
border-radius: var(--radius-l);
}
.empty-icon {
display: grid;
place-items: center;
width: 4rem;
height: 4rem;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
}
.empty p { max-width: 36ch; }
.spinner-row { display: flex; align-items: center; gap: 0.7rem; padding: var(--space-4) 0; }
.spinner {
width: 1.1rem;
height: 1.1rem;
border-radius: 50%;
border: 2px solid var(--border-strong);
border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 50% { opacity: 0.25; } }
.skeleton {
background: linear-gradient(100deg, var(--surface) 30%, var(--surface-3) 50%, var(--surface) 70%);
background-size: 300% 100%;
animation: shimmer 1.4s linear infinite;
border-radius: var(--radius);
}
@keyframes shimmer { to { background-position: -150% 0; } }
/* ---- 5. shell / navigation --------------------------------------------- */
.layout { min-height: 100vh; display: flex; flex-direction: column; }
.layout > main { flex: 1; }
.topbar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
gap: var(--space-5);
height: var(--nav-h);
padding: 0 var(--space-5);
background: var(--topbar-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid var(--border);
}
.brand {
display: flex;
align-items: center;
gap: 0.55rem;
font-weight: 800;
font-size: 1.2rem;
letter-spacing: -0.02em;
}
.brand-mark {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 9px;
background: var(--accent);
color: var(--accent-ink);
}
.topbar nav { display: flex; gap: 0.25rem; flex: 1; }
.nav-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
height: 2.4rem;
padding: 0 0.9rem;
border-radius: var(--radius-s);
color: var(--text-2);
font-weight: 550;
transition: background 0.15s, color 0.15s;
}
.nav-link:hover { background: var(--surface-2); color: var(--text); }
.nav-link.active { background: var(--accent-soft); color: var(--accent); }
.menu { position: relative; }
.menu-trigger { gap: 0.55rem; padding: 0 0.6rem 0 0.35rem; }
.avatar {
display: grid;
place-items: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 50%;
background: var(--accent);
color: var(--accent-ink);
font-weight: 700;
font-size: 0.9rem;
flex-shrink: 0;
}
.menu-panel {
position: absolute;
right: 0;
top: calc(100% + 8px);
min-width: 15rem;
padding: 0.4rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-3);
z-index: 40;
animation: pop 0.12s ease-out;
}
@keyframes pop { from { opacity: 0; transform: translateY(-4px); } }
.menu-head { padding: 0.6rem 0.7rem 0.7rem; border-bottom: 1px solid var(--border); margin-bottom: 0.3rem; }
.menu-item {
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
min-height: 2.5rem;
padding: 0 0.7rem;
background: none;
border: 0;
border-radius: var(--radius-s);
color: var(--text-2);
font: inherit;
text-align: left;
cursor: pointer;
}
.menu-item:hover, .menu-item.active { background: var(--surface-2); color: var(--text); }
.menu-item.danger { color: var(--danger); }
.menu-sep { height: 1px; background: var(--border); margin: 0.3rem 0; }
.menu-section { padding: 0.5rem 0.7rem 0.2rem; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.tabbar { display: none; }
.page { padding: var(--space-5); max-width: 1500px; margin: 0 auto; width: 100%; }
.page.narrow { max-width: 600px; }
.page.medium { max-width: 960px; }
.back-link { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--muted); margin-bottom: var(--space-4); font-weight: 550; }
.back-link:hover { color: var(--text); }
/* auth (login / forced credentials) */
.auth-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4);
background:
radial-gradient(900px 500px at 15% -10%, var(--accent-soft), transparent 60%),
radial-gradient(700px 500px at 100% 110%, var(--info-soft), transparent 60%),
var(--bg);
}
.auth-card { width: min(420px, 100%); display: flex; flex-direction: column; gap: var(--space-4); padding: 2rem; box-shadow: var(--shadow-2); }
.auth-card .brand { font-size: 1.6rem; margin-bottom: 0.2rem; }
.auth-card .brand-mark { width: 2.6rem; height: 2.6rem; border-radius: 12px; }
.auth-theme { position: fixed; top: 0.8rem; right: 0.8rem; }
/* ---- 6. library --------------------------------------------------------- */
.toolbar { display: flex; gap: var(--space-3); flex-wrap: wrap; align-items: center; margin-bottom: var(--space-4); }
.search { position: relative; flex: 1; min-width: 240px; }
.search svg { position: absolute; left: 0.85rem; top: 50%; transform: translateY(-50%); color: var(--muted); pointer-events: none; }
.search input { padding-left: 2.6rem; height: 2.9rem; border-radius: var(--radius); font-size: 1.02rem; }
.section-title { display: flex; align-items: center; gap: 0.5rem; margin: var(--space-5) 0 var(--space-3); }
.section-title .count { color: var(--muted); font-weight: 500; font-size: 0.9rem; }
.shelf { display: flex; gap: var(--space-4); overflow-x: auto; padding-bottom: 0.6rem; scroll-snap-type: x proximity; margin: 0 calc(var(--space-5) * -1); padding-inline: var(--space-5); scrollbar-width: thin; }
.shelf .recent-card { flex: 0 0 min(300px, 78vw); scroll-snap-align: start; }
.recent-card {
position: relative;
display: block;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--border);
background: var(--surface);
transition: transform 0.18s, border-color 0.18s, box-shadow 0.18s;
}
.recent-card:hover { transform: translateY(-3px); border-color: var(--accent); box-shadow: var(--shadow-2); }
.recent-card .cover { aspect-ratio: 16 / 9; border-radius: 0; }
.recent-body { display: flex; align-items: center; gap: 0.7rem; padding: 0.7rem 0.9rem; }
.recent-body b { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recent-play {
display: grid;
place-items: center;
width: 2.2rem;
height: 2.2rem;
border-radius: 50%;
background: var(--accent);
color: var(--accent-ink);
flex-shrink: 0;
}
.recent-card .recent-x {
position: absolute;
top: 0.5rem;
right: 0.5rem;
opacity: 0;
background: rgba(0, 0, 0, 0.55);
color: #fff;
border: 0;
border-radius: 50%;
width: 1.8rem;
height: 1.8rem;
display: grid;
place-items: center;
cursor: pointer;
transition: opacity 0.15s;
}
.recent-card:hover .recent-x, .recent-card:focus-within .recent-x { opacity: 1; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: var(--space-4); }
.game-card {
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: transform 0.18s, border-color 0.18s, box-shadow 0.18s;
}
.game-card:hover, .game-card:focus-visible { transform: translateY(-4px); border-color: var(--accent); box-shadow: var(--shadow-2); }
.game-card:focus-visible { outline-offset: 3px; border-radius: var(--radius); }
.game-card-media { position: relative; }
.game-card-media .badge { position: absolute; top: 0.6rem; left: 0.6rem; backdrop-filter: blur(8px); background: rgba(10, 12, 20, 0.72); }
.game-card-media .badge-ok { color: var(--ok); }
.game-card-media .badge-warn { color: #ffb066; }
.game-card-media .badge-error { color: #ff8588; }
.game-card-body { padding: 0.75rem 0.9rem 0.9rem; display: flex; flex-direction: column; gap: 0.4rem; flex: 1; }
.game-card-title { font-weight: 650; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.game-card-meta { margin-top: auto; font-size: 0.82rem; color: var(--muted); }
.cover {
aspect-ratio: 16 / 10;
background: linear-gradient(135deg, var(--surface-3), var(--surface-2));
display: flex;
align-items: center;
justify-content: center;
font-size: 2.6rem;
font-weight: 700;
color: var(--muted);
overflow: hidden;
}
.cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* ---- 7. game page ------------------------------------------------------- */
.hero {
position: relative;
margin: calc(var(--space-5) * -1) calc(var(--space-5) * -1) 0;
padding: var(--space-5) var(--space-5) var(--space-6);
overflow: hidden;
isolation: isolate;
}
.hero-bg {
position: absolute;
inset: -40px;
z-index: -2;
background-size: cover;
background-position: center;
filter: blur(36px) saturate(1.2);
opacity: 0.45;
transform: scale(1.1);
}
.hero::after {
content: "";
position: absolute;
inset: 0;
z-index: -1;
background: linear-gradient(to bottom, transparent 20%, var(--bg) 100%);
}
.hero-inner { display: flex; gap: var(--space-5); align-items: flex-end; max-width: 1500px; margin: 0 auto; flex-wrap: wrap; }
.hero .cover.big {
width: 380px;
max-width: 100%;
border-radius: var(--radius-l);
box-shadow: var(--shadow-3);
border: 1px solid var(--border);
flex-shrink: 0;
}
.hero-info { flex: 1; min-width: 280px; display: flex; flex-direction: column; gap: var(--space-3); }
.hero-info h1 { font-size: clamp(1.8rem, 4vw, 2.8rem); }
.description { white-space: pre-wrap; color: var(--text-2); max-width: 70ch; }
.hero-actions { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; margin-top: var(--space-2); }
.editor { display: flex; flex-direction: column; gap: var(--space-3); background: var(--surface); padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius); }
.versions { display: flex; flex-direction: column; gap: 0.6rem; }
.version {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.85rem 1rem;
transition: border-color 0.15s;
}
.version:hover { border-color: var(--border-strong); }
.version.main { border-color: var(--accent); }
.version-main { display: flex; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; align-items: center; }
.version-title { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.version-title b { font-size: 1.02rem; }
.version-meta { margin-top: 0.2rem; }
.version-progress { margin-top: 0.6rem; }
.fork-mark { color: var(--muted); display: inline-flex; }
.report { margin-top: var(--space-3); padding-top: var(--space-3); border-top: 1px solid var(--border); font-size: 0.92rem; }
.report-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 0.6rem; margin-bottom: var(--space-3); }
.stat { background: var(--surface-2); border-radius: var(--radius-s); padding: 0.55rem 0.75rem; }
.stat b { display: block; font-size: 1.15rem; }
.stat span { color: var(--muted); font-size: 0.8rem; }
.report table { border-collapse: collapse; margin-top: 0.4rem; width: 100%; }
.report td { padding: 0.25rem 0.7rem 0.25rem 0; vertical-align: top; }
.report summary { cursor: pointer; margin: 0.4rem 0; font-weight: 550; }
.report ul { margin: 0.3rem 0; padding-left: 1.2rem; color: var(--text-2); }
/* ---- 8. admin ----------------------------------------------------------- */
.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { text-align: left; padding: 0.7rem 0.9rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
.table tbody tr:last-child td { border-bottom: 0; }
.table th { color: var(--muted); font-weight: 600; font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.04em; background: var(--surface-2); white-space: nowrap; }
.table tbody tr:hover { background: var(--surface-2); }
.table tr.inactive td { opacity: 0.55; }
.table tr.inactive td:last-child { opacity: 1; }
.table .row-actions { display: flex; gap: 0.4rem; justify-content: flex-end; flex-wrap: wrap; }
.center-cell { text-align: center !important; }
.roles th:not(:first-child), .roles td:not(:first-child) { text-align: center; }
.roles td:first-child small { display: block; color: var(--muted); font-family: ui-monospace, Consolas, monospace; }
.roles-th { display: inline-flex; align-items: center; gap: 0.3rem; }
/* toggle switch for permissions */
.switch { position: relative; display: inline-block; width: 2.4rem; height: 1.4rem; }
.switch input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; z-index: 1; }
.switch i { position: absolute; inset: 0; border-radius: 99px; background: var(--surface-3); border: 1px solid var(--border-strong); transition: background 0.15s; }
.switch i::after { content: ""; position: absolute; top: 2px; left: 2px; width: 1rem; height: 1rem; border-radius: 50%; background: var(--text-2); transition: transform 0.15s, background 0.15s; }
.switch input:checked + i { background: var(--accent); border-color: var(--accent); }
.switch input:checked + i::after { transform: translateX(1rem); background: #fff; }
.switch input:disabled { cursor: default; }
.switch input:disabled + i { opacity: 0.55; }
.switch input:focus-visible + i { outline: 3px solid var(--accent); outline-offset: 2px; }
.issued { border-color: var(--accent); background: var(--accent-soft); margin-bottom: var(--space-4); }
.password-box { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-top: var(--space-3); }
.password-box code { font-size: 1.25rem; padding: 0.35rem 0.9rem; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-s); user-select: all; letter-spacing: 0.04em; }
.user-cards { display: none; }
/* ---- 9. feedback: modal, toasts ---------------------------------------- */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: var(--space-4);
background: var(--overlay);
backdrop-filter: blur(4px);
animation: fade 0.15s ease-out;
}
@keyframes fade { from { opacity: 0; } }
.modal {
width: min(440px, 100%);
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-5);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-l);
box-shadow: var(--shadow-3);
animation: pop 0.15s ease-out;
}
.modal-actions { display: flex; justify-content: flex-end; gap: 0.6rem; margin-top: var(--space-2); }
.toasts {
position: fixed;
right: var(--space-4);
bottom: var(--space-4);
z-index: 120;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: min(420px, calc(100vw - 2rem));
pointer-events: none;
}
.toast {
display: flex;
gap: 0.6rem;
align-items: flex-start;
padding: 0.7rem 0.95rem;
background: var(--surface);
border: 1px solid var(--border-strong);
border-left-width: 4px;
border-radius: var(--radius-s);
box-shadow: var(--shadow-2);
animation: pop 0.18s ease-out;
}
.toast svg { margin-top: 0.12rem; flex-shrink: 0; }
.toast-ok { border-left-color: var(--ok); }
.toast-ok svg { color: var(--ok); }
.toast-error { border-left-color: var(--danger); }
.toast-error svg { color: var(--danger); }
.toast-info { border-left-color: var(--info); }
.toast-info svg { color: var(--info); }
/* ---- play route --------------------------------------------------------- */
.play { position: fixed; inset: 0; background: #000; color: #fff; }
.play-host { position: absolute; inset: 0; }
.play-status {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
justify-content: center;
color: #b4b9ca;
pointer-events: none;
background: #000;
}
.play-status .btn { pointer-events: auto; }
.play-status .error-text { color: #f2686b; max-width: 40ch; text-align: center; }
/* ---- 10. responsive ----------------------------------------------------- */
@media (max-width: 860px) {
.topbar { padding: 0 var(--space-4); gap: var(--space-3); }
.topbar nav { display: none; }
.topbar .brand { flex: 1; }
.menu-label, .menu-chevron { display: none; }
.menu-trigger { padding: 0.25rem; }
.menu-panel .desktop-only { display: none; }
.tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
display: flex;
height: calc(var(--tabbar-h) + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: var(--topbar-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-top: 1px solid var(--border);
}
.tabbar a {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.15rem;
font-size: 0.72rem;
font-weight: 550;
color: var(--muted);
}
.tabbar a.active { color: var(--accent); }
.layout > main { padding-bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 0.5rem); }
.toasts { bottom: calc(var(--tabbar-h) + 1rem); }
.page { padding: var(--space-4); }
.hero { margin: calc(var(--space-4) * -1) calc(var(--space-4) * -1) 0; padding: var(--space-4) var(--space-4) var(--space-5); }
.hero .cover.big { width: 100%; }
.shelf { margin: 0 calc(var(--space-4) * -1); padding-inline: var(--space-4); }
h1 { font-size: 1.5rem; }
/* users table -> cards */
.users-table { display: none; }
.user-cards { display: flex; flex-direction: column; gap: 0.6rem; }
.user-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.9rem; display: flex; flex-direction: column; gap: 0.6rem; }
.user-card.inactive { opacity: 0.65; }
.user-card-head { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
}
@media (max-width: 520px) {
.grid { grid-template-columns: repeat(2, 1fr); gap: 0.7rem; }
.game-card-body { padding: 0.6rem 0.7rem 0.75rem; }
.auth-card { padding: 1.5rem; }
.modal-actions .btn { flex: 1; }
.hero-actions .btn-lg { flex: 1; }
}
/* touch: bigger hit areas */
@media (pointer: coarse) {
:root { --control-h: 2.9rem; }
.btn-sm { min-height: 2.5rem; }
.btn-sm.btn-icon { width: 2.5rem; }
.chip { min-height: 2.5rem; }
}
/* large screens / TV */
@media (min-width: 1700px) {
body { font-size: 17px; }
.grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.page { max-width: 1900px; }
}
@media (min-width: 2400px) {
body { font-size: 22px; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
+39
View File
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useState } from "react";
export type ThemeChoice = "system" | "dark" | "light";
const KEY = "pg-theme";
function read(): ThemeChoice {
try {
const v = localStorage.getItem(KEY);
return v === "dark" || v === "light" ? v : "system";
} catch {
return "system";
}
}
function apply(choice: ThemeChoice) {
const root = document.documentElement;
if (choice === "system") delete root.dataset.theme;
else root.dataset.theme = choice;
const dark = choice === "dark" || (choice === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", dark ? "#0e1016" : "#f4f1ec");
}
export function useTheme() {
const [choice, setChoice] = useState<ThemeChoice>(read);
useEffect(() => {
apply(choice);
try {
if (choice === "system") localStorage.removeItem(KEY);
else localStorage.setItem(KEY, choice);
} catch {
/* private mode */
}
}, [choice]);
const cycle = useCallback(
() => setChoice((c) => (c === "system" ? "dark" : c === "dark" ? "light" : "system")),
[],
);
return { choice, setChoice, cycle };
}
+53
View File
@@ -0,0 +1,53 @@
import type { ReactNode } from "react";
import { Icon, type IconName } from "./Icon";
export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: ReactNode; actions?: ReactNode }) {
return (
<div className="page-header">
<div>
<h1>{title}</h1>
{subtitle && <p className="muted">{subtitle}</p>}
</div>
{actions && <div className="actions">{actions}</div>}
</div>
);
}
export function EmptyState({ icon, title, text, action }: {
icon: IconName; title: string; text?: string; action?: ReactNode;
}) {
return (
<div className="empty">
<div className="empty-icon"><Icon name={icon} size={30} /></div>
<h3>{title}</h3>
{text && <p className="muted">{text}</p>}
{action}
</div>
);
}
export function ErrorNote({ children }: { children: ReactNode }) {
return (
<div className="note note-error" role="alert">
<Icon name="alert" size={18} />
<span>{children}</span>
</div>
);
}
export function Spinner({ label = "Загрузка…" }: { label?: string }) {
return (
<div className="spinner-row muted">
<span className="spinner" />
{label}
</div>
);
}
export function Cover({ hash, title, className = "" }: { hash: string | null; title: string; className?: string }) {
return (
<div className={`cover ${className}`}>
{hash ? <img src={`/blobs/${hash}`} alt="" loading="lazy" /> : <span>{title.trim()[0]?.toUpperCase()}</span>}
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import type { SVGProps } from "react";
// Inline Lucide-style stroke icons: no dependency, works offline.
const PATHS = {
library: "M4 19.5V5a2 2 0 0 1 2-2h12v16H6a2 2 0 0 0-2 2.5ZM4 19.5A2 2 0 0 0 6 21h12M9 7h5",
upload: "M12 16V4m0 0L7 9m5-5 5 5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3",
users: "M16 20v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 18.5V20M10 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm9 9v-1.5a3.5 3.5 0 0 0-2.5-3.36M15.5 4.2a3.5 3.5 0 0 1 0 6.6",
shield: "M12 3 4 6v6c0 4.5 3.2 7.8 8 9 4.8-1.2 8-4.5 8-9V6l-8-3Zm-3 9 2.2 2.2L15.5 10",
user: "M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z",
play: "M7 4.5v15a.8.8 0 0 0 1.2.7l12-7.5a.8.8 0 0 0 0-1.4l-12-7.5A.8.8 0 0 0 7 4.5Z",
search: "M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm10 2-4.3-4.3",
sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0-15v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4",
moon: "M20.5 14.2A8.5 8.5 0 1 1 9.8 3.5a6.7 6.7 0 0 0 10.7 10.7Z",
monitor: "M3 5h18v11H3zM8 20h8m-4-4v4",
logout: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4m7 14 5-5-5-5m5 5H9",
fork: "M6 3v6m0 0a3 3 0 1 0 0 6 3 3 0 0 0 0-6Zm0 6v0m12-6a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm0 0v1a3 3 0 0 1-3 3H9",
trash: "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14M10 11v5m4-5v5",
edit: "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z",
image: "M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Zm5.5 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM21 15l-5-5L5 21",
plus: "M12 5v14M5 12h14",
back: "M19 12H5m7-7-7 7 7 7",
chevron: "m9 6 6 6-6 6",
check: "m5 12 5 5L20 7",
x: "M18 6 6 18M6 6l12 12",
alert: "M12 9v4m0 4h.01M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z",
info: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-6v-4m0-4h.01",
retry: "M3 12a9 9 0 0 1 15.5-6.2L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.5 6.2L3 16m0 5v-5h5",
file: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Zm0 0v6h6",
copy: "M9 9h11v11H9zM5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1",
key: "m21 2-9.6 9.6M15.5 7.5l3 3M7.5 21a5.5 5.5 0 1 1 3.9-9.4A5.5 5.5 0 0 1 7.5 21Z",
clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-15v5l3 2",
menu: "M4 6h16M4 12h16M4 18h16",
power: "M12 2v10m6.4-6.4a9 9 0 1 1-12.8 0",
} as const;
export type IconName = keyof typeof PATHS;
export function Icon({ name, size = 20, ...rest }: { name: IconName; size?: number } & SVGProps<SVGSVGElement>) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}
strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false" {...rest}>
<path d={PATHS[name]} />
</svg>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useTheme, type ThemeChoice } from "../theme";
import { Icon, type IconName } from "./Icon";
export const THEME_LABEL: Record<ThemeChoice, string> = {
system: "Тема: как в системе",
dark: "Тема: тёмная",
light: "Тема: светлая",
};
export const THEME_ICON: Record<ThemeChoice, IconName> = { system: "monitor", dark: "moon", light: "sun" };
export function ThemeButton({ className = "" }: { className?: string }) {
const { choice, cycle } = useTheme();
return (
<button type="button" className={`btn btn-ghost btn-icon ${className}`} onClick={cycle}
title={THEME_LABEL[choice]} aria-label={THEME_LABEL[choice]}>
<Icon name={THEME_ICON[choice]} />
</button>
);
}
+131
View File
@@ -0,0 +1,131 @@
import {
createContext, useCallback, useContext, useEffect, useMemo, useRef, useState,
type FormEvent, type ReactNode,
} from "react";
import { Icon } from "./Icon";
interface ConfirmOpts {
title: string;
text?: string;
confirmLabel?: string;
danger?: boolean;
}
interface PromptOpts {
title: string;
text?: string;
label?: string;
initial?: string;
confirmLabel?: string;
}
type Kind = "info" | "ok" | "error";
interface ToastItem {
id: number;
kind: Kind;
text: string;
}
type DialogState =
| { type: "confirm"; opts: ConfirmOpts; done: (v: boolean) => void }
| { type: "prompt"; opts: PromptOpts; done: (v: string | null) => void };
interface UiApi {
confirm: (opts: ConfirmOpts) => Promise<boolean>;
prompt: (opts: PromptOpts) => Promise<string | null>;
toast: (text: string, kind?: Kind) => void;
}
const UiContext = createContext<UiApi | null>(null);
export function UiProvider({ children }: { children: ReactNode }) {
const [dialog, setDialog] = useState<DialogState | null>(null);
const [toasts, setToasts] = useState<ToastItem[]>([]);
const seq = useRef(0);
const confirm = useCallback(
(opts: ConfirmOpts) => new Promise<boolean>((resolve) => setDialog({ type: "confirm", opts, done: resolve })),
[],
);
const prompt = useCallback(
(opts: PromptOpts) => new Promise<string | null>((resolve) => setDialog({ type: "prompt", opts, done: resolve })),
[],
);
const toast = useCallback((text: string, kind: Kind = "info") => {
const id = ++seq.current;
setToasts((t) => [...t, { id, kind, text }]);
setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), kind === "error" ? 7000 : 3500);
}, []);
const api = useMemo(() => ({ confirm, prompt, toast }), [confirm, prompt, toast]);
return (
<UiContext.Provider value={api}>
{children}
{dialog && <Dialog state={dialog} close={() => setDialog(null)} />}
<div className="toasts" role="status" aria-live="polite">
{toasts.map((t) => (
<div key={t.id} className={`toast toast-${t.kind}`}>
<Icon name={t.kind === "ok" ? "check" : t.kind === "error" ? "alert" : "info"} size={18} />
<span>{t.text}</span>
</div>
))}
</div>
</UiContext.Provider>
);
}
function Dialog({ state, close }: { state: DialogState; close: () => void }) {
const [value, setValue] = useState(state.type === "prompt" ? state.opts.initial ?? "" : "");
const input = useRef<HTMLInputElement>(null);
const ok = useRef<HTMLButtonElement>(null);
const finish = (accepted: boolean) => {
if (state.type === "confirm") state.done(accepted);
else state.done(accepted && value.trim() ? value.trim() : null);
close();
};
useEffect(() => {
(state.type === "prompt" ? input.current : ok.current)?.focus();
input.current?.select();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") finish(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const o = state.opts;
const danger = state.type === "confirm" && state.opts.danger;
const submit = (e: FormEvent) => {
e.preventDefault();
finish(true);
};
return (
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && finish(false)}>
<form className="modal" role="dialog" aria-modal="true" aria-label={o.title} onSubmit={submit}>
<h2>{o.title}</h2>
{o.text && <p className="muted">{o.text}</p>}
{state.type === "prompt" && (
<label className="field">
{state.opts.label && <span>{state.opts.label}</span>}
<input ref={input} value={value} onChange={(e) => setValue(e.target.value)} />
</label>
)}
<div className="modal-actions">
<button type="button" className="btn" onClick={() => finish(false)}>Отмена</button>
<button ref={ok} className={`btn ${danger ? "btn-danger-solid" : "btn-primary"}`}
disabled={state.type === "prompt" && !value.trim()}>
{o.confirmLabel ?? (danger ? "Удалить" : "Ок")}
</button>
</div>
</form>
</div>
);
}
export function useUi(): UiApi {
const ctx = useContext(UiContext);
if (!ctx) throw new Error("UiProvider missing");
return ctx;
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect } from "react";
// Arrow-key and gamepad navigation: moves focus to the nearest focusable
// element in the pressed direction. Works on the site and inside the player's
// overlay screens; on a plain page of the player it does nothing.
const FOCUSABLE =
'a[href], button:not(:disabled), input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), summary, [tabindex="0"]';
type Dir = "left" | "right" | "up" | "down";
function scopeRoot(): Element | null {
const play = document.querySelector(".play");
if (!play) return document.body;
const overlay = play.querySelector(".pg-screen:not(.pg-hidden), .pg-menu:not(.pg-hidden)");
return overlay;
}
function visible(el: Element): boolean {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) return false;
const s = getComputedStyle(el);
return s.visibility !== "hidden" && s.display !== "none";
}
function move(dir: Dir): boolean {
const root = scopeRoot();
if (!root) return false;
const items = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(visible);
if (!items.length) return false;
const cur = document.activeElement as HTMLElement | null;
if (!cur || !items.includes(cur)) {
items[0].focus();
return true;
}
const a = cur.getBoundingClientRect();
const ac = { x: a.left + a.width / 2, y: a.top + a.height / 2 };
let best: HTMLElement | null = null;
let bestScore = Infinity;
for (const el of items) {
if (el === cur) continue;
const b = el.getBoundingClientRect();
const bc = { x: b.left + b.width / 2, y: b.top + b.height / 2 };
const dx = bc.x - ac.x;
const dy = bc.y - ac.y;
const main = dir === "left" ? -dx : dir === "right" ? dx : dir === "up" ? -dy : dy;
if (main <= 1) continue;
const cross = dir === "left" || dir === "right" ? Math.abs(dy) : Math.abs(dx);
const score = main + cross * 2.5;
if (score < bestScore) {
bestScore = score;
best = el;
}
}
if (!best) return false;
best.focus();
best.scrollIntoView({ block: "nearest", inline: "nearest" });
return true;
}
function isTextField(el: Element | null): boolean {
if (!el) return false;
const tag = el.tagName;
if (tag === "TEXTAREA") return true;
if (tag === "INPUT") return !["checkbox", "radio", "button", "file", "range"].includes((el as HTMLInputElement).type);
return false;
}
function fireKey(key: string) {
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
window.dispatchEvent(new KeyboardEvent("keyup", { key, bubbles: true }));
}
export function useSpatialNav() {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
const dir: Dir | null =
e.key === "ArrowLeft" ? "left" : e.key === "ArrowRight" ? "right" :
e.key === "ArrowUp" ? "up" : e.key === "ArrowDown" ? "down" : null;
if (!dir || isTextField(document.activeElement)) return;
// let native widgets keep their arrows
const tag = document.activeElement?.tagName;
if (tag === "SELECT" || (tag === "INPUT" && (document.activeElement as HTMLInputElement).type === "range")) return;
if (move(dir)) e.preventDefault();
};
window.addEventListener("keydown", onKey);
// gamepad: d-pad / left stick = move, A = press, B = back/escape, Y = hide UI, X = auto
let raf = 0;
const held = new Map<string, number>();
const edge = (id: string, down: boolean, now: number, repeat = 0): boolean => {
if (!down) {
held.delete(id);
return false;
}
const last = held.get(id);
if (last === undefined) {
held.set(id, now + 380);
return true;
}
if (repeat && now >= last) {
held.set(id, now + repeat);
return true;
}
return false;
};
const poll = (now: number) => {
const pad = [...(navigator.getGamepads?.() ?? [])].find((g) => g?.connected);
if (pad) {
const ax = pad.axes[0] ?? 0;
const ay = pad.axes[1] ?? 0;
const b = (i: number) => !!pad.buttons[i]?.pressed;
const dirs: [Dir, boolean][] = [
["left", b(14) || ax < -0.6], ["right", b(15) || ax > 0.6],
["up", b(12) || ay < -0.6], ["down", b(13) || ay > 0.6],
];
for (const [d, down] of dirs) if (edge(`d-${d}`, down, now, 130)) move(d);
if (edge("a", b(0), now)) {
const el = document.activeElement as HTMLElement | null;
const root = scopeRoot();
if (el && root && el !== document.body && root.contains(el)) el.click();
else if (document.querySelector(".play")) fireKey("Enter");
}
if (edge("b", b(1), now)) {
if (document.querySelector(".play")) fireKey("Escape");
else history.back();
}
if (edge("x", b(2), now)) fireKey("a");
if (edge("y", b(3), now)) fireKey("h");
}
raf = requestAnimationFrame(poll);
};
raf = requestAnimationFrame(poll);
return () => {
window.removeEventListener("keydown", onKey);
cancelAnimationFrame(raf);
};
}, []);
}