Files
ForbiddenStarsApp/frontend/src/pages/GroupPage.tsx
T
NotBigGhostandClaude Fable 5 8a2b53d0b2 UI: доводка выпадашек игроков — без пустых вариантов, фон как у select, быстрее отклик
Из списков убраны варианты-плейсхолдеры («— игрок —» в выборе игрока,
«— фракция —» в выборе фракции — disabled+hidden, в закрытом виде текст
остаётся). Фон .suggest-list — surface-2, как у input/select; hover строк —
--border. Скорость: дебаунс поиска 250→100 мс, в инвайте показанный список
дофильтровывается по вводу на клиенте, не дожидаясь ответа сервера;
touch-action: manipulation на строках и триггере.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 03:13:05 +03:00

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>
);
}