Initialize project with basic structure, including Docker configuration, backend and frontend setup, environment configuration, and essential files for development.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 00:17:31 +03:00
parent d401e9009c
commit 1239fcee08
69 changed files with 12391 additions and 10 deletions

4
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
dist
.git
*.log

7
frontend/.env.example Normal file
View File

@@ -0,0 +1,7 @@
# Leave empty in production to use same-origin /api through nginx.
VITE_API_BASE_URL=
# Local development defaults to demo fixtures. Production builds always use the API.
# Set false to develop against the backend locally.
VITE_DEMO_MODE=true
VITE_DEV_API_TARGET=http://localhost:8080

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

18
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.29-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1/health || exit 1
CMD ["nginx", "-g", "daemon off;"]

32
frontend/README.md Normal file
View File

@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

15
frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0d10" />
<meta name="description" content="Mixmaker community scrim control room" />
<title>Mixmaker · Scrim Control Room</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

39
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,39 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location /api/ {
proxy_pass http://api:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# SSE must stream each event immediately rather than buffering it.
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
chunked_transfer_encoding off;
add_header X-Accel-Buffering no always;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|svg|png|jpg|jpeg|webp|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

5984
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"typecheck": "tsc -b --pretty false",
"lint": "oxlint src --deny-warnings",
"test": "vitest run",
"test:watch": "vitest",
"check": "npm run typecheck && npm run lint && npm run test && npm run build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-router": "^1.170.18",
"clsx": "^2.1.1",
"lucide-react": "^1.25.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"eslint-plugin-jsx-a11y": "^6.10.2",
"jsdom": "^29.1.1",
"oxlint": "^1.71.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^4.1.10"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
frontend/src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

23
frontend/src/App.test.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import App from './App'
describe('Mixmaker frontend', () => {
afterEach(() => {
cleanup()
window.history.replaceState({}, '', '/')
})
it('renders the event dashboard', async () => {
window.history.replaceState({}, '', '/events')
render(<App />)
expect(await screen.findByRole('heading', { name: 'Your events' })).toBeInTheDocument()
expect(screen.getByText('Saturday Night Scrim')).toBeInTheDocument()
})
it('renders the Discord login screen', async () => {
window.history.replaceState({}, '', '/login')
render(<App />)
expect(await screen.findByRole('link', { name: /continue with discord/i })).toHaveAttribute('href', '/api/auth/discord')
})
})

345
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,345 @@
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
import {
CalendarDays, Check, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound,
UsersRound, X, Zap,
} from 'lucide-react'
import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Link, Outlet, RouterProvider, createRootRouteWithContext, createRoute,
createRouter, redirect,
} from '@tanstack/react-router'
import {
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
type PlayerRole, type RsvpStatus, type Series, type Session, type Team,
} from './api/client'
import {
demoBalanceCandidates, demoBracket, demoEvents, demoProfile,
demoMode, demoRegistrations, demoSeries, demoSession,
} from './api/demo'
import { LanguageSelector } from './i18n'
import { useLanguage } from './i18n-context'
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
type RouterContext = { session: Session | null }
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false }, mutations: { retry: 0 } } })
const rootRoute = createRootRouteWithContext<RouterContext>()({ component: Outlet })
const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: '/login', component: LoginPage })
const protectedRoute = createRoute({
getParentRoute: () => rootRoute, id: '_protected', component: AppShell,
beforeLoad: ({ context }) => { if (!context.session) throw redirect({ to: '/login' }) },
})
const indexRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/events' }) } })
const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/profile', component: ProfilePage })
const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage })
const eventRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events/$eventId', component: EventPage })
const liveRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/series/$seriesId', component: LiveSeriesPage })
const spectatorRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/watch/$seriesId', component: SpectatorPage })
const bracketRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/bracket/$eventId', component: BracketPage })
const adminRoute = createRoute({
getParentRoute: () => protectedRoute, path: '/admin', component: AdminPage,
beforeLoad: ({ context }) => { if (context.session?.account.role !== 'admin') throw redirect({ to: '/events' }) },
})
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
indexRoute, profileRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
])])
const router = createRouter({ routeTree, context: { session: null } })
declare module '@tanstack/react-router' { interface Register { router: typeof router } }
const navItems = [
{ to: '/events', label: 'Events', icon: CalendarDays },
...(demoMode ? [
{ to: '/series/$seriesId' as const, params: { seriesId: 'series-1' }, label: 'Live', icon: Radio },
{ to: '/bracket/$eventId' as const, params: { eventId: 'bracket-1' }, label: 'Bracket', icon: Trophy },
] : []),
{ to: '/admin', label: 'Admin', icon: Shield },
]
function Brand() {
return <Link to="/events" className="brand" aria-label="Mixmaker home"><span className="brand-mark"><Swords aria-hidden="true" /></span><span><strong>MIX</strong>MAKER</span></Link>
}
function AppShell() {
const [open, setOpen] = useState(false)
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
const logout = useMutation({
mutationFn: api.logout,
onSuccess: () => {
queryClient.clear()
void router.navigate({ to: '/login' })
},
})
const initials = session.data?.account.displayName.slice(0, 2).toUpperCase() ?? 'MM'
return <div className="app-shell">
<header className="topbar">
<Brand />
<nav className="desktop-nav" aria-label="Primary navigation">{navItems.map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} activeProps={{ className: 'active' }}><Icon />{item.label}</Link>)}</nav>
<div className="account"><LanguageSelector /><span className="live-dot" title="Realtime connected" /><Link to="/profile" className="avatar" aria-label="Open profile">{initials}</Link><button className="icon-button" onClick={() => logout.mutate()} aria-label="Sign out" title="Sign out"><LogOut /></button><button className="icon-button mobile-menu" onClick={() => setOpen(!open)} aria-label="Toggle navigation" aria-expanded={open}>{open ? <X /> : <Menu />}</button></div>
</header>
{open && <nav className="mobile-nav" aria-label="Mobile navigation">{[...navItems, { to: '/profile' as const, label: 'Profile', icon: UserRound }].map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} onClick={() => setOpen(false)}><Icon />{item.label}</Link>)}</nav>}
<main><Outlet /></main>
<nav className="bottom-nav" aria-label="Quick navigation">{navItems.map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} activeProps={{ className: 'active' }}><Icon /><span>{item.label}</span></Link>)}</nav>
</div>
}
function LoginPage() {
return <div className="login-page">
<div className="login-orb login-orb-orange" /><div className="login-orb login-orb-blue" />
<section className="login-card"><div className="login-brand-row"><Brand /><LanguageSelector /></div><span className="eyebrow">Community scrims, organized</span><h1>Ready up.<br /><span>Play together.</span></h1><p>Balance teams, run drafts, and follow every map from one esports control room.</p><a className="button discord-button" href="/api/auth/discord"><Gamepad2 />Continue with Discord</a><small>Signing in creates a secure session. We never receive your Discord password.</small></section>
<div className="login-stats"><span><strong>5v5</strong> role-locked teams</span><span><strong>LIVE</strong> synchronized drafts</span><span><strong>BO3</strong> tournament series</span></div>
</div>
}
function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description?: string; actions?: ReactNode }) {
return <header className="page-header"><div><span className="eyebrow">{eyebrow}</span><h1>{title}</h1>{description && <p>{description}</p>}</div>{actions && <div className="page-actions">{actions}</div>}</header>
}
function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return <div className="state-card" role="status"><RefreshCw className="spin" /><h2>{label}</h2><p>Syncing with the server.</p></div> }
function ErrorState({ retry }: { retry?: () => void }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldnt load this view</h2><p>Your data is safe. Check your connection and try again.</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
function EventsPage() {
const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming')
const events = useQuery({ queryKey: ['events'], queryFn: api.events, initialData: demoMode ? demoEvents : undefined })
if (events.isLoading) return <LoadingState label="Loading events…" />
if (events.isError) return <ErrorState retry={() => void events.refetch()} />
if (!events.data) return <LoadingState label="Loading events…" />
const visible = filter === 'upcoming' ? events.data.filter((e) => e.status !== 'completed') : events.data
return <div className="page">
<PageHeader eyebrow="Scrim schedule" title="Your events" description="Join a mix, check the roster, and get match-ready." actions={<div className="segmented"><button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button><button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button></div>} />
{demoMode && <section className="featured-event"><div><Badge tone="live"><Radio />Live now</Badge><h2>{demoEvents[2].title}</h2><p>Semifinal 1 · Map 2 draft in progress</p></div><div className="featured-score"><span>EMBER</span><strong>1<i>:</i>0</strong><span>AZURE</span></div><Link className="button primary" to="/watch/$seriesId" params={{ seriesId: 'series-1' }}>Watch live <ChevronRight /></Link></section>}
<div className="section-title"><div><h2>Coming up</h2><p>{visible.length} scheduled events</p></div><button className="icon-button" aria-label="Filter events"><ListFilter /></button></div>
{visible.length === 0 ? <div className="state-card"><CalendarDays /><h2>No events yet</h2><p>New community nights will appear here.</p></div> : <div className="event-grid">{visible.filter((e) => e.status !== 'live').map((event) => <EventCard key={event.id} event={event} />)}</div>}
</div>
}
function EventCard({ event }: { event: MixEvent }) {
const date = new Date(event.startsAt)
return <article className="card event-card"><div className="date-block"><strong>{date.toLocaleDateString(undefined, { day: '2-digit' })}</strong><span>{date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}</span></div><div className="event-card-body"><div className="event-meta"><Badge tone={event.myRsvp === 'going' ? 'success' : 'warning'}>{event.myRsvp === 'going' ? <Check /> : <Clock3 />}{event.myRsvp === 'going' ? 'Going' : 'Maybe'}</Badge><span>{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span></div><h3>{event.title}</h3><p>{event.description}</p><div className="event-footer"><span><UsersRound />{event.counts.going} going</span><Link to="/events/$eventId" params={{ eventId: event.id }}>View event <ChevronRight /></Link></div></div></article>
}
function EventPage() {
const { eventId } = eventRoute.useParams()
const client = useQueryClient()
const demoEvent = demoEvents.find((event) => event.id === eventId)
const eventQuery = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId), initialData: demoMode ? demoEvent : undefined })
const registrations = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined })
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
const rsvpMutation = useMutation({
mutationFn: (status: RsvpStatus) => api.setRsvp(eventId, session.data!.player.id, status),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['event', eventId] })
void client.invalidateQueries({ queryKey: ['events'] })
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
},
})
useEffect(() => subscribeToEvents(`event:${eventId}`, () => {
void client.invalidateQueries({ queryKey: ['event', eventId] })
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
}), [client, eventId])
const labels: Record<RsvpStatus, string> = { going: 'Going', maybe: 'Maybe', not_going: 'Cant go' }
if (eventQuery.isLoading || registrations.isLoading || session.isLoading) return <LoadingState label="Loading event…" />
if (eventQuery.isError || registrations.isError || session.isError || !eventQuery.data || !session.data) return <ErrorState retry={() => { void eventQuery.refetch(); void registrations.refetch(); void session.refetch() }} />
const event = eventQuery.data
const rsvp = registrations.data?.find((registration) => registration.player.id === session.data.player.id)?.status ?? event.myRsvp
const counts = registrations.data?.reduce((total, registration) => ({ ...total, [registration.status]: total[registration.status] + 1 }), { going: 0, maybe: 0, not_going: 0 }) ?? event.counts
return <div className="page"><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<Badge tone="success">Registration open</Badge>} /><div className="detail-layout">
<section className="card event-hero"><div className="event-time"><CalendarDays /><div><span>Starts</span><strong>{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="event-time"><Clock3 /><div><span>Registration closes</span><strong>{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}</strong></div></div><div className="divider" /><h2>Are you playing?</h2><p>Your latest response is sent to the organizer.</p><div className="rsvp-control" role="group" aria-label="RSVP status">{(Object.keys(labels) as RsvpStatus[]).map((status) => <button key={status} disabled={rsvpMutation.isPending} className={rsvp === status ? `active ${status}` : ''} aria-pressed={rsvp === status} onClick={() => rsvpMutation.mutate(status)}>{status === 'going' ? <Check /> : status === 'maybe' ? <CircleHelp /> : <X />}{labels[status]}</button>)}</div>{rsvpMutation.isSuccess && <div className="save-note"><Check />Response saved · Server confirmed just now</div>}{rsvpMutation.isError && <p className="error-note">{rsvpMutation.error.message}</p>}</section>
<aside className="card roster-preview"><div className="section-title"><div><h2>Attendance</h2><p>{Object.values(counts).reduce((a, b) => a + b, 0)} responses</p></div><UsersRound /></div><div className="attendance-bar"><span /><span /><span /></div><div className="attendance-stats"><span><i className="green" />Going <strong>{counts.going}</strong></span><span><i className="yellow" />Maybe <strong>{counts.maybe}</strong></span><span><i />No <strong>{counts.not_going}</strong></span></div>{session.data.account.role === 'admin' && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
</div></div>
}
function ProfilePage() {
const client = useQueryClient()
const language = useLanguage()
const options = useMemo(() => rankOptions(language), [language])
const profile = useQuery({ queryKey: ['profile'], queryFn: api.profile, initialData: demoMode ? demoProfile : undefined })
const players = useQuery({ queryKey: ['players'], queryFn: api.players, initialData: demoMode ? demoRegistrations.map((registration) => registration.player) : undefined })
const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 20, damage: 20, support: 20 })
const [preferredRoles, setPreferredRoles] = useState<PlayerRole[]>([])
const [preferredPlayerIds, setPreferredPlayerIds] = useState<string[]>([])
useEffect(() => {
if (profile.data) {
setRatings({ tank: profile.data.ratings.tank, damage: profile.data.ratings.damage, support: profile.data.ratings.support })
setPreferredRoles(profile.data.preferredRoles)
setPreferredPlayerIds(profile.data.preferredPlayerIds)
}
}, [profile.data])
const update = useMutation({
mutationFn: () => api.updateRatings(profile.data!.displayName, ratings, preferredRoles, preferredPlayerIds),
onSuccess: (player) => {
client.setQueryData(['profile'], player)
void client.invalidateQueries({ queryKey: ['session'] })
},
})
if (profile.isLoading || players.isLoading) return <LoadingState label="Loading profile…" />
if (profile.isError || players.isError) return <ErrorState retry={() => { void profile.refetch(); void players.refetch() }} />
if (!profile.data || !players.data) return <LoadingState label="Loading profile…" />
const roles: Array<{ key: PlayerRole; label: string; hint: string }> = [{ key: 'tank', label: 'Tank', hint: 'Space, pressure, and frontline' }, { key: 'damage', label: 'Damage', hint: 'Eliminations and map control' }, { key: 'support', label: 'Support', hint: 'Sustain, utility, and tempo' }]
const submit = (e: FormEvent) => { e.preventDefault(); update.mutate() }
return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{players.data.filter((player) => player.id !== profile.data.id).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
}
type AdminTab = 'events' | 'participants' | 'balance'
function AdminPage() {
const [tab, setTab] = useState<AdminTab>('events')
const [createMode, setCreateMode] = useState(false)
const events = useQuery({ queryKey: ['events'], queryFn: api.events, initialData: demoMode ? demoEvents : undefined })
const [selectedId, setSelectedId] = useState<string>()
const selected = events.data?.find((event) => event.id === selectedId) ?? events.data?.[0]
useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
if (events.isLoading) return <LoadingState label="Loading admin control room…" />
if (events.isError) return <ErrorState retry={() => void events.refetch()} />
return <div className="page"><PageHeader eyebrow="Admin control room" title={createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={<><select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data?.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select><button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></>} /><div className="tabs" role="tablist">{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : 'Balance teams'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}</div>
}
function toLocalInput(value: string) {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '' : new Date(date.getTime() - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16)
}
function eventInput(event?: MixEvent): EventInput {
const start = new Date(Date.now() + 86_400_000)
const end = new Date(start.getTime() + 4 * 3_600_000)
const deadline = new Date(start.getTime() - 3_600_000)
return {
name: event?.title ?? '',
description: event?.description ?? '',
startsAt: toLocalInput(event?.startsAt ?? start.toISOString()),
endsAt: toLocalInput(event?.endsAt ?? end.toISOString()),
registrationDeadline: toLocalInput(event?.registrationDeadline ?? deadline.toISOString()),
}
}
function EventSetup({ event, onCreated }: { event?: MixEvent; onCreated: (event: MixEvent) => void }) {
const client = useQueryClient()
const initialForm = useMemo(() => eventInput(event), [event])
const [form, setForm] = useState<EventInput>(initialForm)
useEffect(() => setForm(initialForm), [initialForm])
const save = useMutation({
mutationFn: () => {
const payload = { ...form, startsAt: new Date(form.startsAt).toISOString(), endsAt: new Date(form.endsAt).toISOString(), registrationDeadline: new Date(form.registrationDeadline).toISOString() }
return event ? api.updateEvent(event.id, payload) : api.createEvent(payload)
},
onSuccess: (saved) => {
void client.invalidateQueries({ queryKey: ['events'] })
onCreated(saved)
},
})
const set = (key: keyof EventInput, value: string) => setForm((current) => ({ ...current, [key]: value }))
return <div className="admin-grid"><form className="card settings-card" onSubmit={(e) => { e.preventDefault(); save.mutate() }}><div className="section-title"><div><h2>{event ? 'Event settings' : 'New event'}</h2><p>Scheduling and registration</p></div>{event && <Badge tone="success">Published</Badge>}</div><div className="field-grid"><label>Event name<input required value={form.name} onChange={(e) => set('name', e.target.value)} /></label><label>Start time<input required type="datetime-local" value={form.startsAt} onChange={(e) => set('startsAt', e.target.value)} /></label><label>End time<input required type="datetime-local" value={form.endsAt} onChange={(e) => set('endsAt', e.target.value)} /></label><label>Registration deadline<input required type="datetime-local" value={form.registrationDeadline} onChange={(e) => set('registrationDeadline', e.target.value)} /></label></div><label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label><div className="form-footer"><p>{save.isError ? save.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div></form><aside className="card admin-summary"><h2>Server authority</h2><div className="readiness-score"><Shield /><span>Validation runs on the backend</span></div><ul><li><Check />UTC scheduling</li><li><Check />Audited admin changes</li><li><Check />Registration deadline rules</li></ul></aside></div>
}
function Participants({ eventId }: { eventId: string }) {
const client = useQueryClient()
const language = useLanguage()
const rows = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined })
const override = useMutation({
mutationFn: ({ playerId, status }: { playerId: string; status: RsvpStatus }) => api.setRsvp(eventId, playerId, status),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
void client.invalidateQueries({ queryKey: ['events'] })
},
})
if (rows.isLoading) return <LoadingState label="Loading participants…" />
if (rows.isError || !rows.data) return <ErrorState retry={() => void rows.refetch()} />
const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName]))
return <section className="card table-card"><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><strong>{r.player.displayName}</strong><span>{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}</>}{r.changedBy?.source === 'admin' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></div>)}</div>{override.isError && <p className="error-note">{override.error.message}</p>}</section>
}
function BalanceView({ eventId }: { eventId: string }) {
const client = useQueryClient()
const existingTeams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode ? demoBalanceCandidates[0].teams : undefined })
const generate = useMutation({ mutationFn: () => api.generateBalance(eventId) })
const candidates = useMemo(() => generate.data ?? (demoMode ? demoBalanceCandidates : []), [generate.data])
const [selected, setSelected] = useState<string>()
useEffect(() => { if (!selected && candidates[0]) setSelected(candidates[0].id) }, [candidates, selected])
const candidate = candidates.find((item) => item.id === selected) ?? candidates[0]
const select = useMutation({
mutationFn: () => api.selectBalance(eventId, candidate),
onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }),
})
if (existingTeams.isLoading) return <LoadingState label="Loading teams…" />
if (!candidate) return <div className="state-card"><Sparkles /><h2>Generate balance candidates</h2><p>The backend uses confirmed Going players and returns three ranked alternatives.</p><button className="button primary" disabled={generate.isPending} onClick={() => generate.mutate()}>{generate.isPending ? 'Balancing…' : 'Generate on server'}</button>{generate.isError && <p className="error-note">{generate.error.message}</p>}{existingTeams.data && existingTeams.data.length > 0 && <div className="teams-grid">{existingTeams.data.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div>}</div>
return <div className="balance-layout"><aside className="candidate-list"><div className="section-title"><div><h2>Server candidates</h2><p>Lower imbalance score is better</p></div><button className="icon-button" aria-label="Generate again" disabled={generate.isPending} onClick={() => generate.mutate()}><RefreshCw /></button></div>{candidates.map((item, i) => <button key={item.id} className={`candidate-card ${selected === item.id ? 'active' : ''}`} onClick={() => setSelected(item.id)}><span className="candidate-rank">0{i + 1}</span><span><strong>Score {item.score}</strong><small>Computed by backend balancer</small></span><ChevronRight /></button>)}</aside><section className="balance-main"><div className="balance-score card"><div><span className="eyebrow">Imbalance score</span><strong>{candidate.score}</strong></div><div className="balance-explanations"><span>Preference fit</span>{candidate.explanations.map((explanation) => <small key={explanation}><Check />{explanation}</small>)}</div><p>Lower scores represent closer team and role totals. The server remains the source of truth for roster validity.</p></div><div className="teams-grid">{candidate.teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="reserve-bar card"><div><UsersRound /><span><strong>Reserve · {candidate.reserve.length}</strong><small>Not assigned to a complete 5v5 roster</small></span></div><div>{candidate.reserve.map((p) => <Badge key={p.id}>{p.displayName}</Badge>)}</div></div><div className="sticky-action"><p>{select.isError ? select.error.message : 'The server will save these rosters and unlock captain assignment.'}</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}><Check />{select.isPending ? 'Saving…' : 'Use this balance'}</button></div></section></div>
}
function TeamCard({ team, eventId }: { team: Team; eventId: string }) {
const client = useQueryClient()
const language = useLanguage()
const [captain, setCaptain] = useState(team.captainId ?? '')
useEffect(() => setCaptain(team.captainId ?? ''), [team.captainId])
const assign = useMutation({
mutationFn: (playerId: string) => api.assignCaptain(team.id, playerId),
onSuccess: () => void client.invalidateQueries({ queryKey: ['teams', eventId] }),
})
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div><label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label></article>
}
function LiveSeriesPage() {
const { seriesId } = liveRoute.useParams()
const client = useQueryClient()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
const [selected, setSelected] = useState<string>()
const [mapName, setMapName] = useState('')
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
const result = useMutation({
mutationFn: () => api.recordResult(seriesId, mapName, outcome, series.data!.version),
onSuccess: (updated) => {
client.setQueryData(['series', seriesId], updated)
setMapName('')
},
})
useEffect(() => subscribeToEvents(`series:${seriesId}`, () => {
void client.invalidateQueries({ queryKey: ['series', seriesId] })
}), [client, seriesId])
if (series.isLoading) return <LoadingState />
if (series.isError) return <ErrorState retry={() => void series.refetch()} />
if (!series.data) return <LoadingState />
const data = series.data
return <div className="page live-page"><PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description="Actions are validated and recorded by the server." actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} /><Scoreboard series={data} /><div className="live-layout"><section className={`turn-card team-${data.currentStep.activeTeam ?? 'alpha'}`}><div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}</strong></div>}</div>{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(e) => { e.preventDefault(); result.mutate() }}><label>Map name<input required value={mapName} onChange={(e) => setMapName(e.target.value)} placeholder="e.g. Lijiang Tower" /></label><label>Outcome<select value={outcome} onChange={(e) => setOutcome(e.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={result.isPending}>{result.isPending ? 'Recording…' : 'Record result'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form> : <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />Draft commands use dedicated backend endpoints and server-side validation.</p><button className="button team-action" disabled={!selected}>Confirm action <ChevronRight /></button></div></>}</section><aside className="match-sidebar"><MapTimeline series={data} /><AuditLog series={data} /></aside></div></div>
}
function Scoreboard({ series }: { series: Series }) {
const alphaCaptain = series.teamAlpha.members.find(({ player }) => player.id === series.teamAlpha.captainId)?.player.displayName
const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player.displayName
return <section className="scoreboard" aria-label="Current series score"><div className="score-team alpha"><span>Team Alpha</span><h2>{series.teamAlpha.name}</h2><small>{alphaCaptain ? `Captain · ${alphaCaptain}` : 'Captain not assigned'}</small></div><div className="series-score"><span>Best of 3</span><strong>{series.score.alpha}<i>:</i>{series.score.beta}</strong><Badge tone="live"><Radio />{series.status}</Badge></div><div className="score-team beta"><span>Team Beta</span><h2>{series.teamBeta.name}</h2><small>{betaCaptain ? `Captain · ${betaCaptain}` : 'Captain not assigned'}</small></div></section>
}
function MapTimeline({ series }: { series: Series }) { return <section className="card map-timeline"><div className="section-title"><div><h2>Map timeline</h2><p>First to 2 wins</p></div></div>{series.maps.length === 0 ? <p className="empty-note">No results recorded.</p> : series.maps.map((map) => <div className={`map-row ${map.status}`} key={map.number}><span>0{map.number}</span><div><strong>{map.name}</strong><small>{map.mode} · {map.status}</small></div>{map.winner && <Badge tone={map.winner === 'alpha' ? 'warning' : 'blue'}>{map.winner === 'alpha' ? 'A' : map.winner === 'beta' ? 'B' : 'Draw'}</Badge>}</div>)}</section> }
function AuditLog({ series }: { series: Series }) { return <section className="card audit-log"><div className="section-title"><div><h2>Match history</h2><p>Auditable server actions</p></div></div>{series.audit.length === 0 ? <p className="empty-note">No actions yet.</p> : series.audit.slice(-4).reverse().map((a) => <div key={a.id}><span className="history-dot" /><p><strong>{a.summary}</strong><small>{a.actorName} · {new Date(a.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</small></p></div>)}</section> }
function SpectatorPage() {
const { seriesId } = spectatorRoute.useParams()
const client = useQueryClient()
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
useEffect(() => subscribeToEvents(`series:${seriesId}`, () => void client.invalidateQueries({ queryKey: ['series', seriesId] })), [client, seriesId])
if (series.isLoading) return <LoadingState />
if (series.isError || !series.data) return <ErrorState retry={() => void series.refetch()} />
const data = series.data
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<Badge tone="success"><span className="live-dot" />Connected</Badge>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p>{demoMode && data.currentStep.kind === 'hero_ban' && <div className="ban-display">{data.maps.at(-1)?.heroBans.map((ban, index) => <div key={`${ban.hero}-${index}`}><span>Ban {index + 1}</span><strong>{ban.hero}</strong><small>{ban.role}</small></div>)}</div>}</section><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
}
function BracketPage() {
const { eventId: tournamentId } = bracketRoute.useParams()
const bracket = useQuery({ queryKey: ['tournament', tournamentId], queryFn: () => api.bracket(tournamentId), initialData: demoMode && tournamentId === demoBracket.id ? demoBracket : undefined })
if (bracket.isLoading) return <LoadingState label="Loading bracket…" />
if (bracket.isError || !bracket.data) return <ErrorState retry={() => void bracket.refetch()} />
const data = bracket.data
return <div className="page bracket-page"><PageHeader eyebrow="Single elimination" title={data.title} description="Winners advance automatically when the server completes a series." actions={<Badge tone="live"><Radio />{data.matches.filter((match) => match.status === 'live').length} live</Badge>} /><div className="bracket-scroll" tabIndex={0} aria-label="Tournament bracket">{data.rounds.map((round, ri) => <section className="bracket-round" key={round}><header><span>Round {ri + 1}</span><h2>{round}</h2></header><div className="bracket-matches">{data.matches.filter((m) => m.round === ri).map((m) => <article className={`bracket-match ${m.status}`} key={m.id}><div className="match-label"><span>{m.label}</span>{m.status === 'live' && <Badge tone="live">Live</Badge>}</div><div className={m.winner === 'alpha' ? 'winner' : ''}><span>{m.teamAlpha ?? 'TBD'}</span><strong>{m.scoreAlpha ?? '—'}</strong></div><div className={m.winner === 'beta' ? 'winner' : ''}><span>{m.teamBeta ?? 'TBD'}</span><strong>{m.scoreBeta ?? '—'}</strong></div>{m.seriesId && <Link to="/watch/$seriesId" params={{ seriesId: m.seriesId }}>Open series <ChevronRight /></Link>}</article>)}</div></section>)}</div><p className="bracket-help"><ChevronRight />Scroll horizontally to follow the bracket on smaller screens.</p></div>
}
function App() {
return <QueryClientProvider client={queryClient}><AppRouter /></QueryClientProvider>
}
function AppRouter() {
const session = useQuery({
queryKey: ['session'],
queryFn: api.session,
initialData: demoMode ? demoSession : undefined,
retry: false,
})
const context = useMemo(() => ({ session: session.data ?? null }), [session.data])
if (session.isLoading) return <LoadingState label="Restoring your session…" />
return <RouterProvider router={router} context={context} />
}
export default App

View File

@@ -0,0 +1,89 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { adaptEvent, adaptPlayer, adaptSeries, api, type RawEvent, type RawPlayer, type RawSeries } from './client'
const player: RawPlayer = {
id: 'player-1',
accountId: 'account-1',
displayName: 'Nova',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['Tank'],
preferredPlayerIds: ['player-2'],
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-18T00:00:00Z',
}
afterEach(() => vi.restoreAllMocks())
describe('backend DTO adapters', () => {
it('adapts backend role ratings without changing their values', () => {
expect(adaptPlayer(player)).toMatchObject({
displayName: 'Nova',
ratings: { tank: 36, damage: 28, support: 24 },
preferredRoles: ['tank'],
preferredPlayerIds: ['player-2'],
})
})
it('adapts backend RSVP values and counts', () => {
const event: RawEvent = {
id: 'event-1',
name: 'Scrim',
description: '',
startsAt: '2099-07-20T19:00:00Z',
endsAt: '2099-07-20T23:00:00Z',
registrationDeadline: '2099-07-20T18:00:00Z',
createdBy: 'account-1',
createdAt: '2026-07-01T00:00:00Z',
updatedAt: '2026-07-01T00:00:00Z',
}
const adapted = adaptEvent(event, [
{ eventId: event.id, playerId: player.id, actorAccountId: 'account-1', status: 'Going', source: 'player', updatedAt: event.updatedAt },
], player.id)
expect(adapted.myRsvp).toBe('going')
expect(adapted.counts.going).toBe(1)
})
it('keeps corrected results out of the authoritative score', () => {
const series: RawSeries = {
id: 'series-1',
tournamentId: 't-1',
teamAId: 'a',
teamBId: 'b',
winnerTeamId: '',
bestOf: 3,
version: 2,
results: [
{ mapName: 'Lijiang', outcome: 'TeamAWin', actorAccountId: 'admin', recordedAt: '2026-07-18T00:00:00Z' },
{ mapName: 'Lijiang', outcome: 'TeamBWin', actorAccountId: 'admin', recordedAt: '2026-07-18T00:01:00Z', correctionOf: 1 },
],
}
expect(adaptSeries(series).score).toEqual({ alpha: 0, beta: 1 })
})
})
describe('actual backend routes', () => {
it('updates the current player through PATCH /api/me/player', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(player), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.updateRatings('Nova', { tank: 37, damage: 29, support: 25 }, ['tank', 'damage'], ['player-2'])
expect(fetchMock).toHaveBeenCalledWith('/api/me/player', expect.objectContaining({ method: 'PATCH' }))
expect(fetchMock.mock.calls[0][1]?.body).toContain('"preferredRoles":["Tank","Damage"]')
})
it('sends backend RSVP enum values to the player-specific route', async () => {
const responses = [
{ eventId: 'event-1', playerId: 'player-1', actorAccountId: 'account-1', status: 'Going', source: 'player', updatedAt: '2026-07-18T00:00:00Z' },
[player],
{ account: { id: 'account-1', discordId: 'discord', username: 'Nova', avatarUrl: '', role: 'player', createdAt: '2026-07-01T00:00:00Z' }, player },
]
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify(responses.shift()), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.setRsvp('event-1', 'player-1', 'going')
const rsvpCall = fetchMock.mock.calls.find(([url]) => url === '/api/events/event-1/rsvps/player-1')
expect(rsvpCall?.[1]?.body).toBe(JSON.stringify({ status: 'Going' }))
})
})

529
frontend/src/api/client.ts Normal file
View File

@@ -0,0 +1,529 @@
import { toRankOrdinal, type RankOrdinal } from '../ranks'
export type AccountRole = 'admin' | 'player'
export type RsvpStatus = 'going' | 'maybe' | 'not_going'
export type PlayerRole = 'tank' | 'damage' | 'support'
export type TeamSide = 'alpha' | 'beta'
export type MapOutcome = 'TeamAWin' | 'TeamBWin' | 'Draw'
export interface Ratings { tank: RankOrdinal; damage: RankOrdinal; support: RankOrdinal; updatedAt: string }
export interface Player {
id: string
accountId?: string
displayName: string
avatarUrl?: string
battleTag?: string
ratings: Ratings
preferredRoles: PlayerRole[]
preferredPlayerIds: string[]
}
export interface Session {
account: { id: string; discordId: string; displayName: string; avatarUrl?: string; role: AccountRole }
player: Player
}
export interface Registration {
id: string
player: Player
status: RsvpStatus
updatedAt: string
changedBy?: { id: string; displayName: string; source: 'self' | 'admin' }
}
export interface MixEvent {
id: string
title: string
description: string
startsAt: string
endsAt: string
registrationDeadline: string
status: 'registration' | 'balancing' | 'live' | 'completed'
rulesetName: string
counts: Record<RsvpStatus, number>
myRsvp?: RsvpStatus
}
export interface TeamPlayer { player: Player; assignedRole: PlayerRole }
export interface Team {
id: string
eventId?: string
name: string
side: TeamSide
captainId?: string
averageRating: number
members: TeamPlayer[]
}
export interface BalanceCandidate {
id: string
score: number
teams: Team[]
reserve: Player[]
explanations: string[]
payload?: RawBalanceCandidate
}
export interface DraftOption {
id: string
name: string
role?: PlayerRole
mode?: string
disabled?: boolean
disabledReason?: string
}
export interface DraftAction {
id: string
kind: 'coin_toss' | 'map_ban' | 'map_selected' | 'hero_ban' | 'result'
summary: string
actorName: string
createdAt: string
}
export interface Series {
id: string
tournamentId?: string
status: 'scheduled' | 'coin_toss' | 'map_draft' | 'hero_draft' | 'playing' | 'completed'
roundLabel: string
teamAlpha: Team
teamBeta: Team
score: { alpha: number; beta: number }
version: number
currentStep: {
title: string
instruction: string
activeTeam?: TeamSide
kind: 'coin_toss' | 'map_ban' | 'hero_ban' | 'result' | 'complete'
version: number
}
options: DraftOption[]
maps: Array<{
number: number
name: string
mode: string
status: 'drafting' | 'ready' | 'playing' | 'completed'
winner?: TeamSide | 'draw'
heroBans: Array<{ hero: string; team: TeamSide; role: PlayerRole }>
}>
audit: DraftAction[]
}
export interface BracketMatch {
id: string; round: number; slot: number; label: string
teamAlpha?: string; teamBeta?: string; scoreAlpha?: number; scoreBeta?: number
winner?: TeamSide; seriesId?: string; status: 'pending' | 'ready' | 'live' | 'completed'
}
export interface Bracket { id: string; title: string; rounds: string[]; matches: BracketMatch[] }
export interface EventInput {
name: string
description: string
startsAt: string
endsAt: string
registrationDeadline: string
}
// Raw backend DTOs mirror Go's current camelCase JSON tags exactly.
export interface RawAccount {
id: string; discordId: string; username: string; avatarUrl: string
role: AccountRole; createdAt: string
}
export interface RawRatings { tank: number; damage: number; support: number }
export interface RawPlayer {
id: string; accountId: string; displayName: string; ratings: RawRatings
preferredRoles?: Array<'Tank' | 'Damage' | 'Support'>
preferredPlayerIds?: string[]
createdAt: string; updatedAt: string
}
export interface RawSession { account: RawAccount; player: RawPlayer }
export interface RawEvent {
id: string; name: string; description: string; startsAt: string; endsAt: string
registrationDeadline: string; createdBy: string; createdAt: string; updatedAt: string
}
export interface RawRSVP {
eventId: string; playerId: string; actorAccountId: string
status: 'Going' | 'Maybe' | 'NotGoing'; source: 'player' | 'admin'; updatedAt: string
}
export interface RawSlot { playerId: string; role: 'Tank' | 'Damage' | 'Support'; rating: number }
export interface RawTeam {
id: string; eventId: string; name: string; captainPlayerId: string; slots: RawSlot[]
}
export interface RawBalanceCandidate { teams: RawTeam[]; reserve: string[]; score: number; explanation?: string[] }
export interface RawMapResult {
mapName: string; actorAccountId: string; outcome: MapOutcome
recordedAt: string; correctionOf?: number
}
export interface RawSeries {
id: string; tournamentId: string; teamAId: string; teamBId: string
winnerTeamId: string; bestOf: number; results: RawMapResult[]; version: number
}
export interface RawTournament {
id: string; eventId: string; name: string; winnerTeamId: string
teamIds: string[]; rounds: RawSeries[][]
}
export interface RawRealtimeEvent { topic: string; data: unknown }
const roleFromRaw = (role: RawSlot['role']): PlayerRole =>
role === 'Tank' ? 'tank' : role === 'Damage' ? 'damage' : 'support'
const statusFromRaw = (status: RawRSVP['status']): RsvpStatus =>
status === 'Going' ? 'going' : status === 'Maybe' ? 'maybe' : 'not_going'
const statusToRaw = (status: RsvpStatus): RawRSVP['status'] =>
status === 'going' ? 'Going' : status === 'maybe' ? 'Maybe' : 'NotGoing'
export function adaptPlayer(raw: RawPlayer): Player {
const ratings = {
tank: toRankOrdinal(raw.ratings.tank),
damage: toRankOrdinal(raw.ratings.damage),
support: toRankOrdinal(raw.ratings.support),
updatedAt: raw.updatedAt,
}
const preferredRoles = (raw.preferredRoles ?? []).map((role) =>
role === 'Tank' ? 'tank' : role === 'Damage' ? 'damage' : 'support',
)
return {
id: raw.id,
accountId: raw.accountId,
displayName: raw.displayName,
ratings,
preferredRoles,
preferredPlayerIds: raw.preferredPlayerIds ?? [],
}
}
export function adaptSession(raw: RawSession): Session {
return {
account: {
id: raw.account.id,
discordId: raw.account.discordId,
displayName: raw.account.username,
avatarUrl: raw.account.avatarUrl || undefined,
role: raw.account.role,
},
player: adaptPlayer(raw.player),
}
}
export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId?: string): MixEvent {
const now = Date.now()
const starts = new Date(raw.startsAt).getTime()
const ends = new Date(raw.endsAt).getTime()
const deadline = new Date(raw.registrationDeadline).getTime()
const counts = { going: 0, maybe: 0, not_going: 0 }
rsvps.forEach((rsvp) => { counts[statusFromRaw(rsvp.status)]++ })
const own = rsvps.find((rsvp) => rsvp.playerId === currentPlayerId)
return {
id: raw.id,
title: raw.name,
description: raw.description,
startsAt: raw.startsAt,
endsAt: raw.endsAt,
registrationDeadline: raw.registrationDeadline,
status: now > ends ? 'completed' : now >= starts ? 'live' : now > deadline ? 'balancing' : 'registration',
rulesetName: 'Server ruleset',
counts,
myRsvp: own ? statusFromRaw(own.status) : undefined,
}
}
export function adaptRegistration(raw: RawRSVP, players: Map<string, Player>, session?: Session): Registration {
const player = players.get(raw.playerId) ?? {
id: raw.playerId,
displayName: `Player ${raw.playerId.slice(0, 6)}`,
ratings: { tank: 1, damage: 1, support: 1, updatedAt: raw.updatedAt },
preferredRoles: [],
preferredPlayerIds: [],
}
return {
id: `${raw.eventId}:${raw.playerId}`,
player,
status: statusFromRaw(raw.status),
updatedAt: raw.updatedAt,
changedBy: {
id: raw.actorAccountId,
displayName: raw.actorAccountId === session?.account.id ? session.account.displayName : raw.source === 'admin' ? 'Administrator' : player.displayName,
source: raw.source === 'admin' ? 'admin' : 'self',
},
}
}
export function adaptTeam(raw: RawTeam, players: Map<string, Player>, index = 0): Team {
const members = raw.slots.map((slot) => ({
player: players.get(slot.playerId) ?? {
id: slot.playerId,
displayName: `Player ${slot.playerId.slice(0, 6)}`,
ratings: {
tank: toRankOrdinal(slot.rating),
damage: toRankOrdinal(slot.rating),
support: toRankOrdinal(slot.rating),
updatedAt: '',
},
preferredRoles: [roleFromRaw(slot.role)],
preferredPlayerIds: [],
},
assignedRole: roleFromRaw(slot.role),
}))
const averageRating = raw.slots.length
? raw.slots.reduce((sum, slot) => sum + slot.rating, 0) / raw.slots.length
: 0
return {
id: raw.id,
eventId: raw.eventId,
name: raw.name,
side: index % 2 === 0 ? 'alpha' : 'beta',
captainId: raw.captainPlayerId || undefined,
averageRating: Number(averageRating.toFixed(1)),
members,
}
}
export function adaptBalance(raw: RawBalanceCandidate, players: Player[], index: number): BalanceCandidate {
const byId = new Map(players.map((player) => [player.id, player]))
const teamForPlayer = new Map(raw.teams.flatMap((team) => team.slots.map((slot) => [slot.playerId, team.id] as const)))
const preferredRoleAssignments = raw.teams.flatMap((team) => team.slots).filter((slot) =>
byId.get(slot.playerId)?.preferredRoles.includes(roleFromRaw(slot.role)),
).length
const preferredPairs = players.reduce((count, player) =>
count + player.preferredPlayerIds.filter((preferredId) =>
teamForPlayer.get(player.id) && teamForPlayer.get(player.id) === teamForPlayer.get(preferredId),
).length, 0) / 2
return {
id: `candidate-${index + 1}`,
score: raw.score,
teams: raw.teams.map((team, teamIndex) => adaptTeam(team, byId, teamIndex)),
reserve: raw.reserve.map((id) => byId.get(id)).filter((player): player is Player => Boolean(player)),
explanations: raw.explanation ?? [
`${preferredRoleAssignments} preferred role assignments`,
`${Math.floor(preferredPairs)} preferred teammate pairs kept together`,
],
payload: raw,
}
}
export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
const alpha = teams.find((team) => team.id === raw.teamAId) ?? emptyTeam(raw.teamAId, 'alpha')
const beta = teams.find((team) => team.id === raw.teamBId) ?? emptyTeam(raw.teamBId, 'beta')
const activeResults = raw.results.filter((_, index) =>
!raw.results.some((correction) => correction.correctionOf === index + 1),
)
const score = activeResults.reduce((total, result) => ({
alpha: total.alpha + (result.outcome === 'TeamAWin' ? 1 : 0),
beta: total.beta + (result.outcome === 'TeamBWin' ? 1 : 0),
}), { alpha: 0, beta: 0 })
const complete = Boolean(raw.winnerTeamId)
return {
id: raw.id,
tournamentId: raw.tournamentId,
status: complete ? 'completed' : raw.teamAId && raw.teamBId ? 'playing' : 'scheduled',
roundLabel: 'Tournament series',
teamAlpha: alpha,
teamBeta: beta,
score,
version: raw.version,
currentStep: {
title: complete ? 'Series complete' : `Record map ${activeResults.length + 1}`,
instruction: complete ? 'The tournament bracket has advanced.' : 'An admin can record the authoritative map result.',
kind: complete ? 'complete' : 'result',
version: raw.version,
},
options: [],
maps: activeResults.map((result, index) => ({
number: index + 1,
name: result.mapName,
mode: 'Map',
status: 'completed',
winner: result.outcome === 'TeamAWin' ? 'alpha' : result.outcome === 'TeamBWin' ? 'beta' : 'draw',
heroBans: [],
})),
audit: raw.results.map((result, index) => ({
id: `result-${index}`,
kind: 'result',
summary: `${result.mapName}: ${result.outcome === 'TeamAWin' ? alpha.name : result.outcome === 'TeamBWin' ? beta.name : 'Draw'}`,
actorName: result.actorAccountId,
createdAt: result.recordedAt,
})),
}
}
export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket {
const names = new Map(teams.map((team) => [team.id, team.name]))
return {
id: raw.id,
title: raw.name,
rounds: raw.rounds.map((_, index) => index === raw.rounds.length - 1 ? 'Grand Final' : index === raw.rounds.length - 2 ? 'Semifinals' : `Round ${index + 1}`),
matches: raw.rounds.flatMap((round, roundIndex) => round.map((series, slot) => {
const adapted = adaptSeries(series, teams)
return {
id: series.id,
round: roundIndex,
slot,
label: `Match ${slot + 1}`,
teamAlpha: names.get(series.teamAId) ?? (series.teamAId || undefined),
teamBeta: names.get(series.teamBId) ?? (series.teamBId || undefined),
scoreAlpha: adapted.score.alpha,
scoreBeta: adapted.score.beta,
winner: series.winnerTeamId ? (series.winnerTeamId === series.teamAId ? 'alpha' : 'beta') : undefined,
seriesId: series.id,
status: series.winnerTeamId ? 'completed' : series.teamAId && series.teamBId ? 'ready' : 'pending',
} satisfies BracketMatch
})),
}
}
function emptyTeam(id: string, side: TeamSide): Team {
return { id, name: id ? `Team ${id.slice(0, 8)}` : 'TBD', side, averageRating: 0, members: [] }
}
export class ApiError extends Error {
readonly status: number
readonly details?: unknown
constructor(message: string, status: number, details?: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.details = details
}
}
const API_BASE = import.meta.env.VITE_API_BASE_URL || '/api'
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
credentials: 'include',
...init,
headers: {
Accept: 'application/json',
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
})
if (!response.ok) {
const details = await response.json().catch(() => undefined)
throw new ApiError(
typeof details === 'object' && details && 'error' in details ? String(details.error) : `Request failed (${response.status})`,
response.status,
details,
)
}
if (response.status === 204) return undefined as T
return response.json() as Promise<T>
}
const raw = {
session: () => request<RawSession>('/auth/me'),
players: () => request<RawPlayer[]>('/players'),
events: () => request<RawEvent[]>('/events'),
event: (id: string) => request<RawEvent>(`/events/${id}`),
rsvps: (eventId: string) => request<RawRSVP[]>(`/events/${eventId}/rsvps`),
teams: (eventId: string) => request<RawTeam[]>(`/events/${eventId}/teams`),
}
async function adaptSeriesWithTeams(series: RawSeries) {
if (!series.tournamentId) return adaptSeries(series)
const tournament = await request<RawTournament>(`/tournaments/${series.tournamentId}`)
const [teams, players] = await Promise.all([raw.teams(tournament.eventId), raw.players()])
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return adaptSeries(series, teams.map((team, index) => adaptTeam(team, byId, index)))
}
export const api = {
session: async () => adaptSession(await raw.session()),
logout: () => request<void>('/auth/logout', { method: 'POST' }),
profile: async () => adaptPlayer((await raw.session()).player),
updateRatings: async (
displayName: string,
ratings: Pick<Ratings, PlayerRole>,
preferredRoles: PlayerRole[],
preferredPlayerIds: string[],
) =>
adaptPlayer(await request<RawPlayer>('/me/player', {
method: 'PATCH',
body: JSON.stringify({
displayName,
ratings: { tank: ratings.tank, damage: ratings.damage, support: ratings.support },
preferredRoles: preferredRoles.map((role) => role === 'tank' ? 'Tank' : role === 'damage' ? 'Damage' : 'Support'),
preferredPlayerIds,
}),
})),
players: async () => (await raw.players()).map(adaptPlayer),
events: async () => {
const [events, session] = await Promise.all([raw.events(), raw.session()])
return Promise.all(events.map(async (event) => {
const rsvps = await raw.rsvps(event.id)
return adaptEvent(event, rsvps, session.player.id)
}))
},
event: async (eventId: string) => {
const [event, rsvps, session] = await Promise.all([raw.event(eventId), raw.rsvps(eventId), raw.session()])
return adaptEvent(event, rsvps, session.player.id)
},
registrations: async (eventId: string) => {
const [rsvps, players, session] = await Promise.all([raw.rsvps(eventId), raw.players(), raw.session()])
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return rsvps.map((rsvp) => adaptRegistration(rsvp, byId, adaptSession(session)))
},
setRsvp: async (eventId: string, playerId: string, status: RsvpStatus) => {
const [result, players, session] = await Promise.all([
request<RawRSVP>(`/events/${eventId}/rsvps/${playerId}`, { method: 'PUT', body: JSON.stringify({ status: statusToRaw(status) }) }),
raw.players(),
raw.session(),
])
return adaptRegistration(result, new Map(players.map(adaptPlayer).map((player) => [player.id, player])), adaptSession(session))
},
createEvent: async (event: EventInput) => adaptEvent(await request<RawEvent>('/events', { method: 'POST', body: JSON.stringify(event) })),
updateEvent: async (eventId: string, event: EventInput) => adaptEvent(await request<RawEvent>(`/events/${eventId}`, { method: 'PUT', body: JSON.stringify(event) })),
generateBalance: async (eventId: string) => {
const [candidates, players] = await Promise.all([
request<RawBalanceCandidate[]>(`/events/${eventId}/balance`, { method: 'POST' }),
raw.players(),
])
const adaptedPlayers = players.map(adaptPlayer)
return candidates.map((candidate, index) => adaptBalance(candidate, adaptedPlayers, index))
},
selectBalance: (eventId: string, candidate: BalanceCandidate) =>
request<{ status: string }>(`/events/${eventId}/teams`, {
method: 'PUT',
body: JSON.stringify(candidate.payload ?? {
teams: candidate.teams.map((team) => ({
id: team.id,
eventId,
name: team.name,
captainPlayerId: team.captainId ?? '',
slots: team.members.map(({ player, assignedRole }) => ({
playerId: player.id,
role: assignedRole === 'tank' ? 'Tank' : assignedRole === 'damage' ? 'Damage' : 'Support',
rating: player.ratings[assignedRole],
})),
})),
reserve: candidate.reserve.map((player) => player.id),
score: candidate.score,
} satisfies RawBalanceCandidate),
}),
teams: async (eventId: string) => {
const [teams, players] = await Promise.all([raw.teams(eventId), raw.players()])
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return teams.map((team, index) => adaptTeam(team, byId, index))
},
assignCaptain: async (teamId: string, playerId: string) =>
adaptTeam(await request<RawTeam>(`/teams/${teamId}/captain`, { method: 'PUT', body: JSON.stringify({ playerId }) }), new Map()),
series: async (seriesId: string) => {
const series = await request<RawSeries>(`/series/${seriesId}`)
return adaptSeriesWithTeams(series)
},
recordResult: async (seriesId: string, mapName: string, outcome: MapOutcome, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/results`, {
method: 'POST',
body: JSON.stringify({ mapName, outcome, expectedVersion }),
})),
correctResult: async (seriesId: string, index: number, mapName: string, outcome: MapOutcome, expectedVersion: number) =>
adaptSeriesWithTeams(await request<RawSeries>(`/series/${seriesId}/results/${index}`, {
method: 'PATCH',
body: JSON.stringify({ mapName, outcome, expectedVersion }),
})),
coinToss: (input: { teamAId: string; teamBId: string; seed: string; eventId: string; actingTeamId: string }) =>
request<{ seed: string; winnerTeamId: string; performedAt: string }>('/coin-toss', { method: 'POST', body: JSON.stringify(input) }),
mapBan: (draftId: string, input: { teamId: string; map: string; eventId: string; version: number }) =>
request<{ draft: unknown; version: number }>(`/drafts/${draftId}/map-bans`, { method: 'POST', body: JSON.stringify(input) }),
heroBan: (draftId: string, input: { teamId: string; hero: string; eventId: string; version: number }) =>
request<{ draft: unknown; version: number }>(`/drafts/${draftId}/hero-bans`, { method: 'POST', body: JSON.stringify(input) }),
bracket: async (eventId: string) => {
const tournament = await request<RawTournament>(`/events/${eventId}/tournament`)
const [teams, players] = await Promise.all([raw.teams(tournament.eventId), raw.players()])
const byId = new Map(players.map(adaptPlayer).map((player) => [player.id, player]))
return adaptTournament(tournament, teams.map((team, index) => adaptTeam(team, byId, index)))
},
}
export function subscribeToEvents(
topic: string,
onMessage: (event: RawRealtimeEvent) => void,
onStatus?: (status: 'connected' | 'reconnecting') => void,
) {
const source = new EventSource(`${API_BASE}/events/stream?topic=${encodeURIComponent(topic)}`, { withCredentials: true })
source.onopen = () => onStatus?.('connected')
source.onerror = () => onStatus?.('reconnecting')
source.addEventListener('update', (event) => {
try { onMessage(JSON.parse(event.data) as RawRealtimeEvent) } catch { /* next query refresh reconciles state */ }
})
return () => source.close()
}

248
frontend/src/api/demo.ts Normal file
View File

@@ -0,0 +1,248 @@
import type {
BalanceCandidate,
Bracket,
MixEvent,
Player,
Registration,
Series,
Session,
Team,
} from './client'
import type { RankOrdinal } from '../ranks'
// Fixtures keep local development and visual review useful without a backend.
// Production builds always use the real API.
export const demoMode =
import.meta.env.DEV && import.meta.env.VITE_DEMO_MODE !== 'false'
const now = new Date()
const daysFromNow = (days: number, hour = 19) => {
const date = new Date(now)
date.setDate(date.getDate() + days)
date.setHours(hour, 0, 0, 0)
return date.toISOString()
}
const makePlayer = (
id: string,
displayName: string,
tank: RankOrdinal,
damage: RankOrdinal,
support: RankOrdinal,
): Player => ({
id,
displayName,
battleTag: `${displayName.replace(/\s/g, '')}#${1000 + Number(id.replace(/\D/g, '') || 0)}`,
ratings: { tank, damage, support, updatedAt: daysFromNow(-4) },
preferredRoles: tank >= damage && tank >= support ? ['tank'] : damage >= support ? ['damage'] : ['support'],
preferredPlayerIds: [],
})
export const demoProfile: Player = {
...makePlayer('p1', 'Lemintare', 30, 33, 27),
preferredRoles: ['damage', 'tank'],
preferredPlayerIds: ['p2', 'p5'],
}
export const demoSession: Session = {
account: {
id: 'account-1',
discordId: '125661705720381444',
displayName: 'Lemintare',
role: 'admin',
},
player: demoProfile,
}
export const demoEvents: MixEvent[] = [
{
id: 'event-1',
title: 'Saturday Night Scrim',
description: 'Community draft night · 4 teams · Bo3 single elimination',
startsAt: daysFromNow(2),
endsAt: daysFromNow(2, 23),
registrationDeadline: daysFromNow(1, 21),
status: 'registration',
rulesetName: 'Community Bo3 v2',
counts: { going: 18, maybe: 4, not_going: 3 },
myRsvp: 'going',
},
{
id: 'event-2',
title: 'Midweek Mix',
description: 'Fast queue with two balanced teams',
startsAt: daysFromNow(5),
endsAt: daysFromNow(5, 23),
registrationDeadline: daysFromNow(4),
status: 'registration',
rulesetName: 'Quick Mix v1',
counts: { going: 9, maybe: 6, not_going: 2 },
myRsvp: 'maybe',
},
{
id: 'event-3',
title: 'Summer Clash #4',
description: 'Live playoff bracket',
startsAt: daysFromNow(0, 20),
endsAt: daysFromNow(1, 1),
registrationDeadline: daysFromNow(-1),
status: 'live',
rulesetName: 'Tournament Bo3 v2',
counts: { going: 22, maybe: 0, not_going: 4 },
myRsvp: 'going',
},
]
const players = [
demoProfile,
makePlayer('p2', 'Nova', 36, 28, 29),
makePlayer('p3', 'Kairo', 24, 35, 26),
makePlayer('p4', 'MercyMain', 18, 27, 37),
makePlayer('p5', 'Pulse', 25, 34, 29),
makePlayer('p6', 'Astra', 31, 25, 36),
makePlayer('p7', 'Hex', 35, 29, 23),
makePlayer('p8', 'Orbit', 22, 34, 30),
makePlayer('p9', 'Miko', 27, 30, 36),
makePlayer('p10', 'Rook', 37, 26, 24),
makePlayer('p11', 'Echo', 26, 32, 31),
makePlayer('p12', 'Zenith', 28, 30, 35),
]
players[1].preferredPlayerIds = ['p1']
players[4].preferredPlayerIds = ['p1']
export const demoRegistrations: Registration[] = players.map((player, index) => ({
id: `reg-${index + 1}`,
player,
status: index < 9 ? 'going' : index < 11 ? 'maybe' : 'not_going',
updatedAt: daysFromNow(-(index % 4)),
changedBy:
index === 4
? { id: 'account-1', displayName: 'Lemintare', source: 'admin' }
: { id: player.id, displayName: player.displayName, source: 'self' },
}))
const alpha: Team = {
id: 'team-alpha',
name: 'Ember Wolves',
side: 'alpha',
captainId: 'p2',
averageRating: 35.6,
members: [
{ player: players[1], assignedRole: 'tank' },
{ player: players[2], assignedRole: 'damage' },
{ player: players[4], assignedRole: 'damage' },
{ player: players[3], assignedRole: 'support' },
{ player: players[5], assignedRole: 'support' },
],
}
const beta: Team = {
id: 'team-beta',
name: 'Azure Phantoms',
side: 'beta',
captainId: 'p7',
averageRating: 34.4,
members: [
{ player: players[6], assignedRole: 'tank' },
{ player: players[7], assignedRole: 'damage' },
{ player: players[10], assignedRole: 'damage' },
{ player: players[8], assignedRole: 'support' },
{ player: players[11], assignedRole: 'support' },
],
}
export const demoBalanceCandidates: BalanceCandidate[] = [
{
id: 'candidate-1',
score: 4,
teams: [alpha, beta],
reserve: [players[0], players[9]],
explanations: ['9 preferred role assignments', '2 preferred teammate pairs kept together'],
},
{
id: 'candidate-2',
score: 9,
teams: [
{ ...alpha, averageRating: 35.2 },
{ ...beta, averageRating: 34.6 },
],
reserve: [players[0], players[9]],
explanations: ['8 preferred role assignments', '2 preferred teammate pairs kept together'],
},
{
id: 'candidate-3',
score: 13,
teams: [
{ ...alpha, averageRating: 35.4 },
{ ...beta, averageRating: 34.5 },
],
reserve: [players[0], players[9]],
explanations: ['10 preferred role assignments', '1 preferred teammate pair kept together'],
},
]
export const demoSeries: Series = {
id: 'series-1',
status: 'hero_draft',
roundLabel: 'Semifinal 1',
teamAlpha: alpha,
teamBeta: beta,
score: { alpha: 1, beta: 0 },
version: 12,
currentStep: {
title: 'Hero ban 2 of 4',
instruction: 'Azure Phantoms choose a hero to remove for Map 2.',
activeTeam: 'beta',
kind: 'hero_ban',
version: 12,
},
options: [
{ id: 'hero-1', name: 'Ana', role: 'support' },
{ id: 'hero-2', name: 'Kiriko', role: 'support' },
{ id: 'hero-3', name: 'Tracer', role: 'damage' },
{ id: 'hero-4', name: 'Sojourn', role: 'damage' },
{ id: 'hero-5', name: 'Winston', role: 'tank', disabled: true, disabledReason: 'Already banned by this team' },
{ id: 'hero-6', name: 'Sigma', role: 'tank' },
],
maps: [
{
number: 1,
name: 'Lijiang Tower',
mode: 'Control',
status: 'completed',
winner: 'alpha',
heroBans: [
{ hero: 'Sombra', team: 'alpha', role: 'damage' },
{ hero: 'Winston', team: 'beta', role: 'tank' },
{ hero: 'Ana', team: 'alpha', role: 'support' },
{ hero: 'Tracer', team: 'beta', role: 'damage' },
],
},
{
number: 2,
name: 'Kings Row',
mode: 'Hybrid',
status: 'drafting',
heroBans: [{ hero: 'D.Va', team: 'alpha', role: 'tank' }],
},
{ number: 3, name: 'TBD', mode: 'Control', status: 'ready', heroBans: [] },
],
audit: [
{ id: 'a1', kind: 'coin_toss', summary: 'Ember Wolves won the toss', actorName: 'System', createdAt: daysFromNow(0, 20) },
{ id: 'a2', kind: 'map_selected', summary: 'Lijiang Tower selected', actorName: 'Nova', createdAt: daysFromNow(0, 20) },
{ id: 'a3', kind: 'result', summary: 'Map 1 won by Ember Wolves', actorName: 'Lemintare', createdAt: daysFromNow(0, 21) },
{ id: 'a4', kind: 'map_selected', summary: 'Kings Row selected', actorName: 'Hex', createdAt: daysFromNow(0, 21) },
{ id: 'a5', kind: 'hero_ban', summary: 'D.Va banned by Ember Wolves', actorName: 'Nova', createdAt: daysFromNow(0, 21) },
],
}
export const demoBracket: Bracket = {
id: 'bracket-1',
title: 'Summer Clash #4',
rounds: ['Semifinals', 'Grand Final'],
matches: [
{ id: 'm1', round: 0, slot: 0, label: 'Semifinal 1', teamAlpha: 'Ember Wolves', teamBeta: 'Azure Phantoms', scoreAlpha: 1, scoreBeta: 0, status: 'live', seriesId: 'series-1' },
{ id: 'm2', round: 0, slot: 1, label: 'Semifinal 2', teamAlpha: 'Neon Foxes', teamBeta: 'Void Runners', scoreAlpha: 2, scoreBeta: 1, winner: 'alpha', status: 'completed', seriesId: 'series-2' },
{ id: 'm3', round: 1, slot: 0, label: 'Grand Final', teamBeta: 'Neon Foxes', status: 'pending' },
],
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react'
export type Language = 'en' | 'ru'
export type LanguageContextValue = { language: Language; setLanguage: (language: Language) => void }
export const LanguageContext = createContext<LanguageContextValue | null>(null)
export function useLanguage() {
const context = useContext(LanguageContext)
if (!context) throw new Error('useLanguage must be used inside LanguageProvider')
return context.language
}

View File

@@ -0,0 +1,27 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { LanguageProvider, LanguageSelector } from './i18n'
afterEach(() => {
cleanup()
localStorage.clear()
})
describe('runtime language selection', () => {
it('switches UI text to Russian and persists the choice', async () => {
localStorage.setItem('mixmaker-language', 'en')
render(<LanguageProvider><LanguageSelector /><p>Your events</p></LanguageProvider>)
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'ru' } })
await waitFor(() => expect(screen.getByText('Ваши события')).toBeInTheDocument())
expect(localStorage.getItem('mixmaker-language')).toBe('ru')
expect(document.documentElement.lang).toBe('ru')
})
it('restores English after switching back', async () => {
localStorage.setItem('mixmaker-language', 'ru')
render(<LanguageProvider><LanguageSelector /><p>Your events</p></LanguageProvider>)
await waitFor(() => expect(screen.getByText('Ваши события')).toBeInTheDocument())
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'en' } })
await waitFor(() => expect(screen.getByText('Your events')).toBeInTheDocument())
})
})

