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:
1 parent
51a7e77e56
commit
9ee3e1ff6e
27 files changed
+2331
-440
No files matched your search
@@ -57,6 +57,9 @@ class Game(Base):
|
||||
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
|
||||
engine: Mapped[str] = mapped_column(String(32), default="renpy")
|
||||
cover_hash: Mapped[str | None] = mapped_column(String(80))
|
||||
# the version "Play" opens; translations are published by merging into it
|
||||
main_version_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("game_versions.id", ondelete="SET NULL", use_alter=True))
|
||||
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -74,6 +77,9 @@ class GameVersion(Base):
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
# uploading -> queued -> converting -> ready | failed; importing = app.import_dir run
|
||||
status: Mapped[str] = mapped_column(String(16), default="uploading", index=True)
|
||||
# draft: visible to the owner and reviewers only
|
||||
visibility: Mapped[str] = mapped_column(String(16), default="published")
|
||||
kind: Mapped[str] = mapped_column(String(16), default="original") # original|translation|mod
|
||||
progress: Mapped[str] = mapped_column(String(255), default="")
|
||||
upload_name: Mapped[str | None] = mapped_column(String(255))
|
||||
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
|
||||
@@ -84,6 +90,19 @@ class GameVersion(Base):
|
||||
owner: Mapped[User | None] = relationship(lazy="joined")
|
||||
|
||||
|
||||
class VersionHistory(Base):
|
||||
"""Previous manifests of a version that was changed in place (e.g. a merged translation)."""
|
||||
__tablename__ = "version_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
version_id: Mapped[int] = mapped_column(ForeignKey("game_versions.id", ondelete="CASCADE"),
|
||||
index=True)
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
|
||||
note: Mapped[str] = mapped_column(String(255), default="")
|
||||
manifest: Mapped[dict[str, Any]] = mapped_column(JSONB)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class Save(Base):
|
||||
__tablename__ = "saves"
|
||||
__table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),)
|
||||
|
||||
@@ -7,6 +7,8 @@ PERMISSIONS: dict[str, str] = {
|
||||
"games.delete": "Удаление игр и любых версий",
|
||||
"versions.fork": "Создание своих версий (форков)",
|
||||
"versions.manage_any": "Управление чужими версиями",
|
||||
"translations.edit": "Перевод игр в своих версиях",
|
||||
"versions.publish": "Публикация версий и переводов, выбор главной версии",
|
||||
"users.manage": "Управление пользователями",
|
||||
"roles.manage": "Управление ролями",
|
||||
}
|
||||
@@ -15,6 +17,7 @@ ALL = sorted(PERMISSIONS)
|
||||
|
||||
SYSTEM_ROLES: dict[str, list[str]] = {
|
||||
"admin": ALL,
|
||||
"moderator": ["games.play", "games.upload", "games.edit", "versions.fork"],
|
||||
"moderator": ["games.play", "games.upload", "games.edit", "versions.fork",
|
||||
"translations.edit", "versions.publish"],
|
||||
"player": ["games.play"],
|
||||
}
|
||||
@@ -3,7 +3,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import exists, or_, select, true
|
||||
from sqlalchemy import exists, func, or_, select, true
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.blobs import put_bytes
|
||||
@@ -40,7 +40,7 @@ def can_edit_game(user: User, game: Game) -> bool:
|
||||
|
||||
|
||||
@router.get("", response_model=list[GameOut])
|
||||
async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[Game]:
|
||||
async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[dict]:
|
||||
stmt = select(Game).order_by(Game.title)
|
||||
if q.strip():
|
||||
pattern = f"%{q.strip()}%"
|
||||
@@ -57,7 +57,27 @@ async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[G
|
||||
exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user))
|
||||
| (Game.created_by_id == user.id)
|
||||
)
|
||||
return list((await db.scalars(stmt.limit(500))).all())
|
||||
games = list((await db.scalars(stmt.limit(500))).all())
|
||||
counts: dict[int, dict[str, int]] = {}
|
||||
if games:
|
||||
rows = await db.execute(
|
||||
select(GameVersion.game_id, GameVersion.status, func.count())
|
||||
.where(GameVersion.game_id.in_([g.id for g in games]), visible_versions_filter(user))
|
||||
.group_by(GameVersion.game_id, GameVersion.status)
|
||||
)
|
||||
for game_id, st, n in rows:
|
||||
key = "ready" if st == "ready" else "failed" if st == "failed" else "pending"
|
||||
counts.setdefault(game_id, {}).setdefault(key, 0)
|
||||
counts[game_id][key] += n
|
||||
return [
|
||||
{
|
||||
**GameOut.model_validate(g).model_dump(),
|
||||
"ready_versions": counts.get(g.id, {}).get("ready", 0),
|
||||
"pending_versions": counts.get(g.id, {}).get("pending", 0),
|
||||
"failed_versions": counts.get(g.id, {}).get("failed", 0),
|
||||
}
|
||||
for g in games
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=GameOut, status_code=201)
|
||||
|
||||
@@ -90,6 +90,10 @@ class GameOut(ORM):
|
||||
engine: str
|
||||
cover_hash: str | None
|
||||
created_at: datetime
|
||||
# version counts visible to the requesting user (filled by the list endpoint)
|
||||
ready_versions: int = 0
|
||||
pending_versions: int = 0
|
||||
failed_versions: int = 0
|
||||
|
||||
|
||||
class GameDetailOut(GameOut):
|
||||
|
||||
@@ -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
@@ -2,8 +2,16 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<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>
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+84
-15
@@ -1,4 +1,5 @@
|
||||
import { Navigate, NavLink, Route, Routes } from "react-router-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Navigate, NavLink, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "./auth";
|
||||
import { ChangeCredentials } from "./pages/ChangeCredentials";
|
||||
import { GamePage } from "./pages/GamePage";
|
||||
@@ -8,15 +9,78 @@ import { NewGame } from "./pages/NewGame";
|
||||
import { Play } from "./pages/Play";
|
||||
import { Roles } from "./pages/Roles";
|
||||
import { Users } from "./pages/Users";
|
||||
import { useTheme } from "./theme";
|
||||
import { Icon } from "./ui/Icon";
|
||||
import { useSpatialNav } from "./ui/nav";
|
||||
import { THEME_ICON, THEME_LABEL } from "./ui/ThemeButton";
|
||||
|
||||
function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) {
|
||||
const { can } = useAuth();
|
||||
return can(perm) ? children : <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() {
|
||||
const { me, loading, logout, can } = useAuth();
|
||||
if (loading) return <div className="center muted">Загрузка…</div>;
|
||||
const { me, loading, can } = useAuth();
|
||||
useSpatialNav();
|
||||
if (loading) return <div className="center"><span className="spinner" /></div>;
|
||||
if (!me) return <Login />;
|
||||
if (me.must_change_password || me.must_change_username) return <ChangeCredentials forced />;
|
||||
|
||||
@@ -28,18 +92,16 @@ export function App() {
|
||||
element={
|
||||
<div className="layout">
|
||||
<header className="topbar">
|
||||
<NavLink to="/" className="brand">PGames</NavLink>
|
||||
<nav>
|
||||
<NavLink to="/" end>Библиотека</NavLink>
|
||||
{can("games.upload") && <NavLink to="/games/new">Загрузить игру</NavLink>}
|
||||
{can("users.manage") && <NavLink to="/admin/users">Пользователи</NavLink>}
|
||||
{can("roles.manage") && <NavLink to="/admin/roles">Роли</NavLink>}
|
||||
<NavLink to="/" className="brand">
|
||||
<span className="brand-mark"><Icon name="play" size={16} /></span>PGames
|
||||
</NavLink>
|
||||
<nav aria-label="Основная навигация">
|
||||
<NavLink to="/" end className="nav-link"><Icon name="library" size={18} />Библиотека</NavLink>
|
||||
{can("games.upload") && (
|
||||
<NavLink to="/games/new" className="nav-link"><Icon name="upload" size={18} />Загрузить игру</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
<div className="user">
|
||||
<NavLink to="/account">{me.username}</NavLink>
|
||||
<span className="muted"> · {me.role.name}</span>
|
||||
<button className="link" onClick={() => void logout()}>Выйти</button>
|
||||
</div>
|
||||
<UserMenu />
|
||||
</header>
|
||||
<main>
|
||||
<Routes>
|
||||
@@ -52,6 +114,13 @@ export function App() {
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -41,6 +41,9 @@ export interface Game {
|
||||
engine: string;
|
||||
cover_hash: string | null;
|
||||
created_at: string;
|
||||
ready_versions?: number;
|
||||
pending_versions?: number;
|
||||
failed_versions?: number;
|
||||
}
|
||||
|
||||
export interface GameDetail extends Game {
|
||||
|
||||
@@ -3,14 +3,17 @@ import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import { AuthProvider } from "./auth";
|
||||
import { UiProvider } from "./ui/Ui";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
<UiProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</UiProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -1,16 +1,22 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, type Me } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import { ErrorNote, PageHeader } from "../ui/Bits";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { useUi } from "../ui/Ui";
|
||||
import { ThemeButton } from "../ui/ThemeButton";
|
||||
|
||||
export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
|
||||
const { me, setMe, logout } = useAuth();
|
||||
const ui = useUi();
|
||||
const needUsername = !!me?.must_change_username;
|
||||
const [username, setUsername] = useState(needUsername ? "" : me?.username ?? "");
|
||||
const [current, setCurrent] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [repeat, setRepeat] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const mismatch = repeat.length > 0 && password !== repeat;
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -26,7 +32,7 @@ export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
|
||||
new_username: username.trim() || null,
|
||||
});
|
||||
setMe(updated);
|
||||
setDone(true);
|
||||
if (!forced) ui.toast("Данные сохранены", "ok");
|
||||
setCurrent("");
|
||||
setPassword("");
|
||||
setRepeat("");
|
||||
@@ -36,39 +42,54 @@ export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
|
||||
};
|
||||
|
||||
const form = (
|
||||
<form className="card auth-card" onSubmit={submit}>
|
||||
<h1>{forced ? "Смена учётных данных" : "Аккаунт"}</h1>
|
||||
{forced && (
|
||||
<p className="muted">
|
||||
{needUsername
|
||||
? "Это первый вход администратора. Задайте собственный логин и пароль."
|
||||
: "Пароль был выдан администратором. Задайте свой пароль."}
|
||||
</p>
|
||||
)}
|
||||
<label>
|
||||
Логин{needUsername && " (новый)"}
|
||||
<form className={forced ? "card auth-card" : "card form"} onSubmit={submit}>
|
||||
{forced ? (
|
||||
<div>
|
||||
<div className="brand"><span className="brand-mark"><Icon name="key" size={20} /></span>Смена данных</div>
|
||||
<p className="muted" style={{ marginTop: "0.6rem" }}>
|
||||
{needUsername
|
||||
? "Это первый вход администратора. Задайте собственный логин и пароль."
|
||||
: "Пароль был выдан администратором. Задайте свой пароль."}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<label className="field">
|
||||
<span>Логин{needUsername && " (новый)"}</span>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Текущий пароль
|
||||
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)}
|
||||
autoComplete="current-password" />
|
||||
<label className="field">
|
||||
<span>Текущий пароль</span>
|
||||
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" />
|
||||
</label>
|
||||
<label>
|
||||
Новый пароль (от 8 символов)
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password" />
|
||||
<label className="field">
|
||||
<span>Новый пароль</span>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
|
||||
<span className="hint">Минимум 8 символов</span>
|
||||
</label>
|
||||
<label>
|
||||
Повторите пароль
|
||||
<input type="password" value={repeat} onChange={(e) => setRepeat(e.target.value)}
|
||||
autoComplete="new-password" />
|
||||
<label className="field">
|
||||
<span>Повторите пароль</span>
|
||||
<input type="password" value={repeat} onChange={(e) => setRepeat(e.target.value)} autoComplete="new-password"
|
||||
aria-invalid={mismatch} style={mismatch ? { borderColor: "var(--danger)" } : undefined} />
|
||||
{mismatch && <span className="hint" style={{ color: "var(--danger)" }}>Пароли не совпадают</span>}
|
||||
</label>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{done && !forced && <div className="ok">Сохранено</div>}
|
||||
<button className="primary" disabled={!current || password.length < 8}>Сохранить</button>
|
||||
{forced && <button type="button" className="link" onClick={() => void logout()}>Выйти</button>}
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
<button className="btn btn-primary btn-lg" disabled={!current || password.length < 8 || mismatch}>Сохранить</button>
|
||||
{forced && <button type="button" className="btn btn-ghost" onClick={() => void logout()}>Выйти</button>}
|
||||
</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
@@ -2,19 +2,30 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../api";
|
||||
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> = {
|
||||
uploading: "загружается",
|
||||
queued: "в очереди",
|
||||
converting: "конвертируется",
|
||||
importing: "импортируется",
|
||||
ready: "готова",
|
||||
failed: "ошибка",
|
||||
uploading: "Загружается",
|
||||
queued: "В очереди",
|
||||
converting: "Конвертируется",
|
||||
importing: "Импортируется",
|
||||
ready: "Готова",
|
||||
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() {
|
||||
const { gameId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const ui = useUi();
|
||||
const { me, can } = useAuth();
|
||||
const [game, setGame] = useState<GameDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
@@ -33,43 +44,75 @@ export function GamePage() {
|
||||
}, [load]);
|
||||
|
||||
// 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(() => {
|
||||
if (!busy) return;
|
||||
const t = setInterval(() => void load(), 2000);
|
||||
return () => clearInterval(t);
|
||||
}, [busy, load]);
|
||||
|
||||
if (error) return <div className="page"><div className="error">{error}</div></div>;
|
||||
if (!game) return <div className="page muted">Загрузка…</div>;
|
||||
if (error && !game) return <div className="page"><ErrorNote>{error}</ErrorNote></div>;
|
||||
if (!game) return <div className="page"><Spinner /></div>;
|
||||
|
||||
// the backend also lets uploaders edit only games they created
|
||||
const canEdit = can("games.edit") || can("games.upload");
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setError("");
|
||||
const run = async (fn: () => Promise<unknown>, okText?: string) => {
|
||||
try {
|
||||
await fn();
|
||||
if (okText) ui.toast(okText, "ok");
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
ui.toast((e as Error).message, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const fork = (v: Version) => {
|
||||
const name = prompt("Название новой версии", `${v.name} (${me?.username})`);
|
||||
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }));
|
||||
const play = (v: Version) => {
|
||||
pushRecent({ gameId: game.id, versionId: v.id, title: game.title, coverHash: game.cover_hash });
|
||||
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) =>
|
||||
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}));
|
||||
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}), "Обложка обновлена");
|
||||
|
||||
const uploadVersion = (file: File) => {
|
||||
const name = prompt("Название версии", file.name);
|
||||
const uploadVersion = async (file: File) => {
|
||||
const name = await ui.prompt({
|
||||
title: "Загрузить новую версию", label: "Название версии", initial: file.name.replace(/\.[^.]+$/, ""),
|
||||
confirmLabel: "Загрузить",
|
||||
});
|
||||
if (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[]>();
|
||||
for (const v of game.versions) {
|
||||
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[] =>
|
||||
(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")}
|
||||
canFork={can("versions.fork")} onPlay={() => navigate(`/play/${v.id}`)} onFork={() => fork(v)}
|
||||
<VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id}
|
||||
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`))}
|
||||
onDelete={() => confirm(`Удалить версию «${v.name}»?`) && void run(() => api.del(`/api/versions/${v.id}`))} />,
|
||||
onDelete={() => void removeVersion(v)} />,
|
||||
...renderTree(v.id, depth + 1),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<Link to="/" className="muted">← Библиотека</Link>
|
||||
<div className="game-head">
|
||||
<div className="cover big">
|
||||
{game.cover_hash ? <img src={`/blobs/${game.cover_hash}`} alt="" /> : <span>{game.title[0]}</span>}
|
||||
</div>
|
||||
<div className="game-info">
|
||||
{editing ? (
|
||||
<GameEditor game={game} onDone={() => { setEditing(false); void load(); }} />
|
||||
) : (
|
||||
<>
|
||||
<h1>{game.title}</h1>
|
||||
<div className="tags">{game.tags.map((t) => <span key={t} className="tag">{t}</span>)}</div>
|
||||
<p className="description">{game.description || <span className="muted">Нет описания</span>}</p>
|
||||
<div className="actions">
|
||||
{canEdit && <button onClick={() => setEditing(true)}>Редактировать</button>}
|
||||
{canEdit && (
|
||||
<label className="button">
|
||||
Обложка
|
||||
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
|
||||
</label>
|
||||
)}
|
||||
{can("games.upload") && (
|
||||
<label className="button">
|
||||
Загрузить ещё архив
|
||||
<input type="file" hidden onChange={(e) => e.target.files?.[0] && uploadVersion(e.target.files[0])} />
|
||||
</label>
|
||||
)}
|
||||
{can("games.delete") && (
|
||||
<button className="danger" onClick={() => confirm("Удалить игру со всеми версиями?") &&
|
||||
void api.del(`/api/games/${game.id}`).then(() => navigate("/"))}>Удалить игру</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="hero">
|
||||
{game.cover_hash && <div className="hero-bg" style={{ backgroundImage: `url(/blobs/${game.cover_hash})` }} />}
|
||||
<Link to="/" className="back-link"><Icon name="back" size={18} />Библиотека</Link>
|
||||
<div className="hero-inner">
|
||||
<Cover hash={game.cover_hash} title={game.title} className="big" />
|
||||
<div className="hero-info">
|
||||
{editing ? (
|
||||
<GameEditor game={game} onDone={(changed) => { setEditing(false); if (changed) { ui.toast("Сохранено", "ok"); void load(); } }} />
|
||||
) : (
|
||||
<>
|
||||
<h1>{game.title}</h1>
|
||||
{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>
|
||||
<div className="hero-actions">
|
||||
{playVersion ? (
|
||||
<button className="btn btn-primary btn-lg" onClick={() => play(playVersion)}>
|
||||
<Icon name="play" size={22} />Играть
|
||||
</button>
|
||||
) : (
|
||||
<span className="badge badge-warn busy">{busy ? "Идёт конвертация…" : "Нет готовых версий"}</span>
|
||||
)}
|
||||
{playVersion && game.versions.length > 1 && <span className="muted small">версия «{playVersion.name}»</span>}
|
||||
</div>
|
||||
<div className="actions">
|
||||
{canEdit && <button className="btn btn-sm" onClick={() => setEditing(true)}><Icon name="edit" size={16} />Редактировать</button>}
|
||||
{canEdit && (
|
||||
<label className="btn btn-sm">
|
||||
<Icon name="image" size={16} />Обложка
|
||||
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
|
||||
</label>
|
||||
)}
|
||||
{can("games.delete") && (
|
||||
<button className="btn btn-sm btn-danger" onClick={() => void removeGame()}><Icon name="trash" size={16} />Удалить игру</button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}) {
|
||||
const { v } = props;
|
||||
@@ -139,25 +198,38 @@ function VersionRow(props: {
|
||||
if (report !== undefined) setReport(undefined);
|
||||
else setReport((await api.get<Version>(`/api/versions/${v.id}`)).report ?? null);
|
||||
};
|
||||
const inProgress = BUSY.includes(v.status);
|
||||
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>
|
||||
<b>{props.depth > 0 && "↳ "}{v.name}</b>
|
||||
<span className={`status status-${v.status}`}>{STATUS[v.status]}</span>
|
||||
<div className="muted small">
|
||||
<div className="version-title">
|
||||
{props.depth > 0 && <span className="fork-mark" title="Форк"><Icon name="fork" size={16} /></span>}
|
||||
<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.progress && ` · ${v.progress}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{v.status === "ready" && <button className="primary" onClick={props.onPlay}>Играть</button>}
|
||||
{v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>}
|
||||
{(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>}
|
||||
{v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>}
|
||||
{props.canManage && !["converting", "importing"].includes(v.status) && <button className="danger" onClick={props.onDelete}>Удалить</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 className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
|
||||
{(v.status === "ready" || v.status === "failed") && (
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => void toggleReport()} aria-expanded={report !== undefined}>
|
||||
<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>
|
||||
{inProgress && <div className="version-progress progress"><div style={{ width: "100%", opacity: 0.5 }} /></div>}
|
||||
{report !== undefined && <Report report={report} />}
|
||||
</div>
|
||||
);
|
||||
@@ -165,17 +237,20 @@ function VersionRow(props: {
|
||||
|
||||
function Report({ report }: { report: Record<string, any> | null }) {
|
||||
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 (
|
||||
<div className="report">
|
||||
<div className="report-stats">
|
||||
<span>Скриптов: {report.scripts} (декомпилировано {report.decompiled})</span>
|
||||
<span>Меток: {report.labels}</span>
|
||||
<span>Реплик: {report.stats?.say ?? 0}</span>
|
||||
<span>Меню: {report.stats?.menu ?? 0}</span>
|
||||
<span>Картинок: {report.media?.image}, аудио: {report.media?.audio}, видео: {report.media?.video}</span>
|
||||
<span>Размер: {formatSize(report.sourceSize)} → {formatSize(report.outputSize)}</span>
|
||||
<span>Время: {report.durationSec} с</span>
|
||||
{stat(`${report.scripts} / ${report.decompiled}`, "скриптов / декомпилировано")}
|
||||
{stat(report.labels, "меток")}
|
||||
{stat(report.stats?.say ?? 0, "реплик")}
|
||||
{stat(report.stats?.menu ?? 0, "меню выбора")}
|
||||
{stat(`${report.media?.image ?? 0} · ${report.media?.audio ?? 0} · ${report.media?.video ?? 0}`, "картинок · аудио · видео")}
|
||||
{stat(`${formatSize(report.sourceSize)} → ${formatSize(report.outputSize)}`, "размер")}
|
||||
{stat(`${report.durationSec} с`, "время конвертации")}
|
||||
</div>
|
||||
{report.missingFiles?.length > 0 && (
|
||||
<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 [description, setDescription] = useState(game.description);
|
||||
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}`, {
|
||||
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
});
|
||||
onDone();
|
||||
onDone(true);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="editor">
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} />
|
||||
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="теги через запятую" />
|
||||
{error && <div className="error">{error}</div>}
|
||||
<form className="editor" onSubmit={(e) => { e.preventDefault(); void save(); }}>
|
||||
<label className="field"><span>Название</span><input value={title} onChange={(e) => setTitle(e.target.value)} /></label>
|
||||
<label className="field"><span>Описание</span><textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} /></label>
|
||||
<label className="field"><span>Теги</span><input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="через запятую: романтика, драма" /></label>
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
<div className="actions">
|
||||
<button className="primary" onClick={() => void save()}>Сохранить</button>
|
||||
<button onClick={onDone}>Отмена</button>
|
||||
<button className="btn btn-primary" disabled={!title.trim()}>Сохранить</button>
|
||||
<button type="button" className="btn" onClick={() => onDone(false)}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
+158
-22
@@ -1,52 +1,180 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
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() {
|
||||
const { can } = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const q = params.get("q") ?? "";
|
||||
const tag = params.get("tag") ?? "";
|
||||
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 [error, setError] = useState("");
|
||||
const [recent, setRecent] = useState<RecentPlay[]>(getRecent);
|
||||
|
||||
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);
|
||||
}, [query, setParams]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [query]);
|
||||
|
||||
const hasPending = games?.some((g) => g.pending_versions) ?? false;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get<Game[]>(`/api/games?q=${encodeURIComponent(q)}`)
|
||||
.then((g) => !cancelled && setGames(g))
|
||||
.catch((e) => !cancelled && setError(e.message));
|
||||
const load = () =>
|
||||
api.get<Game[]>(`/api/games?q=${encodeURIComponent(q)}`)
|
||||
.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 () => {
|
||||
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 (
|
||||
<div className="page">
|
||||
<div className="search">
|
||||
<input placeholder="Поиск по названию, описанию или тегу…" value={query}
|
||||
onChange={(e) => setQuery(e.target.value)} autoFocus />
|
||||
<PageHeader
|
||||
title="Библиотека"
|
||||
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>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{games === null ? (
|
||||
<p className="muted">Загрузка…</p>
|
||||
) : games.length === 0 ? (
|
||||
<p className="muted">{q ? "Ничего не найдено." : "В библиотеке пока нет игр."}</p>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className="chips" style={{ marginBottom: "var(--space-4)" }} role="group" aria-label="Теги">
|
||||
<button className={`chip ${!tag ? "on" : ""}`} onClick={() => setTag("")}>Все</button>
|
||||
{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">
|
||||
{games.map((g) => (
|
||||
{shown.map((g) => (
|
||||
<Link key={g.id} to={`/games/${g.id}`} className="game-card">
|
||||
<div className="cover">
|
||||
{g.cover_hash ? <img src={`/blobs/${g.cover_hash}`} alt="" loading="lazy" /> : <span>{g.title[0]}</span>}
|
||||
<div className="game-card-media">
|
||||
<Cover hash={g.cover_hash} title={g.title} />
|
||||
<StatusBadge g={g} />
|
||||
</div>
|
||||
<div className="game-card-body">
|
||||
<b>{g.title}</b>
|
||||
<div className="tags">
|
||||
{g.tags.slice(0, 4).map((t) => <span key={t} className="tag">{t}</span>)}
|
||||
</div>
|
||||
<span className="game-card-title">{g.title}</span>
|
||||
{g.tags.length > 0 && (
|
||||
<div className="tags">{g.tags.slice(0, 3).map((t) => <span key={t} className="tag">{t}</span>)}</div>
|
||||
)}
|
||||
{!!g.ready_versions && g.ready_versions > 1 && (
|
||||
<span className="game-card-meta">{g.ready_versions} {plural(g.ready_versions, "версия", "версии", "версий")}</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
@@ -55,3 +183,11 @@ export function Library() {
|
||||
</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;
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, type Me } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import { ErrorNote } from "../ui/Bits";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { ThemeButton } from "../ui/ThemeButton";
|
||||
|
||||
export function Login() {
|
||||
const { setMe } = useAuth();
|
||||
@@ -23,21 +26,26 @@ export function Login() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="center">
|
||||
<div className="auth-wrap">
|
||||
<ThemeButton className="auth-theme" />
|
||||
<form className="card auth-card" onSubmit={submit}>
|
||||
<h1>PGames</h1>
|
||||
<p className="muted">Локальная библиотека визуальных новелл</p>
|
||||
<label>
|
||||
Логин
|
||||
<div>
|
||||
<div className="brand"><span className="brand-mark"><Icon name="play" size={20} /></span>PGames</div>
|
||||
<p className="muted">Локальная библиотека визуальных новелл</p>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Логин</span>
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<label className="field">
|
||||
<span>Пароль</span>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password" />
|
||||
</label>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<button className="primary" disabled={busy || !username || !password}>Войти</button>
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
<button className="btn btn-primary btn-lg btn-block" disabled={busy || !username || !password}>
|
||||
{busy ? "Входим…" : "Войти"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 { 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() {
|
||||
const navigate = useNavigate();
|
||||
@@ -8,9 +12,25 @@ export function NewGame() {
|
||||
const [description, setDescription] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [over, setOver] = useState(false);
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
@@ -34,38 +54,63 @@ export function NewGame() {
|
||||
}
|
||||
};
|
||||
|
||||
const uploading = progress !== null;
|
||||
const pct = Math.round((progress ?? 0) * 100);
|
||||
|
||||
return (
|
||||
<div className="page narrow">
|
||||
<form className="card" onSubmit={submit}>
|
||||
<h1>Новая игра</h1>
|
||||
<p className="muted">
|
||||
Загрузите архив с игрой Ren'Py (zip, 7z, rar, tar). После загрузки сервер распакует его,
|
||||
сконвертирует скрипты и ассеты в формат PGVN и удалит исходники.
|
||||
</p>
|
||||
<label>
|
||||
Архив
|
||||
<input type="file" accept=".zip,.7z,.rar,.tar,.gz,.tgz,.bz2,.xz"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
|
||||
<div className="page medium">
|
||||
<PageHeader title="Новая игра"
|
||||
subtitle="Загрузите архив с игрой на Ren'Py. Сервер распакует его, сконвертирует скрипты и ассеты в формат PGVN и удалит исходники." />
|
||||
<form className="card form" onSubmit={submit}>
|
||||
<label className={`dropzone ${over ? "over" : ""} ${file ? "has-file" : ""}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={onDrop}>
|
||||
<input type="file" accept=".zip,.7z,.rar,.tar,.gz,.tgz,.bz2,.xz" disabled={uploading}
|
||||
onChange={(e) => pick(e.target.files?.[0])} />
|
||||
<Icon name={file ? "file" : "upload"} size={34} className="dz-icon" />
|
||||
{file ? (
|
||||
<>
|
||||
<b>{file.name}</b>
|
||||
<span className="muted">{formatSize(file.size)} · нажмите, чтобы выбрать другой</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<b>Перетащите архив сюда или нажмите, чтобы выбрать</b>
|
||||
<span className="muted small">zip, 7z, rar, tar</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
<label>
|
||||
Название
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="По имени архива" />
|
||||
</label>
|
||||
<label>
|
||||
Описание
|
||||
|
||||
<div className="form-row">
|
||||
<label className="field">
|
||||
<span>Название</span>
|
||||
<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} />
|
||||
</label>
|
||||
<label>
|
||||
Теги через запятую
|
||||
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="романтика, драма" />
|
||||
</label>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{progress !== null && (
|
||||
<div className="progress"><div style={{ width: `${Math.round(progress * 100)}%` }} /></div>
|
||||
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
{uploading && (
|
||||
<div>
|
||||
<div className="progress"><div style={{ width: `${pct}%` }} /></div>
|
||||
<p className="muted small" style={{ marginTop: "0.4rem" }}>
|
||||
{pct < 100 ? `Загрузка архива… ${pct}%` : "Архив загружен, передаём на конвертацию…"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<button className="primary" disabled={!file || progress !== null}>
|
||||
{progress !== null ? `Загрузка ${Math.round(progress * 100)}%` : "Загрузить"}
|
||||
</button>
|
||||
<div className="actions">
|
||||
<button className="btn btn-primary btn-lg" disabled={!file || uploading}>
|
||||
<Icon name="upload" size={20} />{uploading ? `Загрузка ${pct}%` : "Загрузить"}
|
||||
</button>
|
||||
{!uploading && <button type="button" className="btn btn-ghost" onClick={() => navigate(-1)}>Отмена</button>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import type { Engine } from "../player/engine";
|
||||
import { Icon } from "../ui/Icon";
|
||||
|
||||
export function Play() {
|
||||
const { versionId } = useParams();
|
||||
@@ -33,8 +34,18 @@ export function Play() {
|
||||
<div ref={host} className="play-host" />
|
||||
{(status || error) && (
|
||||
<div className="play-status">
|
||||
{error ? <span className="error">{error}</span> : status}
|
||||
{error && <button onClick={() => navigate(-1)}>Назад</button>}
|
||||
{error ? (
|
||||
<>
|
||||
<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>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
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() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const ui = useUi();
|
||||
const [roles, setRoles] = useState<Role[] | null>(null);
|
||||
const [perms, setPerms] = useState<Record<string, string>>({});
|
||||
const [newName, setNewName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
@@ -18,13 +22,13 @@ export function Roles() {
|
||||
load().catch((e) => setError(e.message));
|
||||
}, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setError("");
|
||||
const run = async (fn: () => Promise<unknown>, okText?: string) => {
|
||||
try {
|
||||
await fn();
|
||||
if (okText) ui.toast(okText, "ok");
|
||||
await load();
|
||||
} 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 }));
|
||||
};
|
||||
|
||||
const remove = async (r: Role) => {
|
||||
if (await ui.confirm({ title: `Удалить роль «${r.name}»?`, danger: true }))
|
||||
void run(() => api.del(`/api/roles/${r.id}`), "Роль удалена");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h1>Роли и права</h1>
|
||||
<p className="muted">Роль — набор прав. Роль admin всегда имеет все права.</p>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="table-wrap">
|
||||
<table className="table roles">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<div className="page medium">
|
||||
<PageHeader title="Роли и права" subtitle="Роль — набор прав. Роль admin всегда имеет все права. Изменения применяются сразу." />
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
{roles === null ? <Spinner /> : (
|
||||
<div className="table-wrap">
|
||||
<table className="table roles">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Право</th>
|
||||
{roles.map((r) => (
|
||||
<td key={r.id} className="center-cell">
|
||||
<input type="checkbox" checked={r.permissions.includes(code)} disabled={r.name === "admin"}
|
||||
onChange={() => toggle(r, code)} />
|
||||
</td>
|
||||
<th key={r.id}>
|
||||
<span className="roles-th">
|
||||
{r.name}
|
||||
{!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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form className="card inline-form" onSubmit={(e) => {
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(perms).map(([code, label]) => (
|
||||
<tr key={code}>
|
||||
<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();
|
||||
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)} />
|
||||
<button className="primary" disabled={!newName.trim()}>Добавить роль</button>
|
||||
<h2>Новая роль</h2>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
+115
-59
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { api, type Role, type User } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import { ErrorNote, PageHeader, Spinner } from "../ui/Bits";
|
||||
import { Icon } from "../ui/Icon";
|
||||
import { useUi } from "../ui/Ui";
|
||||
|
||||
interface Issued {
|
||||
user: User;
|
||||
@@ -9,7 +12,8 @@ interface Issued {
|
||||
|
||||
export function Users() {
|
||||
const { me } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const ui = useUi();
|
||||
const [users, setUsers] = useState<User[] | null>(null);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [username, setUsername] = useState("");
|
||||
const [roleId, setRoleId] = useState<number | "">("");
|
||||
@@ -27,13 +31,13 @@ export function Users() {
|
||||
load().catch((e) => setError(e.message));
|
||||
}, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setError("");
|
||||
const run = async (fn: () => Promise<unknown>, okText?: string) => {
|
||||
try {
|
||||
await fn();
|
||||
if (okText) ui.toast(okText, "ok");
|
||||
await load();
|
||||
} 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 (
|
||||
<div className="page">
|
||||
<h1>Пользователи</h1>
|
||||
<form className="card inline-form" onSubmit={create}>
|
||||
<input placeholder="Логин нового пользователя" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||||
<select value={roleId} onChange={(e) => setRoleId(Number(e.target.value))}>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
<button className="primary" disabled={username.trim().length < 3}>Создать</button>
|
||||
<div className="page medium">
|
||||
<PageHeader title="Пользователи" subtitle="Регистрации нет — пользователей создаёт администратор." />
|
||||
|
||||
<form className="card form" onSubmit={create}>
|
||||
<h2>Новый пользователь</h2>
|
||||
<div className="form-row">
|
||||
<label className="field">
|
||||
<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>
|
||||
|
||||
{issued && (
|
||||
<div className="card issued">
|
||||
<div className="card issued" style={{ marginTop: "var(--space-4)" }}>
|
||||
<p>
|
||||
Пароль для <b>{issued.user.username}</b> (показывается один раз, передайте его пользователю —
|
||||
при первом входе он задаст свой):
|
||||
Пароль для <b>{issued.user.username}</b> показывается один раз. Передайте его пользователю —
|
||||
при первом входе он задаст свой.
|
||||
</p>
|
||||
<div className="password-box">
|
||||
<code>{issued.password}</code>
|
||||
<button onClick={() => void navigator.clipboard?.writeText(`${issued.user.username} / ${issued.password}`)}>
|
||||
Копировать
|
||||
</button>
|
||||
<button onClick={() => setIssued(null)}>Скрыть</button>
|
||||
<button className="btn" onClick={() => void copy()}><Icon name="copy" size={16} />Копировать</button>
|
||||
<button className="btn btn-ghost" onClick={() => setIssued(null)}>Скрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="error">{error}</div>}
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Логин</th><th>Роль</th><th>Статус</th><th>Создан</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className={u.is_active ? "" : "inactive"}>
|
||||
<td>
|
||||
{u.username}
|
||||
{u.id === me?.id && <span className="muted"> (вы)</span>}
|
||||
</td>
|
||||
<td>
|
||||
<select value={u.role.id} disabled={u.id === me?.id}
|
||||
onChange={(e) => void run(() => api.patch(`/api/users/${u.id}`, { role_id: Number(e.target.value) }))}>
|
||||
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{u.is_active ? "активен" : "отключён"}
|
||||
{u.must_change_password && <span className="muted"> · ждёт смены пароля</span>}
|
||||
</td>
|
||||
<td className="muted">{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td className="actions">
|
||||
{u.id !== me?.id && (
|
||||
<>
|
||||
<button onClick={() => void run(async () =>
|
||||
setIssued(await api.post<Issued>(`/api/users/${u.id}/reset-password`)))}>Сбросить пароль</button>
|
||||
<button onClick={() => void run(() => api.patch(`/api/users/${u.id}`, { is_active: !u.is_active }))}>
|
||||
{u.is_active ? "Отключить" : "Включить"}
|
||||
</button>
|
||||
<button className="danger" onClick={() => confirm(`Удалить ${u.username}?`) &&
|
||||
void run(() => api.del(`/api/users/${u.id}`))}>Удалить</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{error && <ErrorNote>{error}</ErrorNote>}
|
||||
<div className="section-title"><h2>Все пользователи</h2>{users && <span className="count">{users.length}</span>}</div>
|
||||
{users === null ? <Spinner /> : (
|
||||
<>
|
||||
<div className="table-wrap users-table">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th>Логин</th><th>Роль</th><th>Статус</th><th>Создан</th><th /></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className={u.is_active ? "" : "inactive"}>
|
||||
<td><b>{u.username}</b>{u.id === me?.id && <span className="muted"> (вы)</span>}</td>
|
||||
<td>{roleSelect(u)}</td>
|
||||
<td>{statusOf(u)}</td>
|
||||
<td className="muted">{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td>{actions(u)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="user-cards">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className={`user-card ${u.is_active ? "" : "inactive"}`}>
|
||||
<div className="user-card-head">
|
||||
<b>{u.username}{u.id === me?.id && <span className="muted"> (вы)</span>}</b>
|
||||
{roleSelect(u)}
|
||||
</div>
|
||||
<div>{statusOf(u)}</div>
|
||||
{actions(u)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1149,6 +1149,18 @@ export class Engine {
|
||||
this.notifyEl = el("div", "pg-notify pg-hidden");
|
||||
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) => {
|
||||
if (!this.screen.classList.contains("pg-hidden")) return;
|
||||
if ((ev.target as HTMLElement).closest(".pg-menu, .pg-quick")) return;
|
||||
@@ -1169,6 +1181,37 @@ export class Engine {
|
||||
}).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() {
|
||||
if (this.skip) {
|
||||
this.setSkip(false);
|
||||
@@ -1212,6 +1255,7 @@ export class Engine {
|
||||
private openScreen(title: string, build: (body: HTMLElement) => void, onClose?: () => void) {
|
||||
this.screen.textContent = "";
|
||||
this.screen.classList.remove("pg-main");
|
||||
this.container.classList.remove("pg-in-main");
|
||||
this.screen.style.backgroundImage = "";
|
||||
this.screenOnClose = onClose ?? null;
|
||||
const head = el("div", "pg-screen-head");
|
||||
@@ -1235,6 +1279,7 @@ export class Engine {
|
||||
this.screenOnClose = null;
|
||||
this.screen.classList.add("pg-hidden");
|
||||
this.screen.classList.remove("pg-main");
|
||||
this.container.classList.remove("pg-in-main");
|
||||
this.screen.style.backgroundImage = "";
|
||||
}
|
||||
|
||||
@@ -1248,6 +1293,7 @@ export class Engine {
|
||||
this.clearNvl();
|
||||
this.screen.textContent = "";
|
||||
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;
|
||||
if (bg) this.screen.style.backgroundImage = `url("${bg}")`;
|
||||
const panel = el("div", "pg-main-panel");
|
||||
@@ -1302,11 +1348,15 @@ export class Engine {
|
||||
list.append(b);
|
||||
};
|
||||
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("load"));
|
||||
add("Настройки", () => this.openSettings());
|
||||
add("Главное меню", () => {
|
||||
if (confirm("Выйти в главное меню? Несохранённый прогресс будет потерян.")) this.showMainMenu();
|
||||
void this.confirmAsk("Выйти в главное меню? Несохранённый прогресс будет потерян.", "Выйти")
|
||||
.then((yes) => yes && this.showMainMenu());
|
||||
});
|
||||
add("В библиотеку", () => this.onExit());
|
||||
body.append(list);
|
||||
@@ -1346,7 +1396,7 @@ export class Engine {
|
||||
card.disabled = mode === "load" && !meta;
|
||||
card.onclick = async () => {
|
||||
if (mode === "save") {
|
||||
if (meta && !confirm("Перезаписать сохранение?")) return;
|
||||
if (meta && !(await this.confirmAsk("Перезаписать сохранение?", "Перезаписать"))) return;
|
||||
await this.save(slot);
|
||||
render(await this.saves.list());
|
||||
} else {
|
||||
|
||||
+113
-40
@@ -69,22 +69,33 @@
|
||||
|
||||
.pg-quick {
|
||||
position: absolute;
|
||||
bottom: 0.4em;
|
||||
bottom: 0.35em;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
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 {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
border-radius: 99em;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font: inherit;
|
||||
font-size: calc(var(--pg-font) * 0.55);
|
||||
padding: 0.2em 0.6em;
|
||||
padding: 0.25em 0.75em;
|
||||
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 {
|
||||
position: absolute;
|
||||
@@ -147,37 +158,50 @@
|
||||
.pg-screen {
|
||||
position: absolute;
|
||||
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;
|
||||
flex-direction: column;
|
||||
padding: 3% 6%;
|
||||
font-size: calc(var(--pg-font) * 0.85);
|
||||
z-index: 10;
|
||||
animation: pg-fade 0.15s ease-out;
|
||||
}
|
||||
.pg-screen-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.pg-screen-head h2 { margin: 0 0 0.6em; color: var(--pg-accent); font-weight: 600; }
|
||||
.pg-screen button {
|
||||
@keyframes pg-fade { from { opacity: 0; } }
|
||||
.pg-screen-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4em; }
|
||||
.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;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.3em;
|
||||
padding: 0.4em 1em;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 0.5em;
|
||||
padding: 0.5em 1.1em;
|
||||
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:disabled { opacity: 0.4; cursor: default; }
|
||||
.pg-close { background: none !important; border: 0 !important; font-size: 1.3em !important; }
|
||||
.pg-screen-body { flex: 1; overflow: auto; }
|
||||
.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:hover:not(:disabled), .pg-dialog button:hover { background: rgba(255, 255, 255, 0.15); border-color: var(--pg-accent); }
|
||||
.pg-screen button:focus-visible, .pg-dialog button:focus-visible, .pg-screen select:focus-visible, .pg-hud-btn:focus-visible {
|
||||
outline: 3px solid var(--pg-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.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-slots { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.6em; }
|
||||
.pg-slot { display: flex; flex-direction: column; align-items: flex-start; gap: 0.25em; text-align: left; min-height: 5em; }
|
||||
.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 small { opacity: 0.5; font-size: 0.65em; }
|
||||
.pg-setting { display: grid; grid-template-columns: 30% 1fr 9em; align-items: center; gap: 1em; margin-bottom: 0.7em; }
|
||||
.pg-setting input { width: 100%; accent-color: var(--pg-accent); }
|
||||
.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: 6em; }
|
||||
.pg-slot b { color: var(--pg-accent); font-weight: 600; }
|
||||
.pg-slot-title { font-size: 0.85em; opacity: 0.9; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.pg-slot small { opacity: 0.55; font-size: 0.7em; margin-top: auto; }
|
||||
.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-wide { margin-top: 0.8em; margin-right: 0.6em; }
|
||||
|
||||
@@ -185,22 +209,71 @@
|
||||
.pg-main-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
width: 32%;
|
||||
gap: 0.55em;
|
||||
width: min(26em, 100%);
|
||||
padding: 2em;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 0.5em;
|
||||
background: rgba(8, 10, 20, 0.72);
|
||||
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 button { text-align: left; }
|
||||
.pg-select {
|
||||
font: inherit;
|
||||
font-size: 0.85em;
|
||||
.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; padding: 0.7em 1.2em; }
|
||||
.pg-main-panel button:first-of-type { background: var(--pg-accent); border-color: var(--pg-accent); color: #1a1205; font-weight: 650; }
|
||||
.pg-main-panel button:first-of-type:hover { filter: brightness(1.1); }
|
||||
|
||||
/* 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;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.3em;
|
||||
padding: 0.3em 0.6em;
|
||||
background: rgba(8, 10, 20, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, background 0.15s;
|
||||
}
|
||||
.pg-select option { background: #151827; }
|
||||
.pg-lang { margin-top: 0.6em; }
|
||||
.pg-root:hover .pg-hud-btn, .pg-hud-btn:focus-visible { opacity: 0.8; }
|
||||
.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; }
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
+773
-98
@@ -1,137 +1,734 @@
|
||||
/* ==========================================================================
|
||||
PGames design system
|
||||
1. tokens + themes 2. base 3. controls 4. surfaces 5. shell/nav
|
||||
6. library 7. game page 8. admin 9. feedback 10. responsive
|
||||
========================================================================== */
|
||||
|
||||
/* ---- 1. tokens ---------------------------------------------------------- */
|
||||
:root {
|
||||
--bg: #0f1117;
|
||||
--panel: #171a23;
|
||||
--panel-2: #1f2330;
|
||||
--border: #2a2f3e;
|
||||
--text: #e6e8ef;
|
||||
--muted: #8b90a0;
|
||||
/* dark (default) */
|
||||
--bg: #0e1016;
|
||||
--bg-2: #12151d;
|
||||
--surface: #171b25;
|
||||
--surface-2: #1e2330;
|
||||
--surface-3: #272d3d;
|
||||
--border: #2a3042;
|
||||
--border-strong: #3a4258;
|
||||
--text: #eceef5;
|
||||
--text-2: #b4b9ca;
|
||||
--muted: #8087a0;
|
||||
--accent: #ff9f43;
|
||||
--danger: #ef5a5a;
|
||||
--accent-hover: #ffb066;
|
||||
--accent-ink: #1a1205;
|
||||
--accent-soft: rgba(255, 159, 67, 0.14);
|
||||
--danger: #f2686b;
|
||||
--danger-soft: rgba(242, 104, 107, 0.14);
|
||||
--ok: #4cc38a;
|
||||
--ok-soft: rgba(76, 195, 138, 0.14);
|
||||
--info: #6aa8ff;
|
||||
--info-soft: rgba(106, 168, 255, 0.14);
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
--shadow-2: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
--shadow-3: 0 18px 48px rgba(0, 0, 0, 0.55);
|
||||
--overlay: rgba(5, 7, 12, 0.7);
|
||||
--topbar-bg: rgba(14, 16, 22, 0.82);
|
||||
|
||||
--radius-s: 8px;
|
||||
--radius: 12px;
|
||||
--radius-l: 18px;
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
--space-6: 2.25rem;
|
||||
--control-h: 2.5rem;
|
||||
--nav-h: 3.75rem;
|
||||
--tabbar-h: 3.6rem;
|
||||
--font: "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, Roboto, sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f4f1ec;
|
||||
--bg-2: #ece8e1;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f6f3ee;
|
||||
--surface-3: #ebe6de;
|
||||
--border: #e0dad0;
|
||||
--border-strong: #cbc3b5;
|
||||
--text: #1c1d25;
|
||||
--text-2: #464a5c;
|
||||
--muted: #6c7188;
|
||||
--accent: #e8710a;
|
||||
--accent-hover: #cf6406;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-soft: rgba(232, 113, 10, 0.12);
|
||||
--danger: #d6393c;
|
||||
--danger-soft: rgba(214, 57, 60, 0.1);
|
||||
--ok: #1d8f5c;
|
||||
--ok-soft: rgba(29, 143, 92, 0.12);
|
||||
--info: #2a6fd6;
|
||||
--info-soft: rgba(42, 111, 214, 0.1);
|
||||
--shadow-1: 0 1px 2px rgba(60, 45, 20, 0.08);
|
||||
--shadow-2: 0 8px 24px rgba(60, 45, 20, 0.12);
|
||||
--shadow-3: 0 18px 48px rgba(60, 45, 20, 0.2);
|
||||
--overlay: rgba(30, 24, 14, 0.45);
|
||||
--topbar-bg: rgba(244, 241, 236, 0.85);
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root:not([data-theme]) {
|
||||
--bg: #f4f1ec;
|
||||
--bg-2: #ece8e1;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f6f3ee;
|
||||
--surface-3: #ebe6de;
|
||||
--border: #e0dad0;
|
||||
--border-strong: #cbc3b5;
|
||||
--text: #1c1d25;
|
||||
--text-2: #464a5c;
|
||||
--muted: #6c7188;
|
||||
--accent: #e8710a;
|
||||
--accent-hover: #cf6406;
|
||||
--accent-ink: #ffffff;
|
||||
--accent-soft: rgba(232, 113, 10, 0.12);
|
||||
--danger: #d6393c;
|
||||
--danger-soft: rgba(214, 57, 60, 0.1);
|
||||
--ok: #1d8f5c;
|
||||
--ok-soft: rgba(29, 143, 92, 0.12);
|
||||
--info: #2a6fd6;
|
||||
--info-soft: rgba(42, 111, 214, 0.1);
|
||||
--shadow-1: 0 1px 2px rgba(60, 45, 20, 0.08);
|
||||
--shadow-2: 0 8px 24px rgba(60, 45, 20, 0.12);
|
||||
--shadow-3: 0 18px 48px rgba(60, 45, 20, 0.2);
|
||||
--overlay: rgba(30, 24, 14, 0.45);
|
||||
--topbar-bg: rgba(244, 241, 236, 0.85);
|
||||
color-scheme: light;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 2. base ------------------------------------------------------------ */
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
html, body, #root { min-height: 100%; margin: 0; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 15px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
font: 15px/1.5 var(--font);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
h1 { font-size: 1.6rem; margin: 0 0 0.75rem; }
|
||||
h2 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; }
|
||||
h1, h2, h3 { margin: 0; line-height: 1.2; letter-spacing: -0.01em; }
|
||||
h1 { font-size: 1.75rem; font-weight: 700; }
|
||||
h2 { font-size: 1.2rem; font-weight: 650; }
|
||||
h3 { font-size: 1.02rem; font-weight: 650; }
|
||||
p { margin: 0; }
|
||||
code { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; }
|
||||
::selection { background: var(--accent-soft); }
|
||||
|
||||
input, select, textarea, button, .button {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.45rem 0.7rem;
|
||||
:focus { outline: none; }
|
||||
:focus-visible {
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-s);
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); }
|
||||
button, .button { cursor: pointer; white-space: nowrap; }
|
||||
button:hover:not(:disabled), .button:hover { border-color: var(--accent); }
|
||||
button:disabled { opacity: 0.5; cursor: default; }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #1a1205; font-weight: 600; }
|
||||
button.danger { color: var(--danger); }
|
||||
button.link { background: none; border: 0; padding: 0 0.3rem; color: var(--muted); }
|
||||
button.link:hover { color: var(--text); }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 0.85em; }
|
||||
.error { color: var(--danger); margin: 0.5rem 0; }
|
||||
.ok { color: var(--ok); }
|
||||
.center { min-height: 100%; display: flex; align-items: center; justify-content: center; padding: 1rem; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.grow { flex: 1; }
|
||||
.center { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: var(--space-4); }
|
||||
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; }
|
||||
.auth-card { width: min(380px, 100%); display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
.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; }
|
||||
.inline-form input { flex: 1; min-width: 200px; }
|
||||
/* ---- 3. controls -------------------------------------------------------- */
|
||||
.btn, .field input, .field select, .field textarea, input, select, textarea {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
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; }
|
||||
|
||||
.layout { min-height: 100%; display: flex; flex-direction: column; }
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.7rem 1.5rem;
|
||||
background: var(--panel);
|
||||
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);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.brand { font-weight: 700; color: var(--accent); font-size: 1.15rem; }
|
||||
.topbar nav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; }
|
||||
.topbar nav a { color: var(--muted); }
|
||||
.topbar nav a.active, .topbar nav a:hover { color: var(--text); }
|
||||
.user { display: flex; align-items: center; gap: 0.3rem; }
|
||||
.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); }
|
||||
|
||||
.page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.page.narrow { max-width: 560px; }
|
||||
|
||||
.search input { width: 100%; font-size: 1.05rem; padding: 0.7rem 1rem; margin-bottom: 1.25rem; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; }
|
||||
.game-card {
|
||||
background: var(--panel);
|
||||
.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: 10px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.15s, border-color 0.15s;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-3);
|
||||
z-index: 40;
|
||||
animation: pop 0.12s ease-out;
|
||||
}
|
||||
.game-card:hover { transform: translateY(-3px); border-color: var(--accent); }
|
||||
.game-card-body { padding: 0.6rem 0.8rem; display: flex; flex-direction: column; gap: 0.35rem; }
|
||||
.cover {
|
||||
aspect-ratio: 16 / 10;
|
||||
background: linear-gradient(135deg, #262b3b, #1a1d29);
|
||||
@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;
|
||||
font-size: 2.5rem;
|
||||
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; }
|
||||
.cover.big { width: 340px; max-width: 100%; border-radius: 10px; flex-shrink: 0; }
|
||||
.tags { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
||||
.tag { font-size: 0.75rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); color: var(--muted); }
|
||||
.cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
.game-head { display: flex; gap: 1.5rem; margin-top: 1rem; flex-wrap: wrap; }
|
||||
.game-info { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.description { white-space: pre-wrap; margin: 0; }
|
||||
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
.editor { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
/* ---- 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(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; }
|
||||
.version-main { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: center; }
|
||||
.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); }
|
||||
.report { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); font-size: 0.9rem; }
|
||||
.report-stats { display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem; margin-bottom: 0.5rem; }
|
||||
.report table { border-collapse: collapse; margin-top: 0.4rem; }
|
||||
.report td { padding: 0.15rem 0.6rem 0.15rem 0; vertical-align: top; }
|
||||
.report summary { cursor: pointer; margin: 0.3rem 0; }
|
||||
.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; }
|
||||
|
||||
.progress { height: 8px; background: var(--panel-2); border-radius: 99px; overflow: hidden; }
|
||||
.progress > div { height: 100%; background: var(--accent); transition: width 0.2s; }
|
||||
.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); }
|
||||
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
|
||||
.table th, .table td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--border); }
|
||||
.table th { color: var(--muted); font-weight: 500; }
|
||||
/* ---- 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) { text-align: center; }
|
||||
.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; }
|
||||
|
||||
.issued { border-color: var(--accent); margin-bottom: 1rem; }
|
||||
.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; }
|
||||
/* 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; }
|
||||
|
||||
.play { position: fixed; inset: 0; background: #000; }
|
||||
.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;
|
||||
@@ -141,13 +738,91 @@ button.link:hover { color: var(--text); }
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
color: #b4b9ca;
|
||||
pointer-events: none;
|
||||
background: #000;
|
||||
}
|
||||
.play-status button { pointer-events: auto; }
|
||||
.play-status .btn { pointer-events: auto; }
|
||||
.play-status .error-text { color: #f2686b; max-width: 40ch; text-align: center; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page { padding: 1rem; }
|
||||
.topbar { padding: 0.6rem 1rem; gap: 0.8rem; }
|
||||
.grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
|
||||
/* ---- 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; }
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
Reference in new issue
Block a user