From 3587fc8af22938eb7ad7050dd7ac536af9743236 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 15:45:34 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A2=D0=BE=D1=81=D1=82=D1=8B:=20=D1=83=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D1=8C=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?,=20=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=BA=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D0=B2=D0=B5=D1=80=D1=85=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Многие тосты повторяли то, что пользователь и так видит сразу после действия: новую фракцию или аватар в профиле, переход на страницу партии, исчезнувшую карточку приглашения, закрывшийся редактор. Такой шум приучает не читать всплывашки, в том числе ошибки. Убраны 19 таких тостов в профиле, группах, партиях и админке. Остались ошибки, предупреждения (лимит фото, повторы фракций, конфликт правок, «вы не в группе») и сообщения о действиях без видимого следа: приглашение отправлено, дополнения сохранены, вступление в группу, пароль задан. Сохранение текстового поля, где заметна только погасшая кнопка, тоже осталось. По просьбе владельца оставшиеся тосты показываются не нижней «таблеткой», а всплывашкой сверху, как входящие уведомления. ToastProvider держит одну стопку на всё приложение: show — обычное сообщение, error — ошибка с красной полосой (держится 5 с), notify — уведомление с переходом по клику. Одинаковое сообщение не копится, а заменяет прежнее; клик по карточке закрывает её. NotificationToaster больше не рисует свою стопку, а отдаёт новые уведомления в общую, поэтому они не наезжают на сообщения. Все вызовы с ApiError и отказы (лимит фото, партия изменилась на другом устройстве) переведены на toast.error. Нижний класс .toast удалён. #41 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- frontend/src/components/CreateGroupForm.tsx | 3 - frontend/src/components/GroupInvitations.tsx | 5 +- frontend/src/components/MatchMedia.tsx | 6 +- .../src/components/NotificationToaster.tsx | 46 ++++----- frontend/src/context/ToastContext.tsx | 94 ++++++++++++++++--- frontend/src/pages/AccountPage.tsx | 19 ++-- frontend/src/pages/CreateMatchPage.tsx | 3 +- frontend/src/pages/GroupPage.tsx | 2 +- frontend/src/pages/GroupSettingsPage.tsx | 6 +- frontend/src/pages/MatchDetailPage.tsx | 10 +- frontend/src/pages/PublicProfilePage.tsx | 2 +- .../src/pages/admin/AdminAccountsPage.tsx | 8 +- .../src/pages/admin/AdminAchievementsPage.tsx | 12 +-- .../src/pages/admin/AdminFactionsPage.tsx | 2 +- frontend/src/pages/admin/AdminMatchEdit.tsx | 3 - frontend/src/pages/admin/AdminRecordsPage.tsx | 3 +- .../pages/admin/DevDeleteAccountButton.tsx | 3 +- frontend/src/styles/global.css | 17 +--- 18 files changed, 129 insertions(+), 115 deletions(-) diff --git a/frontend/src/components/CreateGroupForm.tsx b/frontend/src/components/CreateGroupForm.tsx index 5037bc9..2d2fa3b 100644 --- a/frontend/src/components/CreateGroupForm.tsx +++ b/frontend/src/components/CreateGroupForm.tsx @@ -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>(new Set()); const [error, setError] = useState(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 : "Не удалось создать группу"); diff --git a/frontend/src/components/GroupInvitations.tsx b/frontend/src/components/GroupInvitations.tsx index 7b1fd7e..62877bd 100644 --- a/frontend/src/components/GroupInvitations.tsx +++ b/frontend/src/components/GroupInvitations.tsx @@ -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 : "Ошибка"); } }; diff --git a/frontend/src/components/MatchMedia.tsx b/frontend/src/components/MatchMedia.tsx index 742a640..93568c3 100644 --- a/frontend/src/components/MatchMedia.tsx +++ b/frontend/src/components/MatchMedia.tsx @@ -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 : "Не удалось удалить"); } }; diff --git a/frontend/src/components/NotificationToaster.tsx b/frontend/src/components/NotificationToaster.tsx index 789a90c..7278f46 100644 --- a/frontend/src/components/NotificationToaster.tsx +++ b/frontend/src/components/NotificationToaster.tsx @@ -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 | null>(null); - const [toasts, setToasts] = useState([]); 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 ( -
- {toasts.map((n) => ( - - ))} -
- ); + return null; } diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx index 819c647..9ba3598 100644 --- a/frontend/src/context/ToastContext.tsx +++ b/frontend/src/context/ToastContext.tsx @@ -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({ 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 = { info: 3000, error: 5000, notification: 5000 }; +const MAX_VISIBLE = 4; + +const Ctx = createContext({ show: () => {}, error: () => {}, notify: () => {} }); export function ToastProvider({ children }: PropsWithChildren) { - const [message, setMessage] = useState(null); - const timer = useRef | null>(null); + const [items, setItems] = useState([]); + const nextId = useRef(1); + const timers = useRef(new Map>()); - 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) => { + 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( + () => ({ + 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 ( {children} - {message &&
{message}
} + {/* Контейнер есть всегда: aria-live объявляет только то, что появилось внутри уже + существующей области. */} +
+ {items.map((t) => ( + + ))} +
); } diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 123d5b0..0cf112a 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -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) => { 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"); } }; diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx index d50afeb..39a1e54 100644 --- a/frontend/src/pages/CreateMatchPage.tsx +++ b/frontend/src/pages/CreateMatchPage.tsx @@ -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 : "Не удалось начать партию"); diff --git a/frontend/src/pages/GroupPage.tsx b/frontend/src/pages/GroupPage.tsx index 5cf5f33..3d71967 100644 --- a/frontend/src/pages/GroupPage.tsx +++ b/frontend/src/pages/GroupPage.tsx @@ -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 : "Ошибка"); } }; diff --git a/frontend/src/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx index 2166844..878852a 100644 --- a/frontend/src/pages/GroupSettingsPage.tsx +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -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 : "Ошибка"); } }; diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index 9988193..4a0fc31 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -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 : "Не удалось выполнить"); } }; diff --git a/frontend/src/pages/PublicProfilePage.tsx b/frontend/src/pages/PublicProfilePage.tsx index 438936f..7910ae0 100644 --- a/frontend/src/pages/PublicProfilePage.tsx +++ b/frontend/src/pages/PublicProfilePage.tsx @@ -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 : "Не удалось пригласить"); } }; diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx index 6997a06..cc1b46f 100644 --- a/frontend/src/pages/admin/AdminAccountsPage.tsx +++ b/frontend/src/pages/admin/AdminAccountsPage.tsx @@ -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 : "Не удалось сохранить"); } }; diff --git a/frontend/src/pages/admin/AdminAchievementsPage.tsx b/frontend/src/pages/admin/AdminAchievementsPage.tsx index c7b3d2d..78e1a7a 100644 --- a/frontend/src/pages/admin/AdminAchievementsPage.tsx +++ b/frontend/src/pages/admin/AdminAchievementsPage.tsx @@ -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 : "Ошибка"); } }; diff --git a/frontend/src/pages/admin/AdminFactionsPage.tsx b/frontend/src/pages/admin/AdminFactionsPage.tsx index b0dd5eb..82cca73 100644 --- a/frontend/src/pages/admin/AdminFactionsPage.tsx +++ b/frontend/src/pages/admin/AdminFactionsPage.tsx @@ -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 : "Ошибка"); } }; diff --git a/frontend/src/pages/admin/AdminMatchEdit.tsx b/frontend/src/pages/admin/AdminMatchEdit.tsx index d987a70..d71156d 100644 --- a/frontend/src/pages/admin/AdminMatchEdit.tsx +++ b/frontend/src/pages/admin/AdminMatchEdit.tsx @@ -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([]); @@ -83,7 +81,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: })), }, }); - toast.show("Партия обновлена"); onClose(); } catch (e) { setError(e instanceof ApiError ? e.message : "Не удалось сохранить"); diff --git a/frontend/src/pages/admin/AdminRecordsPage.tsx b/frontend/src/pages/admin/AdminRecordsPage.tsx index 6f762d4..d47873e 100644 --- a/frontend/src/pages/admin/AdminRecordsPage.tsx +++ b/frontend/src/pages/admin/AdminRecordsPage.tsx @@ -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 : "Не удалось удалить"); } }} /> diff --git a/frontend/src/pages/admin/DevDeleteAccountButton.tsx b/frontend/src/pages/admin/DevDeleteAccountButton.tsx index 884b137..3b0b7fc 100644 --- a/frontend/src/pages/admin/DevDeleteAccountButton.tsx +++ b/frontend/src/pages/admin/DevDeleteAccountButton.tsx @@ -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 : "Не удалось удалить"); } }} /> diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index e5cd70b..295df2d 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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); }