290
frontend/src/i18n.tsx Normal file
View File

@@ -0,0 +1,290 @@
import { useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { LanguageContext, type Language } from './i18n-context'
const translations: Record<string, string> = {
'Events': 'События',
'Live': 'Эфир',
'Bracket': 'Сетка',
'Admin': 'Админ',
'Profile': 'Профиль',
'Language': 'Язык',
'Primary navigation': 'Основная навигация',
'Mobile navigation': 'Мобильная навигация',
'Quick navigation': 'Быстрая навигация',
'Open profile': 'Открыть профиль',
'Sign out': 'Выйти',
'Toggle navigation': 'Открыть навигацию',
'Realtime connected': 'Онлайн-соединение установлено',
'Community scrims, organized': 'Организованные командные миксы',
'Ready up.': 'Приготовьтесь.',
'Play together.': 'Играйте вместе.',
'Balance teams, run drafts, and follow every map from one esports control room.': 'Балансируйте команды, проводите драфты и следите за каждой картой из единого киберспортивного центра.',
'Continue with Discord': 'Войти через Discord',
'Signing in creates a secure session. We never receive your Discord password.': 'Вход создаёт защищённую сессию. Мы не получаем ваш пароль Discord.',
'role-locked teams': 'команды по ролям',
'synchronized drafts': 'синхронные драфты',
'tournament series': 'турнирные серии',
'Loading match data…': 'Загрузка данных матча…',
'Syncing with the server.': 'Синхронизация с сервером.',
'Couldnt load this view': 'Не удалось загрузить экран',
'Your data is safe. Check your connection and try again.': 'Ваши данные в безопасности. Проверьте соединение и повторите попытку.',
'Try again': 'Повторить',
'Loading events…': 'Загрузка событий…',
'Scrim schedule': 'Расписание миксов',
'Your events': 'Ваши события',
'Join a mix, check the roster, and get match-ready.': 'Присоединяйтесь к миксу, проверяйте состав и готовьтесь к матчу.',
'Upcoming': 'Предстоящие',
'All': 'Все',
'Live now': 'Сейчас в эфире',
'Watch live': 'Смотреть',
'Coming up': 'Скоро',
'Filter events': 'Фильтровать события',
'New community nights will appear here.': 'Новые игровые вечера появятся здесь.',
'Going': 'Иду',
'Maybe': 'Возможно',
'going': 'идут',
'maybe': 'возможно',
'Cant go': 'Не иду',
'View event': 'Открыть событие',
'Loading event…': 'Загрузка события…',
'Event details': 'Детали события',
'Registration open': 'Регистрация открыта',
'Starts': 'Начало',
'Registration closes': 'Регистрация закрывается',
'Are you playing?': 'Вы участвуете?',
'Your latest response is sent to the organizer.': 'Ваш последний ответ отправлен организатору.',
'RSVP status': 'Статус участия',
'RSVP for': 'Статус участия для',
'Response saved · Server confirmed just now': 'Ответ сохранён · Сервер подтвердил только что',
'Attendance': 'Участие',
'No': 'Нет',
'Manage participants': 'Управлять участниками',
'Loading profile…': 'Загрузка профиля…',
'Player profile': 'Профиль игрока',
'Tune your ratings': 'Настройте рейтинги',
'These self-ratings help the server build fair, role-locked teams.': 'Эти самооценки помогают серверу создавать честные команды по ролям.',
'Set your competitive ranks': 'Укажите соревновательные ранги',
'Your role ranks help the server build fair, role-locked teams.': 'Ранги по ролям помогают серверу создавать честные команды.',
'Connected via Discord': 'Подключено через Discord',
'Role ratings': 'Рейтинги ролей',
'Use the full 1100 range honestly.': 'Честно используйте весь диапазон от 1 до 100.',
'Competitive ranks': 'Соревновательные ранги',
'Select your current Overwatch rank for each role.': 'Выберите текущий ранг Overwatch для каждой роли.',
'Tank': 'Танк',
'Damage': 'Урон',
'Support': 'Поддержка',
'Space, pressure, and frontline': 'Пространство, давление и передовая',
'Eliminations and map control': 'Устранения и контроль карты',
'Sustain, utility, and tempo': 'Выживаемость, польза и темп',
'Only you can edit your ratings.': 'Только вы можете менять свои рейтинги.',
'Only you can edit your ranks and preferences.': 'Только вы можете менять свои ранги и предпочтения.',
'Save profile': 'Сохранить профиль',
'Saving…': 'Сохранение…',
'Saved': 'Сохранено',
'Preferred roles': 'Предпочитаемые роли',
'Choose every role you enjoy playing.': 'Выберите все роли, на которых вам нравится играть.',
'Preferred teammates': 'Предпочитаемые напарники',
'Choose up to 3 players. The balancer treats these as preferences, not guarantees.': 'Выберите до 3 игроков. Балансировщик учитывает это как пожелание, а не гарантию.',
'Loading admin control room…': 'Загрузка панели администратора…',
'Admin control room': 'Панель администратора',
'Create event': 'Создать событие',
'No events yet': 'Событий пока нет',
'Create the first community event.': 'Создайте первое событие сообщества.',
'Selected event': 'Выбранное событие',
'New event': 'Новое событие',
'Event setup': 'Настройки события',
'Participants': 'Участники',
'Balance teams': 'Баланс команд',
'Event settings': 'Настройки события',
'Scheduling and registration': 'Расписание и регистрация',
'Published': 'Опубликовано',
'Event name': 'Название события',
'Start time': 'Время начала',
'End time': 'Время окончания',
'Registration deadline': 'Крайний срок регистрации',
'Description': 'Описание',
'Times are sent to the server in UTC.': 'Время отправляется на сервер в UTC.',
'Save changes': 'Сохранить изменения',
'Server authority': 'Сервер — источник истины',
'Validation runs on the backend': 'Проверка выполняется на сервере',
'UTC scheduling': 'Расписание в UTC',
'Audited admin changes': 'Аудит действий администратора',
'Registration deadline rules': 'Правила срока регистрации',
'Loading participants…': 'Загрузка участников…',
'Participant responses': 'Ответы участников',
'Admin overrides are attributed and audited.': 'Изменения администратора подписываются и аудируются.',
'Not going': 'Не идёт',
'Preferred with': 'Предпочитает играть с',
'No preferred roles': 'Нет предпочитаемых ролей',
'Loading teams…': 'Загрузка команд…',
'Generate balance candidates': 'Создать варианты баланса',
'The backend uses confirmed Going players and returns three ranked alternatives.': 'Сервер использует подтверждённых участников и возвращает три ранжированных варианта.',
'Balancing…': 'Балансировка…',
'Generate on server': 'Создать на сервере',
'Server candidates': 'Варианты сервера',
'Lower imbalance score is better': 'Меньший дисбаланс лучше',
'Generate again': 'Создать заново',
'Computed by backend balancer': 'Рассчитано серверным балансировщиком',
'Imbalance score': 'Оценка дисбаланса',
'Lower scores represent closer team and role totals. The server remains the source of truth for roster validity.': 'Меньшая оценка означает более близкие суммы команд и ролей. Сервер остаётся источником истины для составов.',
'Preference fit': 'Соответствие предпочтениям',
'Not assigned to a complete 5v5 roster': 'Не назначены в полный состав 5 на 5',
'The server will save these rosters and unlock captain assignment.': 'Сервер сохранит составы и откроет назначение капитанов.',
'Use this balance': 'Использовать баланс',
'Team captain': 'Капитан команды',
'Select from roster': 'Выбрать из состава',
'avg': 'среднее',
'Series control': 'Управление серией',
'Actions are validated and recorded by the server.': 'Действия проверяются и записываются сервером.',
'Spectator view': 'Режим зрителя',
'Current step': 'Текущий шаг',
'Current series score': 'Текущий счёт серии',
'Available draft choices': 'Доступные варианты драфта',
'Acting team': 'Активная команда',
'Map name': 'Название карты',
'Outcome': 'Результат',
'Draw': 'Ничья',
'Record result': 'Записать результат',
'Recording…': 'Запись…',
'Draft commands use dedicated backend endpoints and server-side validation.': 'Команды драфта используют отдельные серверные методы и серверную проверку.',
'Confirm action': 'Подтвердить действие',
'Best of 3': 'До двух побед',
'Captain not assigned': 'Капитан не назначен',
'Map timeline': 'Карты серии',
'First to 2 wins': 'До двух побед',
'No results recorded.': 'Результатов пока нет.',
'Match history': 'История матча',
'Auditable server actions': 'Аудируемые действия сервера',
'No actions yet.': 'Действий пока нет.',
'Spectator mode': 'Режим зрителя',
'Read-only live view · Updates automatically': 'Просмотр без редактирования · Обновляется автоматически',
'Connected': 'Подключено',
'Loading bracket…': 'Загрузка сетки…',
'Single elimination': 'Олимпийская система',
'Winners advance automatically when the server completes a series.': 'Победители проходят дальше автоматически после завершения серии сервером.',
'Open series': 'Открыть серию',
'Scroll horizontally to follow the bracket on smaller screens.': 'Прокручивайте сетку горизонтально на небольших экранах.',
'Restoring your session…': 'Восстановление сессии…',
'Series complete': 'Серия завершена',
'The tournament bracket has advanced.': 'Турнирная сетка обновлена.',
'An admin can record the authoritative map result.': 'Администратор может записать официальный результат карты.',
'Tournament series': 'Турнирная серия',
'Server ruleset': 'Серверный регламент',
'registration': 'регистрация',
'balancing': 'балансировка',
'live': 'эфир',
'completed': 'завершено',
'scheduled': 'запланировано',
'playing': 'игра',
'hero_draft': 'бан героев',
'map_draft': 'бан карт',
'coin_toss': 'жеребьёвка',
'Map': 'Карта',
}
function translate(value: string) {
const direct = translations[value]
if (direct) return direct
const rules: Array<[RegExp, (match: RegExpMatchArray) => string]> = [
[/^(\d+) scheduled events$/, (m) => `${m[1]} запланированных событий`],
[/^(\d+) going$/, (m) => `${m[1]} идут`],
[/^(\d+) responses$/, (m) => `${m[1]} ответов`],
[/^Updated (.+)$/, (m) => `Обновлено ${m[1]}`],
[/^Registration closes (.+)\.$/, (m) => `Регистрация закрывается ${m[1]}.`],
[/^Score (\d+)$/, (m) => `Оценка ${m[1]}`],
[/^Reserve · (\d+)$/, (m) => `Резерв · ${m[1]}`],
[/^Team ([AB])$/, (m) => `Команда ${m[1]}`],
[/^Captain · (.+)$/, (m) => `Капитан · ${m[1]}`],
[/^Round (\d+)$/, (m) => `Раунд ${m[1]}`],
[/^Match (\d+)$/, (m) => `Матч ${m[1]}`],
[/^Ban (\d+)$/, (m) => `Бан ${m[1]}`],
[/^Record map (\d+)$/, (m) => `Записать карту ${m[1]}`],
[/^(.+) wins$/, (m) => `${m[1]} побеждает`],
[/^Team (.+)$/, (m) => `Команда ${m[1]}`],
[/^RSVP for (.+)$/, (m) => `Статус участия для ${m[1]}`],
[/^(\d+) preferred role assignments$/, (m) => `${m[1]} назначений на предпочитаемые роли`],
[/^(\d+) preferred teammate pairs? kept together$/, (m) => `${m[1]} предпочитаемых пар сохранено`],
[/^set by (.+)$/, (m) => `изменил ${m[1]}`],
]
for (const [pattern, formatter] of rules) {
const match = value.match(pattern)
if (match) return formatter(match)
}
return value
}
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguage] = useState<Language>(() => {
const saved = localStorage.getItem('mixmaker-language')
if (saved === 'en' || saved === 'ru') return saved
return navigator.language.toLowerCase().startsWith('ru') ? 'ru' : 'en'
})
const originals = useRef(new WeakMap<Text, string>())
const attributeOriginals = useRef(new WeakMap<Element, Map<string, string>>())
useEffect(() => {
localStorage.setItem('mixmaker-language', language)
document.documentElement.lang = language
let applying = false
const localize = (root: Node) => {
if (applying) return
applying = true
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
let node = walker.nextNode() as Text | null
while (node) {
const parent = node.parentElement
if (parent && !['SCRIPT', 'STYLE'].includes(parent.tagName)) {
const current = node.data
const known = originals.current.get(node)
const knownRussian = known
? `${known.match(/^\s*/)?.[0] ?? ''}${translate(known.trim())}${known.match(/\s*$/)?.[0] ?? ''}`
: undefined
if (!known || (current !== known && current !== knownRussian)) originals.current.set(node, current)
const original = originals.current.get(node) ?? current
const trimmed = original.trim()
const replacement = language === 'ru' ? translate(trimmed) : trimmed
const leading = original.match(/^\s*/)?.[0] ?? ''
const trailing = original.match(/\s*$/)?.[0] ?? ''
const next = `${leading}${replacement}${trailing}`
if (node.data !== next) node.data = next
}
node = walker.nextNode() as Text | null
}
const elements = root instanceof Element ? [root, ...root.querySelectorAll('*')] : [...document.querySelectorAll('*')]
for (const element of elements) {
for (const attribute of ['aria-label', 'title', 'placeholder']) {
const value = element.getAttribute(attribute)
if (!value) continue
let stored = attributeOriginals.current.get(element)
if (!stored) { stored = new Map(); attributeOriginals.current.set(element, stored) }
if (!stored.has(attribute)) stored.set(attribute, value)
const original = stored.get(attribute) ?? value
element.setAttribute(attribute, language === 'ru' ? translate(original) : original)
}
}
applying = false
}
localize(document.body)
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) localize(mutation.target)
})
observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['aria-label', 'title', 'placeholder'] })
return () => observer.disconnect()
}, [language])
return <LanguageContext.Provider value={{ language, setLanguage }}>{children}</LanguageContext.Provider>
}
export function LanguageSelector() {
const context = useContext(LanguageContext)
if (!context) return null
return (
<label className="language-selector">
<span className="sr-only">Language</span>
<select aria-label="Language" value={context.language} onChange={(event) => context.setLanguage(event.target.value as Language)}>
<option value="en">EN</option>
<option value="ru">RU</option>
</select>
</label>
)
}

