v0.8 - Пересмотр элементов интерфейса, улучшены навигация по приложению, улучшена читаемость некоторых эллементов, добавлены анимации

This commit is contained in:
2026-06-26 18:19:13 +03:00
26 changed files with 764 additions and 101 deletions
@@ -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")
+2
View File
@@ -305,6 +305,8 @@ class MatchParticipant(SQLModel, table=True):
) )
) )
place: int | None = Field(default=None, sa_column=Column(Integer, nullable=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")) was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
comment: str | None = Field(sa_column=Column(Text, nullable=True)) comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_at: datetime = Field(default_factory=_utcnow, nullable=False) created_at: datetime = Field(default_factory=_utcnow, nullable=False)
+1
View File
@@ -216,6 +216,7 @@ def update_match(
user_id=p.user_id, user_id=p.user_id,
faction_id=p.faction_id, faction_id=p.faction_id,
place=p.place, place=p.place,
eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment, comment=p.comment,
) )
+9 -1
View File
@@ -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_id=f.id, # type: ignore[arg-type]
faction_name=f.name_ru, faction_name=f.name_ru,
place=p.place, place=p.place,
eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment, comment=p.comment,
) )
@@ -138,7 +139,13 @@ def finish_match(
match = match_service.get_match(session, match_id) match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user) match_service.assert_can_modify(session, match, user)
finish = [ 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 for p in body.participants
] ]
match = match_service.finish_match( match = match_service.finish_match(
@@ -194,6 +201,7 @@ def update_match(
user_id=p.user_id, user_id=p.user_id,
faction_id=p.faction_id, faction_id=p.faction_id,
place=p.place, place=p.place,
eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment, comment=p.comment,
) )
+6 -2
View File
@@ -204,7 +204,8 @@ class MatchCreate(BaseModel):
# Этап 2 (завершение): места, комментарии, причина победы. # Этап 2 (завершение): места, комментарии, причина победы.
class MatchFinishParticipant(BaseModel): class MatchFinishParticipant(BaseModel):
user_id: int 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 comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении faction_id: int | None = None # опц. смена фракции при завершении
@@ -221,7 +222,8 @@ class MatchFinish(BaseModel):
class ParticipantInput(BaseModel): class ParticipantInput(BaseModel):
user_id: int user_id: int
faction_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 was_random: bool = False
comment: str | None = None comment: str | None = None
@@ -240,6 +242,7 @@ class MatchParticipantRead(BaseModel):
faction_id: int faction_id: int
faction_name: str faction_name: str
place: int | None = None place: int | None = None
eliminated: bool = False
was_random: bool was_random: bool
comment: str | None = None comment: str | None = None
@@ -357,6 +360,7 @@ class MatchListParticipant(BaseModel):
faction_id: int faction_id: int
faction_name: str faction_name: str
place: int | None = None place: int | None = None
eliminated: bool = False
was_random: bool was_random: bool
comment: str | None = None comment: str | None = None
+28 -6
View File
@@ -38,7 +38,8 @@ class FinishInput:
"""Результат участника на этапе завершения.""" """Результат участника на этапе завершения."""
user_id: int user_id: int
place: int place: int | None = None # None у выбывшего (eliminated) — место проставится авто
eliminated: bool = False
comment: str | None = None comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении faction_id: int | None = None # опц. смена фракции при завершении
@@ -49,7 +50,8 @@ class ParticipantInput:
user_id: int user_id: int
faction_id: int faction_id: int
place: int place: int | None = None
eliminated: bool = False
was_random: bool = False was_random: bool = False
comment: str | None = None comment: str | None = None
@@ -129,6 +131,22 @@ def _validate_ranking(places: list[int]) -> None:
expected = current + tie 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]: def _group_member_ids(session: Session, group_id: int) -> set[int]:
return { return {
m.user_id m.user_id
@@ -235,11 +253,12 @@ def finish_match(
if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)): if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)):
raise FactionNotAvailableError() 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: for f in finish:
p = existing[f.user_id] 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 p.comment = f.comment or None
if f.faction_id is not None: if f.faction_id is not None:
p.faction_id = f.faction_id p.faction_id = f.faction_id
@@ -310,7 +329,9 @@ def update_match(
[p.user_id for p in participants], [p.user_id for p in participants],
[p.faction_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( for old in session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id) select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all(): ).all():
@@ -322,7 +343,8 @@ def update_match(
match_id=match.id, # type: ignore[arg-type] match_id=match.id, # type: ignore[arg-type]
user_id=p.user_id, user_id=p.user_id,
faction_id=p.faction_id, faction_id=p.faction_id,
place=p.place, place=places[p.user_id],
eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment or None, comment=p.comment or None,
) )
+2
View File
@@ -297,6 +297,7 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
"faction_id": f.id, "faction_id": f.id,
"faction_name": f.name_ru, "faction_name": f.name_ru,
"place": p.place, "place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random, "was_random": p.was_random,
"comment": p.comment, "comment": p.comment,
} }
@@ -343,6 +344,7 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
"faction_id": f.id, "faction_id": f.id,
"faction_name": f.name_ru, "faction_name": f.name_ru,
"place": p.place, "place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random, "was_random": p.was_random,
"comment": p.comment, "comment": p.comment,
} }
+71
View File
@@ -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 # должен остаться хотя бы один с местом
+21
View File
@@ -11,6 +11,25 @@
# #
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/ # 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 { forbiddenstars.ru {
tls /etc/caddy/certs/forbiddenstars.ru/fullchain.pem /etc/caddy/certs/forbiddenstars.ru/privkey.pem tls /etc/caddy/certs/forbiddenstars.ru/fullchain.pem /etc/caddy/certs/forbiddenstars.ru/privkey.pem
@@ -24,6 +43,7 @@ forbiddenstars.ru {
encode zstd gzip encode zstd gzip
reverse_proxy 127.0.0.1:9000 reverse_proxy 127.0.0.1:9000
} }
import offline
} }
forbidden-stars.ru { forbidden-stars.ru {
@@ -38,4 +58,5 @@ forbidden-stars.ru {
encode zstd gzip encode zstd gzip
reverse_proxy 127.0.0.1:9001 reverse_proxy 127.0.0.1:9001
} }
import offline
} }
+13 -3
View File
@@ -85,16 +85,26 @@ rm -rf /root/certs-tmp
> как «промежуточную» часть. Если дал один файл, где leaf и chain уже вместе, — это и есть > как «промежуточную» часть. Если дал один файл, где leaf и chain уже вместе, — это и есть
> `fullchain.pem` (склейка не нужна). > `fullchain.pem` (склейка не нужна).
## 6. Caddyfile ## 6. Caddyfile + страница-заглушка
Скопируй `deploy/vps/Caddyfile` из репозитория в `/etc/caddy/Caddyfile`, затем: Скопируй `deploy/vps/Caddyfile` из репозитория в `/etc/caddy/Caddyfile` и положи страницу
«сайт недоступен» (показывается, когда туннель к хосту лежит — вместо зависшей «Загрузки» SPA):
```bash ```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 caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy 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. Проверка ## 7. Проверка
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК (`run.ps1` при `LOCAL_PUBLIC=vps`). 1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК (`run.ps1` при `LOCAL_PUBLIC=vps`).
2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`. 2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`.
3. Пока соответствующий туннель не поднят — Caddy отдаёт `502`, это ожидаемо. 3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки»
(HTTP 503), это ожидаемо.
Логи Caddy: `journalctl -u caddy -f`. Логи Caddy: `journalctl -u caddy -f`.
+64
View File
@@ -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>
+22 -2
View File
@@ -1566,7 +1566,12 @@ export interface components {
/** User Id */ /** User Id */
user_id: number; user_id: number;
/** Place */ /** Place */
place: number; place?: number | null;
/**
* Eliminated
* @default false
*/
eliminated: boolean;
/** Comment */ /** Comment */
comment?: string | null; comment?: string | null;
/** Faction Id */ /** Faction Id */
@@ -1626,6 +1631,11 @@ export interface components {
faction_name: string; faction_name: string;
/** Place */ /** Place */
place?: number | null; place?: number | null;
/**
* Eliminated
* @default false
*/
eliminated: boolean;
/** Was Random */ /** Was Random */
was_random: boolean; was_random: boolean;
/** Comment */ /** Comment */
@@ -1643,6 +1653,11 @@ export interface components {
faction_name: string; faction_name: string;
/** Place */ /** Place */
place?: number | null; place?: number | null;
/**
* Eliminated
* @default false
*/
eliminated: boolean;
/** Was Random */ /** Was Random */
was_random: boolean; was_random: boolean;
/** Comment */ /** Comment */
@@ -1816,7 +1831,12 @@ export interface components {
/** Faction Id */ /** Faction Id */
faction_id: number; faction_id: number;
/** Place */ /** Place */
place: number; place?: number | null;
/**
* Eliminated
* @default false
*/
eliminated: boolean;
/** /**
* Was Random * Was Random
* @default false * @default false
+8 -8
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useMe } from "../hooks/auth"; import { useMe } from "../hooks/auth";
@@ -5,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents";
import { BottomBar } from "./BottomBar"; import { BottomBar } from "./BottomBar";
import { NotificationBell } from "./NotificationBell"; import { NotificationBell } from "./NotificationBell";
import { NotificationToaster } from "./NotificationToaster"; import { NotificationToaster } from "./NotificationToaster";
import { SideMenu } from "./SideMenu";
const TITLES: Record<string, string> = { const TITLES: Record<string, string> = {
"/": "Forbidden Stars", "/": "Forbidden Stars",
@@ -19,6 +21,7 @@ export function AppShell() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: me } = useMe(); const { data: me } = useMe();
const [menuOpen, setMenuOpen] = useState(false);
useServerEvents(!!me); // живые обновления (SSE) только при наличии сессии useServerEvents(!!me); // живые обновления (SSE) только при наличии сессии
const title = const title =
TITLES[location.pathname] ?? TITLES[location.pathname] ??
@@ -29,13 +32,9 @@ export function AppShell() {
<div className="app-shell"> <div className="app-shell">
<header className="top-bar"> <header className="top-bar">
<div className="top-bar-left"> <div className="top-bar-left">
{isHome ? ( <button className="top-bar-home" onClick={() => navigate("/")}>
<span className="title">{title}</span> Forbidden Stars
) : ( </button>
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
← Назад
</button>
)}
</div> </div>
<span className="title small muted top-bar-center">{!isHome ? title : ""}</span> <span className="title small muted top-bar-center">{!isHome ? title : ""}</span>
<div className="top-bar-right"> <div className="top-bar-right">
@@ -49,7 +48,8 @@ export function AppShell() {
<Outlet /> <Outlet />
</main> </main>
<BottomBar /> <SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
<BottomBar onMenu={() => setMenuOpen(true)} />
</div> </div>
); );
} }
+43 -5
View File
@@ -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 { useNavigate } from "react-router-dom";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth"; 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 { data: me } = useMe();
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useToast(); const toast = useToast();
const hasGroup = me?.active_group_id != null; const hasGroup = me?.active_group_id != null;
const activeGroup = me?.groups.find((g) => g.id === me?.active_group_id); 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 = () => { const onCreate = () => {
if (!hasGroup) { if (!hasGroup) {
toast.show("Вы не состоите в группе. Создайте или вступите в группу."); toast.show("Вы не состоите в группе. Создайте или вступите в группу.");
@@ -29,9 +59,17 @@ export function BottomBar() {
<div className="spacer" /> <div className="spacer" />
<button className="slot" onClick={() => navigate("/account")}> <button
<Settings size={20} /> className="slot"
<span>Аккаунт</span> aria-label="Меню"
onClick={onMenuClick}
onPointerDown={startHold}
onPointerUp={clearHold}
onPointerLeave={clearHold}
onPointerCancel={clearHold}
>
<Menu size={20} />
<span>Меню</span>
</button> </button>
<button <button
+41
View File
@@ -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>
);
}
+9 -3
View File
@@ -21,7 +21,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
onClick={() => navigate(`/match/${m.id}`)} onClick={() => navigate(`/match/${m.id}`)}
> >
<div className="row-between"> <div className="row-between">
<b>{m.played_at}</b> <span className="muted small">{m.played_at}</span>
{inProgress ? ( {inProgress ? (
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span> <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 }}> <div className="stack" style={{ marginTop: 6, gap: 4 }}>
{sorted.map((p) => ( {sorted.map((p) => (
<div key={p.user_id} className="row" style={{ gap: 8 }}> <div key={p.user_id} className="row" style={{ gap: 8 }}>
<span className={"place-badge" + (p.place === 1 ? " first" : "")}> <span
{p.place ?? "—"} className={
"place-badge" +
(p.place === 1 && !p.eliminated ? " first" : "") +
(p.eliminated ? " eliminated" : "")
}
>
{p.eliminated ? "✕" : (p.place ?? "—")}
</span> </span>
<span>{p.nickname}</span> <span>{p.nickname}</span>
<span className="muted small">· {p.faction_name}</span> <span className="muted small">· {p.faction_name}</span>
+51
View File
@@ -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>
</>
);
}
+1 -9
View File
@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { GroupSwitcher } from "../components/GroupSwitcher";
import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner"; import { Spinner } from "../components/Spinner";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
@@ -125,6 +124,7 @@ export function AccountPage() {
rows={3} rows={3}
maxLength={500} maxLength={500}
placeholder="Пара слов о себе…" placeholder="Пара слов о себе…"
style={{ resize: "none" }}
value={bioValue} value={bioValue}
onChange={(e) => setBio(e.target.value)} onChange={(e) => setBio(e.target.value)}
/> />
@@ -153,17 +153,9 @@ export function AccountPage() {
{stats && <ProfileStatsCard stats={stats} />} {stats && <ProfileStatsCard stats={stats} />}
<GroupSwitcher />
<button className="btn btn-danger" style={{ width: "100%" }} onClick={doLogout}> <button className="btn btn-danger" style={{ width: "100%" }} onClick={doLogout}>
Выйти Выйти
</button> </button>
<p className="center" style={{ marginTop: 24 }}>
<button className="btn btn-ghost small muted" onClick={() => navigate("/admin/login")}>
Вход для администратора
</button>
</p>
</div> </div>
); );
} }
+90 -25
View File
@@ -1,5 +1,5 @@
import { Dices, Trash2 } from "lucide-react"; import { Dices, Trash2 } from "lucide-react";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
@@ -34,6 +34,17 @@ export function CreateMatchPage() {
const [rows, setRows] = useState<Draft[]>([emptyDraft(), emptyDraft()]); const [rows, setRows] = useState<Draft[]>([emptyDraft(), emptyDraft()]);
const [error, setError] = useState<string | null>(null); 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 (lm || lf) return <Spinner />;
if (!groupId) return <div className="muted">Нет активной группы.</div>; if (!groupId) return <div className="muted">Нет активной группы.</div>;
@@ -50,27 +61,72 @@ export function CreateMatchPage() {
const addRow = () => setRows((rs) => [...rs, emptyDraft()]); const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx)); 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 randomizeRow = async (idx: number) => {
const pool = factions ?? [];
if (pool.length === 0 || spin[idx] != null) return;
try { try {
const faction = await randomize.mutateAsync({ const faction = await randomize.mutateAsync({
group_id: groupId, group_id: groupId,
exclude_faction_ids: takenFactionIds(idx), 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) { } catch (e) {
if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]);
setSpin((s) => ({ ...s, [idx]: null }));
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома"); toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
} }
}; };
const randomizeAll = () => { const randomizeAll = async () => {
const pool = [...(factions ?? [])]; const all = factions ?? [];
if (all.length === 0) return;
const pool = [...all];
for (let i = pool.length - 1; i > 0; i--) { for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]]; [pool[i], pool[j]] = [pool[j], pool[i]];
} }
if (pool.length < rows.length) toast.show("Фракций меньше, чем игроков — возможны повторы."); if (pool.length < rows.length) toast.show("Фракций меньше, чем игроков — возможны повторы.");
setRows((rs) => const names = all.map((f) => f.name_ru);
rs.map((r, i) => ({ ...r, faction_id: pool[i % pool.length]?.id ?? "", was_random: true })), // Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
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 memberOptions = (members ?? []) as MemberRead[];
const spinning = Object.values(spin).some((v) => v != null);
return ( return (
<div> <div>
<div className="card"> <div className="card">
<h3>Этап 1 — старт партии</h3> <h3>Этап 1 — старт партии</h3>
<p className="muted small">Выберите игроков и фракции. Места и итоги укажете при завершении.</p> <p className="muted small">Выберите игроков и фракции. Места и итоги укажете при завершении.</p>
<button className="btn" onClick={randomizeAll}> <button className="btn" onClick={randomizeAll} disabled={spinning}>
<Dices size={18} /> Рандом всем <Dices size={18} /> Рандом всем
</button> </button>
</div> </div>
@@ -137,28 +194,36 @@ export function CreateMatchPage() {
</select> </select>
<div className="row"> <div className="row">
<select {spin[idx] != null ? (
value={row.faction_id} <div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div>
onChange={(e) => ) : (
update(idx, { <select
faction_id: e.target.value ? Number(e.target.value) : "", value={row.faction_id}
was_random: false, onChange={(e) =>
}) update(idx, {
} faction_id: e.target.value ? Number(e.target.value) : "",
style={{ flex: 1 }} 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} /> <Dices size={18} />
</button> </button>
</div> </div>
{row.was_random && row.faction_id !== "" && <span className="badge">🎲 случайная</span>}
</div> </div>
))} ))}
+18 -15
View File
@@ -72,21 +72,24 @@ export function GroupSettingsPage() {
<div className="card"> <div className="card">
<h3>Дополнения</h3> <h3>Дополнения</h3>
<div className="stack"> <div className="stack">
{(expansions ?? []).map((e) => ( {(expansions ?? [])
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}> .filter((e) => !e.is_base)
<span> .map((e) => (
{e.name_ru} <label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
{e.is_base && <span className="muted small"> (всегда включено)</span>} <span>{e.name_ru}</span>
</span> <span className="switch">
<input <input
type="checkbox" type="checkbox"
style={{ width: "auto" }} disabled={!isOwner}
disabled={e.is_base || !isOwner} checked={selected.has(e.id)}
checked={e.is_base || selected.has(e.id)} onChange={() => toggle(e.id)}
onChange={() => toggle(e.id)} />
/> <span className="switch-track">
</label> <span className="switch-thumb" />
))} </span>
</span>
</label>
))}
</div> </div>
{isOwner && ( {isOwner && (
<button className="btn btn-primary" style={{ marginTop: 10 }} onClick={saveExpansions}> <button className="btn btn-primary" style={{ marginTop: 10 }} onClick={saveExpansions}>
-10
View File
@@ -93,16 +93,6 @@ export function LoginPage() {
{showStub && !pendingTg && <DevLogin onError={setError} />} {showStub && !pendingTg && <DevLogin onError={setError} />}
{error && <p className="error-text center">{error}</p>} {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>
</div> </div>
); );
+47 -4
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { MatchMedia } from "../components/MatchMedia"; import { MatchMedia } from "../components/MatchMedia";
import { Spinner } from "../components/Spinner"; import { Spinner } from "../components/Spinner";
import { formatDuration, formatTime } from "../domain/format"; import { formatDuration, formatTime } from "../domain/format";
@@ -20,6 +21,7 @@ interface FinishRow {
nickname: string; nickname: string;
faction_name: string; faction_name: string;
place: number; place: number;
eliminated: boolean;
comment: string; comment: string;
} }
@@ -38,6 +40,7 @@ export function MatchDetailPage() {
const [winReason, setWinReason] = useState<WinReason>("objectives"); const [winReason, setWinReason] = useState<WinReason>("objectives");
const [overall, setOverall] = useState(""); const [overall, setOverall] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [confirmRemove, setConfirmRemove] = useState(false);
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (!match) return <div className="muted">Партия не найдена.</div>; if (!match) return <div className="muted">Партия не найдена.</div>;
@@ -53,6 +56,7 @@ export function MatchDetailPage() {
nickname: p.nickname, nickname: p.nickname,
faction_name: p.faction_name, faction_name: p.faction_name,
place: i + 1, place: i + 1,
eliminated: false,
comment: p.comment ?? "", comment: p.comment ?? "",
})); }));
@@ -71,7 +75,8 @@ export function MatchDetailPage() {
body: { body: {
participants: finishRows.map((r) => ({ participants: finishRows.map((r) => ({
user_id: r.user_id, user_id: r.user_id,
place: r.place, place: r.eliminated ? null : r.place,
eliminated: r.eliminated,
comment: r.comment.trim() || null, comment: r.comment.trim() || null,
})), })),
win_reason: winReason, win_reason: winReason,
@@ -141,7 +146,15 @@ export function MatchDetailPage() {
{sorted.map((p) => ( {sorted.map((p) => (
<div key={p.user_id}> <div key={p.user_id}>
<div className="row" style={{ gap: 8 }}> <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> <b>{p.nickname}</b>
<span className="muted small">· {p.faction_name}</span> <span className="muted small">· {p.faction_name}</span>
{p.was_random && <span className="badge">🎲</span>} {p.was_random && <span className="badge">🎲</span>}
@@ -189,12 +202,20 @@ export function MatchDetailPage() {
<div className="row"> <div className="row">
<div style={{ flex: "0 0 130px" }}> <div style={{ flex: "0 0 130px" }}>
<label className="label">Место</label> <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) => ( {Array.from({ length: finishRows.length }, (_, k) => k + 1).map((n) => (
<option key={n} value={n}> <option key={n} value={n}>
{n} {n}
</option> </option>
))} ))}
<option value="elim">✕</option>
</select> </select>
</div> </div>
</div> </div>
@@ -252,10 +273,32 @@ export function MatchDetailPage() {
)} )}
{canModify && ( {canModify && (
<button className="btn btn-danger" style={{ width: "100%" }} onClick={remove}> <button
className="btn btn-danger"
style={{ width: "100%" }}
onClick={() => setConfirmRemove(true)}
>
{inProgress ? "Отменить партию" : "Удалить партию"} {inProgress ? "Отменить партию" : "Удалить партию"}
</button> </button>
)} )}
<ConfirmDialog
open={confirmRemove}
danger
title={inProgress ? "Отменить партию?" : "Удалить партию?"}
message={
inProgress
? "Незавершённая партия будет удалена. Действие необратимо."
: "Сыгранная партия и её результаты будут удалены. Действие необратимо."
}
confirmLabel={inProgress ? "Отменить партию" : "Удалить"}
cancelLabel="Назад"
onCancel={() => setConfirmRemove(false)}
onConfirm={() => {
setConfirmRemove(false);
remove();
}}
/>
</div> </div>
); );
} }
+14 -1
View File
@@ -19,6 +19,7 @@ interface Row {
nickname: string; nickname: string;
faction_id: number; faction_id: number;
place: number; place: number;
eliminated: boolean;
was_random: boolean; was_random: boolean;
comment: string; comment: string;
} }
@@ -46,6 +47,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
nickname: p.nickname, nickname: p.nickname,
faction_id: p.faction_id, faction_id: p.faction_id,
place: p.place ?? 1, place: p.place ?? 1,
eliminated: p.eliminated,
was_random: p.was_random, was_random: p.was_random,
comment: p.comment ?? "", comment: p.comment ?? "",
})), })),
@@ -71,7 +73,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
participants: rows.map((r) => ({ participants: rows.map((r) => ({
user_id: r.user_id, user_id: r.user_id,
faction_id: r.faction_id, faction_id: r.faction_id,
place: r.place, place: r.eliminated ? null : r.place,
eliminated: r.eliminated,
was_random: r.was_random, was_random: r.was_random,
comment: r.comment.trim() || null, comment: r.comment.trim() || null,
})), })),
@@ -133,9 +136,19 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
type="number" type="number"
min={1} min={1}
value={r.place} value={r.place}
disabled={r.eliminated}
onChange={(e) => upd(i, { place: Math.max(1, Number(e.target.value) || 1) })} onChange={(e) => upd(i, { place: Math.max(1, Number(e.target.value) || 1) })}
/> />
</div> </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> </div>
<input <input
placeholder="Комментарий об игроке" placeholder="Комментарий об игроке"
+88
View File
@@ -50,6 +50,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
border-radius: var(--radius); border-radius: var(--radius);
padding: 14px; padding: 14px;
margin-bottom: 12px; margin-bottom: 12px;
color: var(--text); /* карточки-кнопки (список партий) — белый текст по умолчанию */
} }
.btn { .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-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-ghost { background: transparent; } .btn-ghost { background: transparent; }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); } .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; } .btn[disabled] { opacity: 0.5; pointer-events: none; }
.row { display: flex; align-items: center; gap: 10px; } .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; } .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 { .toast {
position: fixed; position: fixed;
left: 50%; left: 50%;
@@ -121,6 +201,14 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
padding: 0 8px; padding: 0 8px;
} }
.top-bar-right { display: flex; align-items: center; } .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-wrap { position: relative; }
.notif-bell { .notif-bell {
+73 -5
View File
@@ -56,30 +56,35 @@
background: none; background: none;
border: none; border: none;
font-size: 12px; 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 .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); } .bottom-bar .spacer { width: var(--fab-width); flex: 0 0 var(--fab-width); }
/* Круг создания партии: полукруг-купол, по центру разделяет слоты, смещён вниз
(база у нижней кромки панели) и поднят на передний план (z-index выше панели). */
.fab { .fab {
position: absolute; position: absolute;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
bottom: calc(var(--bottombar-height) + var(--safe-bottom) - 2px); bottom: calc(var(--safe-bottom) + 8px);
width: var(--fab-width); width: var(--fab-width);
height: var(--fab-height); height: var(--fab-height);
border: 1px solid var(--accent); border: 1px solid var(--accent);
border-bottom: none; border-bottom: none;
border-top-left-radius: var(--fab-width); /* Радиус = половина ширины → ровный полукруг (ширина = 2× высоты). */
border-top-right-radius: var(--fab-width); 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-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
display: grid; display: grid;
place-items: center; place-items: center;
background: var(--accent); background: var(--accent);
color: #fff; 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"] { .fab[aria-disabled="true"] {
background: var(--surface-2); background: var(--surface-2);
border-color: var(--border); border-color: var(--border);
@@ -186,7 +191,70 @@
border-radius: 6px; border-radius: 6px;
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
color: var(--text); /* номер места — белым для контраста */
font-weight: 700; font-weight: 700;
font-size: 13px; font-size: 13px;
} }
.place-badge.first { background: var(--gold); color: #1a1a1a; border-color: var(--gold); } .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); }
+2 -2
View File
@@ -15,8 +15,8 @@
/* Размеры */ /* Размеры */
--app-max-w: 480px; --app-max-w: 480px;
--bottombar-height: 64px; --bottombar-height: 64px;
--fab-width: 88px; --fab-width: 125px;
--fab-height: 44px; --fab-height: 62.5px;
--radius: 14px; --radius: 14px;
--radius-sm: 10px; --radius-sm: 10px;
--safe-bottom: env(safe-area-inset-bottom, 0px); --safe-bottom: env(safe-area-inset-bottom, 0px);