- админка, записи: удаление партии раньше срабатывало сразу по клику на иконку и показывало «Удалено» даже при ошибке — теперь модалка подтверждения и честная обработка ошибок; - ачивки и dev-удаление аккаунта: window.confirm заменён на ConfirmDialog (в приложении принят именно он); - настройки группы: удаление участника — с подтверждением. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
155 lines
5.8 KiB
TypeScript
155 lines
5.8 KiB
TypeScript
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";
|
|
import { MAX_GROUP_SIZE } from "../domain/constants";
|
|
import { useToast } from "../context/ToastContext";
|
|
import { useMe } from "../hooks/auth";
|
|
import { useGroup, useGroupMembers, useInviteMember, useRemoveMember, useSetGroupExpansions } from "../hooks/groups";
|
|
import { useExpansions } from "../hooks/reference";
|
|
|
|
export function GroupSettingsPage() {
|
|
const { data: me } = useMe();
|
|
const groupId = me?.active_group_id ?? null;
|
|
const { data: group, isLoading } = useGroup(groupId);
|
|
const { data: expansions } = useExpansions();
|
|
const { data: members } = useGroupMembers(groupId);
|
|
const setExpansions = useSetGroupExpansions(groupId ?? 0);
|
|
const inviteMember = useInviteMember();
|
|
const removeMember = useRemoveMember(groupId ?? 0);
|
|
const toast = useToast();
|
|
|
|
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));
|
|
}, [group]);
|
|
|
|
if (isLoading || !group) return <Spinner />;
|
|
// Управление группой доступно всем участникам (создатель лишь создаёт и приглашает).
|
|
const isOwner = true;
|
|
|
|
const toggle = (id: number) =>
|
|
setSelected((s) => {
|
|
const next = new Set(s);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
return next;
|
|
});
|
|
|
|
const saveExpansions = async () => {
|
|
try {
|
|
await setExpansions.mutateAsync([...selected]);
|
|
toast.show("Дополнения сохранены");
|
|
} catch (e) {
|
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
|
}
|
|
};
|
|
|
|
const add = async () => {
|
|
const nick = newNick.trim();
|
|
if (!nick) return;
|
|
try {
|
|
await inviteMember.mutateAsync({ groupId: group.id, nickname: nick });
|
|
setNewNick("");
|
|
toast.show("Приглашение отправлено");
|
|
} catch (e) {
|
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
|
}
|
|
};
|
|
|
|
const remove = async (userId: number) => {
|
|
try {
|
|
await removeMember.mutateAsync(userId);
|
|
} catch (e) {
|
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="card">
|
|
<h3>Дополнения</h3>
|
|
<div className="stack">
|
|
{/* Базовые дополнения показываем, как и при создании группы: включены и заблокированы. */}
|
|
{(expansions ?? []).map((e) => (
|
|
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
|
|
<span>
|
|
{e.name_ru}
|
|
{e.is_base && <span className="muted small"> (всегда включено)</span>}
|
|
</span>
|
|
<Switch
|
|
disabled={e.is_base || !isOwner}
|
|
checked={e.is_base || selected.has(e.id)}
|
|
onChange={() => toggle(e.id)}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
{isOwner && (
|
|
<button className="btn btn-primary" style={{ marginTop: 10 }} onClick={saveExpansions}>
|
|
Сохранить дополнения
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3>
|
|
{isOwner && (members ?? []).length >= MAX_GROUP_SIZE && (
|
|
<p className="muted small">Достигнут максимум участников ({MAX_GROUP_SIZE}).</p>
|
|
)}
|
|
{isOwner && (members ?? []).length < MAX_GROUP_SIZE && (
|
|
<div className="row" style={{ marginBottom: 10 }}>
|
|
<input
|
|
placeholder="Ник игрока — отправить приглашение"
|
|
value={newNick}
|
|
onChange={(e) => setNewNick(e.target.value)}
|
|
/>
|
|
<button className="btn btn-primary" onClick={add} disabled={inviteMember.isPending}>
|
|
<UserPlus size={18} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="stack">
|
|
{(members ?? []).map((m) => (
|
|
<div key={m.user_id} className="row-between">
|
|
{/* Кликабельна вся левая часть строки, не только текст ника. */}
|
|
<PlayerLink userId={m.user_id} style={{ flex: 1 }}>
|
|
{m.nickname} <span className="muted small">· {m.role}</span>
|
|
</PlayerLink>
|
|
{isOwner && m.role !== "owner" && (
|
|
<button
|
|
className="btn btn-ghost btn-danger small"
|
|
onClick={() => setRemoveTarget({ userId: m.user_id, nickname: m.nickname })}
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|