Files
mixmaker/frontend/src/App.tsx
2026-07-19 01:21:00 +03:00

388 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 Registration, 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 mainNavItems = [
{ 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 },
] : []),
]
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'
const navItems = session.data?.account.role === 'admin'
? [...mainNavItems, { to: '/admin' as const, label: 'Admin', icon: Shield }]
: mainNavItems
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
const statusOrder: Record<RsvpStatus, number> = { going: 0, maybe: 1, not_going: 2 }
const attendees = [...(registrations.data ?? [])].sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.player.displayName.localeCompare(b.player.displayName))
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><section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></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={<>{events.data && events.data.length > 0 && <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 options = useMemo(() => rankOptions(language), [language])
const [displayName, setDisplayName] = useState('')
const [status, setStatus] = useState<RsvpStatus>('going')
const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 13, damage: 13, support: 13 })
const rows = useQuery({ queryKey: ['registrations', eventId], queryFn: () => api.registrations(eventId), initialData: demoMode ? demoRegistrations : undefined })
const create = useMutation({
mutationFn: () => {
if (!demoMode) return api.createParticipant(eventId, displayName.trim(), ratings, status)
const now = new Date().toISOString()
const id = `guest-${Date.now()}`
return Promise.resolve<Registration>({
id: `${eventId}:${id}`,
player: {
id,
displayName: displayName.trim(),
ratings: { ...ratings, updatedAt: now },
preferredRoles: [],
preferredPlayerIds: [],
},
status,
updatedAt: now,
changedBy: { id: demoSession.account.id, displayName: demoSession.account.displayName, source: 'admin' },
})
},
onSuccess: (registration) => {
client.setQueryData<Registration[]>(['registrations', eventId], (current) => [...(current ?? []), registration])
setDisplayName('')
setRatings({ tank: 13, damage: 13, support: 13 })
setStatus('going')
if (!demoMode) void client.invalidateQueries({ queryKey: ['events'] })
},
})
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"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><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() {
if (window.location.pathname === '/login') {
return <RouterProvider router={router} context={{ session: null }} />
}
return <SessionRouter />
}
function SessionRouter() {
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