UI: единое подтверждение удаления через ConfirmDialog

- админка, записи: удаление партии раньше срабатывало сразу по клику на
  иконку и показывало «Удалено» даже при ошибке — теперь модалка
  подтверждения и честная обработка ошибок;
- ачивки и dev-удаление аккаунта: window.confirm заменён на ConfirmDialog
  (в приложении принят именно он);
- настройки группы: удаление участника — с подтверждением.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 17:40:53 +03:00
co-authored by Claude Fable 5
parent d2c0feafcc
commit 7b61443fb9
4 changed files with 96 additions and 34 deletions
+21 -1
View File
@@ -2,6 +2,7 @@ import { Trash2, UserPlus } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { PlayerLink } from "../components/PlayerLink"; import { PlayerLink } from "../components/PlayerLink";
import { Spinner } from "../components/Spinner"; import { Spinner } from "../components/Spinner";
import { Switch } from "../components/Switch"; import { Switch } from "../components/Switch";
@@ -24,6 +25,7 @@ export function GroupSettingsPage() {
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
const [newNick, setNewNick] = useState(""); const [newNick, setNewNick] = useState("");
const [removeTarget, setRemoveTarget] = useState<{ userId: number; nickname: string } | null>(null);
useEffect(() => { useEffect(() => {
if (group) setSelected(new Set(group.expansion_ids)); if (group) setSelected(new Set(group.expansion_ids));
@@ -121,7 +123,10 @@ export function GroupSettingsPage() {
{m.nickname} <span className="muted small">· {m.role}</span> {m.nickname} <span className="muted small">· {m.role}</span>
</PlayerLink> </PlayerLink>
{isOwner && m.role !== "owner" && ( {isOwner && m.role !== "owner" && (
<button className="btn btn-ghost btn-danger small" onClick={() => remove(m.user_id)}> <button
className="btn btn-ghost btn-danger small"
onClick={() => setRemoveTarget({ userId: m.user_id, nickname: m.nickname })}
>
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
)} )}
@@ -129,6 +134,21 @@ export function GroupSettingsPage() {
))} ))}
</div> </div>
</div> </div>
<ConfirmDialog
open={removeTarget != null}
danger
title={`Удалить участника «${removeTarget?.nickname}»?`}
message="Игрок будет удалён из группы. Его сыгранные партии сохранятся."
confirmLabel="Удалить"
onCancel={() => setRemoveTarget(null)}
onConfirm={() => {
if (!removeTarget) return;
const userId = removeTarget.userId;
setRemoveTarget(null);
remove(userId);
}}
/>
</div> </div>
); );
} }
@@ -2,6 +2,7 @@ import { useRef, useState } from "react";
import { ApiError } from "../../api/client"; import { ApiError } from "../../api/client";
import { Avatar } from "../../components/Avatar"; import { Avatar } from "../../components/Avatar";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { Spinner } from "../../components/Spinner"; import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext"; import { useToast } from "../../context/ToastContext";
import { import {
@@ -87,6 +88,7 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
const toast = useToast(); const toast = useToast();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false);
const [name, setName] = useState(ach.name); const [name, setName] = useState(ach.name);
const [description, setDescription] = useState(ach.description); const [description, setDescription] = useState(ach.description);
const [condition, setCondition] = useState(ach.condition); const [condition, setCondition] = useState(ach.condition);
@@ -117,7 +119,6 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
}; };
const remove = async () => { const remove = async () => {
if (!window.confirm(`Удалить ачивку «${ach.name}»?`)) return;
try { try {
await del.mutateAsync(ach.slug); await del.mutateAsync(ach.slug);
toast.show("Удалено"); toast.show("Удалено");
@@ -175,7 +176,11 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
<button className="btn small" onClick={() => setEditing(true)}> <button className="btn small" onClick={() => setEditing(true)}>
Изменить Изменить
</button> </button>
<button className="btn btn-ghost btn-danger small" onClick={remove} disabled={del.isPending}> <button
className="btn btn-ghost btn-danger small"
onClick={() => setConfirmRemove(true)}
disabled={del.isPending}
>
Удалить Удалить
</button> </button>
<input <input
@@ -187,6 +192,19 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
/> />
</div> </div>
)} )}
<ConfirmDialog
open={confirmRemove}
danger
title={`Удалить ачивку «${ach.name}»?`}
message="Ачивка и её иконка будут удалены. Действие необратимо."
confirmLabel="Удалить"
onCancel={() => setConfirmRemove(false)}
onConfirm={() => {
setConfirmRemove(false);
remove();
}}
/>
</div> </div>
); );
} }
+23 -7
View File
@@ -1,6 +1,8 @@
import { Pencil, Trash2 } from "lucide-react"; import { Pencil, Trash2 } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { Spinner } from "../../components/Spinner"; import { Spinner } from "../../components/Spinner";
import { Switch } from "../../components/Switch"; import { Switch } from "../../components/Switch";
import { useToast } from "../../context/ToastContext"; import { useToast } from "../../context/ToastContext";
@@ -20,6 +22,7 @@ export function AdminRecordsPage() {
const [factionFilter, setFactionFilter] = useState<number | null>(null); const [factionFilter, setFactionFilter] = useState<number | null>(null);
const [grouped, setGrouped] = useState(true); const [grouped, setGrouped] = useState(true);
const [editId, setEditId] = useState<number | null>(null); const [editId, setEditId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<AdminMatchRead | null>(null);
const { data: groups } = useAdminGroups(); const { data: groups } = useAdminGroups();
const { data: users } = useAdminUsers(); const { data: users } = useAdminUsers();
@@ -61,13 +64,7 @@ export function AdminRecordsPage() {
<button className="btn btn-ghost small" onClick={() => setEditId(m.id)}> <button className="btn btn-ghost small" onClick={() => setEditId(m.id)}>
<Pencil size={16} /> <Pencil size={16} />
</button> </button>
<button <button className="btn btn-ghost btn-danger small" onClick={() => setDeleteTarget(m)}>
className="btn btn-ghost btn-danger small"
onClick={async () => {
await del.mutateAsync(m.id).catch(() => {});
toast.show("Удалено");
}}
>
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</div> </div>
@@ -137,6 +134,25 @@ export function AdminRecordsPage() {
)} )}
{editId != null && <AdminMatchEdit matchId={editId} onClose={() => setEditId(null)} />} {editId != null && <AdminMatchEdit matchId={editId} onClose={() => setEditId(null)} />}
<ConfirmDialog
open={deleteTarget != null}
danger
title={`Удалить партию #${deleteTarget?.id}?`}
message="Партия и её результаты будут удалены. Действие необратимо."
confirmLabel="Удалить"
onCancel={() => setDeleteTarget(null)}
onConfirm={async () => {
if (!deleteTarget) return;
setDeleteTarget(null);
try {
await del.mutateAsync(deleteTarget.id);
toast.show("Удалено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
}
}}
/>
</div> </div>
); );
} }
@@ -1,7 +1,9 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { ApiError, api, unwrap } from "../../api/client"; import { ApiError, api, unwrap } from "../../api/client";
import { qk } from "../../api/queryKeys"; import { qk } from "../../api/queryKeys";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { useToast } from "../../context/ToastContext"; import { useToast } from "../../context/ToastContext";
/** /**
@@ -21,6 +23,7 @@ export function DevDeleteAccountButton({
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
const toast = useToast(); const toast = useToast();
const [confirmOpen, setConfirmOpen] = useState(false);
const del = useMutation({ const del = useMutation({
mutationFn: async () => mutationFn: async () =>
unwrap( unwrap(
@@ -32,29 +35,34 @@ export function DevDeleteAccountButton({
}); });
return ( return (
<button <>
className="btn small" <button
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS, className="btn small"
// чтобы в прод/тест-стили не попало ничего, связанного с удалением. // Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }} // чтобы в прод/тест-стили не попало ничего, связанного с удалением.
disabled={del.isPending} style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
onClick={async () => { disabled={del.isPending}
if ( onClick={() => setConfirmOpen(true)}
!window.confirm( >
`Удалить аккаунт «${nickname}»? Партии сохранятся, но игрок будет ` + Удалить
`вычеркнут из них. Действие необратимо.`, </button>
) <ConfirmDialog
) open={confirmOpen}
return; danger
try { title={`Удалить аккаунт «${nickname}»?`}
await del.mutateAsync(); message="Партии сохранятся, но игрок будет вычеркнут из них. Действие необратимо."
toast.show("Аккаунт удалён"); confirmLabel="Удалить"
} catch (e) { onCancel={() => setConfirmOpen(false)}
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить"); onConfirm={async () => {
} setConfirmOpen(false);
}} try {
> await del.mutateAsync();
Удалить toast.show("Аккаунт удалён");
</button> } catch (e) {
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
}
}}
/>
</>
); );
} }