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()({ 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) => ({ 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 MIXMAKER } 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
{initials}
{open && }
} function LoginPage() { return
Community scrims, organized

Ready up.
Play together.

Balance teams, run drafts, and follow every map from one esports control room.

Continue with DiscordSigning in creates a secure session. We never receive your Discord password.
5v5 role-locked teamsLIVE synchronized draftsBO3 tournament series
} function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description?: string; actions?: ReactNode }) { return
{eyebrow}

{title}

{description &&

{description}

}
{actions &&
{actions}
}
} function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'danger' | 'neutral' | 'blue' }) { return {children} } function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return

{label}

Syncing with the server.

} function ErrorState({ retry, message }: { retry?: () => void; message?: string }) { return

Couldn’t load this view

{message || 'Your data is safe. Check your connection and try again.'}

{retry && }
} function PlayerLink({ player, className }: { player: Pick; className?: string }) { return {player.displayName} } 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 if (events.isError) return void events.refetch()} /> if (!events.data) return const visible = filter === 'upcoming' ? events.data.filter((e) => e.status !== 'completed') : events.data const liveEvent = events.data.find((event) => event.workflowState === 'Live') return
} /> {liveEvent && }

Coming up

{visible.length} scheduled events

{visible.length === 0 ?

No events yet

New community nights will appear here.

:
{visible.map((event) => )}
}
} 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
1 ? 'multiple-matches' : ''}`}>
Live now

{event.title}

{liveMatches.length > 0 ? <>{liveMatches.length} active matches : match ? `${match.roundLabel} · ${match.currentStep.title}` : 'The scrim has started'}

{liveMatches.length > 0 ?
{liveMatches.map((item) =>
{item.label}{item.teamAlpha ?? 'TBD'}{item.scoreAlpha ?? 0}:{item.scoreBeta ?? 0}{item.teamBeta ?? 'TBD'}
)}
: match &&
{match.teamAlpha.name}{match.score.alpha}:{match.score.beta}{match.teamBeta.name}
}Open live
} function EventCard({ event }: { event: MixEvent }) { const date = new Date(event.startsAt) const statusBadge = event.status === 'cancelled' ? Cancelled : event.status === 'completed' ? Completed : event.status === 'live' ? LIVE : {event.myRsvp === 'going' ? : }{event.myRsvp === 'going' ? 'Going' : 'Maybe'} return
{date.toLocaleDateString(undefined, { day: '2-digit' })}{date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}
{statusBadge}{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}

{event.title}

{event.description}

{event.counts.going} goingView event
} 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 = { going: 'Going', maybe: 'Maybe', not_going: 'Can’t go' } if (eventQuery.isLoading || registrations.isLoading || session.isLoading) return if (eventQuery.isError || registrations.isError || session.isError || !eventQuery.data || !session.data) return { 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 = { 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
{event.status !== 'cancelled' && event.workflowState === 'Live' && Open live}{event.workflowState === 'Completed' && event.activeSeriesId && !event.tournamentId && Match history}{event.status !== 'cancelled' && event.tournamentId && Open bracket}{event.workflowState.replace(/([a-z])([A-Z])/g, '$1 $2')}} />
Starts{new Date(event.startsAt).toLocaleString(undefined, { weekday: 'long', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
Registration closes{new Date(event.registrationDeadline).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' })}

Are you playing?

{event.workflowState === 'RegistrationOpen' ? 'Your latest response is sent to the organizer.' : 'Registration has been closed by the organizer.'}

{(Object.keys(labels) as RsvpStatus[]).map((status) => )}
{rsvpMutation.isSuccess &&
Response saved · Server confirmed just now
}{rsvpMutation.isError &&

{rsvpMutation.error.message}

}
{teams.data && teams.data.length > 0 &&

Balanced teams

Confirmed match rosters are always available here.

{teams.data.map((team) => )}
}

Registered players

Everyone can see who responded and their current status.

{attendees.length === 0 ?

No responses yet.

:
{attendees.map((registration) =>
{registration.player.displayName.slice(0, 2).toUpperCase()}{labels[registration.status]}
)}
}
} 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 { void event.refetch(); void session.refetch(); void teams.refetch(); void bracket.refetch() }} /> if (!loading && !targetSeriesId) return return } function FAQPage() { const settings = useQuery({ queryKey: ['community-settings'], queryFn: api.communitySettings, initialData: demoMode ? demoCommunitySettings : undefined }) return

Join the community Discord

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.

{settings.data?.discordInviteUrl && Open Discord }
01

How are teams balanced?

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.

02

How does registration work?

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.

03

What happens before the scrim?

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.

04

How is the first map selected?

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.

05

Who picks later maps?

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.

06

How do hero bans work?

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.

07

How does Bo3 scoring work?

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.

08

What can captains do?

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.

} 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 if (profile.isError || session.isError || !profile.data) return 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: }, { key: 'damage', label: 'Damage', icon: }, { key: 'support', label: 'Support', icon: }, ] return
Edit profile : undefined} />
{player.displayName.slice(0, 2).toUpperCase()}

{player.displayName}

{player.battleTag &&

{player.battleTag}

}{player.isGuest ? 'Guest player' : 'Discord player'}

Role ranks

Current competitive ratings

{roles.map((role) =>
{role.icon}{role.label}{rankLabel(player.ratings[role.key], language)}
)}
Series played{stats.seriesPlayed}
Series won{stats.seriesWon}
Series lost{stats.seriesLost}
Win rate{winRate}%

Map record

{stats.mapsPlayed} maps played

{stats.mapsWon}Won{stats.mapsLost}Lost{stats.mapsDrawn}Drawn

Recent matches

Latest completed series

{player.recentMatches.length === 0 ?

No completed matches yet.

:
{player.recentMatches.map((match) =>
{match.outcome}{match.eventName}{new Date(match.startedAt).toLocaleDateString()}
{match.teamName}{match.scoreFor}:{match.scoreAgainst}{match.opponentTeamName}
)}
}
} 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>({ tank: 20, damage: 20, support: 20 }) const [displayName, setDisplayName] = useState('') const [battleTag, setBattleTag] = useState('') const [preferredRoles, setPreferredRoles] = useState([]) const [preferredPlayerIds, setPreferredPlayerIds] = useState([]) const [avoidedPlayerIds, setAvoidedPlayerIds] = useState([]) 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 if (profile.isError || players.isError) return { void profile.refetch(); void players.refetch() }} /> if (!profile.data || !players.data) return 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
{displayName.slice(0, 2).toUpperCase()}

{displayName || profile.data.displayName}

{battleTag &&

{battleTag}

}Connected via Discord

Nicknames are unique across the community.

Competitive ranks

Select your current Overwatch rank for each role.

Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}
{roles.map((role) => )}

Preferred roles

Choose every role you enjoy playing.

{roles.map((role) => )}

Preferred teammates

Choose up to 3 players. The balancer treats these as preferences, not guarantees.

{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.id)).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return })}

Avoided teammates

Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.

{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return })}

Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}

} type AdminTab = 'events' | 'participants' | 'balance' | 'moderators' | 'community' function AdminPage() { const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity }) const [tab, setTab] = useState('events') const [createMode, setCreateMode] = useState(false) const events = useQuery({ queryKey: ['events'], queryFn: api.events, initialData: demoMode ? demoEvents : undefined }) const [selectedId, setSelectedId] = useState() 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 if (events.isError || session.isError) return { 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
{events.data && events.data.length > 0 && } : undefined} />
{tabs.map((item) => )}
{tab === 'events' && { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && }{tab === 'balance' && selected && }{tab === 'moderators' && session.data?.account.role === 'admin' && }{tab === 'community' && session.data?.account.role === 'admin' && }
} 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 if (settings.isError) return void settings.refetch()} /> return
{ event.preventDefault(); update.mutate() }}>

Community Discord

This invite is shown in FAQ to signed-in players.

{update.isError ? update.error.message : 'Leave empty to hide the invite button.'}

} 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(['accounts'], (current) => current?.map((item) => item.id === account.id ? account : item)), onError: () => void accounts.refetch(), }) if (accounts.isLoading) return if (accounts.isError || !accounts.data) return void accounts.refetch()} /> return

Moderators

Moderators can manage events, participants, rosters, and live scrims. Only administrators can change this list.

{accounts.data.filter((account) => account.role === 'moderator').length} active
{accounts.data.map((account) =>
{account.username.slice(0, 2).toUpperCase()}{account.username}Discord ID · {account.discordId}{account.role === 'admin' ? Administrator : }
)}
{update.isError &&

{update.error.message}

}
} 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(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(['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(['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
{ e.preventDefault(); save.mutate() }}>

{event ? 'Event settings' : 'New event'}

Scheduling and registration

{event &&
{event.status === 'cancelled' ? 'Cancelled' : event.status === 'completed' ? 'Completed' : event.status === 'live' ? 'LIVE' : 'Published'}
}