Files
ForbiddenStarsApp/frontend/src/pages/GroupSettingsPage.tsx
T
NotBigGhostandClaude Fable 5 c4f75d5de0 Настройки группы: переименование группы
Новый хук useRenameGroup (PATCH /api/groups/{id} — эндпойнт уже был на
бэкенде) с инвалидацией всех мест, где видно имя группы (шапка группы,
переключатель групп, главная). В настройках — карточка «Название» с
полем и кнопкой «Сохранить» (по образцу смены ника в аккаунте).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 17:19:07 +03:00

171 lines
6.1 KiB
TypeScript

import { Trash2 } 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,
useRemoveMember,
useRenameGroup,
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 renameGroup = useRenameGroup(groupId ?? 0);
const removeMember = useRemoveMember(groupId ?? 0);
const toast = useToast();
const [selected, setSelected] = useState<Set<number>>(new Set());
// Название редактируется локально; до первого ввода берём значение из группы.
const [name, setName] = useState<string | null>(null);
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 nameValue = name ?? group.name;
const saveName = async () => {
const trimmed = nameValue.trim();
if (!trimmed || trimmed === group.name) return;
try {
await renameGroup.mutateAsync(trimmed);
setName(null);
toast.show("Название сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saveExpansions = async () => {
try {
await setExpansions.mutateAsync([...selected]);
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="row">
<input
value={nameValue}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveName();
}}
/>
<button
className="btn btn-primary"
onClick={saveName}
disabled={renameGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
>
Сохранить
</button>
</div>
</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>
<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 === "owner" ? "владелец" : "участник"}</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>
);
}