v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика

This commit is contained in:
2026-06-16 17:41:06 +03:00
parent 6ab74f01aa
commit 56b5d09a4d
123 changed files with 13572 additions and 21 deletions
+128
View File
@@ -0,0 +1,128 @@
import { Trash2, UserPlus } from "lucide-react";
import { useEffect, useState } from "react";
import { ApiError } from "../api/client";
import { Spinner } from "../components/Spinner";
import { MAX_GROUP_SIZE } from "../domain/constants";
import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth";
import { useAddMember, useGroup, useGroupMembers, 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 addMember = useAddMember(groupId ?? 0);
const removeMember = useRemoveMember(groupId ?? 0);
const toast = useToast();
const [selected, setSelected] = useState<Set<number>>(new Set());
const [newNick, setNewNick] = useState("");
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 addMember.mutateAsync(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>
<input
type="checkbox"
style={{ width: "auto" }}
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}>
<UserPlus size={18} />
</button>
</div>
)}
<div className="stack">
{(members ?? []).map((m) => (
<div key={m.user_id} className="row-between">
<span>
{m.nickname} <span className="muted small">· {m.role}</span>
</span>
{isOwner && m.role !== "owner" && (
<button className="btn btn-ghost btn-danger small" onClick={() => remove(m.user_id)}>
<Trash2 size={16} />
</button>
)}
</div>
))}
</div>
</div>
</div>
);
}