Из списков убраны варианты-плейсхолдеры («— игрок —» в выборе игрока, «— фракция —» в выборе фракции — disabled+hidden, в закрытом виде текст остаётся). Фон .suggest-list — surface-2, как у input/select; hover строк — --border. Скорость: дебаунс поиска 250→100 мс, в инвайте показанный список дофильтровывается по вводу на клиенте, не дожидаясь ответа сервера; touch-action: manipulation на строках и триггере. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
160 lines
6.1 KiB
TypeScript
160 lines
6.1 KiB
TypeScript
import { UserPlus } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
|
|
import { ApiError } from "../api/client";
|
|
import { CreateGroupForm } from "../components/CreateGroupForm";
|
|
import { GroupInvitations } from "../components/GroupInvitations";
|
|
import { GroupSwitcher } from "../components/GroupSwitcher";
|
|
import { Leaderboard } from "../components/Leaderboard";
|
|
import { MatchListView } from "../components/MatchList";
|
|
import { PlayerSuggestions } from "../components/PlayerSuggestions";
|
|
import { Spinner } from "../components/Spinner";
|
|
import { MAX_GROUP_SIZE } from "../domain/constants";
|
|
import { formatDate } from "../domain/format";
|
|
import { useToast } from "../context/ToastContext";
|
|
import { useMe } from "../hooks/auth";
|
|
import {
|
|
useGroup,
|
|
useGroupMatches,
|
|
useGroupMembers,
|
|
useGroupStats,
|
|
useInviteMember,
|
|
} from "../hooks/groups";
|
|
import { useUserSearch } from "../hooks/users";
|
|
|
|
export function GroupPage() {
|
|
const { data: me, isLoading } = useMe();
|
|
const navigate = useNavigate();
|
|
const groupId = me?.active_group_id ?? null;
|
|
|
|
const { data: group } = useGroup(groupId);
|
|
const { data: stats } = useGroupStats(groupId);
|
|
const { data: matches } = useGroupMatches(groupId);
|
|
const { data: members } = useGroupMembers(groupId);
|
|
const inviteMember = useInviteMember();
|
|
const toast = useToast();
|
|
const [newNick, setNewNick] = useState("");
|
|
const [nickFocused, setNickFocused] = useState(false);
|
|
// После подстановки ника из подсказки список прячем: подставленный ник сам
|
|
// совпадает с собой и без флага выпадашка вернулась бы сразу.
|
|
const [nickPicked, setNickPicked] = useState(false);
|
|
// limit с запасом: уже состоящих в группе отфильтруем ниже, до 3 подсказок.
|
|
const { data: foundUsers } = useUserSearch(newNick, 10);
|
|
|
|
if (isLoading) return <Spinner />;
|
|
|
|
const invite = async () => {
|
|
const nick = newNick.trim();
|
|
if (!nick || !groupId) return;
|
|
try {
|
|
await inviteMember.mutateAsync({ groupId, nickname: nick });
|
|
setNewNick("");
|
|
toast.show("Приглашение отправлено");
|
|
} catch (e) {
|
|
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
|
}
|
|
};
|
|
|
|
// Подсказки: уже состоящих в группе не предлагаем, показываем до 3.
|
|
// Дофильтровка по текущему вводу — на клиенте: пока сервер отвечает на новую
|
|
// букву, показанный список (keepPreviousData) сужается мгновенно.
|
|
const memberIds = new Set((members ?? []).map((m) => m.user_id));
|
|
const needle = newNick.trim().toLowerCase();
|
|
const suggestions = (foundUsers ?? [])
|
|
.filter((u) => !memberIds.has(u.user_id) && u.nickname.toLowerCase().includes(needle))
|
|
.slice(0, 3);
|
|
const showSuggest =
|
|
nickFocused && !nickPicked && newNick.trim().length > 0 && suggestions.length > 0;
|
|
|
|
if (!groupId) {
|
|
return (
|
|
<div>
|
|
<div className="card">
|
|
<h3>Активная группа не выбрана</h3>
|
|
<p className="muted small">
|
|
Создайте группу, выберите существующую или примите приглашение, чтобы вести учёт партий.
|
|
</p>
|
|
</div>
|
|
<GroupInvitations />
|
|
<GroupSwitcher />
|
|
<CreateGroupForm />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="card">
|
|
<div className="row-between">
|
|
<h2 style={{ margin: 0 }}>{group?.name ?? "Группа"}</h2>
|
|
{group && (
|
|
<button className="btn btn-ghost small" onClick={() => navigate("/group/settings")}>
|
|
Настройки
|
|
</button>
|
|
)}
|
|
</div>
|
|
<p className="muted small">
|
|
Партий: {stats?.total_matches ?? 0}
|
|
{stats?.last_match_at ? ` · последняя: ${formatDate(stats.last_match_at)}` : ""}
|
|
</p>
|
|
</div>
|
|
|
|
<GroupInvitations />
|
|
|
|
<div className="card">
|
|
<h3>Список игроков</h3>
|
|
{stats && (
|
|
<Leaderboard
|
|
entries={stats.leaderboard}
|
|
provisional={stats.provisional}
|
|
inactive={stats.inactive}
|
|
/>
|
|
)}
|
|
{/* Приглашение нового игрока — здесь, рядом со списком (не в настройках). */}
|
|
{(members ?? []).length >= MAX_GROUP_SIZE ? (
|
|
<p className="muted small" style={{ margin: "10px 0 0" }}>
|
|
Достигнут максимум участников ({MAX_GROUP_SIZE}).
|
|
</p>
|
|
) : (
|
|
<div className="row" style={{ marginTop: 10 }}>
|
|
<div className="suggest-wrap">
|
|
<input
|
|
placeholder="Ник игрока — отправить приглашение"
|
|
value={newNick}
|
|
onChange={(e) => {
|
|
setNewNick(e.target.value);
|
|
setNickPicked(false);
|
|
}}
|
|
onFocus={() => setNickFocused(true)}
|
|
onBlur={() => setNickFocused(false)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") invite();
|
|
if (e.key === "Escape") e.currentTarget.blur();
|
|
}}
|
|
/>
|
|
{showSuggest && (
|
|
<PlayerSuggestions
|
|
items={suggestions}
|
|
onPick={(u) => {
|
|
setNewNick(u.nickname);
|
|
setNickPicked(true);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
<button className="btn btn-primary" onClick={invite} disabled={inviteMember.isPending}>
|
|
<UserPlus size={18} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<GroupSwitcher />
|
|
|
|
<h3 style={{ marginTop: 8 }}>Партии</h3>
|
|
{matches && <MatchListView items={matches.items} />}
|
|
</div>
|
|
);
|
|
}
|