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
+39
View File
@@ -0,0 +1,39 @@
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { BottomBar } from "./BottomBar";
const TITLES: Record<string, string> = {
"/": "Forbidden Stars",
"/group": "Группа",
"/group/settings": "Настройки группы",
"/account": "Аккаунт",
"/match/new": "Новая партия",
};
export function AppShell() {
const location = useLocation();
const navigate = useNavigate();
const title = TITLES[location.pathname] ?? "Forbidden Stars";
const isHome = location.pathname === "/";
return (
<div className="app-shell">
<header className="top-bar">
{isHome ? (
<span className="title">{title}</span>
) : (
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
← Назад
</button>
)}
<span className="title small muted">{!isHome ? title : ""}</span>
</header>
<main className="app-main">
<Outlet />
</main>
<BottomBar />
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
import { Plus, Settings, Users } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth";
export function BottomBar() {
const { data: me } = useMe();
const navigate = useNavigate();
const toast = useToast();
const hasGroup = me?.active_group_id != null;
const activeGroup = me?.groups.find((g) => g.id === me?.active_group_id);
const onCreate = () => {
if (!hasGroup) {
toast.show("Вы не состоите в группе. Создайте или вступите в группу.");
navigate("/group");
return;
}
navigate("/match/new");
};
return (
<nav className="bottom-bar">
<button className="slot" onClick={() => navigate("/group")}>
<Users size={20} />
<span className="name">{activeGroup ? activeGroup.name : "Группа"}</span>
</button>
<div className="spacer" />
<button className="slot" onClick={() => navigate("/account")}>
<Settings size={20} />
<span>Аккаунт</span>
</button>
<button
className="fab"
aria-disabled={!hasGroup}
aria-label="Создать партию"
onClick={onCreate}
>
<Plus size={26} />
</button>
</nav>
);
}
@@ -0,0 +1,67 @@
import { useState } from "react";
import { ApiError } from "../api/client";
import { useToast } from "../context/ToastContext";
import { useCreateGroup } from "../hooks/groups";
import { useExpansions } from "../hooks/reference";
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
const { data: expansions } = useExpansions();
const createGroup = useCreateGroup();
const toast = useToast();
const [name, setName] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [error, setError] = useState<string | null>(null);
const toggle = (id: number) =>
setSelected((s) => {
const next = new Set(s);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const submit = async () => {
setError(null);
try {
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
toast.show("Группа создана");
onCreated?.();
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
}
};
return (
<div className="card">
<h3>Новая группа</h3>
<div className="field">
<label className="label">Название</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Например: Вечер 40k" />
</div>
<div className="field">
<label className="label">Дополнения группы</label>
<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}
checked={e.is_base || selected.has(e.id)}
onChange={() => toggle(e.id)}
/>
</label>
))}
</div>
</div>
{error && <p className="error-text">{error}</p>}
<button className="btn btn-primary" style={{ width: "100%" }} onClick={submit} disabled={createGroup.isPending}>
Создать
</button>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useMe, useSetActiveGroup } from "../hooks/auth";
export function GroupSwitcher() {
const { data: me } = useMe();
const setActive = useSetActiveGroup();
if (!me || me.groups.length === 0) return null;
return (
<div className="card">
<h3>Мои группы</h3>
<div className="stack">
{me.groups.map((g) => {
const active = g.id === me.active_group_id;
return (
<button
key={g.id}
className={"btn" + (active ? " btn-primary" : "")}
onClick={() => !active && setActive.mutate(g.id)}
>
{g.name} {active && "✓"} <span className="muted small">({g.role})</span>
</button>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,53 @@
import { ChevronRight } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { formatTime } from "../domain/format";
import type { HomeInProgressMatch } from "../domain/types";
export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
const navigate = useNavigate();
if (items.length === 0) return null;
return (
<div className="card" style={{ borderColor: "var(--accent)" }}>
<h3 style={{ color: "var(--accent-2)" }}>Незавершённые партии</h3>
<div className="stack">
{items.map((m) => (
<button
key={m.id}
className="row-between"
onClick={() => navigate(`/match/${m.id}`)}
style={{
width: "100%",
textAlign: "left",
background: "var(--surface-2)",
border: "1px solid var(--border)",
borderRadius: "var(--radius-sm)",
padding: 12,
color: "var(--text)",
}}
>
<div style={{ minWidth: 0 }}>
<div className="row" style={{ gap: 8 }}>
<b>{m.group_name}</b>
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
</div>
<div className="muted small">
{formatTime(m.started_at)} · {m.player_count} игр.
</div>
<div
className="small"
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{m.participants.map((p) => p.nickname).join(", ")}
</div>
</div>
<div className="row" style={{ gap: 4, color: "var(--accent-2)", flexShrink: 0 }}>
Завершить <ChevronRight size={18} />
</div>
</button>
))}
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import type { LeaderboardEntry } from "../domain/types";
function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`;
}
function Row({ entry }: { entry: LeaderboardEntry }) {
return (
<div className="lb-row">
<div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}>
{entry.rank ?? "—"}
</div>
<div>
<div style={{ fontWeight: 600 }}>{entry.nickname}</div>
<div className="lb-metrics">
<span>Игр: {entry.games}</span>
<span>Побед: {entry.wins}</span>
<span>WR: {pct(entry.win_rate)}</span>
</div>
</div>
<div className="lb-score">{entry.score ?? "—"}</div>
</div>
);
}
export function Leaderboard({
entries,
provisional,
}: {
entries: LeaderboardEntry[];
provisional?: LeaderboardEntry[];
}) {
if (entries.length === 0 && (!provisional || provisional.length === 0)) {
return <div className="muted small">Пока нет сыгранных партий.</div>;
}
return (
<div>
{entries.map((e) => (
<Row key={e.user_id} entry={e} />
))}
{provisional && provisional.length > 0 && (
<>
<div className="label" style={{ marginTop: 10 }}>
Новички (мало игр)
</div>
{provisional.map((e) => (
<Row key={e.user_id} entry={e} />
))}
</>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useNavigate } from "react-router-dom";
import { formatDuration } from "../domain/format";
import { winReasonLabel } from "../domain/winReasons";
import type { MatchListItem } from "../domain/types";
export function MatchListView({ items }: { items: MatchListItem[] }) {
const navigate = useNavigate();
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
return (
<div className="stack">
{items.map((m) => {
const inProgress = m.status === "in_progress";
const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
return (
<button
key={m.id}
className="card"
style={{ textAlign: "left", width: "100%", margin: 0 }}
onClick={() => navigate(`/match/${m.id}`)}
>
<div className="row-between">
<b>{m.played_at}</b>
{inProgress ? (
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
) : (
<span className="muted small">
{formatDuration(m.duration_minutes)} · {winReasonLabel(m.win_reason)}
</span>
)}
</div>
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
{sorted.map((p) => (
<div key={p.user_id} className="row" style={{ gap: 8 }}>
<span className={"place-badge" + (p.place === 1 ? " first" : "")}>
{p.place ?? "—"}
</span>
<span>{p.nickname}</span>
<span className="muted small">· {p.faction_name}</span>
</div>
))}
</div>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,40 @@
import type { ProfileStats } from "../domain/types";
function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`;
}
export function ProfileStatsCard({ stats }: { stats: ProfileStats }) {
const o = stats.overall;
return (
<div className="card">
<h3>Моя статистика</h3>
<div className="row-between">
<div>Игр</div>
<b>{o.games}</b>
</div>
<div className="row-between">
<div>Побед</div>
<b>{o.wins}</b>
</div>
<div className="row-between">
<div>Winrate</div>
<b>{pct(o.win_rate)}</b>
</div>
<div className="row-between">
<div>Среднее место</div>
<b>{o.avg_place ?? "—"}</b>
</div>
<div className="row-between">
<div>Очки (рейтинг)</div>
<b className="lb-score">{o.score ?? "—"}</b>
</div>
{stats.most_played_faction && (
<div className="row-between">
<div>Любимая фракция</div>
<b>{stats.most_played_faction.name_ru}</b>
</div>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
export function Spinner({ label }: { label?: string }) {
return (
<div className="center muted" style={{ padding: 40 }}>
<div
style={{
width: 28,
height: 28,
border: "3px solid var(--border)",
borderTopColor: "var(--accent)",
borderRadius: "50%",
margin: "0 auto 10px",
animation: "fs-spin 0.8s linear infinite",
}}
/>
{label ?? "Загрузка…"}
<style>{`@keyframes fs-spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
@@ -0,0 +1,52 @@
import { useEffect, useRef } from "react";
interface TelegramUser {
id: number;
first_name?: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
}
declare global {
interface Window {
__fsTelegramAuth?: (user: TelegramUser) => void;
}
}
/**
* Официальный Telegram Login Widget. Требует бота (@BotFather) и /setdomain
* на текущий HTTPS-домен. botUsername — без «@».
*/
export function TelegramLoginButton({
botUsername,
onAuth,
}: {
botUsername: string;
onAuth: (user: TelegramUser) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
window.__fsTelegramAuth = (user) => onAuth(user);
const script = document.createElement("script");
script.src = "https://telegram.org/js/telegram-widget.js?22";
script.async = true;
script.setAttribute("data-telegram-login", botUsername);
script.setAttribute("data-size", "large");
script.setAttribute("data-userpic", "false");
script.setAttribute("data-request-access", "write");
script.setAttribute("data-onauth", "__fsTelegramAuth(user)");
const node = ref.current;
node?.appendChild(script);
return () => {
if (node) node.innerHTML = "";
delete window.__fsTelegramAuth;
};
}, [botUsername, onAuth]);
return <div ref={ref} />;
}