264
frontend/src/index.css Normal file
View File

@@ -0,0 +1,264 @@
@import "tailwindcss";
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f3f5f7;
background: #0b0d10;
font-synthesis: none;
text-rendering: optimizeLegibility;
--bg: #0b0d10;
--surface: #111419;
--surface-2: #171b21;
--surface-3: #1d222a;
--border: #272d36;
--muted: #89919d;
--orange: #ff7a1a;
--orange-soft: #2b1a10;
--blue: #38a9ff;
--blue-soft: #102435;
--green: #54dc95;
--yellow: #f8c552;
}
* { box-sizing: border-box; }
html { background: var(--bg); scroll-behavior: smooth; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 20% -10%, #21252d 0, transparent 28rem), var(--bg); }
button, input, select, textarea { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { color: inherit; }
a { color: inherit; text-decoration: none; }
svg { width: 1.1em; height: 1.1em; flex: none; }
h1, h2, h3, p { margin: 0; }
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; }
.app-shell { min-height: 100vh; }
.topbar { height: 72px; padding: 0 max(22px, calc((100vw - 1240px) / 2)); display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); background: rgba(11,13,16,.88); backdrop-filter: blur(18px); position: sticky; top: 0; z-index: 20; }
.brand { display: inline-flex; align-items: center; gap: 10px; font-size: 15px; letter-spacing: .12em; font-weight: 600; }
.brand strong { color: var(--orange); }
.brand-mark { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid #56301a; background: var(--orange-soft); color: var(--orange); clip-path: polygon(15% 0, 100% 0, 85% 100%, 0 100%); }
.desktop-nav { display: flex; align-self: stretch; gap: 8px; }
.desktop-nav a { position: relative; display: flex; align-items: center; gap: 7px; padding: 0 15px; color: var(--muted); font-size: 13px; font-weight: 650; }
.desktop-nav a:hover, .desktop-nav a.active { color: #fff; }
.desktop-nav a.active::after { content: ""; position: absolute; height: 2px; bottom: 0; left: 14px; right: 14px; background: var(--orange); box-shadow: 0 0 12px var(--orange); }
.account { display: flex; align-items: center; gap: 12px; }
.language-selector select { min-width: 54px; padding: 7px 8px; color: #d7dbe0; background: var(--surface-2); border-color: var(--border); font-size: 10px; font-weight: 850; cursor: pointer; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
.avatar, .mini-avatar, .large-avatar { display: grid; place-items: center; border-radius: 50%; background: linear-gradient(135deg,#fa8d36,#703514); color: #fff; font-weight: 800; }
.avatar { width: 36px; height: 36px; font-size: 12px; border: 2px solid #393f48; }
.live-dot { width: 7px; height: 7px; display: inline-block; border-radius: 50%; background: var(--green); box-shadow: 0 0 9px var(--green); }
.mobile-menu, .mobile-nav, .bottom-nav { display: none !important; }
main { min-height: calc(100vh - 72px); }
.page { max-width: 1240px; margin: 0 auto; padding: 44px 22px 80px; }
.page.narrow { max-width: 920px; }
.page-header { display: flex; justify-content: space-between; align-items: flex-end; gap: 22px; margin-bottom: 30px; }
.page-header h1 { margin-top: 5px; font-size: clamp(30px, 4vw, 48px); line-height: 1.05; letter-spacing: -.045em; }
.page-header p { color: var(--muted); margin-top: 10px; max-width: 620px; }
.page-actions { display: flex; align-items: center; gap: 10px; }
.eyebrow { color: var(--orange); text-transform: uppercase; letter-spacing: .18em; font-size: 10px; font-weight: 850; }
.card { background: linear-gradient(150deg, rgba(25,29,35,.96), rgba(16,19,24,.98)); border: 1px solid var(--border); border-radius: 12px; }
.button { min-height: 44px; padding: 0 18px; border: 0; border-radius: 7px; display: inline-flex; justify-content: center; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; font-weight: 800; }
.button.primary { color: #110b06; background: var(--orange); box-shadow: 0 8px 28px rgba(255,122,26,.2); }
.button.primary:hover { background: #ff9142; }
.button.secondary { color: #e9ebed; background: var(--surface-2); border: 1px solid #343b45; }
.button.full { width: 100%; }
.icon-button { width: 40px; height: 40px; display: grid; place-items: center; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; cursor: pointer; }
.badge { min-height: 24px; padding: 3px 8px; display: inline-flex; align-items: center; gap: 5px; border-radius: 99px; border: 1px solid #353b44; color: #abb2bd; background: #1a1e24; font-size: 10px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; }
.badge svg { width: 12px; height: 12px; }
.badge-success { color: var(--green); border-color: #23583f; background: #10261c; }
.badge-warning { color: var(--yellow); border-color: #604b20; background: #281f0e; }
.badge-live { color: #ff9160; border-color: #69351f; background: #2a160e; }
.badge-blue { color: var(--blue); border-color: #22547b; background: #102435; }
.segmented { display: flex; padding: 3px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }
.segmented button { min-height: 34px; padding: 0 14px; border: 0; border-radius: 5px; background: transparent; color: var(--muted); font-size: 12px; font-weight: 700; cursor: pointer; }
.segmented button.active { color: #fff; background: var(--surface-3); }
.featured-event { min-height: 180px; padding: 28px 32px; display: grid; grid-template-columns: 1.5fr 1fr auto; align-items: center; gap: 28px; border: 1px solid #46301f; border-radius: 14px; background: linear-gradient(115deg, rgba(53,28,13,.92), rgba(18,20,25,.96) 52%, rgba(11,31,45,.75)), repeating-linear-gradient(135deg,transparent 0 20px,rgba(255,255,255,.015) 20px 21px); overflow: hidden; position: relative; }
.featured-event::after { content: ""; width: 240px; height: 240px; border-radius: 50%; border: 45px solid rgba(255,122,26,.06); position: absolute; right: 15%; top: -70px; pointer-events: none; }
.featured-event h2 { font-size: 26px; margin: 16px 0 6px; }
.featured-event p { color: var(--muted); font-size: 13px; }
.featured-score { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 18px; text-align: center; }
.featured-score > span { font-size: 10px; letter-spacing: .1em; color: var(--muted); font-weight: 800; }
.featured-score strong { font-size: 42px; letter-spacing: -.06em; }
.featured-score i, .series-score i { color: #59616c; font-style: normal; padding: 0 8px; }
.section-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 28px 0 15px; }
.section-title h2 { font-size: 17px; }
.section-title p { color: var(--muted); font-size: 12px; margin-top: 4px; }
.event-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
.event-card { display: grid; grid-template-columns: 95px 1fr; min-height: 210px; overflow: hidden; transition: border-color .2s, transform .2s; }
.event-card:hover { transform: translateY(-2px); border-color: #3d4550; }
.date-block { display: flex; flex-direction: column; align-items: center; justify-content: center; border-right: 1px solid var(--border); background: rgba(255,255,255,.018); }
.date-block strong { font-size: 38px; line-height: 1; }
.date-block span { color: var(--orange); font-size: 11px; letter-spacing: .2em; margin-top: 7px; font-weight: 800; }
.event-card-body { padding: 22px; display: flex; flex-direction: column; }
.event-meta, .event-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.event-meta > span { color: var(--muted); font-size: 12px; }
.event-card h3 { font-size: 19px; margin-top: 18px; }
.event-card p { color: var(--muted); font-size: 12px; margin-top: 6px; }
.event-footer { margin-top: auto; padding-top: 18px; border-top: 1px solid var(--border); }
.event-footer span, .event-footer a { display: flex; align-items: center; gap: 6px; font-size: 11px; font-weight: 750; }
.event-footer span { color: var(--muted); }
.event-footer a { color: var(--orange); }
.detail-layout { display: grid; grid-template-columns: 1.5fr .8fr; gap: 18px; align-items: start; }
.event-hero, .roster-preview { padding: 28px; }
.event-time { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }
.event-time > svg { color: var(--orange); }
.event-time div { display: flex; flex-direction: column; gap: 4px; }
.event-time span, .captain-select span { color: var(--muted); font-size: 11px; }
.event-time strong { font-size: 14px; }
.divider { height: 1px; background: var(--border); margin: 28px 0; }
.event-hero > p { color: var(--muted); font-size: 12px; margin-top: 6px; }
.rsvp-control { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 20px; }
.rsvp-control button { min-height: 52px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); color: var(--muted); display: flex; align-items: center; justify-content: center; gap: 7px; font-weight: 750; cursor: pointer; }
.rsvp-control button.active.going { color: var(--green); border-color: #2e7453; background: #10271c; }
.rsvp-control button.active.maybe { color: var(--yellow); border-color: #735d2a; background: #28200f; }
.rsvp-control button.active.not_going { color: #c9cdd2; border-color: #555e68; }
.save-note { margin-top: 14px; color: var(--green); font-size: 11px; display: flex; align-items: center; gap: 6px; }
.roster-preview .section-title { margin-top: 0; }
.attendance-bar { display: flex; height: 8px; gap: 3px; margin: 28px 0 18px; }
.attendance-bar span { border-radius: 10px; }
.attendance-bar span:nth-child(1) { width: 72%; background: var(--green); }
.attendance-bar span:nth-child(2) { width: 18%; background: var(--yellow); }
.attendance-bar span:nth-child(3) { width: 10%; background: #59616b; }
.attendance-stats { display: grid; gap: 12px; margin-bottom: 28px; }
.attendance-stats span { color: var(--muted); display: flex; align-items: center; gap: 7px; font-size: 12px; }
.attendance-stats strong { margin-left: auto; color: #fff; }
.attendance-stats i { width: 6px; height: 6px; border-radius: 50%; background: #59616b; }
.attendance-stats i.green { background: var(--green); }.attendance-stats i.yellow { background: var(--yellow); }
.profile-banner { padding: 24px; display: flex; align-items: center; gap: 18px; margin-bottom: 16px; }
.large-avatar { width: 70px; height: 70px; font-size: 21px; }
.profile-banner h2 { font-size: 21px; }.profile-banner p { color: var(--muted); margin: 3px 0 10px; font-size: 12px; }
.ratings-form { padding: 28px; }
.ratings-form > .section-title { margin-top: 0; }
.ratings-form > .section-title > span { color: var(--muted); font-size: 11px; }
.rating-row { display: grid; grid-template-columns: 1fr minmax(180px,230px); gap: 10px 24px; padding: 22px 0; border-top: 1px solid var(--border); }
.rating-row > div { display: flex; align-items: center; gap: 12px; }
.rating-row > div > span:last-child { display: flex; flex-direction: column; gap: 4px; }
.rating-row small { color: var(--muted); font-size: 11px; }
.role-icon { width: 38px; height: 38px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); }
.role-tank .role-icon { color: #ad8cff; }.role-damage .role-icon { color: var(--orange); }.role-support .role-icon { color: var(--green); }
.rank-select { align-self: center; width: 100%; color: #fff; border-color: #3b424d; font-weight: 800; }
.preference-section { padding: 22px 0; border-top: 1px solid var(--border); }
.preference-section > div:first-child h2 { font-size: 16px; }
.preference-section > div:first-child p { color: var(--muted); font-size: 11px; margin-top: 5px; }
.preference-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }
.preference-chips button { min-height: 38px; padding: 0 16px; border: 1px solid var(--border); border-radius: 99px; background: var(--surface-2); color: var(--muted); font-size: 11px; font-weight: 800; cursor: pointer; }
.preference-chips button.active { color: var(--orange); border-color: var(--orange); background: var(--orange-soft); }
.teammate-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; margin-top: 14px; }
.teammate-grid label { min-height: 48px; padding: 8px 10px; display: grid; grid-template-columns: auto 28px 1fr; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface-2); cursor: pointer; font-size: 10px; }
.teammate-grid label.selected { border-color: var(--blue); background: var(--blue-soft); }.teammate-grid label:has(input:disabled) { opacity: .45; cursor: not-allowed; }
.teammate-grid .mini-avatar { width: 26px; height: 26px; }
.form-footer { padding-top: 20px; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.form-footer p { color: var(--muted); font-size: 11px; display: flex; align-items: center; gap: 6px; }
.tabs { display: flex; gap: 5px; border-bottom: 1px solid var(--border); margin-bottom: 20px; overflow-x: auto; }
.tabs button { padding: 13px 17px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); font-size: 12px; font-weight: 800; white-space: nowrap; cursor: pointer; }
.tabs button.active { color: #fff; border-color: var(--orange); }
.admin-grid { display: grid; grid-template-columns: 1fr 300px; gap: 16px; align-items: start; }
.settings-card, .admin-summary, .table-card { padding: 24px; }
.settings-card .section-title, .table-card .section-title { margin-top: 0; }
.field-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 16px; }
.settings-card label { display: flex; flex-direction: column; gap: 8px; color: #b6bcc4; font-size: 11px; font-weight: 700; margin-bottom: 16px; }
input, select, textarea { color: #e9ebed; background: #0e1115; border: 1px solid #303640; border-radius: 6px; padding: 11px 12px; }
textarea { min-height: 85px; resize: vertical; }
.admin-summary h2 { font-size: 17px; }
.readiness-score { margin: 25px 0; text-align: center; }.readiness-score strong { display: block; font-size: 48px; color: var(--orange); }.readiness-score span { color: var(--muted); font-size: 11px; }
.admin-summary ul { padding: 0; margin: 0 0 25px; list-style: none; display: grid; gap: 13px; }
.admin-summary li { display: flex; gap: 8px; align-items: center; color: var(--muted); font-size: 12px; }.admin-summary li svg { color: var(--green); }
.count-pills { display: flex; gap: 6px; }
.participant-list { display: grid; }
.participant-row { min-height: 64px; display: grid; grid-template-columns: 38px minmax(130px,1fr) auto 130px; align-items: center; gap: 12px; border-top: 1px solid var(--border); }
.mini-avatar { width: 32px; height: 32px; font-size: 10px; }
.participant-name { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }.participant-name span { color: var(--muted); font-size: 10px; text-transform: capitalize; }.participant-name em { color: var(--yellow); font-style: normal; }
.mini-ratings { display: flex; gap: 5px; }.mini-ratings span { color: var(--muted); background: var(--surface-2); border-radius: 4px; padding: 4px 6px; font-size: 9px; }
.participant-row select { padding: 8px; font-size: 11px; }
.balance-layout { display: grid; grid-template-columns: 260px 1fr; gap: 20px; }
.candidate-list .section-title { margin-top: 0; }
.candidate-card { width: 100%; min-height: 70px; display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 10px; padding: 11px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); text-align: left; margin-bottom: 8px; cursor: pointer; }
.candidate-card.active { border-color: var(--orange); background: var(--orange-soft); }
.candidate-rank { color: #525a66; font-size: 16px; font-weight: 900; }.candidate-card.active .candidate-rank { color: var(--orange); }
.candidate-card > span:nth-child(2) { display: flex; flex-direction: column; gap: 4px; }.candidate-card strong { font-size: 12px; }.candidate-card small { color: var(--muted); font-size: 9px; }
.balance-score { padding: 20px; display: grid; grid-template-columns: 1.5fr .8fr .8fr 2fr; gap: 20px; align-items: center; margin-bottom: 12px; }
.balance-score > div { display: flex; flex-direction: column; gap: 4px; }.balance-score > div > span:not(.eyebrow) { color: var(--muted); font-size: 10px; }.balance-score > div > strong { font-size: 28px; }.balance-score > div:first-child strong { color: var(--green); font-size: 36px; }.balance-score small { font-size: 11px; color: var(--muted); }
.balance-score p { color: var(--muted); font-size: 11px; line-height: 1.6; }
.balance-explanations { display: flex; flex-direction: column; gap: 5px; }.balance-explanations > span { color: var(--muted); font-size: 10px; }.balance-explanations small { display: flex; align-items: center; gap: 5px; color: var(--green); font-size: 9px; }
.teams-grid { display: grid; grid-template-columns: repeat(2,1fr); gap: 12px; }
.team-card { overflow: hidden; }.team-card header { padding: 20px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); }
.team-card header span { color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .16em; }.team-card h3 { margin-top: 4px; }.team-card header > strong { font-size: 23px; }.team-card header > strong.team-rank { display: flex; flex-direction: column; align-items: flex-end; font-size: 13px; }.team-card header small { color: var(--muted); font-size: 9px; }
.team-alpha { --team: var(--orange); --team-bg: var(--orange-soft); }.team-beta { --team: var(--blue); --team-bg: var(--blue-soft); }
.team-card.team-alpha { border-top: 2px solid var(--orange); }.team-card.team-beta { border-top: 2px solid var(--blue); }
.team-members { padding: 8px 18px; }.team-members > div { min-height: 42px; display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 8px; border-bottom: 1px solid #22272e; font-size: 11px; }.team-members > div > span:last-child { color: var(--muted); }
.role-chip { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 4px; font-size: 8px; font-weight: 900; color: #bdc3cb; background: #292e36; }
.role-chip.tank { color: #bc9eff; background: #241c38; }.role-chip.damage { color: #ff9b56; background: #352013; }.role-chip.support { color: #60dda0; background: #142c20; }
.captain-select { padding: 15px 18px 18px; display: flex; flex-direction: column; gap: 7px; }.captain-select span { display: flex; align-items: center; gap: 6px; }.captain-select select { padding: 9px; font-size: 11px; }
.reserve-bar { margin-top: 12px; padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 15px; }.reserve-bar > div { display: flex; align-items: center; gap: 9px; }.reserve-bar span { display: flex; flex-direction: column; }.reserve-bar small { color: var(--muted); margin-top: 3px; font-size: 9px; }
.sticky-action { margin-top: 12px; padding: 14px 0; display: flex; align-items: center; justify-content: flex-end; gap: 18px; }.sticky-action p { color: var(--muted); font-size: 10px; }
.scoreboard { min-height: 150px; display: grid; grid-template-columns: 1fr 210px 1fr; align-items: center; border: 1px solid var(--border); background: linear-gradient(90deg,rgba(74,37,13,.35),rgba(16,19,24,.9) 42% 58%,rgba(10,45,70,.35)); border-radius: 12px; overflow: hidden; }
.score-team { padding: 25px 30px; }.score-team > span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .18em; font-weight: 900; }.score-team h2 { margin: 5px 0; font-size: clamp(18px,2vw,28px); }.score-team small { color: var(--muted); }.score-team.beta { text-align: right; }.score-team.beta > span { color: var(--blue); }
.series-score { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; border-left: 1px solid var(--border); border-right: 1px solid var(--border); }.series-score > span { color: var(--muted); text-transform: uppercase; letter-spacing: .16em; font-size: 9px; }.series-score strong { font-size: 48px; line-height: 1.25; }
.live-layout { display: grid; grid-template-columns: 1fr 330px; gap: 16px; margin-top: 16px; align-items: start; }
.turn-card { border: 1px solid color-mix(in srgb,var(--team) 55%,#2b3038); border-top: 3px solid var(--team); border-radius: 12px; background: linear-gradient(155deg,var(--team-bg),var(--surface) 42%); overflow: hidden; }
.turn-header { padding: 26px; display: flex; align-items: start; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--border); }.turn-header h2 { font-size: 27px; margin: 7px 0; }.turn-header p { color: var(--muted); font-size: 12px; }
.turn-team { text-align: right; display: flex; flex-direction: column; gap: 5px; }.turn-team span { color: var(--muted); font-size: 9px; text-transform: uppercase; }.turn-team strong { color: var(--team); font-size: 12px; }
.draft-options { padding: 22px; display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }
.draft-options button { min-height: 74px; padding: 12px; display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 8px; text-align: left; border: 1px solid var(--border); border-radius: 7px; background: #12161b; cursor: pointer; }
.draft-options button > svg { color: transparent; }.draft-options button.selected { border-color: var(--team); background: var(--team-bg); }.draft-options button.selected > svg { color: var(--team); }
.draft-options button:disabled { opacity: .45; cursor: not-allowed; }.draft-options button small { grid-column: 2/-1; color: #e49765; font-size: 8px; }
.turn-footer { padding: 18px 22px; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; gap: 16px; }.turn-footer p { color: var(--muted); font-size: 9px; display: flex; align-items: center; gap: 5px; }.team-action { background: var(--team); color: #081016; }.team-action:disabled { opacity: .4; cursor: not-allowed; }
.result-form { padding: 24px; display: grid; grid-template-columns: 1fr 1fr auto; align-items: end; gap: 14px; }
.result-form label { display: flex; flex-direction: column; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 700; }
.error-note { color: #ff8f78 !important; font-size: 11px !important; margin-top: 10px; }
.empty-note { color: var(--muted); font-size: 11px; padding: 12px 0; }
.match-sidebar { display: grid; gap: 12px; }.map-timeline, .audit-log { padding: 18px; }.map-timeline .section-title, .audit-log .section-title { margin-top: 0; }
.map-row { display: grid; grid-template-columns: 28px 1fr auto; align-items: center; gap: 9px; min-height: 54px; border-top: 1px solid var(--border); }.map-row > span { color: #515963; font-weight: 900; }.map-row > div { display: flex; flex-direction: column; gap: 4px; font-size: 11px; }.map-row small { color: var(--muted); font-size: 9px; text-transform: capitalize; }.map-row.drafting > span { color: var(--orange); }
.audit-log > div:not(.section-title) { display: grid; grid-template-columns: 10px 1fr; gap: 8px; padding: 9px 0; }.history-dot { width: 6px; height: 6px; margin-top: 4px; border-radius: 50%; background: #4b525d; }.audit-log p { display: flex; flex-direction: column; gap: 4px; font-size: 10px; }.audit-log small { color: var(--muted); }
.spectator-page { max-width: 1100px; }.spectator-focus { margin: 16px 0; padding: 32px; text-align: center; border: 1px solid #244f70; border-radius: 12px; background: radial-gradient(circle at 50% 0,rgba(32,127,192,.17),transparent 60%),var(--surface); }.spectator-focus h2 { font-size: 27px; margin: 8px 0; }.spectator-focus > p { color: var(--blue); font-size: 11px; }
.ban-display { display: grid; grid-template-columns: repeat(4,1fr); gap: 8px; margin-top: 28px; }.ban-display > div { min-height: 105px; padding: 14px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; border: 1px solid var(--border); border-radius: 7px; background: #0d1014; }.ban-display span,.ban-display small { color: var(--muted); font-size: 9px; }.ban-display strong { font-size: 17px; }.ban-display .pending-ban { border-color: var(--blue); background: var(--blue-soft); color: var(--blue); }
.spectator-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.bracket-scroll { min-height: 520px; display: flex; gap: 110px; padding: 30px 60px; overflow-x: auto; border: 1px solid var(--border); border-radius: 12px; background: radial-gradient(circle at 50% 50%,#171b22,transparent 55%),#0d1014; }
.bracket-round { min-width: 300px; }.bracket-round > header { margin-bottom: 20px; }.bracket-round > header span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .15em; }.bracket-round > header h2 { margin-top: 4px; font-size: 18px; }
.bracket-matches { height: 400px; display: flex; flex-direction: column; justify-content: space-around; gap: 50px; }.bracket-match { width: 300px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); position: relative; box-shadow: 0 14px 35px rgba(0,0,0,.25); }.bracket-match::after { content: ""; width: 110px; height: 1px; position: absolute; right: -111px; top: 50%; background: #303640; }.bracket-round:last-child .bracket-match::after { display: none; }
.bracket-match.live { border-color: #78401f; box-shadow: 0 0 25px rgba(255,122,26,.08); }.match-label { padding: 8px 11px; display: flex; justify-content: space-between; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 9px; text-transform: uppercase; }.bracket-match > div:not(.match-label) { min-height: 38px; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; font-size: 11px; }.bracket-match > div.winner { color: var(--green); }.bracket-match > a { padding: 9px 12px; border-top: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; color: var(--orange); font-size: 9px; }
.bracket-help { display: flex; justify-content: center; align-items: center; gap: 5px; color: var(--muted); font-size: 10px; margin-top: 12px; }
.state-card { min-height: 350px; margin: 30px auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; color: var(--muted); text-align: center; }.state-card > svg { width: 32px; height: 32px; color: var(--orange); }.state-card h2 { color: #fff; }.state-card p { font-size: 12px; }
.spin { animation: spin 1.5s linear infinite; }
.login-page { min-height: 100vh; display: grid; place-items: center; overflow: hidden; position: relative; background: linear-gradient(rgba(7,9,12,.85),rgba(7,9,12,.95)),repeating-linear-gradient(120deg,transparent 0 44px,rgba(255,255,255,.018) 44px 45px); }
.login-card { width: min(460px,calc(100% - 36px)); padding: 45px; z-index: 1; text-align: center; border: 1px solid var(--border); border-top: 2px solid var(--orange); border-radius: 14px; background: rgba(17,20,25,.9); backdrop-filter: blur(20px); box-shadow: 0 40px 90px rgba(0,0,0,.45); }
.login-brand-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 45px; }.login-brand-row .brand { margin-bottom: 0; }.login-card .eyebrow { display: block; }.login-card h1 { font-size: 48px; line-height: .98; letter-spacing: -.06em; margin: 13px 0 18px; }.login-card h1 span { color: var(--orange); }.login-card > p { color: var(--muted); line-height: 1.6; font-size: 13px; }.discord-button { width: 100%; margin: 28px 0 15px; background: #5865f2; color: #fff; }.login-card > small { color: #606874; font-size: 9px; }
.login-orb { position: absolute; width: 420px; height: 420px; border-radius: 50%; filter: blur(120px); opacity: .16; }.login-orb-orange { background: var(--orange); left: -150px; top: -140px; }.login-orb-blue { background: var(--blue); right: -150px; bottom: -140px; }
.login-stats { position: absolute; bottom: 28px; display: flex; gap: 40px; color: #59616c; font-size: 9px; text-transform: uppercase; letter-spacing: .1em; }.login-stats strong { color: #b9bfc7; margin-right: 5px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 900px) {
.desktop-nav { display: none; }.mobile-menu { display: grid !important; }.account > .live-dot { display: none; }
.mobile-nav { display: grid !important; position: fixed; z-index: 30; top: 70px; right: 12px; width: 220px; padding: 8px; border: 1px solid var(--border); border-radius: 9px; background: #13171c; box-shadow: 0 25px 70px rgba(0,0,0,.5); }
.mobile-nav a { padding: 13px; display: flex; align-items: center; gap: 10px; color: var(--muted); font-size: 12px; }
.featured-event { grid-template-columns: 1fr auto; }.featured-event > div:first-child { grid-column: 1/-1; }.event-grid { grid-template-columns: 1fr; }
.detail-layout,.admin-grid,.live-layout { grid-template-columns: 1fr; }.balance-layout { grid-template-columns: 1fr; }.candidate-list { display: grid; grid-template-columns: repeat(3,1fr); gap: 8px; }.candidate-list .section-title { grid-column: 1/-1; }.candidate-card { margin: 0; }
.balance-score { grid-template-columns: repeat(3,1fr); }.balance-score p { grid-column: 1/-1; }.turn-header { flex-direction: column; }.turn-team { text-align: left; }
}
@media (max-width: 640px) {
.topbar { height: 62px; padding: 0 16px; }.page { padding: 28px 14px 92px; }.page-header { align-items: flex-start; flex-direction: column; margin-bottom: 22px; }.page-header h1 { font-size: 34px; }.page-actions { width: 100%; }
.bottom-nav { display: grid !important; position: fixed; z-index: 20; bottom: 0; left: 0; right: 0; grid-template-columns: repeat(4,1fr); padding: 6px 8px max(6px,env(safe-area-inset-bottom)); border-top: 1px solid var(--border); background: rgba(13,16,20,.96); backdrop-filter: blur(16px); }
.bottom-nav a { min-height: 52px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; color: #747c87; font-size: 9px; font-weight: 700; }.bottom-nav a svg { font-size: 18px; }.bottom-nav a.active { color: var(--orange); }
.featured-event { padding: 23px; grid-template-columns: 1fr; }.featured-event > * { grid-column: 1; }.featured-score { margin: 10px 0; }.featured-event .button { width: 100%; }.featured-event h2 { font-size: 22px; }
.event-card { grid-template-columns: 70px 1fr; }.date-block strong { font-size: 28px; }.event-card-body { padding: 17px; }
.detail-layout { gap: 10px; }.event-hero,.roster-preview { padding: 20px; }.rsvp-control { grid-template-columns: 1fr; }.rsvp-control button { justify-content: flex-start; padding-left: 18px; }
.profile-banner { padding: 18px; }.ratings-form { padding: 18px; }.rating-row { grid-template-columns: 1fr; }.rank-select { min-height: 46px; }.form-footer { align-items: stretch; flex-direction: column; }.form-footer .button { width: 100%; }
.teammate-grid { grid-template-columns: repeat(2,1fr); }
.settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px; padding: 9px 0; }.mini-ratings { display: none; }
.candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; }
.scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }
.result-form { grid-template-columns: 1fr; }
.ban-display { grid-template-columns: repeat(2,1fr); }.spectator-focus { padding: 24px 14px; }.spectator-grid { grid-template-columns: 1fr; }
.bracket-scroll { padding: 25px; gap: 70px; }.bracket-match::after { width: 70px; right: -71px; }
.login-card { padding: 35px 24px; }.login-card h1 { font-size: 39px; }.login-stats { display: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
}

13
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { LanguageProvider } from './i18n.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<LanguageProvider>
<App />
</LanguageProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { RANK_ORDINALS, ordinalToRank, rankLabel, rankOptions } from './ranks'
describe('Overwatch competitive rank utilities', () => {
it('contains all 40 ordered rank values', () => {
expect(RANK_ORDINALS).toHaveLength(40)
expect(rankLabel(1, 'en')).toBe('Bronze 5')
expect(rankLabel(40, 'en')).toBe('Champion 1')
})
it('maps tier boundaries correctly', () => {
expect(ordinalToRank(5)).toMatchObject({ tier: 'Bronze', division: 1 })
expect(ordinalToRank(6)).toMatchObject({ tier: 'Silver', division: 5 })
expect(ordinalToRank(35)).toMatchObject({ tier: 'Grandmaster', division: 1 })
expect(ordinalToRank(36)).toMatchObject({ tier: 'Champion', division: 5 })
})
it('renders localized options highest rank first', () => {
const options = rankOptions('ru')
expect(options[0]).toEqual({ value: 40, label: 'Чемпион 1' })
expect(options.at(-1)).toEqual({ value: 1, label: 'Бронза 5' })
})
})

50
frontend/src/ranks.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { Language } from './i18n-context'
export type RankOrdinal =
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10
| 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20
| 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30
| 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40
export const RANK_ORDINALS = Array.from({ length: 40 }, (_, index) => index + 1) as RankOrdinal[]
const tiers = [
{ en: 'Bronze', ru: 'Бронза' },
{ en: 'Silver', ru: 'Серебро' },
{ en: 'Gold', ru: 'Золото' },
{ en: 'Platinum', ru: 'Платина' },
{ en: 'Diamond', ru: 'Алмаз' },
{ en: 'Master', ru: 'Мастер' },
{ en: 'Grandmaster', ru: 'Грандмастер' },
{ en: 'Champion', ru: 'Чемпион' },
] as const
export interface CompetitiveRank {
ordinal: RankOrdinal
tier: (typeof tiers)[number]['en']
division: 1 | 2 | 3 | 4 | 5
}
export function toRankOrdinal(value: number): RankOrdinal {
return Math.min(40, Math.max(1, Math.round(value))) as RankOrdinal
}
export function ordinalToRank(value: number): CompetitiveRank {
const ordinal = toRankOrdinal(value)
const tierIndex = Math.floor((ordinal - 1) / 5)
const division = (5 - ((ordinal - 1) % 5)) as CompetitiveRank['division']
return { ordinal, tier: tiers[tierIndex].en, division }
}
export function rankLabel(value: number, language: Language) {
const rank = ordinalToRank(value)
const tier = tiers.find((item) => item.en === rank.tier)!
return `${tier[language]} ${rank.division}`
}
export function rankOptions(language: Language) {
return [...RANK_ORDINALS].reverse().map((ordinal) => ({
value: ordinal,
label: rankLabel(ordinal, language),
}))
}

View File

@@ -0,0 +1,6 @@
import '@testing-library/jest-dom/vitest'
Object.defineProperty(window, 'scrollTo', {
value: () => undefined,
writable: true,
})

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': {
target: process.env.VITE_DEV_API_TARGET ?? 'http://localhost:8080',
changeOrigin: true,
},
},
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
})