Многие тосты повторяли то, что пользователь и так видит сразу после действия: новую фракцию или аватар в профиле, переход на страницу партии, исчезнувшую карточку приглашения, закрывшийся редактор. Такой шум приучает не читать всплывашки, в том числе ошибки. Убраны 19 таких тостов в профиле, группах, партиях и админке. Остались ошибки, предупреждения (лимит фото, повторы фракций, конфликт правок, «вы не в группе») и сообщения о действиях без видимого следа: приглашение отправлено, дополнения сохранены, вступление в группу, пароль задан. Сохранение текстового поля, где заметна только погасшая кнопка, тоже осталось. По просьбе владельца оставшиеся тосты показываются не нижней «таблеткой», а всплывашкой сверху, как входящие уведомления. ToastProvider держит одну стопку на всё приложение: show — обычное сообщение, error — ошибка с красной полосой (держится 5 с), notify — уведомление с переходом по клику. Одинаковое сообщение не копится, а заменяет прежнее; клик по карточке закрывает её. NotificationToaster больше не рисует свою стопку, а отдаёт новые уведомления в общую, поэтому они не наезжают на сообщения. Все вызовы с ApiError и отказы (лимит фото, партия изменилась на другом устройстве) переведены на toast.error. Нижний класс .toast удалён. #41 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
import { Dices, Trash2 } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import { ApiError } from "../api/client";
|
|
import { Avatar } from "../components/Avatar";
|
|
import { PickerSelect } from "../components/PickerSelect";
|
|
import { Spinner } from "../components/Spinner";
|
|
import { MAX_MATCH_PLAYERS } from "../domain/constants";
|
|
import { useToast } from "../context/ToastContext";
|
|
import type { MemberRead } from "../domain/types";
|
|
import { useMe } from "../hooks/auth";
|
|
import { useGroupFactions } from "../hooks/reference";
|
|
import { useGroupMembers } from "../hooks/groups";
|
|
import { useRandomizeFaction, useStartMatch } from "../hooks/matches";
|
|
|
|
interface Draft {
|
|
// Стабильный ключ строки: индексы сдвигаются при удалении, а барабан рандома
|
|
// отложенно дописывает результат в свою строку и промахнулся бы по соседа.
|
|
id: number;
|
|
user_id: number | "";
|
|
faction_id: number | "";
|
|
was_random: boolean;
|
|
}
|
|
|
|
let draftSeq = 0;
|
|
|
|
function emptyDraft(): Draft {
|
|
// Счётчик монотонный на весь модуль: id нужны лишь уникальные в пределах
|
|
// страницы, а сквозная нумерация избавляет от коллизий при перемонтировании.
|
|
draftSeq += 1;
|
|
return { id: draftSeq, user_id: "", faction_id: "", was_random: false };
|
|
}
|
|
|
|
export function CreateMatchPage() {
|
|
const { data: me } = useMe();
|
|
const groupId = me?.active_group_id ?? null;
|
|
const { data: members, isLoading: lm } = useGroupMembers(groupId);
|
|
const { data: factions, isLoading: lf } = useGroupFactions(groupId);
|
|
const startMatch = useStartMatch();
|
|
const randomize = useRandomizeFaction();
|
|
const toast = useToast();
|
|
const navigate = useNavigate();
|
|
|
|
const [rows, setRows] = useState<Draft[]>([emptyDraft(), emptyDraft()]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// Анимация-«барабан» рандома: для крутящейся строки — текущее мелькающее название.
|
|
const [spin, setSpin] = useState<Record<number, string | null>>({});
|
|
const reelTimers = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
|
|
|
|
// Снимаем таймеры барабана при размонтировании.
|
|
useEffect(
|
|
() => () => {
|
|
Object.values(reelTimers.current).forEach((t) => clearTimeout(t));
|
|
},
|
|
[],
|
|
);
|
|
|
|
if (lm || lf) return <Spinner />;
|
|
if (!groupId) return <div className="muted">Нет активной группы.</div>;
|
|
|
|
const takenFactionIds = (exceptId: number) =>
|
|
rows
|
|
.filter((r) => r.id !== exceptId)
|
|
.map((r) => r.faction_id)
|
|
.filter((x): x is number => typeof x === "number");
|
|
|
|
// Игроки, уже выбранные в других строках, — в списке не предлагаются.
|
|
const takenUserIds = (exceptId: number) =>
|
|
rows
|
|
.filter((r) => r.id !== exceptId)
|
|
.map((r) => r.user_id)
|
|
.filter((x): x is number => typeof x === "number");
|
|
|
|
const update = (id: number, patch: Partial<Draft>) =>
|
|
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
|
|
|
const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
|
|
const removeRow = (id: number) => {
|
|
const timer = reelTimers.current[id];
|
|
if (timer) clearTimeout(timer);
|
|
delete reelTimers.current[id];
|
|
setSpin((sp) => {
|
|
const { [id]: _dropped, ...rest } = sp;
|
|
return rest;
|
|
});
|
|
setRows((rs) => rs.filter((r) => r.id !== id));
|
|
};
|
|
|
|
// «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление),
|
|
// затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только
|
|
// после этого фракция «выбирается» (onReveal: показываем select с результатом).
|
|
const animateReel = (
|
|
id: number,
|
|
names: string[],
|
|
finalName: string,
|
|
onReveal: () => void,
|
|
): Promise<void> =>
|
|
new Promise((resolve) => {
|
|
let delay = 50;
|
|
const tick = () => {
|
|
setSpin((s) => ({ ...s, [id]: names[Math.floor(Math.random() * names.length)] }));
|
|
delay *= 1.18;
|
|
if (delay < 300) {
|
|
reelTimers.current[id] = setTimeout(tick, delay);
|
|
} else {
|
|
reelTimers.current[id] = setTimeout(() => {
|
|
setSpin((s) => ({ ...s, [id]: finalName })); // остановка на выбранной
|
|
reelTimers.current[id] = setTimeout(() => {
|
|
onReveal();
|
|
setSpin((s) => ({ ...s, [id]: null }));
|
|
resolve();
|
|
}, 500); // пауза перед «выбором»
|
|
}, delay);
|
|
}
|
|
};
|
|
tick();
|
|
});
|
|
|
|
const randomizeRow = async (id: number) => {
|
|
const pool = factions ?? [];
|
|
if (pool.length === 0 || spin[id] != null) return;
|
|
try {
|
|
const faction = await randomize.mutateAsync({
|
|
group_id: groupId,
|
|
exclude_faction_ids: takenFactionIds(id),
|
|
});
|
|
await animateReel(id, pool.map((f) => f.name_ru), faction.name_ru, () =>
|
|
update(id, { faction_id: faction.id, was_random: true }),
|
|
);
|
|
} catch (e) {
|
|
if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
|
|
setSpin((s) => ({ ...s, [id]: null }));
|
|
toast.error(e instanceof ApiError ? e.message : "Ошибка рандома");
|
|
}
|
|
};
|
|
|
|
const randomizeAll = async () => {
|
|
const all = factions ?? [];
|
|
if (all.length === 0) return;
|
|
const pool = [...all];
|
|
for (let i = pool.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[pool[i], pool[j]] = [pool[j], pool[i]];
|
|
}
|
|
if (pool.length < rows.length) toast.show("Фракций меньше, чем игроков — возможны повторы.");
|
|
const names = all.map((f) => f.name_ru);
|
|
// Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
|
|
await Promise.all(
|
|
rows.map((row, idx) => {
|
|
const f = pool[idx % pool.length];
|
|
return animateReel(row.id, names, f.name_ru, () =>
|
|
update(row.id, { faction_id: f.id, was_random: true }),
|
|
);
|
|
}),
|
|
);
|
|
};
|
|
|
|
const submit = async () => {
|
|
setError(null);
|
|
const filled = rows.filter((r) => r.user_id !== "" && r.faction_id !== "");
|
|
if (filled.length < 2) {
|
|
setError("Нужно минимум 2 игрока с выбранной фракцией.");
|
|
return;
|
|
}
|
|
if (filled.length > MAX_MATCH_PLAYERS) {
|
|
setError(`Максимум ${MAX_MATCH_PLAYERS} игроков в партии.`);
|
|
return;
|
|
}
|
|
try {
|
|
const match = await startMatch.mutateAsync({
|
|
group_id: groupId,
|
|
participants: filled.map((r) => ({
|
|
user_id: r.user_id as number,
|
|
faction_id: r.faction_id as number,
|
|
was_random: r.was_random,
|
|
})),
|
|
});
|
|
navigate(`/match/${match.id}`, { replace: true });
|
|
} catch (e) {
|
|
setError(e instanceof ApiError ? e.message : "Не удалось начать партию");
|
|
}
|
|
};
|
|
|
|
const memberOptions = (members ?? []) as MemberRead[];
|
|
const spinning = Object.values(spin).some((v) => v != null);
|
|
|
|
// Опции выпадашек. Игрок — аватар + ник (в триггере аватар мельче, чтобы
|
|
// поле не разъезжалось по высоте с соседними).
|
|
const playerOptions = memberOptions.map((m) => ({
|
|
id: m.user_id,
|
|
nickname: m.nickname,
|
|
avatar_url: m.avatar_url,
|
|
}));
|
|
const playerLabel = (m: { nickname: string; avatar_url?: string | null }, size: number) => (
|
|
<>
|
|
<Avatar url={m.avatar_url} nickname={m.nickname} size={size} />
|
|
<span className="nick">{m.nickname}</span>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
<div className="card">
|
|
<h3>Этап 1 — старт партии</h3>
|
|
<p className="muted small">Выберите игроков и фракции. Места и итоги укажете при завершении.</p>
|
|
<button className="btn" onClick={randomizeAll} disabled={spinning}>
|
|
<Dices size={18} /> Рандом всем
|
|
</button>
|
|
</div>
|
|
|
|
{rows.map((row, idx) => (
|
|
<div className="participant-row" key={row.id}>
|
|
<div className="row-between">
|
|
<b>Игрок {idx + 1}</b>
|
|
{rows.length > 2 && (
|
|
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(row.id)}>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<PickerSelect
|
|
selected={playerOptions.find((m) => m.id === row.user_id) ?? null}
|
|
options={playerOptions.filter((m) => !takenUserIds(row.id).includes(m.id))}
|
|
placeholder="— игрок —"
|
|
renderOption={(m) => playerLabel(m, 28)}
|
|
renderValue={(m) => playerLabel(m, 24)}
|
|
onPick={(m) => update(row.id, { user_id: m.id })}
|
|
/>
|
|
|
|
<div className="row">
|
|
{spin[row.id] != null ? (
|
|
<div className="faction-spin" style={{ flex: 1 }}>{spin[row.id]}</div>
|
|
) : (
|
|
<PickerSelect
|
|
selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null}
|
|
options={(factions ?? []).filter((f) => !takenFactionIds(row.id).includes(f.id))}
|
|
placeholder="— фракция —"
|
|
renderOption={(f) => f.name_ru}
|
|
onPick={(f) => update(row.id, { faction_id: f.id, was_random: false })}
|
|
/>
|
|
)}
|
|
<button
|
|
className="btn"
|
|
onClick={() => randomizeRow(row.id)}
|
|
disabled={spin[row.id] != null}
|
|
title="Случайная фракция"
|
|
>
|
|
<Dices size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{rows.length < Math.min(MAX_MATCH_PLAYERS, memberOptions.length) && (
|
|
<button className="btn btn-ghost" onClick={addRow}>
|
|
+ Добавить игрока
|
|
</button>
|
|
)}
|
|
|
|
{error && <p className="error-text">{error}</p>}
|
|
|
|
<button
|
|
className="btn btn-primary btn-block"
|
|
style={{ marginTop: 12 }}
|
|
onClick={submit}
|
|
disabled={startMatch.isPending}
|
|
>
|
|
Начать партию
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|