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