Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
712dc529c3 | ||
|
|
395b134c9b | ||
|
|
7269afcd4e | ||
|
|
4d365a373b | ||
|
|
1438a17441 | ||
|
|
cd7e5d16b4 | ||
|
|
c39c96d5dc | ||
|
|
28c0358b45 | ||
|
|
d32237975d | ||
|
|
460b57e792 | ||
|
|
6196c39d25 |
@@ -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: 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)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 # должен остаться хотя бы один с местом
|
||||||
Vendored
+22
-2
@@ -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
|
||||||
|
|||||||
@@ -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 className="btn btn-ghost" onClick={() => navigate(-1)}>
|
|
||||||
← Назад
|
|
||||||
</button>
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,41 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const endHold = () => {
|
||||||
|
clearHold();
|
||||||
|
if (!holdFired.current) onMenu(); // обычный тап
|
||||||
|
};
|
||||||
|
|
||||||
const onCreate = () => {
|
const onCreate = () => {
|
||||||
if (!hasGroup) {
|
if (!hasGroup) {
|
||||||
toast.show("Вы не состоите в группе. Создайте или вступите в группу.");
|
toast.show("Вы не состоите в группе. Создайте или вступите в группу.");
|
||||||
@@ -29,9 +54,16 @@ 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="Меню"
|
||||||
|
onPointerDown={startHold}
|
||||||
|
onPointerUp={endHold}
|
||||||
|
onPointerLeave={clearHold}
|
||||||
|
onPointerCancel={clearHold}
|
||||||
|
>
|
||||||
|
<Menu size={20} />
|
||||||
|
<span>Меню</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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}`)}
|
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>
|
||||||
|
|||||||
@@ -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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ 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>>({});
|
||||||
|
|
||||||
if (lm || lf) return <Spinner />;
|
if (lm || lf) return <Spinner />;
|
||||||
if (!groupId) return <div className="muted">Нет активной группы.</div>;
|
if (!groupId) return <div className="muted">Нет активной группы.</div>;
|
||||||
@@ -51,13 +53,25 @@ export function CreateMatchPage() {
|
|||||||
const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx));
|
const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx));
|
||||||
|
|
||||||
const randomizeRow = async (idx: number) => {
|
const randomizeRow = async (idx: number) => {
|
||||||
|
const pool = factions ?? [];
|
||||||
|
if (pool.length === 0) return;
|
||||||
|
// Запускаем «барабан»: быстро мелькаем названиями фракций.
|
||||||
|
const ticker = setInterval(() => {
|
||||||
|
const name = pool[Math.floor(Math.random() * pool.length)].name_ru;
|
||||||
|
setSpin((s) => ({ ...s, [idx]: name }));
|
||||||
|
}, 60);
|
||||||
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),
|
||||||
});
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 700)); // даём барабану покрутиться
|
||||||
|
clearInterval(ticker);
|
||||||
|
setSpin((s) => ({ ...s, [idx]: null }));
|
||||||
update(idx, { faction_id: faction.id, was_random: true });
|
update(idx, { faction_id: faction.id, was_random: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
clearInterval(ticker);
|
||||||
|
setSpin((s) => ({ ...s, [idx]: null }));
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -137,6 +151,9 @@ export function CreateMatchPage() {
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
|
{spin[idx] != null ? (
|
||||||
|
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div>
|
||||||
|
) : (
|
||||||
<select
|
<select
|
||||||
value={row.faction_id}
|
value={row.faction_id}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -154,11 +171,16 @@ export function CreateMatchPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<button className="btn" onClick={() => randomizeRow(idx)} title="Случайная фракция">
|
)}
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => randomizeRow(idx)}
|
||||||
|
disabled={spin[idx] != null}
|
||||||
|
title="Случайная фракция"
|
||||||
|
>
|
||||||
<Dices size={18} />
|
<Dices size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{row.was_random && row.faction_id !== "" && <span className="badge">🎲 случайная</span>}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -72,19 +72,22 @@ export function GroupSettingsPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Дополнения</h3>
|
<h3>Дополнения</h3>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{(expansions ?? []).map((e) => (
|
{(expansions ?? [])
|
||||||
|
.filter((e) => !e.is_base)
|
||||||
|
.map((e) => (
|
||||||
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
|
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
|
||||||
<span>
|
<span>{e.name_ru}</span>
|
||||||
{e.name_ru}
|
<span className="switch">
|
||||||
{e.is_base && <span className="muted small"> (всегда включено)</span>}
|
|
||||||
</span>
|
|
||||||
<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">
|
||||||
|
<span className="switch-thumb" />
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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="Комментарий об игроке"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -60,26 +60,30 @@
|
|||||||
.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 +190,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); }
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
/* Размеры */
|
/* Размеры */
|
||||||
--app-max-w: 480px;
|
--app-max-w: 480px;
|
||||||
--bottombar-height: 64px;
|
--bottombar-height: 64px;
|
||||||
--fab-width: 88px;
|
--fab-width: 100px;
|
||||||
--fab-height: 44px;
|
--fab-height: 50px;
|
||||||
--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);
|
||||||
|
|||||||
Reference in New Issue
Block a user