v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user