Compare commits
11
Commits
8f4a58d33d
...
712dc529c3
| 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 авто = последнее место (отображается красным крестиком).
|
||||
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
|
||||
comment: str | None = Field(sa_column=Column(Text, nullable=True))
|
||||
created_at: datetime = Field(default_factory=_utcnow, nullable=False)
|
||||
|
||||
@@ -216,6 +216,7 @@ def update_match(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
|
||||
faction_id=f.id, # type: ignore[arg-type]
|
||||
faction_name=f.name_ru,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
@@ -138,7 +139,13 @@ def finish_match(
|
||||
match = match_service.get_match(session, match_id)
|
||||
match_service.assert_can_modify(session, match, user)
|
||||
finish = [
|
||||
FinishInput(user_id=p.user_id, place=p.place, comment=p.comment, faction_id=p.faction_id)
|
||||
FinishInput(
|
||||
user_id=p.user_id,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
comment=p.comment,
|
||||
faction_id=p.faction_id,
|
||||
)
|
||||
for p in body.participants
|
||||
]
|
||||
match = match_service.finish_match(
|
||||
@@ -194,6 +201,7 @@ def update_match(
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment,
|
||||
)
|
||||
|
||||
@@ -204,7 +204,8 @@ class MatchCreate(BaseModel):
|
||||
# Этап 2 (завершение): места, комментарии, причина победы.
|
||||
class MatchFinishParticipant(BaseModel):
|
||||
user_id: int
|
||||
place: int = Field(ge=1)
|
||||
place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated)
|
||||
eliminated: bool = False # выбыл из партии → авто-проставится последнее место
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
|
||||
@@ -221,7 +222,8 @@ class MatchFinish(BaseModel):
|
||||
class ParticipantInput(BaseModel):
|
||||
user_id: int
|
||||
faction_id: int
|
||||
place: int = Field(ge=1)
|
||||
place: int | None = Field(default=None, ge=1) # None у выбывшего (eliminated)
|
||||
eliminated: bool = False
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
|
||||
@@ -240,6 +242,7 @@ class MatchParticipantRead(BaseModel):
|
||||
faction_id: int
|
||||
faction_name: str
|
||||
place: int | None = None
|
||||
eliminated: bool = False
|
||||
was_random: bool
|
||||
comment: str | None = None
|
||||
|
||||
@@ -357,6 +360,7 @@ class MatchListParticipant(BaseModel):
|
||||
faction_id: int
|
||||
faction_name: str
|
||||
place: int | None = None
|
||||
eliminated: bool = False
|
||||
was_random: bool
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
@@ -38,7 +38,8 @@ class FinishInput:
|
||||
"""Результат участника на этапе завершения."""
|
||||
|
||||
user_id: int
|
||||
place: int
|
||||
place: int | None = None # None у выбывшего (eliminated) — место проставится авто
|
||||
eliminated: bool = False
|
||||
comment: str | None = None
|
||||
faction_id: int | None = None # опц. смена фракции при завершении
|
||||
|
||||
@@ -49,7 +50,8 @@ class ParticipantInput:
|
||||
|
||||
user_id: int
|
||||
faction_id: int
|
||||
place: int
|
||||
place: int | None = None
|
||||
eliminated: bool = False
|
||||
was_random: bool = False
|
||||
comment: str | None = None
|
||||
|
||||
@@ -129,6 +131,22 @@ def _validate_ranking(places: list[int]) -> None:
|
||||
expected = current + tie
|
||||
|
||||
|
||||
def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int, int]:
|
||||
"""rows: (user_id, place, eliminated) → {user_id: место}.
|
||||
|
||||
Выжившим (не eliminated) валидируем competition ranking (1,2,2,4). Выбывшим
|
||||
проставляем общее последнее место = len(survivors)+1 (ничья последнего ранга),
|
||||
чтобы подсчёт очков считал их как последнее место без отдельных правок SQL."""
|
||||
survivors = [(uid, place) for uid, place, elim in rows if not elim]
|
||||
if not survivors:
|
||||
raise InvalidRankingError("Хотя бы один игрок должен остаться в партии (с местом).")
|
||||
if any(place is None for _uid, place in survivors):
|
||||
raise InvalidRankingError("Укажите место каждому не выбывшему игроку.")
|
||||
_validate_ranking([place for _uid, place in survivors]) # type: ignore[misc]
|
||||
elim_place = len(survivors) + 1
|
||||
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
|
||||
|
||||
|
||||
def _group_member_ids(session: Session, group_id: int) -> set[int]:
|
||||
return {
|
||||
m.user_id
|
||||
@@ -235,11 +253,12 @@ def finish_match(
|
||||
if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)):
|
||||
raise FactionNotAvailableError()
|
||||
|
||||
_validate_ranking([f.place for f in finish])
|
||||
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
|
||||
|
||||
for f in finish:
|
||||
p = existing[f.user_id]
|
||||
p.place = f.place
|
||||
p.place = places[f.user_id]
|
||||
p.eliminated = f.eliminated
|
||||
p.comment = f.comment or None
|
||||
if f.faction_id is not None:
|
||||
p.faction_id = f.faction_id
|
||||
@@ -310,7 +329,9 @@ def update_match(
|
||||
[p.user_id for p in participants],
|
||||
[p.faction_id for p in participants],
|
||||
)
|
||||
_validate_ranking([p.place for p in participants])
|
||||
places = _resolve_finish_places(
|
||||
[(p.user_id, p.place, p.eliminated) for p in participants]
|
||||
)
|
||||
for old in session.exec(
|
||||
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
|
||||
).all():
|
||||
@@ -322,7 +343,8 @@ def update_match(
|
||||
match_id=match.id, # type: ignore[arg-type]
|
||||
user_id=p.user_id,
|
||||
faction_id=p.faction_id,
|
||||
place=p.place,
|
||||
place=places[p.user_id],
|
||||
eliminated=p.eliminated,
|
||||
was_random=p.was_random,
|
||||
comment=p.comment or None,
|
||||
)
|
||||
|
||||
@@ -297,6 +297,7 @@ def group_match_list(session: Session, group_id: int, limit: int = 20, offset: i
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"eliminated": p.eliminated,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
@@ -343,6 +344,7 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
||||
"faction_id": f.id,
|
||||
"faction_name": f.name_ru,
|
||||
"place": p.place,
|
||||
"eliminated": p.eliminated,
|
||||
"was_random": p.was_random,
|
||||
"comment": p.comment,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Выбывшие игроки: красный крестик = последнее место (засчитывается, без отд. «проигрыша»)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
|
||||
|
||||
|
||||
def _group(client: TestClient, name: str = "Гр") -> int:
|
||||
return client.post(
|
||||
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
|
||||
|
||||
def _start3(client: TestClient, engine):
|
||||
me = login(client, "Капитан")
|
||||
gid = _group(client)
|
||||
b = add_group_member(engine, gid, "Боец")
|
||||
c = add_group_member(engine, gid, "Третий")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
started = start_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[0]},
|
||||
{"user_id": b, "faction_id": fids[1]},
|
||||
{"user_id": c, "faction_id": fids[2]},
|
||||
],
|
||||
)
|
||||
assert started.status_code == 200, started.text
|
||||
return me, b, c, gid, started.json()["id"]
|
||||
|
||||
|
||||
def test_eliminated_gets_last_place(client: TestClient, engine):
|
||||
me, b, c, _gid, mid = _start3(client, engine)
|
||||
r = finish_match(
|
||||
client,
|
||||
mid,
|
||||
[
|
||||
{"user_id": me["id"], "place": 1},
|
||||
{"user_id": b, "place": 2},
|
||||
{"user_id": c, "eliminated": True}, # выбыл
|
||||
],
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
parts = {p["user_id"]: p for p in r.json()["participants"]}
|
||||
assert parts[c]["eliminated"] is True
|
||||
assert parts[c]["place"] == 3 # survivors=2 → последнее место 3
|
||||
assert parts[me["id"]]["eliminated"] is False
|
||||
# Выбывший — в конце списка результатов.
|
||||
assert r.json()["participants"][-1]["user_id"] == c
|
||||
|
||||
# Партия засчитана выбывшему как сыгранная (последнее место, 0 очков).
|
||||
login(client, "Третий")
|
||||
stats = client.get("/api/users/me/stats").json()
|
||||
assert stats["overall"]["games"] == 1
|
||||
assert stats["overall"]["wins"] == 0
|
||||
|
||||
|
||||
def test_all_eliminated_rejected(client: TestClient, engine):
|
||||
me, b, c, _gid, mid = _start3(client, engine)
|
||||
r = finish_match(
|
||||
client,
|
||||
mid,
|
||||
[
|
||||
{"user_id": me["id"], "eliminated": True},
|
||||
{"user_id": b, "eliminated": True},
|
||||
{"user_id": c, "eliminated": True},
|
||||
],
|
||||
)
|
||||
assert r.status_code == 422, r.text # должен остаться хотя бы один с местом
|
||||
Vendored
+22
-2
@@ -1566,7 +1566,12 @@ export interface components {
|
||||
/** User Id */
|
||||
user_id: number;
|
||||
/** Place */
|
||||
place: number;
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/** Comment */
|
||||
comment?: string | null;
|
||||
/** Faction Id */
|
||||
@@ -1626,6 +1631,11 @@ export interface components {
|
||||
faction_name: string;
|
||||
/** Place */
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/** Was Random */
|
||||
was_random: boolean;
|
||||
/** Comment */
|
||||
@@ -1643,6 +1653,11 @@ export interface components {
|
||||
faction_name: string;
|
||||
/** Place */
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/** Was Random */
|
||||
was_random: boolean;
|
||||
/** Comment */
|
||||
@@ -1816,7 +1831,12 @@ export interface components {
|
||||
/** Faction Id */
|
||||
faction_id: number;
|
||||
/** Place */
|
||||
place: number;
|
||||
place?: number | null;
|
||||
/**
|
||||
* Eliminated
|
||||
* @default false
|
||||
*/
|
||||
eliminated: boolean;
|
||||
/**
|
||||
* Was Random
|
||||
* @default false
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { useMe } from "../hooks/auth";
|
||||
@@ -5,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents";
|
||||
import { BottomBar } from "./BottomBar";
|
||||
import { NotificationBell } from "./NotificationBell";
|
||||
import { NotificationToaster } from "./NotificationToaster";
|
||||
import { SideMenu } from "./SideMenu";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
"/": "Forbidden Stars",
|
||||
@@ -19,6 +21,7 @@ export function AppShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { data: me } = useMe();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
useServerEvents(!!me); // живые обновления (SSE) только при наличии сессии
|
||||
const title =
|
||||
TITLES[location.pathname] ??
|
||||
@@ -29,13 +32,9 @@ export function AppShell() {
|
||||
<div className="app-shell">
|
||||
<header className="top-bar">
|
||||
<div className="top-bar-left">
|
||||
{isHome ? (
|
||||
<span className="title">{title}</span>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
|
||||
← Назад
|
||||
</button>
|
||||
)}
|
||||
<button className="top-bar-home" onClick={() => navigate("/")}>
|
||||
Forbidden Stars
|
||||
</button>
|
||||
</div>
|
||||
<span className="title small muted top-bar-center">{!isHome ? title : ""}</span>
|
||||
<div className="top-bar-right">
|
||||
@@ -49,7 +48,8 @@ export function AppShell() {
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<BottomBar />
|
||||
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||
<BottomBar onMenu={() => setMenuOpen(true)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,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 { useToast } from "../context/ToastContext";
|
||||
import { useMe } from "../hooks/auth";
|
||||
|
||||
export function BottomBar() {
|
||||
const ADMIN_HOLD_MS = 10000; // секретный вход в админку — удержание кнопки меню 10с
|
||||
|
||||
export function BottomBar({ onMenu }: { onMenu: () => void }) {
|
||||
const { data: me } = useMe();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const hasGroup = me?.active_group_id != null;
|
||||
const activeGroup = me?.groups.find((g) => g.id === me?.active_group_id);
|
||||
|
||||
// Долгое удержание кнопки меню → /admin/login; короткий тап → открыть боковое меню.
|
||||
const holdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const holdFired = useRef(false);
|
||||
|
||||
const startHold = () => {
|
||||
holdFired.current = false;
|
||||
holdTimer.current = setTimeout(() => {
|
||||
holdFired.current = true;
|
||||
navigate("/admin/login");
|
||||
}, ADMIN_HOLD_MS);
|
||||
};
|
||||
const clearHold = () => {
|
||||
if (holdTimer.current) {
|
||||
clearTimeout(holdTimer.current);
|
||||
holdTimer.current = null;
|
||||
}
|
||||
};
|
||||
const endHold = () => {
|
||||
clearHold();
|
||||
if (!holdFired.current) onMenu(); // обычный тап
|
||||
};
|
||||
|
||||
const onCreate = () => {
|
||||
if (!hasGroup) {
|
||||
toast.show("Вы не состоите в группе. Создайте или вступите в группу.");
|
||||
@@ -29,9 +54,16 @@ export function BottomBar() {
|
||||
|
||||
<div className="spacer" />
|
||||
|
||||
<button className="slot" onClick={() => navigate("/account")}>
|
||||
<Settings size={20} />
|
||||
<span>Аккаунт</span>
|
||||
<button
|
||||
className="slot"
|
||||
aria-label="Меню"
|
||||
onPointerDown={startHold}
|
||||
onPointerUp={endHold}
|
||||
onPointerLeave={clearHold}
|
||||
onPointerCancel={clearHold}
|
||||
>
|
||||
<Menu size={20} />
|
||||
<span>Меню</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Переиспользуемое модальное подтверждение (без нативного confirm()). */
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmLabel = "Подтвердить",
|
||||
cancelLabel = "Отмена",
|
||||
danger = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onCancel}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-title">{title}</div>
|
||||
{message && <p className="modal-msg">{message}</p>}
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-ghost" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
className={"btn " + (danger ? "btn-danger-solid" : "btn-primary")}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
onClick={() => navigate(`/match/${m.id}`)}
|
||||
>
|
||||
<div className="row-between">
|
||||
<b>{m.played_at}</b>
|
||||
<span className="muted small">{m.played_at}</span>
|
||||
{inProgress ? (
|
||||
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
|
||||
) : (
|
||||
@@ -33,8 +33,14 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
|
||||
{sorted.map((p) => (
|
||||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||||
<span className={"place-badge" + (p.place === 1 ? " first" : "")}>
|
||||
{p.place ?? "—"}
|
||||
<span
|
||||
className={
|
||||
"place-badge" +
|
||||
(p.place === 1 && !p.eliminated ? " first" : "") +
|
||||
(p.eliminated ? " eliminated" : "")
|
||||
}
|
||||
>
|
||||
{p.eliminated ? "✕" : (p.place ?? "—")}
|
||||
</span>
|
||||
<span>{p.nickname}</span>
|
||||
<span className="muted small">· {p.faction_name}</span>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { LogOut } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useLogout, useMe } from "../hooks/auth";
|
||||
import { useMyStats } from "../hooks/stats";
|
||||
import { Avatar } from "./Avatar";
|
||||
|
||||
/** Боковое меню, выезжающее из правого края. Сверху — кликабельная плашка аккаунта
|
||||
* (краткая сводка → /account), ниже — выход. Открытием управляет AppShell. */
|
||||
export function SideMenu({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { data: me } = useMe();
|
||||
const { data: stats } = useMyStats();
|
||||
const logout = useLogout();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goAccount = () => {
|
||||
onClose();
|
||||
navigate("/account");
|
||||
};
|
||||
|
||||
const doLogout = async () => {
|
||||
onClose();
|
||||
await logout.mutateAsync().catch(() => {});
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"drawer-overlay" + (open ? " open" : "")} onClick={onClose} />
|
||||
<aside className={"side-menu" + (open ? " open" : "")} aria-hidden={!open}>
|
||||
{me && (
|
||||
<button className="side-account" onClick={goAccount}>
|
||||
<Avatar url={me.avatar_url} nickname={me.nickname} size={48} />
|
||||
<span className="side-account-info">
|
||||
<b>{me.nickname}</b>
|
||||
<span className="muted small">
|
||||
игр {stats?.overall.games ?? 0} · побед {stats?.overall.wins ?? 0}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="side-menu-items">
|
||||
<button className="side-menu-item danger" onClick={doLogout}>
|
||||
<LogOut size={18} /> Выйти
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { GroupSwitcher } from "../components/GroupSwitcher";
|
||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
@@ -125,6 +124,7 @@ export function AccountPage() {
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
placeholder="Пара слов о себе…"
|
||||
style={{ resize: "none" }}
|
||||
value={bioValue}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
/>
|
||||
@@ -153,17 +153,9 @@ export function AccountPage() {
|
||||
|
||||
{stats && <ProfileStatsCard stats={stats} />}
|
||||
|
||||
<GroupSwitcher />
|
||||
|
||||
<button className="btn btn-danger" style={{ width: "100%" }} onClick={doLogout}>
|
||||
Выйти
|
||||
</button>
|
||||
|
||||
<p className="center" style={{ marginTop: 24 }}>
|
||||
<button className="btn btn-ghost small muted" onClick={() => navigate("/admin/login")}>
|
||||
Вход для администратора
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export function CreateMatchPage() {
|
||||
|
||||
const [rows, setRows] = useState<Draft[]>([emptyDraft(), emptyDraft()]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Анимация-«барабан» рандома: для крутящейся строки — текущее мелькающее название.
|
||||
const [spin, setSpin] = useState<Record<number, string | null>>({});
|
||||
|
||||
if (lm || lf) return <Spinner />;
|
||||
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 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 {
|
||||
const faction = await randomize.mutateAsync({
|
||||
group_id: groupId,
|
||||
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 });
|
||||
} catch (e) {
|
||||
clearInterval(ticker);
|
||||
setSpin((s) => ({ ...s, [idx]: null }));
|
||||
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
||||
}
|
||||
};
|
||||
@@ -137,28 +151,36 @@ export function CreateMatchPage() {
|
||||
</select>
|
||||
|
||||
<div className="row">
|
||||
<select
|
||||
value={row.faction_id}
|
||||
onChange={(e) =>
|
||||
update(idx, {
|
||||
faction_id: e.target.value ? Number(e.target.value) : "",
|
||||
was_random: false,
|
||||
})
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
{spin[idx] != null ? (
|
||||
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div>
|
||||
) : (
|
||||
<select
|
||||
value={row.faction_id}
|
||||
onChange={(e) =>
|
||||
update(idx, {
|
||||
faction_id: e.target.value ? Number(e.target.value) : "",
|
||||
was_random: false,
|
||||
})
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<option value="">— фракция —</option>
|
||||
{(factions ?? []).map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name_ru}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => randomizeRow(idx)}
|
||||
disabled={spin[idx] != null}
|
||||
title="Случайная фракция"
|
||||
>
|
||||
<option value="">— фракция —</option>
|
||||
{(factions ?? []).map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name_ru}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn" onClick={() => randomizeRow(idx)} title="Случайная фракция">
|
||||
<Dices size={18} />
|
||||
</button>
|
||||
</div>
|
||||
{row.was_random && row.faction_id !== "" && <span className="badge">🎲 случайная</span>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
@@ -72,21 +72,24 @@ export function GroupSettingsPage() {
|
||||
<div className="card">
|
||||
<h3>Дополнения</h3>
|
||||
<div className="stack">
|
||||
{(expansions ?? []).map((e) => (
|
||||
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span>
|
||||
{e.name_ru}
|
||||
{e.is_base && <span className="muted small"> (всегда включено)</span>}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: "auto" }}
|
||||
disabled={e.is_base || !isOwner}
|
||||
checked={e.is_base || selected.has(e.id)}
|
||||
onChange={() => toggle(e.id)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
{(expansions ?? [])
|
||||
.filter((e) => !e.is_base)
|
||||
.map((e) => (
|
||||
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
|
||||
<span>{e.name_ru}</span>
|
||||
<span className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={!isOwner}
|
||||
checked={selected.has(e.id)}
|
||||
onChange={() => toggle(e.id)}
|
||||
/>
|
||||
<span className="switch-track">
|
||||
<span className="switch-thumb" />
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button className="btn btn-primary" style={{ marginTop: 10 }} onClick={saveExpansions}>
|
||||
|
||||
@@ -93,16 +93,6 @@ export function LoginPage() {
|
||||
{showStub && !pendingTg && <DevLogin onError={setError} />}
|
||||
|
||||
{error && <p className="error-text center">{error}</p>}
|
||||
|
||||
<p className="center" style={{ marginTop: 40 }}>
|
||||
<button
|
||||
className="btn btn-ghost small"
|
||||
onClick={() => navigate("/admin/login")}
|
||||
style={{ opacity: 0.4 }}
|
||||
>
|
||||
·
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { MatchMedia } from "../components/MatchMedia";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { formatDuration, formatTime } from "../domain/format";
|
||||
@@ -20,6 +21,7 @@ interface FinishRow {
|
||||
nickname: string;
|
||||
faction_name: string;
|
||||
place: number;
|
||||
eliminated: boolean;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
@@ -38,6 +40,7 @@ export function MatchDetailPage() {
|
||||
const [winReason, setWinReason] = useState<WinReason>("objectives");
|
||||
const [overall, setOverall] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
||||
@@ -53,6 +56,7 @@ export function MatchDetailPage() {
|
||||
nickname: p.nickname,
|
||||
faction_name: p.faction_name,
|
||||
place: i + 1,
|
||||
eliminated: false,
|
||||
comment: p.comment ?? "",
|
||||
}));
|
||||
|
||||
@@ -71,7 +75,8 @@ export function MatchDetailPage() {
|
||||
body: {
|
||||
participants: finishRows.map((r) => ({
|
||||
user_id: r.user_id,
|
||||
place: r.place,
|
||||
place: r.eliminated ? null : r.place,
|
||||
eliminated: r.eliminated,
|
||||
comment: r.comment.trim() || null,
|
||||
})),
|
||||
win_reason: winReason,
|
||||
@@ -141,7 +146,15 @@ export function MatchDetailPage() {
|
||||
{sorted.map((p) => (
|
||||
<div key={p.user_id}>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<span className={"place-badge" + (p.place === 1 ? " first" : "")}>{p.place ?? "—"}</span>
|
||||
<span
|
||||
className={
|
||||
"place-badge" +
|
||||
(p.place === 1 && !p.eliminated ? " first" : "") +
|
||||
(p.eliminated ? " eliminated" : "")
|
||||
}
|
||||
>
|
||||
{p.eliminated ? "✕" : (p.place ?? "—")}
|
||||
</span>
|
||||
<b>{p.nickname}</b>
|
||||
<span className="muted small">· {p.faction_name}</span>
|
||||
{p.was_random && <span className="badge">🎲</span>}
|
||||
@@ -189,12 +202,20 @@ export function MatchDetailPage() {
|
||||
<div className="row">
|
||||
<div style={{ flex: "0 0 130px" }}>
|
||||
<label className="label">Место</label>
|
||||
<select value={r.place} onChange={(e) => upd(i, { place: Number(e.target.value) })}>
|
||||
<select
|
||||
value={r.eliminated ? "elim" : String(r.place)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "elim") upd(i, { eliminated: true });
|
||||
else upd(i, { eliminated: false, place: Number(v) });
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: finishRows.length }, (_, k) => k + 1).map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
<option value="elim">✕ выбыл</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -252,10 +273,32 @@ export function MatchDetailPage() {
|
||||
)}
|
||||
|
||||
{canModify && (
|
||||
<button className="btn btn-danger" style={{ width: "100%" }} onClick={remove}>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
style={{ width: "100%" }}
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
>
|
||||
{inProgress ? "Отменить партию" : "Удалить партию"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmRemove}
|
||||
danger
|
||||
title={inProgress ? "Отменить партию?" : "Удалить партию?"}
|
||||
message={
|
||||
inProgress
|
||||
? "Незавершённая партия будет удалена. Действие необратимо."
|
||||
: "Сыгранная партия и её результаты будут удалены. Действие необратимо."
|
||||
}
|
||||
confirmLabel={inProgress ? "Отменить партию" : "Удалить"}
|
||||
cancelLabel="Назад"
|
||||
onCancel={() => setConfirmRemove(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmRemove(false);
|
||||
remove();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface Row {
|
||||
nickname: string;
|
||||
faction_id: number;
|
||||
place: number;
|
||||
eliminated: boolean;
|
||||
was_random: boolean;
|
||||
comment: string;
|
||||
}
|
||||
@@ -46,6 +47,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
nickname: p.nickname,
|
||||
faction_id: p.faction_id,
|
||||
place: p.place ?? 1,
|
||||
eliminated: p.eliminated,
|
||||
was_random: p.was_random,
|
||||
comment: p.comment ?? "",
|
||||
})),
|
||||
@@ -71,7 +73,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
participants: rows.map((r) => ({
|
||||
user_id: r.user_id,
|
||||
faction_id: r.faction_id,
|
||||
place: r.place,
|
||||
place: r.eliminated ? null : r.place,
|
||||
eliminated: r.eliminated,
|
||||
was_random: r.was_random,
|
||||
comment: r.comment.trim() || null,
|
||||
})),
|
||||
@@ -133,9 +136,19 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
type="number"
|
||||
min={1}
|
||||
value={r.place}
|
||||
disabled={r.eliminated}
|
||||
onChange={(e) => upd(i, { place: Math.max(1, Number(e.target.value) || 1) })}
|
||||
/>
|
||||
</div>
|
||||
<label className="row small" style={{ flex: "0 0 auto", gap: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
style={{ width: "auto" }}
|
||||
checked={r.eliminated}
|
||||
onChange={(e) => upd(i, { eliminated: e.target.checked })}
|
||||
/>
|
||||
выбыл
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Комментарий об игроке"
|
||||
|
||||
@@ -50,6 +50,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text); /* карточки-кнопки (список партий) — белый текст по умолчанию */
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -68,6 +69,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-ghost { background: transparent; }
|
||||
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
|
||||
.btn-danger-solid { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||
.btn[disabled] { opacity: 0.5; pointer-events: none; }
|
||||
|
||||
.row { display: flex; align-items: center; gap: 10px; }
|
||||
@@ -92,6 +94,84 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
|
||||
.error-text { color: var(--danger); font-size: 13px; }
|
||||
|
||||
/* Модальное окно подтверждения */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 300;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.modal-title { font-weight: 700; font-size: 16px; margin-bottom: 8px; }
|
||||
.modal-msg { color: var(--text-muted); font-size: 14px; margin: 0 0 16px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
/* «Барабан» рандома фракции — мелькающее название вместо select */
|
||||
.faction-spin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
animation: faction-roll 0.45s ease-in-out infinite;
|
||||
}
|
||||
@keyframes faction-roll {
|
||||
0% { opacity: 0.5; transform: translateY(-3px); }
|
||||
50% { opacity: 1; transform: translateY(0); }
|
||||
100% { opacity: 0.5; transform: translateY(3px); }
|
||||
}
|
||||
|
||||
/* Тумблер-переключатель (вкл/выкл) */
|
||||
.switch { position: relative; display: inline-block; width: 44px; height: 26px; flex: 0 0 auto; }
|
||||
.switch input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch-track {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 44px;
|
||||
height: 26px;
|
||||
padding: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.switch-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--text);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.switch input:checked + .switch-track { background: var(--accent); border-color: var(--accent); }
|
||||
.switch input:checked + .switch-track .switch-thumb { transform: translateX(18px); background: #fff; }
|
||||
.switch input:disabled + .switch-track { opacity: 0.5; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
@@ -121,6 +201,14 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
padding: 0 8px;
|
||||
}
|
||||
.top-bar-right { display: flex; align-items: center; }
|
||||
.top-bar-home {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.notif-bell-wrap { position: relative; }
|
||||
.notif-bell {
|
||||
|
||||
@@ -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 .spacer { width: var(--fab-width); flex: 0 0 var(--fab-width); }
|
||||
|
||||
/* Круг создания партии: полукруг-купол, по центру разделяет слоты, смещён вниз
|
||||
(база у нижней кромки панели) и поднят на передний план (z-index выше панели). */
|
||||
.fab {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(var(--bottombar-height) + var(--safe-bottom) - 2px);
|
||||
bottom: calc(var(--safe-bottom) + 8px);
|
||||
width: var(--fab-width);
|
||||
height: var(--fab-height);
|
||||
border: 1px solid var(--accent);
|
||||
border-bottom: none;
|
||||
border-top-left-radius: var(--fab-width);
|
||||
border-top-right-radius: var(--fab-width);
|
||||
/* Радиус = половина ширины → ровный полукруг (ширина = 2× высоты). */
|
||||
border-top-left-radius: calc(var(--fab-width) / 2);
|
||||
border-top-right-radius: calc(var(--fab-width) / 2);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 -4px 14px rgba(224, 83, 61, 0.45);
|
||||
box-shadow: 0 -4px 16px rgba(224, 83, 61, 0.5);
|
||||
z-index: 25;
|
||||
}
|
||||
.fab svg { margin-top: 6px; }
|
||||
.fab svg { margin-top: 8px; }
|
||||
.fab[aria-disabled="true"] {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border);
|
||||
@@ -186,7 +190,70 @@
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text); /* номер места — белым для контраста */
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
.place-badge.first { background: var(--gold); color: #1a1a1a; border-color: var(--gold); }
|
||||
.place-badge.eliminated { background: transparent; color: var(--danger); border-color: var(--danger); }
|
||||
|
||||
/* ─── Боковое меню (выезжает справа) ─────────────────────────────────────────── */
|
||||
.drawer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 60;
|
||||
}
|
||||
.drawer-overlay.open { opacity: 1; pointer-events: auto; }
|
||||
|
||||
.side-menu {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: min(300px, 84vw);
|
||||
background: var(--surface);
|
||||
border-left: 1px solid var(--border);
|
||||
box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.22s ease;
|
||||
z-index: 61;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: var(--safe-top);
|
||||
}
|
||||
.side-menu.open { transform: translateX(0); }
|
||||
|
||||
.side-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
.side-account-info { display: flex; flex-direction: column; min-width: 0; }
|
||||
.side-account-info b { font-size: 16px; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.side-menu-items { display: flex; flex-direction: column; padding: 8px; }
|
||||
.side-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.side-menu-item.danger { color: var(--danger); }
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
/* Размеры */
|
||||
--app-max-w: 480px;
|
||||
--bottombar-height: 64px;
|
||||
--fab-width: 88px;
|
||||
--fab-height: 44px;
|
||||
--fab-width: 100px;
|
||||
--fab-height: 50px;
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
|
||||
Reference in New Issue
Block a user