v0.8 - Пересмотр элементов интерфейса, улучшены навигация по приложению, улучшена читаемость некоторых эллементов, добавлены анимации
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""Участник партии: признак выбытия (eliminated).
|
||||
|
||||
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) → no-op;
|
||||
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
|
||||
|
||||
Revision ID: 0009_participant_eliminated
|
||||
Revises: 0008_notifications
|
||||
Create Date: 2026-06-26
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0009_participant_eliminated"
|
||||
down_revision: Union[str, None] = "0008_notifications"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
cols = {c["name"] for c in insp.get_columns("match_participants")}
|
||||
if "eliminated" not in cols:
|
||||
with op.batch_alter_table("match_participants") as b:
|
||||
b.add_column(
|
||||
sa.Column("eliminated", sa.Boolean(), nullable=False, server_default="0")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
insp = inspect(bind)
|
||||
cols = {c["name"] for c in insp.get_columns("match_participants")}
|
||||
if "eliminated" in cols:
|
||||
with op.batch_alter_table("match_participants") as b:
|
||||
b.drop_column("eliminated")
|
||||
@@ -305,6 +305,8 @@ class MatchParticipant(SQLModel, table=True):
|
||||
)
|
||||
)
|
||||
place: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
|
||||
# Выбыл из партии: place авто = последнее место (отображается красным крестиком).
|
||||
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
@@ -216,6 +216,7 @@ def update_match(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
faction_id=f.id, # type: ignore[arg-type]
|
||||
faction_name=f.name_ru,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
@@ -138,7 +139,13 @@ def finish_match(
|
||||
match = match_service.get_match(session, match_id)
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
finish = [
|
||||
FinishInput(user_id=p.user_id, place=p.place, comment=p.comment, faction_id=p.faction_id)
|
||||
FinishInput(
|
||||
user_id=p.user_id,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
comment=p.comment,
|
||||
faction_id=p.faction_id,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.finish_match(
|
||||
@@ -194,6 +201,7 @@ def update_match(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
|
||||
@@ -204,7 +204,8 @@ class MatchCreate(BaseModel):
|
||||
# Этап 2 (завершение): места, комментарии, причина победы.
|
||||
class MatchFinishParticipant(BaseModel):
|
||||
user_id: int
|
||||
place: int = Field(ge=1)
|
||||
place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated)
|
||||
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
|
||||
@@ -221,7 +222,8 @@ class MatchFinish(BaseModel):
|
||||
class ParticipantInput(BaseModel):
|
||||
user_id: int
|
||||
faction_id: int
|
||||
place: int = Field(ge=1)
|
||||
place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated)
|
||||
eliminated: bool = False
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
|
||||
@@ -240,6 +242,7 @@ class MatchParticipantRead(BaseModel):
|
||||
faction_id: int
|
||||
faction_name: str
|
||||
place: int | None = None
|
||||
eliminated: bool = False
|
||||
was_random: bool
|
||||
comment: str | None = None
|
||||
|
||||
@@ -357,6 +360,7 @@ class MatchListParticipant(BaseModel):
|
||||
faction_id: int
|
||||
faction_name: str
|
||||
place: int | None = None
|
||||
eliminated: bool = False
|
||||
was_random: bool
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ class FinishInput:
|
||||
"""Результат участника на этапе завершения."""
|
||||
|
||||
user_id: int
|
||||
place: int
|
||||
place: int | None = None # None у выбывшего (eliminated) — место проставится авто
|
||||
eliminated: bool = False
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
|
||||
@@ -49,7 +50,8 @@ class ParticipantInput:
|
||||
|
||||
user_id: int
|
||||
faction_id: int
|
||||
place: int
|
||||
place: int | None = None
|
||||
eliminated: bool = False
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
|
||||
@@ -129,6 +131,22 @@ def _validate_ranking(places: list[int]) -> None:
|
||||
expected = current + tie
|
||||
|
||||
|
||||
def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int, int]:
|
||||
"""rows: (user_id, place, eliminated) → {user_id: место}.
|
||||
|
||||
Выжившим (не eliminated) валидируем competition ranking (1,2,2,4). Выбывшим
|
||||
проставляем общее последнее место = len(survivors)+1 (ничья последнего ранга),
|
||||
чтобы подсчёт очков считал их как последнее место без отдельных правок SQL."""
|
||||
survivors = [(uid, place) for uid, place, elim in rows if not elim]
|
||||
if not survivors:
|
||||
raise InvalidRankingError("Хотя бы один игрок должен остаться в партии (с местом).")
|
||||
if any(place is None for _uid, place in survivors):
|
||||
raise InvalidRankingError("Укажите место каждому не выбывшему игроку.")
|
||||
_validate_ranking([place for _uid, place in survivors]) # type: ignore[misc]
|
||||
elim_place = len(survivors) + 1
|
||||
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
|
||||
|
||||
|
||||
def _group_member_ids(session: Session, group_id: int) -> set[int]:
|
||||
return {
|
||||
m.user_id
|
||||
@@ -235,11 +253,12 @@ def finish_match(
|
||||
if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)):
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
_validate_ranking([f.place for f in finish])
|
||||
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
|
||||
|
||||
for f in finish:
|
||||
p = existing[f.user_id]
|
||||
p.place = f.place
|
||||
p.place = places[f.user_id]
|
||||
p.eliminated = f.eliminated
|
||||
p.comment = f.comment or None
|
||||
if f.faction_id is not None:
|
||||
p.faction_id = f.faction_id
|
||||
@@ -310,7 +329,9 @@ def update_match(
|
||||
[p.user_id for p in participants],
|
||||
[p.faction_id for p in participants],
|
||||
)
|
||||
_validate_ranking([p.place for p in participants])
|
||||
places = _resolve_finish_places(
|
||||
[(p.user_id, p.place, p.eliminated) for p in participants]
|
||||
)
|
||||
for old in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all():
|
||||
@@ -322,7 +343,8 @@ def update_match(
|
||||
match_id=match.id, # type: ignore[arg-type]
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
place=places[p.user_id],
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment or None,
|
||||
)
|
||||
|
||||
@@ -297,6 +297,7 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"eliminated": p.eliminated,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
@@ -343,6 +344,7 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"eliminated": p.eliminated,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Выбывшие игроки: красный крестик = последнее место (засчитывается, без отд. «проигрыша»)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||
|
||||
|
||||
def _group(client: TestClient, name: str = "Гр") -> int:
|
||||
return client.post(
|
||||
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
|
||||
|
||||
def _start3(client: TestClient, engine):
|
||||
me = login(client, "Капитан")
|
||||
gid = _group(client)
|
||||
b = add_group_member(engine, gid, "Боец")
|
||||
c = add_group_member(engine, gid, "Третий")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
started = start_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[0]},
|
||||
{"user_id": b, "faction_id": fids[1]},
|
||||
{"user_id": c, "faction_id": fids[2]},
|
||||
],
|
||||
)
|
||||
assert started.status_code == 200, started.text
|
||||
return me, b, c, gid, started.json()["id"]
|
||||
|
||||
|
||||
def test_eliminated_gets_last_place(client: TestClient, engine):
|
||||
me, b, c, _gid, mid = _start3(client, engine)
|
||||
r = finish_match(
|
||||
client,
|
||||
mid,
|
||||
[
|
||||
{"user_id": me["id"], "place": 1},
|
||||
{"user_id": b, "place": 2},
|
||||
{"user_id": c, "eliminated": True}, # выбыл
|
||||
],
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
parts = {p["user_id"]: p for p in r.json()["participants"]}
|
||||
assert parts[c]["eliminated"] is True
|
||||
assert parts[c]["place"] == 3 # survivors=2 → последнее место 3
|
||||
assert parts[me["id"]]["eliminated"] is False
|
||||
# Выбывший — в конце списка результатов.
|
||||
assert r.json()["participants"][-1]["user_id"] == c
|
||||
|
||||
# Партия засчитана выбывшему как сыгранная (последнее место, 0 очков).
|
||||
login(client, "Третий")
|
||||
stats = client.get("/api/users/me/stats").json()
|
||||
assert stats["overall"]["games"] == 1
|
||||
assert stats["overall"]["wins"] == 0
|
||||
|
||||
|
||||
def test_all_eliminated_rejected(client: TestClient, engine):
|
||||
me, b, c, _gid, mid = _start3(client, engine)
|
||||
r = finish_match(
|
||||
client,
|
||||
mid,
|
||||
[
|
||||
{"user_id": me["id"], "eliminated": True},
|
||||
{"user_id": b, "eliminated": True},
|
||||
{"user_id": c, "eliminated": True},
|
||||
],
|
||||
)
|
||||
assert r.status_code == 422, r.text # должен остаться хотя бы один с местом
|
||||
@@ -11,6 +11,25 @@
|
||||
#
|
||||
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
||||
# буферизация задержат поток событий (реал-тайм перестанет работать).
|
||||
#
|
||||
# Туннель лёг (апстрим недоступен) → reverse_proxy отдаёт 502/503/504, и сниппет
|
||||
# (offline) подменяет ответ на свою страницу-заглушку (/etc/caddy/maintenance/maintenance.html),
|
||||
# вместо «зависшей загрузки» SPA. Файл заглушки — deploy/vps/maintenance.html.
|
||||
|
||||
# Заглушка «сайт недоступен» при недоступности апстрима. Импортируется в каждый сайт.
|
||||
(offline) {
|
||||
handle_errors {
|
||||
@down expression `{http.error.status_code} in [502, 503, 504]`
|
||||
handle @down {
|
||||
root * /etc/caddy/maintenance
|
||||
rewrite * /maintenance.html
|
||||
header Cache-Control "no-store"
|
||||
file_server {
|
||||
status 503
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
forbiddenstars.ru {
|
||||
tls /etc/caddy/certs/forbiddenstars.ru/fullchain.pem /etc/caddy/certs/forbiddenstars.ru/privkey.pem
|
||||
@@ -24,6 +43,7 @@ forbiddenstars.ru {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:9000
|
||||
}
|
||||
import offline
|
||||
}
|
||||
|
||||
forbidden-stars.ru {
|
||||
@@ -38,4 +58,5 @@ forbidden-stars.ru {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:9001
|
||||
}
|
||||
import offline
|
||||
}
|
||||
|
||||
+13
-3
@@ -85,16 +85,26 @@ rm -rf /root/certs-tmp
|
||||
> как «промежуточную» часть. Если дал один файл, где leaf и chain уже вместе, — это и есть
|
||||
> `fullchain.pem` (склейка не нужна).
|
||||
|
||||
## 6. Caddyfile
|
||||
Скопируй `deploy/vps/Caddyfile` из репозитория в `/etc/caddy/Caddyfile`, затем:
|
||||
## 6. Caddyfile + страница-заглушка
|
||||
Скопируй `deploy/vps/Caddyfile` из репозитория в `/etc/caddy/Caddyfile` и положи страницу
|
||||
«сайт недоступен» (показывается, когда туннель к хосту лежит — вместо зависшей «Загрузки» SPA):
|
||||
```bash
|
||||
mkdir -p /etc/caddy/maintenance
|
||||
# с локальной машины:
|
||||
scp deploy/vps/maintenance.html root@186.246.51.17:/etc/caddy/maintenance/maintenance.html
|
||||
# на VPS:
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
systemctl reload caddy
|
||||
```
|
||||
> Заглушка живёт в сниппете `(offline)` Caddyfile: при ответе апстрима 502/503/504
|
||||
> Caddy отдаёт `/etc/caddy/maintenance/maintenance.html` со статусом 503 и `Cache-Control: no-store`.
|
||||
> Правки текста/вида — в `deploy/vps/maintenance.html`, затем повтори `scp` (reload Caddy не нужен,
|
||||
> файл читается на каждый запрос).
|
||||
|
||||
## 7. Проверка
|
||||
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК (`run.ps1` при `LOCAL_PUBLIC=vps`).
|
||||
2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`.
|
||||
3. Пока соответствующий туннель не поднят — Caddy отдаёт `502`, это ожидаемо.
|
||||
3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки»
|
||||
(HTTP 503), это ожидаемо.
|
||||
|
||||
Логи Caddy: `journalctl -u caddy -f`.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<!-- Сама перепроверяется: когда хост вернётся, страница перезагрузится и пустит в приложение. -->
|
||||
<meta http-equiv="refresh" content="20" />
|
||||
<title>Сайт недоступен — Forbidden Stars</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--surface: #171a21;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8eaed;
|
||||
--text-muted: #9aa0aa;
|
||||
--accent: #e0533d;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.card {
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 32px 24px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.emoji { font-size: 56px; line-height: 1; margin-bottom: 14px; }
|
||||
h1 { font-size: 22px; margin: 0 0 10px; }
|
||||
p { color: var(--text-muted); font-size: 15px; line-height: 1.5; margin: 0 0 18px; }
|
||||
.hint { font-size: 13px; color: var(--text-muted); }
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
margin-right: 6px;
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="emoji">🍫</div>
|
||||
<h1>Технические шоколадки</h1>
|
||||
<p>Сайт временно недоступен — мы уже чиним. Загляните через минутку.</p>
|
||||
<div class="hint"><span class="dot"></span>Страница обновится сама, как только всё заработает</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
+22
-2
@@ -1566,7 +1566,12 @@ export interface components {
|
||||
/** User Id */
|
||||
user_id: number;
|
||||
/** Place */
|
||||
place: number;
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/** Comment */
|
||||
comment?: string | null;
|
||||
/** Faction Id */
|
||||
@@ -1626,6 +1631,11 @@ export interface components {
|
||||
faction_name: string;
|
||||
/** Place */
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/** Was Random */
|
||||
was_random: boolean;
|
||||
/** Comment */
|
||||
@@ -1643,6 +1653,11 @@ export interface components {
|
||||
faction_name: string;
|
||||
/** Place */
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/** Was Random */
|
||||
was_random: boolean;
|
||||
/** Comment */
|
||||
@@ -1816,7 +1831,12 @@ export interface components {
|
||||
/** Faction Id */
|
||||
faction_id: number;
|
||||
/** Place */
|
||||
place: number;
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/**
|
||||
* Was Random
|
||||
* @default false
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useMe } from "../hooks/auth";
|
||||
@@ -5,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents";
|
||||
import { BottomBar } from "./BottomBar";
|
||||
import { NotificationBell } from "./NotificationBell";
|
||||
import { NotificationToaster } from "./NotificationToaster";
|
||||
import { SideMenu } from "./SideMenu";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
"/": "Forbidden Stars",
|
||||
@@ -19,6 +21,7 @@ export function AppShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { data: me } = useMe();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
useServerEvents(!!me); // живые обновления (SSE) только при наличии сессии
|
||||
const title =
|
||||
TITLES[location.pathname] ??
|
||||
@@ -29,13 +32,9 @@ export function AppShell() {
|
||||
<div className="app-shell">
|
||||
<header className="top-bar">
|
||||
<div className="top-bar-left">
|
||||
{isHome ? (
|
||||
<span className="title">{title}</span>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
|
||||
← Назад
|
||||
</button>
|
||||
)}
|
||||
<button className="top-bar-home" onClick={() => navigate("/")}>
|
||||
Forbidden Stars
|
||||
</button>
|
||||
</div>
|
||||
<span className="title small muted top-bar-center">{!isHome ? title : ""}</span>
|
||||
<div className="top-bar-right">
|
||||
@@ -49,7 +48,8 @@ export function AppShell() {
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<BottomBar />
|
||||
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||
<BottomBar onMenu={() => setMenuOpen(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,46 @@
|
||||
import { Plus, Settings, Users } from "lucide-react";
|
||||
import { Menu, Plus, Users } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useMe } from "../hooks/auth";
|
||||
|
||||
export function BottomBar() {
|
||||
const ADMIN_HOLD_MS = 10000; // секретный вход в админку — удержание кнопки меню 10с
|
||||
|
||||
export function BottomBar({ onMenu }: { onMenu: () => void }) {
|
||||
const { data: me } = useMe();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const hasGroup = me?.active_group_id != null;
|
||||
const activeGroup = me?.groups.find((g) => g.id === me?.active_group_id);
|
||||
|
||||
// Долгое удержание кнопки меню → /admin/login; короткий тап → открыть боковое меню.
|
||||
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const holdFired = useRef(false);
|
||||
|
||||
const startHold = () => {
|
||||
holdFired.current = false;
|
||||
holdTimer.current = setTimeout(() => {
|
||||
holdFired.current = true;
|
||||
navigate("/admin/login");
|
||||
}, ADMIN_HOLD_MS);
|
||||
};
|
||||
const clearHold = () => {
|
||||
if (holdTimer.current) {
|
||||
clearTimeout(holdTimer.current);
|
||||
holdTimer.current = null;
|
||||
}
|
||||
};
|
||||
// Открытие меню — на нативном onClick (надёжно на мобильных, в отличие от pointerup,
|
||||
// который не приходит при pointercancel). Долгий зажим подавляет этот клик.
|
||||
const onMenuClick = () => {
|
||||
if (holdFired.current) {
|
||||
holdFired.current = false;
|
||||
return;
|
||||
}
|
||||
onMenu();
|
||||
};
|
||||
|
||||
const onCreate = () => {
|
||||
if (!hasGroup) {
|
||||
toast.show("Вы не состоите в группе. Создайте или вступите в группу.");
|
||||
@@ -29,9 +59,17 @@ export function BottomBar() {
|
||||
|
||||
<div className="spacer" />
|
||||
|
||||
<button className="slot" onClick={() => navigate("/account")}>
|
||||
<Settings size={20} />
|
||||
<span>Аккаунт</span>
|
||||
<button
|
||||
className="slot"
|
||||
aria-label="Меню"
|
||||
onClick={onMenuClick}
|
||||
onPointerDown={startHold}
|
||||
onPointerUp={clearHold}
|
||||
onPointerLeave={clearHold}
|
||||
onPointerCancel={clearHold}
|
||||
>
|
||||
<Menu size={20} />
|
||||
<span>Меню</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Переиспользуемое модальное подтверждение (без нативного confirm()). */
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Подтвердить",
|
||||
cancelLabel = "Отмена",
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-title">{title}</div>
|
||||
{message && <p className="modal-msg">{message}</p>}
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-ghost" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
className={"btn " + (danger ? "btn-danger-solid" : "btn-primary")}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
onClick={() => navigate(`/match/${m.id}`)}
|
||||
>
|
||||
<div className="row-between">
|
||||
<b>{m.played_at}</b>
|
||||
<span className="muted small">{m.played_at}</span>
|
||||
{inProgress ? (
|
||||
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
|
||||
) : (
|
||||
@@ -33,8 +33,14 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
|
||||
{sorted.map((p) => (
|
||||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||||
<span className={"place-badge" + (p.place === 1 ? " first" : "")}>
|
||||
{p.place ?? "—"}
|
||||
<span
|
||||
className={
|
||||
"place-badge" +
|
||||
(p.place === 1 && !p.eliminated ? " first" : "") +
|
||||
(p.eliminated ? " eliminated" : "")
|
||||
}
|
||||
>
|
||||
{p.eliminated ? "✕" : (p.place ?? "—")}
|
||||
</span>
|
||||
<span>{p.nickname}</span>
|
||||
<span className="muted small">· {p.faction_name}</span>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { LogOut } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useLogout, useMe } from "../hooks/auth";
|
||||
import { useMyStats } from "../hooks/stats";
|
||||
import { Avatar } from "./Avatar";
|
||||
|
||||
/** Боковое меню, выезжающее из правого края. Сверху — кликабельная плашка аккаунта
|
||||
* (краткая сводка → /account), ниже — выход. Открытием управляет AppShell. */
|
||||
export function SideMenu({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { data: me } = useMe();
|
||||
const { data: stats } = useMyStats();
|
||||
const logout = useLogout();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goAccount = () => {
|
||||
onClose();
|
||||
navigate("/account");
|
||||
};
|
||||
|
||||
const doLogout = async () => {
|
||||
onClose();
|
||||
await logout.mutateAsync().catch(() => {});
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"drawer-overlay" + (open ? " open" : "")} onClick={onClose} />
|
||||
<aside className={"side-menu" + (open ? " open" : "")} aria-hidden={!open}>
|
||||
{me && (
|
||||
<button className="side-account" onClick={goAccount}>
|
||||
<Avatar url={me.avatar_url} nickname={me.nickname} size={48} />
|
||||
<span className="side-account-info">
|
||||
<b>{me.nickname}</b>
|
||||
<span className="muted small">
|
||||
игр {stats?.overall.games ?? 0} · побед {stats?.overall.wins ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="side-menu-items">
|
||||
<button className="side-menu-item danger" onClick={doLogout}>
|
||||
<LogOut size={18} /> Выйти
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { GroupSwitcher } from "../components/GroupSwitcher";
|
||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
@@ -125,6 +124,7 @@ export function AccountPage() {
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
placeholder="Пара слов о себе…"
|
||||
style={{ resize: "none" }}
|
||||
value={bioValue}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
/>
|
||||
@@ -153,17 +153,9 @@ export function AccountPage() {
|
||||
|
||||
{stats && <ProfileStatsCard stats={stats} />}
|
||||
|
||||
<GroupSwitcher />
|
||||
|
||||
<button className="btn btn-danger" style={{ width: "100%" }} onClick={doLogout}>
|
||||
Выйти
|
||||
</button>
|
||||
|
||||
<p className="center" style={{ marginTop: 24 }}>
|
||||
<button className="btn btn-ghost small muted" onClick={() => navigate("/admin/login")}>
|
||||
Вход для администратора
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dices, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
@@ -34,6 +34,17 @@ export function CreateMatchPage() {
|
||||
|
||||
const [rows, setRows] = useState<Draft[]>([emptyDraft(), emptyDraft()]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Анимация-«барабан» рандома: для крутящейся строки — текущее мелькающее название.
|
||||
const [spin, setSpin] = useState<Record<number, string | null>>({});
|
||||
const reelTimers = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
|
||||
|
||||
// Снимаем таймеры барабана при размонтировании.
|
||||
useEffect(
|
||||
() => () => {
|
||||
Object.values(reelTimers.current).forEach((t) => clearTimeout(t));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
if (lm || lf) return <Spinner />;
|
||||
if (!groupId) return <div className="muted">Нет активной группы.</div>;
|
||||
@@ -50,27 +61,72 @@ export function CreateMatchPage() {
|
||||
const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
|
||||
const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx));
|
||||
|
||||
// «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление),
|
||||
// затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только
|
||||
// после этого фракция «выбирается» (onReveal: показываем select с результатом).
|
||||
const animateReel = (
|
||||
idx: number,
|
||||
names: string[],
|
||||
finalName: string,
|
||||
onReveal: () => void,
|
||||
): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
let delay = 50;
|
||||
const tick = () => {
|
||||
setSpin((s) => ({ ...s, [idx]: names[Math.floor(Math.random() * names.length)] }));
|
||||
delay *= 1.18;
|
||||
if (delay < 300) {
|
||||
reelTimers.current[idx] = setTimeout(tick, delay);
|
||||
} else {
|
||||
reelTimers.current[idx] = setTimeout(() => {
|
||||
setSpin((s) => ({ ...s, [idx]: finalName })); // остановка на выбранной
|
||||
reelTimers.current[idx] = setTimeout(() => {
|
||||
onReveal();
|
||||
setSpin((s) => ({ ...s, [idx]: null }));
|
||||
resolve();
|
||||
}, 500); // пауза перед «выбором»
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
});
|
||||
|
||||
const randomizeRow = async (idx: number) => {
|
||||
const pool = factions ?? [];
|
||||
if (pool.length === 0 || spin[idx] != null) return;
|
||||
try {
|
||||
const faction = await randomize.mutateAsync({
|
||||
group_id: groupId,
|
||||
exclude_faction_ids: takenFactionIds(idx),
|
||||
});
|
||||
update(idx, { faction_id: faction.id, was_random: true });
|
||||
await animateReel(idx, pool.map((f) => f.name_ru), faction.name_ru, () =>
|
||||
update(idx, { faction_id: faction.id, was_random: true }),
|
||||
);
|
||||
} catch (e) {
|
||||
if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]);
|
||||
setSpin((s) => ({ ...s, [idx]: null }));
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
||||
}
|
||||
};
|
||||
|
||||
const randomizeAll = () => {
|
||||
const pool = [...(factions ?? [])];
|
||||
const randomizeAll = async () => {
|
||||
const all = factions ?? [];
|
||||
if (all.length === 0) return;
|
||||
const pool = [...all];
|
||||
for (let i = pool.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||
}
|
||||
if (pool.length < rows.length) toast.show("Фракций меньше, чем игроков — возможны повторы.");
|
||||
setRows((rs) =>
|
||||
rs.map((r, i) => ({ ...r, faction_id: pool[i % pool.length]?.id ?? "", was_random: true })),
|
||||
const names = all.map((f) => f.name_ru);
|
||||
// Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
|
||||
await Promise.all(
|
||||
rows.map((_, idx) => {
|
||||
const f = pool[idx % pool.length];
|
||||
return animateReel(idx, names, f.name_ru, () =>
|
||||
update(idx, { faction_id: f.id, was_random: true }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -102,13 +158,14 @@ export function CreateMatchPage() {
|
||||
};
|
||||
|
||||
const memberOptions = (members ?? []) as MemberRead[];
|
||||
const spinning = Object.values(spin).some((v) => v != null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>Этап 1 — старт партии</h3>
|
||||
<p className="muted small">Выберите игроков и фракции. Места и итоги укажете при завершении.</p>
|
||||
<button className="btn" onClick={randomizeAll}>
|
||||
<button className="btn" onClick={randomizeAll} disabled={spinning}>
|
||||
<Dices size={18} /> Рандом всем
|
||||
</button>
|
||||
</div>
|
||||
@@ -137,28 +194,36 @@ export function CreateMatchPage() {
|
||||
</select>
|
||||
|
||||
<div className="row">
|
||||
<select
|
||||
value={row.faction_id}
|
||||
onChange={(e) =>
|
||||
update(idx, {
|
||||
faction_id: e.target.value ? Number(e.target.value) : "",
|
||||
was_random: false,
|
||||
})
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
{spin[idx] != null ? (
|
||||
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div>
|
||||
) : (
|
||||
<select
|
||||
value={row.faction_id}
|
||||
onChange={(e) =>
|
||||
update(idx, {
|
||||
faction_id: e.target.value ? Number(e.target.value) : "",
|
||||
was_random: false,
|
||||
})
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<option value="">— фракция —</option>
|
||||
{(factions ?? []).map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name_ru}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => randomizeRow(idx)}
|
||||
disabled={spin[idx] != null}
|
||||
title="Случайная фракция"
|
||||
>
|
||||
<option value="">— фракция —</option>
|
||||
{(factions ?? []).map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name_ru}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn" onClick={() => randomizeRow(idx)} title="Случайная фракция">
|
||||
<Dices size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{row.was_random && row.faction_id !== "" && <span className="badge">🎲 случайная</span>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -72,21 +72,24 @@ export function GroupSettingsPage() {
|
||||
<div className="card">
|
||||
<h3>Дополнения</h3>
|
||||
<div className="stack">
|
||||
{(expansions ?? []).map((e) => (
|
||||
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span>
|
||||
{e.name_ru}
|
||||
{e.is_base && <span className="muted small"> (всегда включено)</span>}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: "auto" }}
|
||||
disabled={e.is_base || !isOwner}
|
||||
checked={e.is_base || selected.has(e.id)}
|
||||
onChange={() => toggle(e.id)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{(expansions ?? [])
|
||||
.filter((e) => !e.is_base)
|
||||
.map((e) => (
|
||||
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span>{e.name_ru}</span>
|
||||
<span className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!isOwner}
|
||||
checked={selected.has(e.id)}
|
||||
onChange={() => toggle(e.id)}
|
||||
/>
|
||||
<span className="switch-track">
|
||||
<span className="switch-thumb" />
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button className="btn btn-primary" style={{ marginTop: 10 }} onClick={saveExpansions}>
|
||||
|
||||
@@ -93,16 +93,6 @@ export function LoginPage() {
|
||||
{showStub && !pendingTg && <DevLogin onError={setError} />}
|
||||
|
||||
{error && <p className="error-text center">{error}</p>}
|
||||
|
||||
<p className="center" style={{ marginTop: 40 }}>
|
||||
<button
|
||||
className="btn btn-ghost small"
|
||||
onClick={() => navigate("/admin/login")}
|
||||
style={{ opacity: 0.4 }}
|
||||
>
|
||||
·
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { MatchMedia } from "../components/MatchMedia";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { formatDuration, formatTime } from "../domain/format";
|
||||
@@ -20,6 +21,7 @@ interface FinishRow {
|
||||
nickname: string;
|
||||
faction_name: string;
|
||||
place: number;
|
||||
eliminated: boolean;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
@@ -38,6 +40,7 @@ export function MatchDetailPage() {
|
||||
const [winReason, setWinReason] = useState<WinReason>("objectives");
|
||||
const [overall, setOverall] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
||||
@@ -53,6 +56,7 @@ export function MatchDetailPage() {
|
||||
nickname: p.nickname,
|
||||
faction_name: p.faction_name,
|
||||
place: i + 1,
|
||||
eliminated: false,
|
||||
comment: p.comment ?? "",
|
||||
}));
|
||||
|
||||
@@ -71,7 +75,8 @@ export function MatchDetailPage() {
|
||||
body: {
|
||||
participants: finishRows.map((r) => ({
|
||||
user_id: r.user_id,
|
||||
place: r.place,
|
||||
place: r.eliminated ? null : r.place,
|
||||
eliminated: r.eliminated,
|
||||
comment: r.comment.trim() || null,
|
||||
})),
|
||||
win_reason: winReason,
|
||||
@@ -141,7 +146,15 @@ export function MatchDetailPage() {
|
||||
{sorted.map((p) => (
|
||||
<div key={p.user_id}>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<span className={"place-badge" + (p.place === 1 ? " first" : "")}>{p.place ?? "—"}</span>
|
||||
<span
|
||||
className={
|
||||
"place-badge" +
|
||||
(p.place === 1 && !p.eliminated ? " first" : "") +
|
||||
(p.eliminated ? " eliminated" : "")
|
||||
}
|
||||
>
|
||||
{p.eliminated ? "✕" : (p.place ?? "—")}
|
||||
</span>
|
||||
<b>{p.nickname}</b>
|
||||
<span className="muted small">· {p.faction_name}</span>
|
||||
{p.was_random && <span className="badge">🎲</span>}
|
||||
@@ -189,12 +202,20 @@ export function MatchDetailPage() {
|
||||
<div className="row">
|
||||
<div style={{ flex: "0 0 130px" }}>
|
||||
<label className="label">Место</label>
|
||||
<select value={r.place} onChange={(e) => upd(i, { place: Number(e.target.value) })}>
|
||||
<select
|
||||
value={r.eliminated ? "elim" : String(r.place)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "elim") upd(i, { eliminated: true });
|
||||
else upd(i, { eliminated: false, place: Number(v) });
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: finishRows.length }, (_, k) => k + 1).map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
<option value="elim">✕</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,10 +273,32 @@ export function MatchDetailPage() {
|
||||
)}
|
||||
|
||||
{canModify && (
|
||||
<button className="btn btn-danger" style={{ width: "100%" }} onClick={remove}>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ width: "100%" }}
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
>
|
||||
{inProgress ? "Отменить партию" : "Удалить партию"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmRemove}
|
||||
danger
|
||||
title={inProgress ? "Отменить партию?" : "Удалить партию?"}
|
||||
message={
|
||||
inProgress
|
||||
? "Незавершённая партия будет удалена. Действие необратимо."
|
||||
: "Сыгранная партия и её результаты будут удалены. Действие необратимо."
|
||||
}
|
||||
confirmLabel={inProgress ? "Отменить партию" : "Удалить"}
|
||||
cancelLabel="Назад"
|
||||
onCancel={() => setConfirmRemove(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmRemove(false);
|
||||
remove();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface Row {
|
||||
nickname: string;
|
||||
faction_id: number;
|
||||
place: number;
|
||||
eliminated: boolean;
|
||||
was_random: boolean;
|
||||
comment: string;
|
||||
}
|
||||
@@ -46,6 +47,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
nickname: p.nickname,
|
||||
faction_id: p.faction_id,
|
||||
place: p.place ?? 1,
|
||||
eliminated: p.eliminated,
|
||||
was_random: p.was_random,
|
||||
comment: p.comment ?? "",
|
||||
})),
|
||||
@@ -71,7 +73,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
participants: rows.map((r) => ({
|
||||
user_id: r.user_id,
|
||||
faction_id: r.faction_id,
|
||||
place: r.place,
|
||||
place: r.eliminated ? null : r.place,
|
||||
eliminated: r.eliminated,
|
||||
was_random: r.was_random,
|
||||
comment: r.comment.trim() || null,
|
||||
})),
|
||||
@@ -133,9 +136,19 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.place}
|
||||
disabled={r.eliminated}
|
||||
onChange={(e) => upd(i, { place: Math.max(1, Number(e.target.value) || 1) })}
|
||||
/>
|
||||
</div>
|
||||
<label className="row small" style={{ flex: "0 0 auto", gap: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: "auto" }}
|
||||
checked={r.eliminated}
|
||||
onChange={(e) => upd(i, { eliminated: e.target.checked })}
|
||||
/>
|
||||
выбыл
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Комментарий об игроке"
|
||||
|
||||
@@ -50,6 +50,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text); /* карточки-кнопки (список партий) — белый текст по умолчанию */
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -68,6 +69,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-ghost { background: transparent; }
|
||||
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
|
||||
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.btn[disabled] { opacity: 0.5; pointer-events: none; }
|
||||
|
||||
.row { display: flex; align-items: center; gap: 10px; }
|
||||
@@ -92,6 +94,84 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
|
||||
.error-text { color: var(--danger); font-size: 13px; }
|
||||
|
||||
/* Модальное окно подтверждения */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.modal-title { font-weight: 700; font-size: 16px; margin-bottom: 8px; }
|
||||
.modal-msg { color: var(--text-muted); font-size: 14px; margin: 0 0 16px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
/* «Барабан» рандома фракции — мелькающее название вместо select */
|
||||
.faction-spin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
animation: faction-roll 0.45s ease-in-out infinite;
|
||||
}
|
||||
@keyframes faction-roll {
|
||||
0% { opacity: 0.5; transform: translateY(-3px); }
|
||||
50% { opacity: 1; transform: translateY(0); }
|
||||
100% { opacity: 0.5; transform: translateY(3px); }
|
||||
}
|
||||
|
||||
/* Тумблер-переключатель (вкл/выкл) */
|
||||
.switch { position: relative; display: inline-block; width: 44px; height: 26px; flex: 0 0 auto; }
|
||||
.switch input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch-track {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
padding: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.switch-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--text);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.switch input:checked + .switch-track { background: var(--accent); border-color: var(--accent); }
|
||||
.switch input:checked + .switch-track .switch-thumb { transform: translateX(18px); background: #fff; }
|
||||
.switch input:disabled + .switch-track { opacity: 0.5; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
@@ -121,6 +201,14 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
padding: 0 8px;
|
||||
}
|
||||
.top-bar-right { display: flex; align-items: center; }
|
||||
.top-bar-home {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.notif-bell-wrap { position: relative; }
|
||||
.notif-bell {
|
||||
|
||||
@@ -56,30 +56,35 @@
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
touch-action: manipulation; /* без задержки/отмены тапа на мобильных */
|
||||
}
|
||||
.bottom-bar .slot .name { color: var(--text); font-weight: 600; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bottom-bar .spacer { width: var(--fab-width); flex: 0 0 var(--fab-width); }
|
||||
|
||||
/* Круг создания партии: полукруг-купол, по центру разделяет слоты, смещён вниз
|
||||
(база у нижней кромки панели) и поднят на передний план (z-index выше панели). */
|
||||
.fab {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(var(--bottombar-height) + var(--safe-bottom) - 2px);
|
||||
bottom: calc(var(--safe-bottom) + 8px);
|
||||
width: var(--fab-width);
|
||||
height: var(--fab-height);
|
||||
border: 1px solid var(--accent);
|
||||
border-bottom: none;
|
||||
border-top-left-radius: var(--fab-width);
|
||||
border-top-right-radius: var(--fab-width);
|
||||
/* Радиус = половина ширины → ровный полукруг (ширина = 2× высоты). */
|
||||
border-top-left-radius: calc(var(--fab-width) / 2);
|
||||
border-top-right-radius: calc(var(--fab-width) / 2);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 -4px 14px rgba(224, 83, 61, 0.45);
|
||||
box-shadow: 0 -4px 16px rgba(224, 83, 61, 0.5);
|
||||
z-index: 25;
|
||||
}
|
||||
.fab svg { margin-top: 6px; }
|
||||
.fab svg { margin-top: 8px; }
|
||||
.fab[aria-disabled="true"] {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border);
|
||||
@@ -186,7 +191,70 @@
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text); /* номер места — белым для контраста */
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
.place-badge.first { background: var(--gold); color: #1a1a1a; border-color: var(--gold); }
|
||||
.place-badge.eliminated { background: transparent; color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* ─── Боковое меню (выезжает справа) ─────────────────────────────────────────── */
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 60;
|
||||
}
|
||||
.drawer-overlay.open { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.side-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: min(300px, 84vw);
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.22s ease;
|
||||
z-index: 61;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--safe-top);
|
||||
}
|
||||
.side-menu.open { transform: translateX(0); }
|
||||
|
||||
.side-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.side-account-info { display: flex; flex-direction: column; min-width: 0; }
|
||||
.side-account-info b { font-size: 16px; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.side-menu-items { display: flex; flex-direction: column; padding: 8px; }
|
||||
.side-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.side-menu-item.danger { color: var(--danger); }
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
/* Размеры */
|
||||
--app-max-w: 480px;
|
||||
--bottombar-height: 64px;
|
||||
--fab-width: 88px;
|
||||
--fab-height: 44px;
|
||||
--fab-width: 125px;
|
||||
--fab-height: 62.5px;
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
Reference in New Issue
Block a user