Тосты: убрать лишние, остальные показывать как уведомления сверху

Многие тосты повторяли то, что пользователь и так видит сразу после действия:
новую фракцию или аватар в профиле, переход на страницу партии, исчезнувшую
карточку приглашения, закрывшийся редактор. Такой шум приучает не читать
всплывашки, в том числе ошибки. Убраны 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
This commit is contained in:
2026-09-13 15:45:34 +03:00
co-authored by Claude Opus 5
parent 0cbee9d866
commit 3587fc8af2
18 changed files with 129 additions and 115 deletions
@@ -1,7 +1,6 @@
import { useState } from "react";
import { ApiError } from "../api/client";
import { useToast } from "../context/ToastContext";
import { useCreateGroup } from "../hooks/groups";
import { useExpansions } from "../hooks/reference";
import { Switch } from "./Switch";
@@ -9,7 +8,6 @@ import { Switch } from "./Switch";
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
const { data: expansions } = useExpansions();
const createGroup = useCreateGroup();
const toast = useToast();
const [name, setName] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [error, setError] = useState<string | null>(null);
@@ -25,7 +23,6 @@ export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
setError(null);
try {
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
toast.show("Группа создана");
onCreated?.();
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
+2 -3
View File
@@ -15,16 +15,15 @@ export function GroupInvitations() {
await accept.mutateAsync(id);
toast.show("Вы вступили в группу");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
const onDecline = async (id: number) => {
try {
await decline.mutateAsync(id);
toast.show("Приглашение отклонено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
+3 -3
View File
@@ -35,14 +35,14 @@ export function MatchMedia({
let slots = max - attachments.length;
for (const f of files) {
if (slots <= 0) {
toast.show(`Максимум ${max} фото`);
toast.error(`Максимум ${max} фото`);
break;
}
try {
await onUpload(f);
slots--;
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
break;
}
}
@@ -53,7 +53,7 @@ export function MatchMedia({
try {
await onDelete(id);
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
}
};
+16 -30
View File
@@ -1,18 +1,17 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useNotifications, type Notification } from "../hooks/notifications";
const TOAST_TTL_MS = 5000;
const MAX_VISIBLE = 4;
import { useToast } from "../context/ToastContext";
import { useNotifications } from "../hooks/notifications";
/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull).
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id. */
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id.
* Показывает их общая стопка ToastProvider — там же, где сообщения приложения. */
export function NotificationToaster({ enabled }: { enabled: boolean }) {
const { data } = useNotifications(enabled);
const navigate = useNavigate();
const toast = useToast();
const seen = useRef<Set<number> | null>(null);
const [toasts, setToasts] = useState<Notification[]>([]);
useEffect(() => {
if (!data) return;
@@ -22,29 +21,16 @@ export function NotificationToaster({ enabled }: { enabled: boolean }) {
return;
}
const fresh = items.filter((n) => !seen.current!.has(n.id));
if (fresh.length === 0) return;
fresh.forEach((n) => seen.current!.add(n.id));
setToasts((prev) => [...fresh, ...prev].slice(0, MAX_VISIBLE));
fresh.forEach((n) => {
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== n.id)), TOAST_TTL_MS);
});
}, [data]);
// Стопка добавляет новое наверх, поэтому идём с конца: первое в списке окажется сверху.
[...fresh].reverse().forEach((n) =>
toast.notify({
title: n.title,
body: n.body,
onClick: n.link ? () => navigate(n.link!) : undefined,
}),
);
}, [data, toast, navigate]);
if (!enabled || toasts.length === 0) return null;
const click = (n: Notification) => {
setToasts((prev) => prev.filter((t) => t.id !== n.id));
if (n.link) navigate(n.link);
};
return (
<div className="toast-top-wrap">
{toasts.map((n) => (
<button key={n.id} className="toast-top" onClick={() => click(n)}>
<div className="toast-top-title">{n.title}</div>
{n.body && <div className="toast-top-body">{n.body}</div>}
</button>
))}
</div>
);
return null;
}
+80 -14
View File
@@ -1,31 +1,97 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import type { PropsWithChildren } from "react";
interface ToastCtx {
show: (message: string) => void;
// Сообщения приложения и входящие уведомления — одна стопка всплывашек сверху экрана.
type ToastKind = "info" | "error" | "notification";
interface ToastItem {
id: number;
kind: ToastKind;
title: string;
body?: string | null;
onClick?: () => void;
}
const Ctx = createContext<ToastCtx>({ show: () => {} });
interface ToastCtx {
/** Нейтральное сообщение: действие прошло, но на экране это не видно. */
show: (message: string) => void;
/** Ошибка или отказ — красная полоса, держится дольше. */
error: (message: string) => void;
/** Входящее уведомление (заголовок, текст, переход по клику). */
notify: (n: { title: string; body?: string | null; onClick?: () => void }) => void;
}
const TTL_MS: Record<ToastKind, number> = { info: 3000, error: 5000, notification: 5000 };
const MAX_VISIBLE = 4;
const Ctx = createContext<ToastCtx>({ show: () => {}, error: () => {}, notify: () => {} });
export function ToastProvider({ children }: PropsWithChildren) {
const [message, setMessage] = useState<string | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [items, setItems] = useState<ToastItem[]>([]);
const nextId = useRef(1);
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
const show = useCallback((msg: string) => {
setMessage(msg);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setMessage(null), 2800);
useEffect(() => {
const pending = timers.current;
return () => pending.forEach(clearTimeout);
}, []);
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост:
const dismiss = useCallback((id: number) => {
clearTimeout(timers.current.get(id));
timers.current.delete(id);
setItems((prev) => prev.filter((t) => t.id !== id));
}, []);
const push = useCallback(
(item: Omit<ToastItem, "id">) => {
const id = nextId.current++;
setItems((prev) => {
// Тот же текст уже на экране (двойной клик, повтор ошибки) — заменяем, а не копим.
// Уведомления не схлопываем: одинаковый заголовок бывает у разных событий.
const rest =
item.kind === "notification"
? prev
: prev.filter((t) => !(t.kind === item.kind && t.title === item.title));
return [{ ...item, id }, ...rest].slice(0, MAX_VISIBLE);
});
timers.current.set(id, setTimeout(() => dismiss(id), TTL_MS[item.kind]));
},
[dismiss],
);
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждую всплывашку:
// без useMemo новое значение контекста заставляло бы перерисоваться и всех
// потребителей, к самому тосту отношения не имеющих.
const value = useMemo(() => ({ show }), [show]);
// потребителей, к самим всплывашкам отношения не имеющих.
const value = useMemo<ToastCtx>(
() => ({
show: (message) => push({ kind: "info", title: message }),
error: (message) => push({ kind: "error", title: message }),
notify: ({ title, body, onClick }) => push({ kind: "notification", title, body, onClick }),
}),
[push],
);
return (
<Ctx.Provider value={value}>
{children}
{message && <div className="toast">{message}</div>}
{/* Контейнер есть всегда: aria-live объявляет только то, что появилось внутри уже
существующей области. */}
<div className="toast-top-wrap" aria-live="polite">
{items.map((t) => (
<button
key={t.id}
type="button"
className={"toast-top" + (t.kind === "error" ? " toast-top-error" : "")}
onClick={() => {
dismiss(t.id);
t.onClick?.();
}}
>
<div className="toast-top-title">{t.title}</div>
{t.body && <div className="toast-top-body">{t.body}</div>}
</button>
))}
</div>
</Ctx.Provider>
);
}
+7 -12
View File
@@ -65,9 +65,8 @@ export function AccountPage() {
const saveFavorite = async (factionId: number | null) => {
try {
await updateFavorite.mutateAsync(factionId);
toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
@@ -75,7 +74,7 @@ export function AccountPage() {
try {
await updateHistory.mutateAsync(prefs);
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
@@ -85,9 +84,8 @@ export function AccountPage() {
try {
await updateNick.mutateAsync(nick);
setNickname("");
toast.show("Никнейм обновлён");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
@@ -96,7 +94,7 @@ export function AccountPage() {
await updateBio.mutateAsync(bioValue.trim());
toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
@@ -106,18 +104,16 @@ export function AccountPage() {
if (!file) return;
try {
await uploadAvatar.mutateAsync(file);
toast.show("Аватар обновлён");
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
}
};
const removeAvatar = async () => {
try {
await deleteAvatar.mutateAsync();
toast.show("Аватар удалён");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
@@ -307,9 +303,8 @@ function LoginSettingsCard({ me }: { me: Me }) {
const onTelegram = async (payload: Record<string, unknown>) => {
try {
await linkTelegram.mutateAsync(payload);
toast.show("Telegram привязан");
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось привязать Telegram");
toast.error(err instanceof ApiError ? err.message : "Не удалось привязать Telegram");
}
};
+1 -2
View File
@@ -131,7 +131,7 @@ export function CreateMatchPage() {
} catch (e) {
if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
setSpin((s) => ({ ...s, [id]: null }));
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
toast.error(e instanceof ApiError ? e.message : "Ошибка рандома");
}
};
@@ -176,7 +176,6 @@ export function CreateMatchPage() {
was_random: r.was_random,
})),
});
toast.show("Партия начата");
navigate(`/match/${match.id}`, { replace: true });
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось начать партию");
+1 -1
View File
@@ -53,7 +53,7 @@ export function GroupPage() {
setNewNick("");
toast.show("Приглашение отправлено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
+3 -3
View File
@@ -59,7 +59,7 @@ export function GroupSettingsPage() {
setName(null);
toast.show("Название сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
@@ -68,7 +68,7 @@ export function GroupSettingsPage() {
await setExpansions.mutateAsync([...selected]);
toast.show("Дополнения сохранены");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
@@ -76,7 +76,7 @@ export function GroupSettingsPage() {
try {
await removeMember.mutateAsync(userId);
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
+4 -6
View File
@@ -165,10 +165,9 @@ export function MatchDetailPage() {
expected_version: match.version,
},
});
toast.show("Партия завершена");
} catch (e) {
if (isStale(e)) {
toast.show("Партия изменилась на другом устройстве — обновлено");
toast.error("Партия изменилась на другом устройстве — обновлено");
refetch();
return;
}
@@ -234,11 +233,10 @@ export function MatchDetailPage() {
expected_version: match.version,
},
});
toast.show("Партия обновлена");
cancelEditing();
} catch (e) {
if (isStale(e)) {
toast.show("Партия изменилась на другом устройстве — обновлено");
toast.error("Партия изменилась на другом устройстве — обновлено");
refetch();
cancelEditing();
return;
@@ -257,11 +255,11 @@ export function MatchDetailPage() {
navigate("/group", { replace: true });
} catch (e) {
if (isStale(e)) {
toast.show("Партия изменилась на другом устройстве — обновлено");
toast.error("Партия изменилась на другом устройстве — обновлено");
refetch();
return;
}
toast.show(e instanceof ApiError ? e.message : "Не удалось выполнить");
toast.error(e instanceof ApiError ? e.message : "Не удалось выполнить");
}
};
+1 -1
View File
@@ -44,7 +44,7 @@ export function PublicProfilePage() {
await invite.mutateAsync({ groupId, nickname: data.nickname });
toast.show("Приглашение отправлено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось пригласить");
toast.error(e instanceof ApiError ? e.message : "Не удалось пригласить");
}
};
@@ -42,7 +42,7 @@ export function AdminAccountsPage() {
setPasswordId(null);
setNewPassword("");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось задать пароль");
toast.error(e instanceof ApiError ? e.message : "Не удалось задать пароль");
}
};
@@ -51,19 +51,17 @@ export function AdminAccountsPage() {
if (editId == null || !name) return;
try {
await update.mutateAsync({ userId: editId, nickname: name });
toast.show("Ник обновлён");
setEditId(null);
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать");
toast.error(e instanceof ApiError ? e.message : "Не удалось переименовать");
}
};
const toggleActive = async (id: number, isActive: boolean) => {
try {
await update.mutateAsync({ userId: id, is_active: !isActive });
toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось сохранить");
toast.error(e instanceof ApiError ? e.message : "Не удалось сохранить");
}
};
@@ -35,9 +35,8 @@ export function AdminAchievementsPage() {
setName("");
setDescription("");
setCondition("");
toast.show("Ачивка создана");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
@@ -100,9 +99,8 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
body: { name: name.trim(), description, condition },
});
setEditing(false);
toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
@@ -112,18 +110,16 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
if (!file) return;
try {
await uploadIcon.mutateAsync({ slug: ach.slug, file });
toast.show("Иконка обновлена");
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
}
};
const remove = async () => {
try {
await del.mutateAsync(ach.slug);
toast.show("Удалено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
@@ -31,7 +31,7 @@ export function AdminFactionsPage() {
await rename.mutateAsync({ factionId: id, name_ru: names[id] });
toast.show("Название обновлено во всей системе");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
@@ -6,7 +6,6 @@ import { MatchMedia } from "../../components/MatchMedia";
import { Spinner } from "../../components/Spinner";
import { Switch } from "../../components/Switch";
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
import { useToast } from "../../context/ToastContext";
import {
useAdminDeleteAttachment,
useAdminFactions,
@@ -33,7 +32,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
const update = useAdminUpdateMatch();
const uploadAtt = useAdminUploadAttachment(matchId);
const deleteAtt = useAdminDeleteAttachment(matchId);
const toast = useToast();
const navigate = useNavigate();
const [rows, setRows] = useState<Row[]>([]);
@@ -83,7 +81,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
})),
},
});
toast.show("Партия обновлена");
onClose();
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
@@ -149,9 +149,8 @@ export function AdminRecordsPage() {
setDeleteTarget(null);
try {
await del.mutateAsync(deleteTarget.id);
toast.show("Удалено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
}
}}
/>
@@ -57,9 +57,8 @@ export function DevDeleteAccountButton({
setConfirmOpen(false);
try {
await del.mutateAsync();
toast.show("Аккаунт удалён");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
}
}}
/>
+1 -16
View File
@@ -193,22 +193,6 @@ input:focus, select:focus, textarea:focus { 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%;
bottom: calc(var(--bottombar-height) + 16px);
transform: translateX(-50%);
background: var(--surface-2);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 16px;
border-radius: 999px;
z-index: 100;
max-width: 90%;
text-align: center;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
/* ─── Уведомления ─────────────────────────────────────────────────────────── */
/* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */
@@ -471,5 +455,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
border-radius: var(--radius-sm);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
}
.toast-top-error { border-left-color: var(--danger); }
.toast-top-title { font-weight: 700; }
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }