Тосты: убрать лишние, остальные показывать как уведомления сверху
Многие тосты повторяли то, что пользователь и так видит сразу после действия: новую фракцию или аватар в профиле, переход на страницу партии, исчезнувшую карточку приглашения, закрывшийся редактор. Такой шум приучает не читать всплывашки, в том числе ошибки. Убраны 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:
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { useToast } from "../context/ToastContext";
|
|
||||||
import { useCreateGroup } from "../hooks/groups";
|
import { useCreateGroup } from "../hooks/groups";
|
||||||
import { useExpansions } from "../hooks/reference";
|
import { useExpansions } from "../hooks/reference";
|
||||||
import { Switch } from "./Switch";
|
import { Switch } from "./Switch";
|
||||||
@@ -9,7 +8,6 @@ import { Switch } from "./Switch";
|
|||||||
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
||||||
const { data: expansions } = useExpansions();
|
const { data: expansions } = useExpansions();
|
||||||
const createGroup = useCreateGroup();
|
const createGroup = useCreateGroup();
|
||||||
const toast = useToast();
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -25,7 +23,6 @@ export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
|
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
|
||||||
toast.show("Группа создана");
|
|
||||||
onCreated?.();
|
onCreated?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
|
setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
|
||||||
|
|||||||
@@ -15,16 +15,15 @@ export function GroupInvitations() {
|
|||||||
await accept.mutateAsync(id);
|
await accept.mutateAsync(id);
|
||||||
toast.show("Вы вступили в группу");
|
toast.show("Вы вступили в группу");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDecline = async (id: number) => {
|
const onDecline = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await decline.mutateAsync(id);
|
await decline.mutateAsync(id);
|
||||||
toast.show("Приглашение отклонено");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ export function MatchMedia({
|
|||||||
let slots = max - attachments.length;
|
let slots = max - attachments.length;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (slots <= 0) {
|
if (slots <= 0) {
|
||||||
toast.show(`Максимум ${max} фото`);
|
toast.error(`Максимум ${max} фото`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await onUpload(f);
|
await onUpload(f);
|
||||||
slots--;
|
slots--;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ export function MatchMedia({
|
|||||||
try {
|
try {
|
||||||
await onDelete(id);
|
await onDelete(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useNotifications, type Notification } from "../hooks/notifications";
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { useNotifications } from "../hooks/notifications";
|
||||||
const TOAST_TTL_MS = 5000;
|
|
||||||
const MAX_VISIBLE = 4;
|
|
||||||
|
|
||||||
/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull).
|
/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull).
|
||||||
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id. */
|
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id.
|
||||||
|
* Показывает их общая стопка ToastProvider — там же, где сообщения приложения. */
|
||||||
export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
||||||
const { data } = useNotifications(enabled);
|
const { data } = useNotifications(enabled);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
const seen = useRef<Set<number> | null>(null);
|
const seen = useRef<Set<number> | null>(null);
|
||||||
const [toasts, setToasts] = useState<Notification[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -22,29 +21,16 @@ export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fresh = items.filter((n) => !seen.current!.has(n.id));
|
const fresh = items.filter((n) => !seen.current!.has(n.id));
|
||||||
if (fresh.length === 0) return;
|
|
||||||
fresh.forEach((n) => seen.current!.add(n.id));
|
fresh.forEach((n) => seen.current!.add(n.id));
|
||||||
setToasts((prev) => [...fresh, ...prev].slice(0, MAX_VISIBLE));
|
// Стопка добавляет новое наверх, поэтому идём с конца: первое в списке окажется сверху.
|
||||||
fresh.forEach((n) => {
|
[...fresh].reverse().forEach((n) =>
|
||||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== n.id)), TOAST_TTL_MS);
|
toast.notify({
|
||||||
});
|
title: n.title,
|
||||||
}, [data]);
|
body: n.body,
|
||||||
|
onClick: n.link ? () => navigate(n.link!) : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [data, toast, navigate]);
|
||||||
|
|
||||||
if (!enabled || toasts.length === 0) return null;
|
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
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) {
|
export function ToastProvider({ children }: PropsWithChildren) {
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [items, setItems] = useState<ToastItem[]>([]);
|
||||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const nextId = useRef(1);
|
||||||
|
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
||||||
|
|
||||||
const show = useCallback((msg: string) => {
|
useEffect(() => {
|
||||||
setMessage(msg);
|
const pending = timers.current;
|
||||||
if (timer.current) clearTimeout(timer.current);
|
return () => pending.forEach(clearTimeout);
|
||||||
timer.current = setTimeout(() => setMessage(null), 2800);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост:
|
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 новое значение контекста заставляло бы перерисоваться и всех
|
// без 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 (
|
return (
|
||||||
<Ctx.Provider value={value}>
|
<Ctx.Provider value={value}>
|
||||||
{children}
|
{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>
|
</Ctx.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,9 +65,8 @@ export function AccountPage() {
|
|||||||
const saveFavorite = async (factionId: number | null) => {
|
const saveFavorite = async (factionId: number | null) => {
|
||||||
try {
|
try {
|
||||||
await updateFavorite.mutateAsync(factionId);
|
await updateFavorite.mutateAsync(factionId);
|
||||||
toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,7 +74,7 @@ export function AccountPage() {
|
|||||||
try {
|
try {
|
||||||
await updateHistory.mutateAsync(prefs);
|
await updateHistory.mutateAsync(prefs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,9 +84,8 @@ export function AccountPage() {
|
|||||||
try {
|
try {
|
||||||
await updateNick.mutateAsync(nick);
|
await updateNick.mutateAsync(nick);
|
||||||
setNickname("");
|
setNickname("");
|
||||||
toast.show("Никнейм обновлён");
|
|
||||||
} catch (e) {
|
} 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());
|
await updateBio.mutateAsync(bioValue.trim());
|
||||||
toast.show("Сохранено");
|
toast.show("Сохранено");
|
||||||
} catch (e) {
|
} 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;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
await uploadAvatar.mutateAsync(file);
|
await uploadAvatar.mutateAsync(file);
|
||||||
toast.show("Аватар обновлён");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeAvatar = async () => {
|
const removeAvatar = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAvatar.mutateAsync();
|
await deleteAvatar.mutateAsync();
|
||||||
toast.show("Аватар удалён");
|
|
||||||
} catch (e) {
|
} 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>) => {
|
const onTelegram = async (payload: Record<string, unknown>) => {
|
||||||
try {
|
try {
|
||||||
await linkTelegram.mutateAsync(payload);
|
await linkTelegram.mutateAsync(payload);
|
||||||
toast.show("Telegram привязан");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.show(err instanceof ApiError ? err.message : "Не удалось привязать Telegram");
|
toast.error(err instanceof ApiError ? err.message : "Не удалось привязать Telegram");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function CreateMatchPage() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
|
if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
|
||||||
setSpin((s) => ({ ...s, [id]: null }));
|
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,
|
was_random: r.was_random,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
toast.show("Партия начата");
|
|
||||||
navigate(`/match/${match.id}`, { replace: true });
|
navigate(`/match/${match.id}`, { replace: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : "Не удалось начать партию");
|
setError(e instanceof ApiError ? e.message : "Не удалось начать партию");
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function GroupPage() {
|
|||||||
setNewNick("");
|
setNewNick("");
|
||||||
toast.show("Приглашение отправлено");
|
toast.show("Приглашение отправлено");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function GroupSettingsPage() {
|
|||||||
setName(null);
|
setName(null);
|
||||||
toast.show("Название сохранено");
|
toast.show("Название сохранено");
|
||||||
} catch (e) {
|
} 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]);
|
await setExpansions.mutateAsync([...selected]);
|
||||||
toast.show("Дополнения сохранены");
|
toast.show("Дополнения сохранены");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ export function GroupSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await removeMember.mutateAsync(userId);
|
await removeMember.mutateAsync(userId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -165,10 +165,9 @@ export function MatchDetailPage() {
|
|||||||
expected_version: match.version,
|
expected_version: match.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.show("Партия завершена");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isStale(e)) {
|
if (isStale(e)) {
|
||||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
toast.error("Партия изменилась на другом устройстве — обновлено");
|
||||||
refetch();
|
refetch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -234,11 +233,10 @@ export function MatchDetailPage() {
|
|||||||
expected_version: match.version,
|
expected_version: match.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.show("Партия обновлена");
|
|
||||||
cancelEditing();
|
cancelEditing();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isStale(e)) {
|
if (isStale(e)) {
|
||||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
toast.error("Партия изменилась на другом устройстве — обновлено");
|
||||||
refetch();
|
refetch();
|
||||||
cancelEditing();
|
cancelEditing();
|
||||||
return;
|
return;
|
||||||
@@ -257,11 +255,11 @@ export function MatchDetailPage() {
|
|||||||
navigate("/group", { replace: true });
|
navigate("/group", { replace: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isStale(e)) {
|
if (isStale(e)) {
|
||||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
toast.error("Партия изменилась на другом устройстве — обновлено");
|
||||||
refetch();
|
refetch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось выполнить");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось выполнить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function PublicProfilePage() {
|
|||||||
await invite.mutateAsync({ groupId, nickname: data.nickname });
|
await invite.mutateAsync({ groupId, nickname: data.nickname });
|
||||||
toast.show("Приглашение отправлено");
|
toast.show("Приглашение отправлено");
|
||||||
} catch (e) {
|
} 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);
|
setPasswordId(null);
|
||||||
setNewPassword("");
|
setNewPassword("");
|
||||||
} catch (e) {
|
} 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;
|
if (editId == null || !name) return;
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({ userId: editId, nickname: name });
|
await update.mutateAsync({ userId: editId, nickname: name });
|
||||||
toast.show("Ник обновлён");
|
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось переименовать");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleActive = async (id: number, isActive: boolean) => {
|
const toggleActive = async (id: number, isActive: boolean) => {
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({ userId: id, is_active: !isActive });
|
await update.mutateAsync({ userId: id, is_active: !isActive });
|
||||||
toast.show("Сохранено");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ export function AdminAchievementsPage() {
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setCondition("");
|
setCondition("");
|
||||||
toast.show("Ачивка создана");
|
|
||||||
} catch (e) {
|
} 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 },
|
body: { name: name.trim(), description, condition },
|
||||||
});
|
});
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
toast.show("Сохранено");
|
|
||||||
} catch (e) {
|
} 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;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
await uploadIcon.mutateAsync({ slug: ach.slug, file });
|
await uploadIcon.mutateAsync({ slug: ach.slug, file });
|
||||||
toast.show("Иконка обновлена");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync(ach.slug);
|
await del.mutateAsync(ach.slug);
|
||||||
toast.show("Удалено");
|
|
||||||
} catch (e) {
|
} 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] });
|
await rename.mutateAsync({ factionId: id, name_ru: names[id] });
|
||||||
toast.show("Название обновлено во всей системе");
|
toast.show("Название обновлено во всей системе");
|
||||||
} catch (e) {
|
} 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 { Spinner } from "../../components/Spinner";
|
||||||
import { Switch } from "../../components/Switch";
|
import { Switch } from "../../components/Switch";
|
||||||
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
||||||
import { useToast } from "../../context/ToastContext";
|
|
||||||
import {
|
import {
|
||||||
useAdminDeleteAttachment,
|
useAdminDeleteAttachment,
|
||||||
useAdminFactions,
|
useAdminFactions,
|
||||||
@@ -33,7 +32,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
const update = useAdminUpdateMatch();
|
const update = useAdminUpdateMatch();
|
||||||
const uploadAtt = useAdminUploadAttachment(matchId);
|
const uploadAtt = useAdminUploadAttachment(matchId);
|
||||||
const deleteAtt = useAdminDeleteAttachment(matchId);
|
const deleteAtt = useAdminDeleteAttachment(matchId);
|
||||||
const toast = useToast();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [rows, setRows] = useState<Row[]>([]);
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
@@ -83,7 +81,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.show("Партия обновлена");
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||||
|
|||||||
@@ -149,9 +149,8 @@ export function AdminRecordsPage() {
|
|||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync(deleteTarget.id);
|
await del.mutateAsync(deleteTarget.id);
|
||||||
toast.show("Удалено");
|
|
||||||
} catch (e) {
|
} 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);
|
setConfirmOpen(false);
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync();
|
await del.mutateAsync();
|
||||||
toast.show("Аккаунт удалён");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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:checked + .switch-track .switch-thumb { transform: translateX(18px); background: #fff; }
|
||||||
.switch input:disabled + .switch-track { opacity: 0.5; }
|
.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/заголовок, по центру название, справа колокольчик */
|
/* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */
|
||||||
@@ -471,5 +455,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
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-title { font-weight: 700; }
|
||||||
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }
|
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }
|
||||||
|
|||||||
Reference in New Issue
Block a user