388 lines
44 KiB
TypeScript
388 lines
44 KiB
TypeScript
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>Couldn’t 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: 'Can’t 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
|