986 lines
100 KiB
TypeScript
986 lines
100 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
|
||
import {
|
||
Bell, CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, ExternalLink, Gamepad2, ListFilter, LogOut,
|
||
Menu, Radio, RefreshCw, Search, 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, useRouterState,
|
||
} from '@tanstack/react-router'
|
||
import {
|
||
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
|
||
type Player, type PlayerRole, type PublicPlayerProfile, type RawAccount, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
|
||
} from './api/client'
|
||
import {
|
||
demoBalanceCandidates, demoBracket, demoCommunitySettings, demoEvents, demoPlayerProfiles, 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'
|
||
import { activeHeroBanMap } from './series-utils'
|
||
import { playTurnSound, primeTurnSound, shouldNotifyTurn, type TurnSnapshot } from './turn-notification'
|
||
import { BracketEditor } from './components/bracket-editor/BracketEditor'
|
||
|
||
type RouterContext = { session: Session | null }
|
||
const isStaff = (role?: Session['account']['role']) => role === 'admin' || role === 'moderator'
|
||
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 playerRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/players/$playerId', component: PublicPlayerProfilePage })
|
||
const faqRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/faq', component: FAQPage })
|
||
const eventsRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events', component: EventsPage })
|
||
const eventRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/events/$eventId', component: EventPage })
|
||
const liveEntryRoute = createRoute({
|
||
getParentRoute: () => protectedRoute,
|
||
path: '/events/$eventId/live',
|
||
validateSearch: (search: Record<string, unknown>) => ({ seriesId: typeof search.seriesId === 'string' ? search.seriesId : undefined }),
|
||
component: LiveEntryPage,
|
||
})
|
||
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 (!isStaff(context.session?.account.role)) throw redirect({ to: '/events' }) },
|
||
})
|
||
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
|
||
indexRoute, profileRoute, playerRoute, faqRoute, eventsRoute, eventRoute, liveEntryRoute, 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 },
|
||
{ to: '/faq', label: 'FAQ', icon: CircleHelp },
|
||
...(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 [realtimeStatus, setRealtimeStatus] = useState<'connected' | 'reconnecting'>('reconnecting')
|
||
const client = useQueryClient()
|
||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
|
||
useEffect(() => {
|
||
window.addEventListener('pointerdown', primeTurnSound, { once: true })
|
||
window.addEventListener('keydown', primeTurnSound, { once: true })
|
||
return () => {
|
||
window.removeEventListener('pointerdown', primeTurnSound)
|
||
window.removeEventListener('keydown', primeTurnSound)
|
||
}
|
||
}, [])
|
||
useEffect(() => subscribeToEvents('*', (message) => {
|
||
const [scope, id] = message.topic.split(':', 2)
|
||
if (scope === 'event' && id) {
|
||
void client.invalidateQueries({ queryKey: ['event', id] })
|
||
void client.invalidateQueries({ queryKey: ['registrations', id] })
|
||
void client.invalidateQueries({ queryKey: ['roster', id] })
|
||
void client.invalidateQueries({ queryKey: ['teams', id] })
|
||
void client.invalidateQueries({ queryKey: ['tournament', id] })
|
||
void client.invalidateQueries({ queryKey: ['events'] })
|
||
} else if (scope === 'series' && id) {
|
||
void client.invalidateQueries({ queryKey: ['series', id] })
|
||
} else if (scope === 'tournament') {
|
||
void client.invalidateQueries({ queryKey: ['tournament'] })
|
||
} else if (scope === 'bracket' && id) {
|
||
void client.invalidateQueries({ queryKey: ['bracket-draft', id] })
|
||
} else if (scope === 'team') {
|
||
void client.invalidateQueries({ queryKey: ['teams'] })
|
||
void client.invalidateQueries({ queryKey: ['series'] })
|
||
} else if (message.topic === 'events') {
|
||
void client.invalidateQueries({ queryKey: ['events'] })
|
||
} else if (message.topic === 'players') {
|
||
void client.invalidateQueries({ queryKey: ['players'] })
|
||
void client.invalidateQueries({ queryKey: ['profile'] })
|
||
} else if (message.topic === 'accounts') {
|
||
void client.invalidateQueries({ queryKey: ['session'] })
|
||
void client.invalidateQueries({ queryKey: ['accounts'] })
|
||
}
|
||
}, setRealtimeStatus), [client])
|
||
const logout = useMutation({
|
||
mutationFn: api.logout,
|
||
onSuccess: () => {
|
||
queryClient.clear()
|
||
void router.navigate({ to: '/login' })
|
||
},
|
||
})
|
||
const initials = session.data?.player.displayName.slice(0, 2).toUpperCase() ?? 'MM'
|
||
const navItems = isStaff(session.data?.account.role)
|
||
? [...mainNavItems, { to: '/admin' as const, label: session.data?.account.role === 'moderator' ? 'Moderation' : '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 ${realtimeStatus}`} title={realtimeStatus === 'connected' ? 'Realtime connected' : 'Realtime reconnecting'} /><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' | 'danger' | '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, message }: { retry?: () => void; message?: string }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldn’t load this view</h2><p>{message || 'Your data is safe. Check your connection and try again.'}</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
|
||
function PlayerLink({ player, className }: { player: Pick<Player, 'id' | 'displayName'>; className?: string }) {
|
||
return <Link className={`player-link ${className ?? ''}`.trim()} to="/players/$playerId" params={{ playerId: player.id }}>{player.displayName}</Link>
|
||
}
|
||
|
||
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
|
||
const liveEvent = events.data.find((event) => event.workflowState === 'Live')
|
||
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>} />
|
||
{liveEvent && <FeaturedLiveEvent event={liveEvent} />}
|
||
<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.map((event) => <EventCard key={event.id} event={event} />)}</div>}
|
||
</div>
|
||
}
|
||
|
||
function FeaturedLiveEvent({ event }: { event: MixEvent }) {
|
||
const series = useQuery({
|
||
queryKey: ['series', event.activeSeriesId],
|
||
queryFn: () => api.series(event.activeSeriesId!),
|
||
enabled: Boolean(event.activeSeriesId) && !event.tournamentId,
|
||
initialData: demoMode && event.activeSeriesId === demoSeries.id ? demoSeries : undefined,
|
||
})
|
||
const tournament = useQuery({
|
||
queryKey: ['tournament', event.id],
|
||
queryFn: () => api.bracket(event.id),
|
||
enabled: Boolean(event.tournamentId),
|
||
initialData: demoMode && event.tournamentId ? demoBracket : undefined,
|
||
})
|
||
const match = series.data
|
||
const liveMatches = tournament.data?.matches.filter((item) => item.status === 'live') ?? []
|
||
const hasScore = Boolean(match) || liveMatches.length > 0
|
||
return <section className={`featured-event ${hasScore ? '' : 'without-score'} ${liveMatches.length > 1 ? 'multiple-matches' : ''}`}><div><Badge tone="live"><Radio />Live now</Badge><h2>{event.title}</h2><p>{liveMatches.length > 0 ? <>{liveMatches.length} <span>active matches</span></> : match ? `${match.roundLabel} · ${match.currentStep.title}` : 'The scrim has started'}</p></div>{liveMatches.length > 0 ? <div className="featured-match-list">{liveMatches.map((item) => <div className="featured-match-score" key={item.id}><small>{item.label}</small><span>{item.teamAlpha ?? 'TBD'}</span><strong>{item.scoreAlpha ?? 0}<i>:</i>{item.scoreBeta ?? 0}</strong><span>{item.teamBeta ?? 'TBD'}</span></div>)}</div> : match && <div className="featured-score"><span>{match.teamAlpha.name}</span><strong>{match.score.alpha}<i>:</i>{match.score.beta}</strong><span>{match.teamBeta.name}</span></div>}<Link className="button primary" to="/events/$eventId/live" params={{ eventId: event.id }} search={{ seriesId: undefined }}>Open live <ChevronRight /></Link></section>
|
||
}
|
||
|
||
function EventCard({ event }: { event: MixEvent }) {
|
||
const date = new Date(event.startsAt)
|
||
const statusBadge = event.status === 'cancelled' ? <Badge tone="danger"><X />Cancelled</Badge>
|
||
: event.status === 'completed' ? <Badge tone="success"><Check />Completed</Badge>
|
||
: event.status === 'live' ? <Badge tone="live"><Radio />LIVE</Badge>
|
||
: <Badge tone={event.myRsvp === 'going' ? 'success' : 'warning'}>{event.myRsvp === 'going' ? <Check /> : <Clock3 />}{event.myRsvp === 'going' ? 'Going' : 'Maybe'}</Badge>
|
||
return <Link className={`card event-card status-${event.status}`} to="/events/$eventId" params={{ eventId: event.id }} aria-label={`Open ${event.title}`}><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">{statusBadge}<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><span className="event-open">View event <ChevronRight /></span></div></div></Link>
|
||
}
|
||
|
||
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 teams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode && demoEvent && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(demoEvent.workflowState) ? demoBalanceCandidates[0].teams : 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] })
|
||
},
|
||
})
|
||
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 attendanceTotal = Object.values(counts).reduce((total, count) => total + count, 0)
|
||
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 event-detail status-${event.status}`}><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<>{event.status !== 'cancelled' && event.workflowState === 'Live' && <Link className="button primary" to="/events/$eventId/live" params={{ eventId }} search={{ seriesId: undefined }}>Open live</Link>}{event.workflowState === 'Completed' && event.activeSeriesId && !event.tournamentId && <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.activeSeriesId }}>Match history</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button secondary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}<Badge tone={event.workflowState === 'Cancelled' ? 'danger' : event.workflowState === 'Live' ? 'live' : event.workflowState === 'Completed' || event.workflowState === 'RegistrationOpen' ? 'success' : 'neutral'}>{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}</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>{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by 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 || event.workflowState !== 'RegistrationOpen'} 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>{attendanceTotal} responses</p></div><UsersRound /></div><div className="attendance-bar">{attendanceTotal > 0 && <>{counts.going > 0 && <span className="going" style={{ flexGrow: counts.going, width: 0 }} />}{counts.maybe > 0 && <span className="maybe" style={{ flexGrow: counts.maybe, width: 0 }} />}{counts.not_going > 0 && <span className="not-going" style={{ flexGrow: counts.not_going, width: 0 }} />}</>}</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>{isStaff(session.data.account.role) && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
|
||
</div>{teams.data && teams.data.length > 0 && <section className="event-teams"><div className="section-title"><div><h2>Balanced teams</h2><p>Confirmed match rosters are always available here.</p></div><Swords /></div><div className="teams-grid">{teams.data.map((team) => <PublicTeamCard key={team.id} team={team} />)}</div></section>}<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><PlayerLink player={registration.player} /><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
|
||
}
|
||
|
||
function LiveEntryPage() {
|
||
const { eventId } = liveEntryRoute.useParams()
|
||
const { seriesId: requestedSeriesId } = liveEntryRoute.useSearch()
|
||
const event = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId), initialData: demoMode ? demoEvents.find((item) => item.id === eventId) : undefined })
|
||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
|
||
const teams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode ? demoBalanceCandidates[0].teams : undefined })
|
||
const bracket = useQuery({
|
||
queryKey: ['tournament', eventId],
|
||
queryFn: () => api.bracket(eventId),
|
||
enabled: Boolean(event.data?.tournamentId),
|
||
initialData: demoMode && event.data?.tournamentId ? demoBracket : undefined,
|
||
})
|
||
const loading = event.isLoading || session.isLoading || teams.isLoading || (Boolean(event.data?.tournamentId) && bracket.isLoading)
|
||
const error = event.error ?? session.error ?? teams.error ?? bracket.error
|
||
const teamIds = useMemo(() => new Set((teams.data ?? []).filter((team) => team.members.some(({ player }) => player.id === session.data?.player.id)).map((team) => team.id)), [session.data?.player.id, teams.data])
|
||
const matches = bracket.data?.matches ?? []
|
||
const activeMatches = matches.filter((match) => match.status === 'live' && match.seriesId)
|
||
const requested = requestedSeriesId ? matches.find((match) => match.seriesId === requestedSeriesId) : undefined
|
||
const ownMatch = activeMatches.find((match) => teamIds.has(match.teamAlphaId ?? '') || teamIds.has(match.teamBetaId ?? ''))
|
||
const target = requested ?? ownMatch ?? activeMatches[0] ?? matches.find((match) => match.seriesId)
|
||
const targetSeriesId = event.data?.tournamentId ? target?.seriesId : event.data?.activeSeriesId
|
||
const targetIsOwn = event.data?.tournamentId
|
||
? Boolean(target && (teamIds.has(target.teamAlphaId ?? '') || teamIds.has(target.teamBetaId ?? '')))
|
||
: teamIds.size > 0
|
||
const control = isStaff(session.data?.account.role) || targetIsOwn
|
||
useEffect(() => {
|
||
if (loading || error || !targetSeriesId) return
|
||
if (control) void router.navigate({ to: '/series/$seriesId', params: { seriesId: targetSeriesId }, replace: true })
|
||
else void router.navigate({ to: '/watch/$seriesId', params: { seriesId: targetSeriesId }, replace: true })
|
||
}, [control, error, loading, targetSeriesId])
|
||
if (error) return <ErrorState message={error.message} retry={() => { void event.refetch(); void session.refetch(); void teams.refetch(); void bracket.refetch() }} />
|
||
if (!loading && !targetSeriesId) return <ErrorState message="No active series is available for this event." />
|
||
return <LoadingState label="Opening your live match…" />
|
||
}
|
||
|
||
function FAQPage() {
|
||
const settings = useQuery({ queryKey: ['community-settings'], queryFn: api.communitySettings, initialData: demoMode ? demoCommunitySettings : undefined })
|
||
return <div className="page faq-page"><PageHeader eyebrow="Rules and help" title="Frequently asked questions" description="Everything players and captains need before the scrim starts." /><div className="faq-grid">
|
||
<section className="card faq-card community-faq"><span><Bell /></span><div><h2>Join the community Discord</h2><p>Discord is where event announcements, role updates, and match notifications arrive. Join the server and enable the bot roles you want to be notified about.</p>{settings.data?.discordInviteUrl && <a className="button secondary" href={settings.data.discordInviteUrl} target="_blank" rel="noreferrer">Open Discord <ExternalLink /></a>}</div></section>
|
||
<section className="card faq-card"><span>01</span><div><h2>How are teams balanced?</h2><p>The server builds role-locked 1 Tank / 2 Damage / 2 Support teams. Rank balance is the main goal. Preferred roles, preferred teammates, and up to three avoided teammates are soft preferences and never override a fair valid roster.</p></div></section>
|
||
<section className="card faq-card"><span>02</span><div><h2>How does registration work?</h2><p>Choose Going, Maybe, or Can’t go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.</p></div></section>
|
||
<section className="card faq-card"><span>03</span><div><h2>What happens before the scrim?</h2><p>An admin generates balance candidates, chooses one, may swap players of the same role or use the reserve, assigns captains, confirms the rosters, and starts the scrim.</p></div></section>
|
||
<section className="card faq-card"><span>04</span><div><h2>How is the first map selected?</h2><p>The initial coin toss decides which team bans first. Teams alternate map bans until one map remains. That map becomes the first map of the series.</p></div></section>
|
||
<section className="card faq-card"><span>05</span><div><h2>Who picks later maps?</h2><p>After a captain or admin records a map result, the team that lost that map immediately chooses the next map from the next ruleset pool. Already played maps are unavailable. After a draw, the initial coin-toss loser receives the pick.</p></div></section>
|
||
<section className="card faq-card"><span>06</span><div><h2>How do hero bans work?</h2><p>Before every map, teams make two bans each in alternating order. The initial coin-toss loser bans first. A team’s two bans must use different roles, and a team cannot repeat its own hero ban during the same series. It may ban a hero previously banned by the opponent.</p></div></section>
|
||
<section className="card faq-card"><span>07</span><div><h2>How does Bo3 scoring work?</h2><p>The first team to win two maps wins the series. A captain selects the result after each map; admins can correct mistakes. Tournament winners advance automatically.</p></div></section>
|
||
<section className="card faq-card"><span>08</span><div><h2>What can captains do?</h2><p>Captains can perform actions for their team: map bans, later-map picks, and hero bans. Admins can perform or correct any authoritative action. Every command is versioned and recorded in the match history.</p></div></section>
|
||
</div></div>
|
||
}
|
||
|
||
function PublicPlayerProfilePage() {
|
||
const { playerId } = playerRoute.useParams()
|
||
const language = useLanguage()
|
||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
|
||
const fallback = demoPlayerProfiles.find((player) => player.id === playerId)
|
||
const profile = useQuery({
|
||
queryKey: ['player-profile', playerId],
|
||
queryFn: () => api.playerProfile(playerId),
|
||
initialData: demoMode ? fallback : undefined,
|
||
retry: false,
|
||
})
|
||
if (profile.isLoading || session.isLoading) return <LoadingState label="Loading player profile…" />
|
||
if (profile.isError || session.isError || !profile.data) return <ErrorState message="This player profile could not be found." retry={() => void profile.refetch()} />
|
||
const player: PublicPlayerProfile = profile.data
|
||
const stats = player.stats
|
||
const winRate = stats.winRate <= 1 ? Math.round(stats.winRate * 100) : Math.round(stats.winRate)
|
||
const roles: Array<{ key: PlayerRole; label: string; icon: ReactNode }> = [
|
||
{ key: 'tank', label: 'Tank', icon: <Shield /> },
|
||
{ key: 'damage', label: 'Damage', icon: <Zap /> },
|
||
{ key: 'support', label: 'Support', icon: <Sparkles /> },
|
||
]
|
||
return <div className="page player-profile-page">
|
||
<PageHeader eyebrow="Player profile" title={player.displayName} description="Competitive history and role ranks." actions={session.data?.player.id === player.id ? <Link className="button secondary" to="/profile">Edit profile</Link> : undefined} />
|
||
<section className="profile-identity card"><div className="large-avatar">{player.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{player.displayName}</h2>{player.battleTag && <p>{player.battleTag}</p>}<Badge tone={player.isGuest ? 'neutral' : 'blue'}>{player.isGuest ? 'Guest player' : 'Discord player'}</Badge></div></section>
|
||
<div className="public-profile-grid">
|
||
<section className="card profile-ranks"><div className="section-title"><div><h2>Role ranks</h2><p>Current competitive ratings</p></div></div>{roles.map((role) => <div className={`profile-rank role-${role.key}`} key={role.key}><span className="role-icon">{role.icon}</span><span><small>{role.label}</small><strong>{rankLabel(player.ratings[role.key], language)}</strong></span></div>)}</section>
|
||
<section className="profile-stat-grid" aria-label="Player statistics">
|
||
<div className="card"><span>Series played</span><strong>{stats.seriesPlayed}</strong></div>
|
||
<div className="card"><span>Series won</span><strong>{stats.seriesWon}</strong></div>
|
||
<div className="card"><span>Series lost</span><strong>{stats.seriesLost}</strong></div>
|
||
<div className="card highlight"><span>Win rate</span><strong>{winRate}%</strong></div>
|
||
</section>
|
||
</div>
|
||
<section className="card map-record"><div className="section-title"><div><h2>Map record</h2><p>{stats.mapsPlayed} maps played</p></div></div><div><span><strong>{stats.mapsWon}</strong>Won</span><span><strong>{stats.mapsLost}</strong>Lost</span><span><strong>{stats.mapsDrawn}</strong>Drawn</span></div></section>
|
||
<section className="card recent-matches"><div className="section-title"><div><h2>Recent matches</h2><p>Latest completed series</p></div></div>{player.recentMatches.length === 0 ? <p className="empty-note">No completed matches yet.</p> : <div>{player.recentMatches.map((match) => <Link to="/watch/$seriesId" params={{ seriesId: match.seriesId }} className="recent-match" key={match.seriesId}><div><Badge tone={match.outcome === 'win' ? 'success' : 'danger'}>{match.outcome}</Badge><span><strong>{match.eventName}</strong><small>{new Date(match.startedAt).toLocaleDateString()}</small></span></div><div className="recent-match-score"><span>{match.teamName}</span><strong>{match.scoreFor}<i>:</i>{match.scoreAgainst}</strong><span>{match.opponentTeamName}</span></div><ChevronRight /></Link>)}</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 ? demoPlayerProfiles : undefined })
|
||
const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 20, damage: 20, support: 20 })
|
||
const [displayName, setDisplayName] = useState('')
|
||
const [battleTag, setBattleTag] = useState('')
|
||
const [preferredRoles, setPreferredRoles] = useState<PlayerRole[]>([])
|
||
const [preferredPlayerIds, setPreferredPlayerIds] = useState<string[]>([])
|
||
const [avoidedPlayerIds, setAvoidedPlayerIds] = useState<string[]>([])
|
||
useEffect(() => {
|
||
if (profile.data) {
|
||
setDisplayName(profile.data.displayName)
|
||
setBattleTag(profile.data.battleTag ?? '')
|
||
setRatings({ tank: profile.data.ratings.tank, damage: profile.data.ratings.damage, support: profile.data.ratings.support })
|
||
setPreferredRoles(profile.data.preferredRoles)
|
||
setPreferredPlayerIds(profile.data.preferredPlayerIds)
|
||
setAvoidedPlayerIds(profile.data.avoidedPlayerIds)
|
||
}
|
||
}, [profile.data])
|
||
useEffect(() => {
|
||
if (players.data) {
|
||
const eligible = new Set(players.data.filter((player) => Boolean(player.accountId)).map((player) => player.id))
|
||
setPreferredPlayerIds((current) => current.filter((id) => eligible.has(id)))
|
||
setAvoidedPlayerIds((current) => current.filter((id) => eligible.has(id)))
|
||
}
|
||
}, [players.data])
|
||
const update = useMutation({
|
||
mutationFn: () => demoMode
|
||
? Promise.resolve({ ...profile.data!, displayName: displayName.trim(), battleTag: battleTag.trim() || undefined, ratings: { ...ratings, updatedAt: new Date().toISOString() }, preferredRoles, preferredPlayerIds, avoidedPlayerIds })
|
||
: api.updateRatings(displayName.trim(), battleTag, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds),
|
||
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() }
|
||
const eligiblePlayers = players.data.filter((player) => player.id !== profile.data.id && Boolean(player.accountId))
|
||
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">{displayName.slice(0, 2).toUpperCase()}</div><div><h2>{displayName || profile.data.displayName}</h2>{battleTag && <p>{battleTag}</p>}<Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><section className="identity-fields"><label>Nickname<input required minLength={2} maxLength={32} autoComplete="nickname" value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label><label>BattleTag <small>Optional</small><input maxLength={33} placeholder="Player#1234" value={battleTag} onChange={(event) => setBattleTag(event.target.value)} /></label><p>Nicknames are unique across the community.</p></section><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">{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.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><section className="preference-section avoid-section"><div><h2>Avoided teammates</h2><p>Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return <label className={selected ? 'selected avoided' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setAvoidedPlayerIds((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 || !displayName.trim()}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
|
||
}
|
||
|
||
type AdminTab = 'events' | 'participants' | 'balance' | 'moderators' | 'community'
|
||
function AdminPage() {
|
||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
|
||
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 || session.isLoading) return <LoadingState label="Loading admin control room…" />
|
||
if (events.isError || session.isError) return <ErrorState retry={() => { void events.refetch(); void session.refetch() }} />
|
||
const tabs: AdminTab[] = ['events', 'participants', 'balance', ...(session.data?.account.role === 'admin' ? ['moderators' as const, 'community' as const] : [])]
|
||
const standaloneTab = tab === 'moderators' || tab === 'community'
|
||
return <div className="page"><PageHeader eyebrow={session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin control room'} title={tab === 'moderators' ? 'Moderator access' : tab === 'community' ? 'Community settings' : createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={tab === 'moderators' ? 'Grant moderation access to trusted Discord users.' : tab === 'community' ? 'Manage the links shown to every community member.' : selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={!standaloneTab ? <>{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></> : undefined} /><div className="tabs" role="tablist">{tabs.map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && !['events', 'moderators', 'community'].includes(item)} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : item === 'balance' ? 'Balance teams' : item === 'community' ? 'Community' : 'Moderators'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}{tab === 'moderators' && session.data?.account.role === 'admin' && <ModeratorManagement />}{tab === 'community' && session.data?.account.role === 'admin' && <CommunitySettingsPanel />}</div>
|
||
}
|
||
|
||
function CommunitySettingsPanel() {
|
||
const client = useQueryClient()
|
||
const settings = useQuery({ queryKey: ['community-settings'], queryFn: api.communitySettings, initialData: demoMode ? demoCommunitySettings : undefined })
|
||
const [discordInviteUrl, setDiscordInviteUrl] = useState('')
|
||
useEffect(() => { if (settings.data) setDiscordInviteUrl(settings.data.discordInviteUrl) }, [settings.data])
|
||
const update = useMutation({
|
||
mutationFn: () => demoMode ? Promise.resolve({ discordInviteUrl: discordInviteUrl.trim() }) : api.updateCommunitySettings(discordInviteUrl.trim()),
|
||
onSuccess: (saved) => client.setQueryData(['community-settings'], saved),
|
||
})
|
||
if (settings.isLoading) return <LoadingState label="Loading community settings…" />
|
||
if (settings.isError) return <ErrorState message={settings.error.message} retry={() => void settings.refetch()} />
|
||
return <form className="card community-settings" onSubmit={(event) => { event.preventDefault(); update.mutate() }}><div className="section-title"><div><h2>Community Discord</h2><p>This invite is shown in FAQ to signed-in players.</p></div><Gamepad2 /></div><label>Discord invite URL<input type="url" inputMode="url" placeholder="https://discord.gg/…" value={discordInviteUrl} onChange={(event) => setDiscordInviteUrl(event.target.value)} /></label><div className="form-footer"><p>{update.isError ? update.error.message : 'Leave empty to hide the invite button.'}</p><button className="button primary" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save settings'}</button></div></form>
|
||
}
|
||
|
||
function ModeratorManagement() {
|
||
const client = useQueryClient()
|
||
const accounts = useQuery({ queryKey: ['accounts'], queryFn: api.accounts })
|
||
const update = useMutation({
|
||
mutationFn: ({ accountId, moderator }: { accountId: string; moderator: boolean }) => api.setModerator(accountId, moderator),
|
||
onSuccess: (account) => client.setQueryData<RawAccount[]>(['accounts'], (current) => current?.map((item) => item.id === account.id ? account : item)),
|
||
onError: () => void accounts.refetch(),
|
||
})
|
||
if (accounts.isLoading) return <LoadingState label="Loading accounts…" />
|
||
if (accounts.isError || !accounts.data) return <ErrorState message={accounts.error?.message} retry={() => void accounts.refetch()} />
|
||
return <section className="card moderator-panel"><div className="section-title"><div><h2>Moderators</h2><p>Moderators can manage events, participants, rosters, and live scrims. Only administrators can change this list.</p></div><Badge tone="blue">{accounts.data.filter((account) => account.role === 'moderator').length} active</Badge></div><div className="moderator-list">{accounts.data.map((account) => <div key={account.id}><span className="mini-avatar">{account.username.slice(0, 2).toUpperCase()}</span><span><strong>{account.username}</strong><small>Discord ID · {account.discordId}</small></span>{account.role === 'admin' ? <Badge tone="warning">Administrator</Badge> : <label className="moderator-toggle"><input type="checkbox" checked={account.role === 'moderator'} disabled={update.isPending} onChange={(event) => update.mutate({ accountId: account.id, moderator: event.target.checked })} /><span>{account.role === 'moderator' ? 'Moderator' : 'Player'}</span></label>}</div>)}</div>{update.isError && <p className="error-note">{update.error.message}</p>}</section>
|
||
}
|
||
|
||
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() + 2 * 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?.startsAt ?? start.toISOString()),
|
||
}
|
||
}
|
||
|
||
function EventSetup({ event, onCreated, onDeleted }: { event?: MixEvent; onCreated: (event: MixEvent) => void; onDeleted: () => void }) {
|
||
const client = useQueryClient()
|
||
const language = useLanguage()
|
||
const initialForm = useMemo(() => eventInput(event), [event])
|
||
const [form, setForm] = useState<EventInput>(initialForm)
|
||
const [pingEveryone, setPingEveryone] = useState(true)
|
||
useEffect(() => {
|
||
setForm(initialForm)
|
||
setPingEveryone(true)
|
||
}, [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, pingEveryone })
|
||
},
|
||
onSuccess: (saved) => {
|
||
void client.invalidateQueries({ queryKey: ['events'] })
|
||
onCreated(saved)
|
||
},
|
||
})
|
||
const cancel = useMutation({
|
||
mutationFn: () => {
|
||
if (!event) return Promise.reject(new Error('Event is required'))
|
||
if (demoMode) return Promise.resolve({ ...event, status: 'cancelled' as const, workflowState: 'Cancelled' as const, version: event.version + 1 })
|
||
return api.cancelEvent(event.id, event.version)
|
||
},
|
||
onSuccess: (cancelled) => {
|
||
client.setQueryData<MixEvent[]>(['events'], (current) => current?.map((item) => item.id === cancelled.id ? cancelled : item))
|
||
void client.invalidateQueries({ queryKey: ['events'] })
|
||
void client.invalidateQueries({ queryKey: ['event', cancelled.id] })
|
||
onCreated(cancelled)
|
||
},
|
||
})
|
||
const remove = useMutation({
|
||
mutationFn: async () => {
|
||
if (!event) return
|
||
if (!demoMode) await api.deleteEvent(event.id)
|
||
},
|
||
onSuccess: () => {
|
||
client.setQueryData<MixEvent[]>(['events'], (current) => current?.filter((item) => item.id !== event?.id))
|
||
void client.invalidateQueries({ queryKey: ['events'] })
|
||
onDeleted()
|
||
},
|
||
})
|
||
const set = (key: keyof EventInput, value: string) => setForm((current) => ({ ...current, [key]: value }))
|
||
const setStart = (value: string) => setForm((current) => {
|
||
if (event) return { ...current, startsAt: value, registrationDeadline: value }
|
||
const start = new Date(value)
|
||
return {
|
||
...current,
|
||
startsAt: value,
|
||
registrationDeadline: value,
|
||
endsAt: Number.isNaN(start.getTime()) ? current.endsAt : toLocalInput(new Date(start.getTime() + 2 * 3_600_000).toISOString()),
|
||
}
|
||
})
|
||
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 && <div className="event-admin-actions"><Badge tone={event.status === 'cancelled' ? 'danger' : event.status === 'live' ? 'live' : 'success'}>{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}</Badge><button className="button danger-outline" type="button" disabled={cancel.isPending || event.status === 'cancelled' || event.status === 'completed'} onClick={() => cancel.mutate()}>Cancel event</button><button className="icon-button delete-event" type="button" disabled={remove.isPending} aria-label="Delete event" title="Delete event" onClick={() => { if (window.confirm(language === 'ru' ? 'Удалить это событие навсегда?' : 'Delete this event permanently?')) remove.mutate() }}><X /></button></div>}</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) => setStart(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 readOnly type="datetime-local" value={form.registrationDeadline} /></label></div>
|
||
<label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label>
|
||
{!event && <label className="announcement-toggle"><input type="checkbox" checked={pingEveryone} onChange={(e) => setPingEveryone(e.target.checked)} /><span><strong>Ping everyone in Discord</strong><small>The announcement is always posted; this controls the @everyone notification.</small></span></label>}
|
||
<div className="form-footer"><p>{save.isError ? save.error.message : cancel.isError ? cancel.error.message : remove.isError ? remove.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 players = useQuery({ queryKey: ['players'], queryFn: api.players, initialData: demoMode ? demoPlayerProfiles : 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: [],
|
||
avoidedPlayerIds: [],
|
||
},
|
||
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'] })
|
||
},
|
||
})
|
||
const remove = useMutation({
|
||
mutationFn: (playerId: string) => demoMode ? Promise.resolve() : api.removeParticipant(eventId, playerId),
|
||
onSuccess: (_, playerId) => {
|
||
client.setQueryData<Registration[]>(['registrations', eventId], (current) => current?.filter((registration) => registration.player.id !== playerId) ?? [])
|
||
if (!demoMode) void client.invalidateQueries({ queryKey: ['events'] })
|
||
},
|
||
})
|
||
if (rows.isLoading || players.isLoading) return <LoadingState label="Loading participants…" />
|
||
if (rows.isError || players.isError || !rows.data || !players.data) return <ErrorState retry={() => { void rows.refetch(); void players.refetch() }} />
|
||
const names = new Map(players.data.map((player) => [player.id, 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"><PlayerLink player={r.player} /><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 || remove.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><button className="icon-button remove-participant" type="button" disabled={remove.isPending} aria-label={`Remove ${r.player.displayName} from event`} title="Remove from event" onClick={() => remove.mutate(r.player.id)}><X /></button></div>)}</div>{(override.isError || remove.isError) && <p className="error-note">{override.error?.message ?? remove.error?.message}</p>}</section>
|
||
}
|
||
|
||
function BalanceView({ eventId }: { eventId: string }) {
|
||
if (demoMode) return <LegacyBalanceView eventId={eventId} />
|
||
return <WorkflowControl eventId={eventId} />
|
||
}
|
||
|
||
function WorkflowControl({ eventId }: { eventId: string }) {
|
||
const client = useQueryClient()
|
||
const event = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId) })
|
||
const players = useQuery({ queryKey: ['players'], queryFn: api.players })
|
||
const roster = useQuery({
|
||
queryKey: ['roster', eventId],
|
||
queryFn: () => api.roster(eventId),
|
||
enabled: Boolean(event.data && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(event.data.workflowState)),
|
||
retry: false,
|
||
})
|
||
const bracketDraft = useQuery({
|
||
queryKey: ['bracket-draft', eventId],
|
||
queryFn: () => api.bracketDraft(eventId),
|
||
enabled: event.data?.workflowState === 'BracketDraft',
|
||
retry: false,
|
||
})
|
||
const bracketTeams = useQuery({
|
||
queryKey: ['teams', eventId],
|
||
queryFn: () => api.teams(eventId),
|
||
enabled: event.data?.workflowState === 'BracketDraft',
|
||
})
|
||
const [candidates, setCandidates] = useState<Awaited<ReturnType<typeof api.generateWorkflowBalance>>['candidates']>([])
|
||
const [selectedCandidate, setSelectedCandidate] = useState(0)
|
||
const refresh = useCallback(() => {
|
||
void client.invalidateQueries({ queryKey: ['event', eventId] })
|
||
void client.invalidateQueries({ queryKey: ['events'] })
|
||
void client.invalidateQueries({ queryKey: ['roster', eventId] })
|
||
void client.invalidateQueries({ queryKey: ['teams', eventId] })
|
||
}, [client, eventId])
|
||
const close = useMutation({
|
||
mutationFn: () => api.closeRegistration(eventId, event.data!.rulesetId, event.data!.version),
|
||
onSuccess: refresh,
|
||
onError: refresh,
|
||
})
|
||
const generate = useMutation({
|
||
mutationFn: () => api.generateWorkflowBalance(eventId, event.data!.version),
|
||
onSuccess: (result) => {
|
||
setCandidates(result.candidates)
|
||
setSelectedCandidate(0)
|
||
refresh()
|
||
},
|
||
onError: refresh,
|
||
})
|
||
const select = useMutation({
|
||
mutationFn: () => api.selectWorkflowBalance(eventId, candidates[selectedCandidate], event.data!.version),
|
||
onSuccess: refresh,
|
||
onError: refresh,
|
||
})
|
||
const confirm = useMutation({
|
||
mutationFn: () => api.confirmRosters(eventId, event.data!.version, roster.data!.version),
|
||
onSuccess: refresh,
|
||
onError: refresh,
|
||
})
|
||
const initializeBracket = useMutation({
|
||
mutationFn: () => api.initializeBracket(eventId, event.data!.version),
|
||
onSuccess: (draft) => {
|
||
client.setQueryData(['bracket-draft', eventId], draft)
|
||
refresh()
|
||
},
|
||
onError: refresh,
|
||
})
|
||
const saveBracket = useMutation({
|
||
mutationFn: (matches: Parameters<typeof api.updateBracket>[1]) => api.updateBracket(eventId, matches, bracketDraft.data!.version),
|
||
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
|
||
onError: () => void bracketDraft.refetch(),
|
||
})
|
||
const resetBracket = useMutation({
|
||
mutationFn: () => api.resetBracket(eventId, bracketDraft.data!.version),
|
||
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
|
||
onError: () => void bracketDraft.refetch(),
|
||
})
|
||
const confirmBracket = useMutation({
|
||
mutationFn: () => api.confirmBracket(eventId, bracketDraft.data!.version),
|
||
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
|
||
onError: () => void bracketDraft.refetch(),
|
||
})
|
||
const start = useMutation({
|
||
mutationFn: () => api.startScrim(eventId, event.data!.version),
|
||
onSuccess: (result) => {
|
||
refresh()
|
||
if (result.series) {
|
||
client.setQueryData(['series', result.series.id], result.series)
|
||
void router.navigate({ to: '/series/$seriesId', params: { seriesId: result.series.id } })
|
||
}
|
||
else if (result.tournament) void router.navigate({ to: '/bracket/$eventId', params: { eventId } })
|
||
},
|
||
onError: refresh,
|
||
})
|
||
const back = useMutation({
|
||
mutationFn: () => api.revertWorkflowStage(eventId, event.data!.version),
|
||
onSuccess: (updated) => {
|
||
if (updated.workflowState === 'RegistrationClosed') setCandidates([])
|
||
if (updated.workflowState === 'Balancing') client.removeQueries({ queryKey: ['roster', eventId] })
|
||
if (updated.workflowState === 'RostersConfirmed') client.removeQueries({ queryKey: ['bracket-draft', eventId] })
|
||
refresh()
|
||
},
|
||
onError: refresh,
|
||
})
|
||
if (event.isLoading || players.isLoading) return <LoadingState label="Loading scrim workflow…" />
|
||
if (event.isError || players.isError || !event.data || !players.data) return <ErrorState retry={() => { void event.refetch(); void players.refetch() }} />
|
||
const state = event.data.workflowState
|
||
const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? initializeBracket.error ?? saveBracket.error ?? resetBracket.error ?? confirmBracket.error ?? start.error ?? back.error ?? bracketDraft.error ?? bracketTeams.error
|
||
return <div className="workflow-control">
|
||
<section className="card workflow-progress">
|
||
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'] as const).map((step, index) => {
|
||
const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].indexOf(state)
|
||
return <div className={index <= activeIndex ? 'done' : ''} key={step}><span>{index + 1}</span><strong>{step.replace(/([a-z])([A-Z])/g, '$1 $2')}</strong></div>
|
||
})}
|
||
</section>
|
||
{state !== 'RegistrationOpen' && state !== 'Live' && state !== 'Completed' && state !== 'Cancelled' && <div className="workflow-back"><button className="button secondary" disabled={back.isPending} onClick={() => back.mutate()}>{back.isPending ? 'Going back…' : '← Back'}</button><small>Return to the previous setup stage</small></div>}
|
||
{state === 'RegistrationOpen' && <WorkflowAction title="Close registration" description="Freeze the Going participant list before balancing." button="Close registration" pending={close.isPending} onClick={() => close.mutate()} />}
|
||
{state === 'RegistrationClosed' && <WorkflowAction title="Generate fair teams" description="The optimizer considers ranks, preferred roles, and preferred teammates." button="Generate balance" pending={generate.isPending} onClick={() => generate.mutate()} />}
|
||
{state === 'Balancing' && candidates.length === 0 && <WorkflowAction title="Generate balance candidates" description="Generate again after a refresh to choose one of the server candidates." button="Generate candidates" pending={generate.isPending} onClick={() => generate.mutate()} />}
|
||
{state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} allowCaptainAssignment={false} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>}
|
||
{roster.data && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(state) && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
|
||
{state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />}
|
||
{state === 'RostersConfirmed' && <WorkflowAction title="Configure bracket" description="Build the match order and connect winner or loser feeds before starting." button="Open bracket editor" pending={initializeBracket.isPending} onClick={() => initializeBracket.mutate()} />}
|
||
{state === 'BracketDraft' && (bracketDraft.isLoading || bracketTeams.isLoading) && <LoadingState label="Loading bracket editor…" />}
|
||
{state === 'BracketDraft' && bracketDraft.data && bracketTeams.data && <BracketEditor draft={bracketDraft.data} teams={bracketTeams.data} pending={saveBracket.isPending || resetBracket.isPending || confirmBracket.isPending || start.isPending} error={error?.message} onSave={(matches) => saveBracket.mutate(matches)} onReset={() => resetBracket.mutate()} onConfirm={() => confirmBracket.mutate()} onStart={() => start.mutate()} />}
|
||
{state === 'Live' && <section className="card workflow-action"><div><h2>Scrim is live</h2><p>Coin toss, bans, and map results are now available.</p></div>{event.data.activeSeriesId ? <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Open series</Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}</section>}
|
||
{state === 'Completed' && <section className="card workflow-action"><div><h2>Scrim completed</h2><p>The final result and full audit trail are preserved.</p></div>{event.data.tournamentId ? <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link> : event.data.activeSeriesId ? <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Match history</Link> : null}</section>}
|
||
{error && <p className="error-note">{error.message}</p>}
|
||
</div>
|
||
}
|
||
|
||
function WorkflowAction({ title, description, button, pending, onClick }: { title: string; description: string; button: string; pending: boolean; onClick: () => void }) {
|
||
return <section className="card workflow-action"><div><h2>{title}</h2><p>{description}</p></div><button className="button primary" disabled={pending} onClick={onClick}>{pending ? 'Working…' : button}</button></section>
|
||
}
|
||
|
||
type RosterDrag = { kind: 'team'; teamId: string; playerId: string; role: 'Tank' | 'Damage' | 'Support' } | { kind: 'reserve'; playerId: string }
|
||
|
||
function WorkflowRosterEditor({ eventId, roster, players, locked, onChanged }: { eventId: string; roster: RawRosterDraft; players: Player[]; locked: boolean; onChanged: () => void }) {
|
||
const language = useLanguage()
|
||
const client = useQueryClient()
|
||
const names = new Map(players.map((player) => [player.id, player.displayName]))
|
||
const mutate = useMutation({
|
||
mutationFn: (action:
|
||
| { kind: 'swap'; teamAId: string; playerAId: string; teamBId: string; playerBId: string }
|
||
| { kind: 'substitute'; teamId: string; outgoingPlayerId: string; reservePlayerId: string }
|
||
| { kind: 'move'; fromTeamId: string; playerId: string; toTeamId: string; role: 'Tank' | 'Damage' | 'Support' }
|
||
| { kind: 'place'; teamId: string; reservePlayerId: string; role: 'Tank' | 'Damage' | 'Support' }
|
||
| { kind: 'remove'; teamId: string; playerId: string }
|
||
| { kind: 'captain'; teamId: string; playerId: string }) => {
|
||
if (action.kind === 'swap') return api.swapRoster(eventId, {
|
||
teamAId: action.teamAId, playerAId: action.playerAId, teamBId: action.teamBId, playerBId: action.playerBId, expectedVersion: roster.version,
|
||
})
|
||
if (action.kind === 'substitute') return api.substituteRoster(eventId, {
|
||
teamId: action.teamId, outgoingPlayerId: action.outgoingPlayerId, reservePlayerId: action.reservePlayerId, expectedVersion: roster.version,
|
||
})
|
||
if (action.kind === 'move') return api.moveRosterPlayer(eventId, {
|
||
fromTeamId: action.fromTeamId, playerId: action.playerId, toTeamId: action.toTeamId, role: action.role, expectedVersion: roster.version,
|
||
})
|
||
if (action.kind === 'place') return api.placeReservePlayer(eventId, {
|
||
teamId: action.teamId, reservePlayerId: action.reservePlayerId, role: action.role, expectedVersion: roster.version,
|
||
})
|
||
if (action.kind === 'remove') return api.removeRosterPlayer(eventId, {
|
||
teamId: action.teamId, playerId: action.playerId, expectedVersion: roster.version,
|
||
})
|
||
return api.setRosterCaptain(eventId, action.teamId, action.playerId, roster.version)
|
||
},
|
||
onSuccess: (updated) => {
|
||
client.setQueryData(['roster', eventId], updated)
|
||
onChanged()
|
||
},
|
||
onError: onChanged,
|
||
})
|
||
const startDrag = (event: DragEvent, payload: RosterDrag) => {
|
||
event.dataTransfer.effectAllowed = 'move'
|
||
event.dataTransfer.setData('application/x-mixmaker-roster', JSON.stringify(payload))
|
||
}
|
||
const readDrag = (event: DragEvent): RosterDrag | undefined => {
|
||
try {
|
||
return JSON.parse(event.dataTransfer.getData('application/x-mixmaker-roster')) as RosterDrag
|
||
} catch {
|
||
return undefined
|
||
}
|
||
}
|
||
const dropOnSlot = (event: DragEvent, teamId: string, slot: RawRosterDraft['teams'][number]['slots'][number]) => {
|
||
event.preventDefault()
|
||
const source = readDrag(event)
|
||
if (!source || locked || mutate.isPending) return
|
||
if (source.kind === 'reserve') {
|
||
mutate.mutate(slot.playerId
|
||
? { kind: 'substitute', teamId, outgoingPlayerId: slot.playerId, reservePlayerId: source.playerId }
|
||
: { kind: 'place', teamId, reservePlayerId: source.playerId, role: slot.role })
|
||
} else if (source.playerId !== slot.playerId) {
|
||
mutate.mutate(slot.playerId
|
||
? { kind: 'swap', teamAId: source.teamId, playerAId: source.playerId, teamBId: teamId, playerBId: slot.playerId }
|
||
: { kind: 'move', fromTeamId: source.teamId, playerId: source.playerId, toTeamId: teamId, role: slot.role })
|
||
}
|
||
}
|
||
return <section className="card roster-editor">
|
||
<div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · drag players into any role slot or through reserve.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div>
|
||
<div className="roster-editor-body"><div className="teams-grid">{roster.teams.map((team) => <article className="workflow-team" key={team.id}><header><h3>{team.name}</h3><label>Captain<select disabled={locked || mutate.isPending} value={team.captainPlayerId} onChange={(event) => mutate.mutate({ kind: 'captain', teamId: team.id, playerId: event.target.value })}><option value="">Choose captain</option>{team.slots.filter((slot) => slot.playerId).map((slot) => <option value={slot.playerId} key={slot.playerId}>{names.get(slot.playerId) ?? slot.playerId}</option>)}</select></label></header>{team.slots.map((slot, slotIndex) => <div className={`roster-slot ${slot.playerId ? '' : 'empty'}`} key={`${slot.role}-${slotIndex}`} draggable={!locked && Boolean(slot.playerId)} onDragStart={(event) => { if (slot.playerId) startDrag(event, { kind: 'team', teamId: team.id, playerId: slot.playerId, role: slot.role }) }} onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => dropOnSlot(event, team.id, slot)}><span className={`role-chip ${slot.role.toLowerCase()}`}>{slot.role[0]}</span>{slot.playerId ? <><strong>{names.get(slot.playerId) ?? slot.playerId}</strong><small>{rankLabel(slot.rating, language)}</small>{!locked && <button className="icon-button slot-remove" type="button" aria-label={`Move ${names.get(slot.playerId) ?? slot.playerId} to reserve`} onClick={() => mutate.mutate({ kind: 'remove', teamId: team.id, playerId: slot.playerId })}><X /></button>}</> : <><strong>Empty</strong><small>{slot.role}</small></>}</div>)}</article>)}</div>
|
||
<aside className="roster-reserve" onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => { event.preventDefault(); const source = readDrag(event); if (!locked && source?.kind === 'team') mutate.mutate({ kind: 'remove', teamId: source.teamId, playerId: source.playerId }) }}><div><UsersRound /><span><strong>Reserve</strong><small>Drop a player here to remove them from a team</small></span></div><div className="reserve-list">{roster.reserve.length === 0 ? <p>Reserve is empty</p> : roster.reserve.map((id) => <button type="button" draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'reserve', playerId: id })} key={id}><span className="mini-avatar">{(names.get(id) ?? id).slice(0, 2).toUpperCase()}</span><strong>{names.get(id) ?? id}</strong></button>)}</div></aside>
|
||
</div>
|
||
{mutate.isError && <p className="error-note">{mutate.error.message}</p>}
|
||
</section>
|
||
}
|
||
|
||
function LegacyBalanceView({ 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, allowCaptainAssignment = true }: { team: Team; eventId: string; allowCaptainAssignment?: boolean }) {
|
||
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><PlayerLink player={player} /><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div>{allowCaptainAssignment && <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 PublicTeamCard({ team }: { team: Team }) {
|
||
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><Badge tone={team.side === 'alpha' ? 'warning' : 'blue'}>{team.members.length}/5</Badge></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><PlayerLink player={player} />{player.id === team.captainId && <Badge tone="neutral">Captain</Badge>}</div>)}</div></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 session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
|
||
const [selected, setSelected] = useState<string>()
|
||
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
|
||
const [heroRoleFilter, setHeroRoleFilter] = useState<'all' | PlayerRole>('all')
|
||
const [heroSearch, setHeroSearch] = useState('')
|
||
const previousTurn = useRef<(TurnSnapshot & { seriesId: string }) | undefined>(undefined)
|
||
const participant = Boolean(series.data && session.data && [series.data.teamAlpha, series.data.teamBeta].some((team) => team.members.some(({ player }) => player.id === session.data?.player.id)))
|
||
const playerSide = series.data && session.data
|
||
? series.data.teamAlpha.members.some(({ player }) => player.id === session.data?.player.id) ? 'alpha'
|
||
: series.data.teamBeta.members.some(({ player }) => player.id === session.data?.player.id) ? 'beta' : undefined
|
||
: undefined
|
||
useEffect(() => {
|
||
if (!series.data) return
|
||
const current = { seriesId, activeTeam: series.data.currentStep.activeTeam, version: series.data.version }
|
||
const previous = previousTurn.current?.seriesId === seriesId ? previousTurn.current : undefined
|
||
if (shouldNotifyTurn(previous, current, playerSide)) playTurnSound()
|
||
previousTurn.current = current
|
||
}, [playerSide, series.data, seriesId])
|
||
useEffect(() => {
|
||
if (series.data && session.data && !participant && !isStaff(session.data.account.role)) {
|
||
void router.navigate({ to: '/watch/$seriesId', params: { seriesId }, replace: true })
|
||
}
|
||
}, [participant, series.data, seriesId, session.data])
|
||
const result = useMutation({
|
||
mutationFn: () => {
|
||
const data = series.data!
|
||
if (demoMode) return api.recordResult(seriesId, `Map ${data.maps.length + 1}`, outcome, data.version)
|
||
const actingTeamId = isStaff(session.data?.account.role)
|
||
? data.teamAlpha.id
|
||
: data.teamAlpha.captainId === session.data?.player.id ? data.teamAlpha.id
|
||
: data.teamBeta.captainId === session.data?.player.id ? data.teamBeta.id : ''
|
||
return api.recordSeriesMap(seriesId, outcome, actingTeamId, data.version)
|
||
},
|
||
onSuccess: (updated) => {
|
||
client.setQueryData(['series', seriesId], updated)
|
||
},
|
||
onError: () => void series.refetch(),
|
||
})
|
||
const action = useMutation({
|
||
mutationFn: () => {
|
||
const data = series.data!
|
||
const teamId = data.currentStep.activeTeam === 'beta' ? data.teamBeta.id : data.teamAlpha.id
|
||
if (data.currentStep.kind === 'coin_toss') return api.tossSeriesCoin(seriesId, `${Date.now()}-${crypto.randomUUID()}`, teamId, data.version)
|
||
if (data.currentStep.kind === 'map_ban') return api.banSeriesMap(seriesId, teamId, selected!, data.version)
|
||
if (data.currentStep.kind === 'map_pick') return api.pickSeriesMap(seriesId, teamId, selected!, data.version)
|
||
if (data.currentStep.kind === 'hero_ban') return api.banSeriesHero(seriesId, teamId, selected!, data.version)
|
||
return Promise.resolve(data)
|
||
},
|
||
onSuccess: (updated) => {
|
||
client.setQueryData(['series', seriesId], updated)
|
||
setSelected(undefined)
|
||
},
|
||
onError: () => void series.refetch(),
|
||
})
|
||
if (series.isLoading || session.isLoading) return <LoadingState />
|
||
if ((series.isError && !series.data) || (session.isError && !session.data)) return <ErrorState message={series.error?.message ?? session.error?.message} retry={() => { void series.refetch(); void session.refetch() }} />
|
||
if (!series.data || !session.data) return <LoadingState />
|
||
const data = series.data
|
||
const activeSide = data.currentStep.activeTeam ?? 'alpha'
|
||
const activeTeam = activeSide === 'alpha' ? data.teamAlpha : data.teamBeta
|
||
const canAct = isStaff(session.data.account.role) || activeTeam.captainId === session.data.player.id
|
||
const canRecord = isStaff(session.data.account.role) || data.teamAlpha.captainId === session.data.player.id || data.teamBeta.captainId === session.data.player.id
|
||
const normalizedHeroSearch = heroSearch.trim().toLocaleLowerCase()
|
||
const visibleOptions = data.currentStep.kind === 'hero_ban'
|
||
? data.options.filter((option) => (heroRoleFilter === 'all' || option.role === heroRoleFilter) && option.name.toLocaleLowerCase().includes(normalizedHeroSearch))
|
||
: data.options
|
||
return <div className="page live-page">
|
||
<PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description={canAct || canRecord ? 'Actions are validated and recorded by the server.' : 'You are playing in this match. Captain actions are read-only for team members.'} 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-${activeSide}`}>
|
||
<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>{activeTeam.name}</strong></div>}</div>
|
||
{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(event) => { event.preventDefault(); if (canRecord) result.mutate() }}><label>Outcome<select disabled={!canRecord} value={outcome} onChange={(event) => setOutcome(event.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={!canRecord || result.isPending}>{canRecord ? result.isPending ? 'Recording…' : 'Record result' : 'Captain action'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form>
|
||
: data.currentStep.kind === 'complete' ? <div className="turn-footer"><p><Check />Series completed and bracket updated.</p></div>
|
||
: <>{data.currentStep.kind === 'hero_ban' && <div className="hero-filter-tools"><label className="hero-search"><Search /><input type="search" value={heroSearch} placeholder="Search hero…" aria-label="Search hero" onChange={(event) => { setHeroSearch(event.target.value); setSelected(undefined) }} /></label><div className="hero-role-filter" role="tablist" aria-label="Filter heroes by role">{([['all', 'All'], ['tank', 'Tank'], ['damage', 'DPS'], ['support', 'Support']] as const).map(([role, label]) => <button type="button" role="tab" aria-selected={heroRoleFilter === role} className={heroRoleFilter === role ? 'active' : ''} key={role} onClick={() => { setHeroRoleFilter(role); setSelected(undefined) }}>{label}<small>{role === 'all' ? data.options.length : data.options.filter((option) => option.role === role).length}</small></button>)}</div></div>}<div className="draft-options" role="radiogroup" aria-label="Available draft choices">{visibleOptions.length === 0 ? <p className="empty-note">No heroes found.</p> : visibleOptions.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled || !canAct} 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 />{canAct ? 'Draft commands use server-side validation.' : `Waiting for ${activeTeam.name} captain.`}</p><button className="button team-action" disabled={!canAct || (data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{canAct ? action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action' : 'Captain action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}
|
||
</section>
|
||
<aside className="match-sidebar"><HeroBanDisplay series={data} /><SeriesBanHistory series={data} /><MapTimeline series={data} /><AuditLog series={data} /></aside>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
function Scoreboard({ series }: { series: Series }) {
|
||
const controlPage = useRouterState({ select: (state) => state.location.pathname.startsWith('/series/') })
|
||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, enabled: controlPage, staleTime: Infinity })
|
||
const alphaCaptain = series.teamAlpha.members.find(({ player }) => player.id === series.teamAlpha.captainId)?.player
|
||
const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player
|
||
const canRenameAlpha = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamAlpha.captainId)
|
||
const canRenameBeta = controlPage && (isStaff(session.data?.account.role) || session.data?.player.id === series.teamBeta.captainId)
|
||
return <><TournamentStrip series={series} /><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 · <PlayerLink player={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 · <PlayerLink player={betaCaptain} /></> : 'Captain not assigned'}</small></div></section>{(canRenameAlpha || canRenameBeta) && <div className="team-name-controls">{canRenameAlpha && <TeamNameEditor team={series.teamAlpha} seriesId={series.id} />}{canRenameBeta && <TeamNameEditor team={series.teamBeta} seriesId={series.id} />}</div>}</>
|
||
}
|
||
|
||
function TournamentStrip({ series }: { series: Series }) {
|
||
const bracket = useQuery({
|
||
queryKey: ['tournament', series.eventId],
|
||
queryFn: () => api.bracket(series.eventId),
|
||
enabled: Boolean(series.tournamentId),
|
||
initialData: demoMode && series.tournamentId ? demoBracket : undefined,
|
||
})
|
||
if (!series.tournamentId || !bracket.data) return null
|
||
const current = bracket.data.matches.find((match) => match.seriesId === series.id)
|
||
const activeRound = bracket.data.matches.find((match) => match.status === 'live')?.round
|
||
const activeMatches = bracket.data.matches.filter((match) => match.status === 'live' && match.round === activeRound && match.seriesId)
|
||
const currentIndex = activeMatches.findIndex((match) => match.seriesId === series.id)
|
||
const previous = currentIndex > 0 ? activeMatches[currentIndex - 1] : undefined
|
||
const next = currentIndex >= 0 && currentIndex < activeMatches.length - 1 ? activeMatches[currentIndex + 1] : activeMatches.find((match) => match.seriesId !== series.id)
|
||
const matchBody = (match: (typeof bracket.data.matches)[number]) => <><span>{match.teamAlpha ?? 'TBD'} <b>{match.scoreAlpha ?? '—'}</b></span><span>{match.teamBeta ?? 'TBD'} <b>{match.scoreBeta ?? '—'}</b></span></>
|
||
return <section className="tournament-strip">
|
||
<header><span><Trophy />Tournament bracket</span><Link to="/bracket/$eventId" params={{ eventId: series.eventId }}>Open full bracket</Link></header>
|
||
<div className="tournament-strip-body">
|
||
{previous?.seriesId ? <Link className="match-arrow" aria-label="Previous live match" to="/events/$eventId/live" params={{ eventId: series.eventId }} search={{ seriesId: previous.seriesId }}><ChevronLeft /></Link> : <span className="match-arrow disabled"><ChevronLeft /></span>}
|
||
<div className="mini-bracket">{bracket.data.rounds.map((round, roundIndex) => <div key={round}><small>{round}</small>{bracket.data.matches.filter((match) => match.round === roundIndex).map((match) => match.status === 'pending' || match.status === 'ready'
|
||
? <div className={`mini-match ${match.status}`} key={match.id}>{matchBody(match)}</div>
|
||
: <Link className={`mini-match ${match.seriesId === series.id ? 'current' : ''} ${match.status}`} key={match.id} to="/events/$eventId/live" params={{ eventId: series.eventId }} search={{ seriesId: match.seriesId }}>{matchBody(match)}</Link>)}</div>)}</div>
|
||
{next?.seriesId ? <Link className="match-arrow" aria-label="Next live match" to="/events/$eventId/live" params={{ eventId: series.eventId }} search={{ seriesId: next.seriesId }}><ChevronRight /></Link> : <span className="match-arrow disabled"><ChevronRight /></span>}
|
||
</div>
|
||
{current && <p>Current match · {current.label}</p>}
|
||
</section>
|
||
}
|
||
|
||
function TeamNameEditor({ team, seriesId }: { team: Team; seriesId: string }) {
|
||
const client = useQueryClient()
|
||
const [name, setName] = useState(team.name)
|
||
useEffect(() => setName(team.name), [team.name])
|
||
const rename = useMutation({
|
||
mutationFn: () => api.renameTeam(team.id, name),
|
||
onSuccess: (updated) => {
|
||
client.setQueryData<Series>(['series', seriesId], (current) => current ? {
|
||
...current,
|
||
teamAlpha: current.teamAlpha.id === updated.id ? { ...current.teamAlpha, name: updated.name } : current.teamAlpha,
|
||
teamBeta: current.teamBeta.id === updated.id ? { ...current.teamBeta, name: updated.name } : current.teamBeta,
|
||
} : current)
|
||
void client.invalidateQueries({ queryKey: ['teams'] })
|
||
},
|
||
})
|
||
return <form className="team-name-editor" onSubmit={(event) => { event.preventDefault(); rename.mutate() }}><label><span>Team name</span><input minLength={2} maxLength={32} required value={name} onChange={(event) => setName(event.target.value)} /></label><button className="button secondary" disabled={rename.isPending || name.trim() === team.name}>{rename.isPending ? 'Saving…' : 'Rename'}</button>{rename.isError && <small className="error-note">{rename.error.message}</small>}</form>
|
||
}
|
||
function HeroBanDisplay({ series }: { series: Series }) {
|
||
const activeMap = activeHeroBanMap(series)
|
||
if (!activeMap) return null
|
||
const bans = activeMap.heroBans
|
||
return <section className="card hero-ban-panel"><div className="section-title"><div><h2>Hero bans</h2><p>{activeMap.name}</p></div><Badge tone="warning">{bans.length}/4</Badge></div><div className="ban-display">{Array.from({ length: 4 }, (_, index) => bans[index] ? <div key={`${bans[index].hero}-${index}`}><span>Ban {index + 1}</span><strong>{bans[index].hero}</strong><small>{bans[index].role}</small></div> : <div className="pending-ban" key={`pending-${index}`}><span>Ban {index + 1}</span><strong>Pending</strong></div>)}</div></section>
|
||
}
|
||
function SeriesBanHistory({ series }: { series: Series }) {
|
||
const hasBans = series.heroBanHistory.alpha.length > 0 || series.heroBanHistory.beta.length > 0
|
||
if (!hasBans) return null
|
||
return <section className="card series-ban-history"><div className="section-title"><div><h2>Bo3 hero ban history</h2><p>A team cannot repeat its own hero ban.</p></div></div><div className="series-ban-teams">{([['alpha', series.teamAlpha], ['beta', series.teamBeta]] as const).map(([side, team]) => <div className={`team-${side}`} key={side}><strong>{team.name}</strong><div>{series.heroBanHistory[side].length > 0 ? series.heroBanHistory[side].map((hero) => <span key={hero}>{hero}</span>) : <small>No bans yet</small>}</div></div>)}</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>All server actions · newest first</p></div><Badge tone="neutral">{series.audit.length}</Badge></div>{series.audit.length === 0 ? <p className="empty-note">No actions yet.</p> : <div className="audit-log-list" tabIndex={0}>{[...series.audit].reverse().map((a) => <div key={a.id}><span className="history-dot" /><p><strong>{a.summary}</strong><small>{a.actorName} · {new Date(a.createdAt).toLocaleString(undefined, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}</small></p></div>)}</div>}</section> }
|
||
|
||
function SpectatorPage() {
|
||
const { seriesId } = spectatorRoute.useParams()
|
||
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
|
||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
|
||
if (series.isLoading || session.isLoading) return <LoadingState />
|
||
if (!series.data || !session.data) return <ErrorState message={series.error?.message ?? session.error?.message} retry={() => { void series.refetch(); void session.refetch() }} />
|
||
const data = series.data
|
||
const participant = [data.teamAlpha, data.teamBeta].some((team) => team.members.some(({ player }) => player.id === session.data.player.id))
|
||
const canOpenControl = participant || isStaff(session.data.account.role)
|
||
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<>{canOpenControl && <Link className="button secondary" to="/series/$seriesId" params={{ seriesId: data.id }}><Shield />Series control</Link>}<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></section><HeroBanDisplay series={data} /><SeriesBanHistory series={data} /><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
|
||
}
|
||
|
||
function BracketPage() {
|
||
const { eventId } = bracketRoute.useParams()
|
||
const bracket = useQuery({ queryKey: ['tournament', eventId], queryFn: () => api.bracket(eventId), initialData: demoMode ? 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="/events/$eventId/live" params={{ eventId }} search={{ 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
|