|
|
|
|
@@ -1,6 +1,6 @@
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
|
|
|
|
|
import {
|
|
|
|
|
CalendarDays, Check, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
|
|
|
|
|
CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
|
|
|
|
|
Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound,
|
|
|
|
|
UsersRound, X, Zap,
|
|
|
|
|
} from 'lucide-react'
|
|
|
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
|
|
import { LanguageSelector } from './i18n'
|
|
|
|
|
import { useLanguage } from './i18n-context'
|
|
|
|
|
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
|
|
|
|
|
import { activeHeroBanMap } from './series-utils'
|
|
|
|
|
|
|
|
|
|
type RouterContext = { session: Session | null }
|
|
|
|
|
const isStaff = (role?: Session['account']['role']) => role === 'admin' || role === 'moderator'
|
|
|
|
|
@@ -35,6 +36,12 @@ const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: '
|
|
|
|
|
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 })
|
|
|
|
|
@@ -43,7 +50,7 @@ const adminRoute = createRoute({
|
|
|
|
|
beforeLoad: ({ context }) => { if (!isStaff(context.session?.account.role)) throw redirect({ to: '/events' }) },
|
|
|
|
|
})
|
|
|
|
|
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
|
|
|
|
|
indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
|
|
|
|
|
indexRoute, profileRoute, 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 } }
|
|
|
|
|
@@ -150,11 +157,19 @@ function FeaturedLiveEvent({ event }: { event: MixEvent }) {
|
|
|
|
|
const series = useQuery({
|
|
|
|
|
queryKey: ['series', event.activeSeriesId],
|
|
|
|
|
queryFn: () => api.series(event.activeSeriesId!),
|
|
|
|
|
enabled: Boolean(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
|
|
|
|
|
return <section className={`featured-event ${match ? '' : 'without-score'}`}><div><Badge tone="live"><Radio />Live now</Badge><h2>{event.title}</h2><p>{match ? `${match.roundLabel} · ${match.currentStep.title}` : 'The scrim has started'}</p></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>}{event.activeSeriesId ? <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.activeSeriesId }}>Watch live <ChevronRight /></Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId: event.id }}>Open bracket <ChevronRight /></Link>}</section>
|
|
|
|
|
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 }) {
|
|
|
|
|
@@ -190,12 +205,47 @@ function EventPage() {
|
|
|
|
|
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.activeSeriesId && <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.activeSeriesId }}>Open live series</Link>}{event.status !== 'cancelled' && event.tournamentId && <Link className="button primary" 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">
|
|
|
|
|
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><section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><strong>{registration.player.displayName}</strong><Badge tone={registration.status === 'going' ? 'success' : registration.status === 'maybe' ? 'warning' : 'neutral'}>{labels[registration.status]}</Badge></div>)}</div>}</section></div>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function 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() {
|
|
|
|
|
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"><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>
|
|
|
|
|
@@ -286,13 +336,12 @@ function toLocalInput(value: string) {
|
|
|
|
|
function eventInput(event?: MixEvent): EventInput {
|
|
|
|
|
const start = new Date(Date.now() + 86_400_000)
|
|
|
|
|
const end = new Date(start.getTime() + 2 * 3_600_000)
|
|
|
|
|
const deadline = new Date(start.getTime() - 3_600_000)
|
|
|
|
|
return {
|
|
|
|
|
name: event?.title ?? '',
|
|
|
|
|
description: event?.description ?? '',
|
|
|
|
|
startsAt: toLocalInput(event?.startsAt ?? start.toISOString()),
|
|
|
|
|
endsAt: toLocalInput(event?.endsAt ?? end.toISOString()),
|
|
|
|
|
registrationDeadline: toLocalInput(event?.registrationDeadline ?? deadline.toISOString()),
|
|
|
|
|
registrationDeadline: toLocalInput(event?.startsAt ?? start.toISOString()),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -338,18 +387,19 @@ function EventSetup({ event, onCreated, onDeleted }: { event?: MixEvent; onCreat
|
|
|
|
|
})
|
|
|
|
|
const set = (key: keyof EventInput, value: string) => setForm((current) => ({ ...current, [key]: value }))
|
|
|
|
|
const setStart = (value: string) => setForm((current) => {
|
|
|
|
|
if (event) return { ...current, startsAt: value }
|
|
|
|
|
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 type="datetime-local" value={form.registrationDeadline} onChange={(e) => set('registrationDeadline', e.target.value)} /></label></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>
|
|
|
|
|
<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>
|
|
|
|
|
@@ -501,7 +551,7 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
|
|
|
|
{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="Start scrim" description="This atomically creates the Bo3 series or tournament bracket." button="Start scrim" pending={start.isPending} onClick={() => 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></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>
|
|
|
|
|
}
|
|
|
|
|
@@ -617,6 +667,12 @@ function LiveSeriesPage() {
|
|
|
|
|
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
|
|
|
|
|
const [selected, setSelected] = useState<string>()
|
|
|
|
|
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
|
|
|
|
|
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)))
|
|
|
|
|
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!
|
|
|
|
|
@@ -652,7 +708,23 @@ function LiveSeriesPage() {
|
|
|
|
|
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
|
|
|
|
|
return <div className="page live-page"><PageHeader eyebrow={`${data.roundLabel} · ${data.status}`} title="Series control" description="Actions are validated and recorded by the server." actions={<Link className="button secondary" to="/watch/$seriesId" params={{ seriesId: data.id }}><Radio />Spectator view</Link>} /><Scoreboard series={data} /><div className="live-layout"><section className={`turn-card team-${data.currentStep.activeTeam ?? 'alpha'}`}><div className="turn-header"><div><span className="eyebrow">Current step</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p></div>{data.currentStep.activeTeam && <div className="turn-team"><span>Acting team</span><strong>{data.currentStep.activeTeam === 'alpha' ? data.teamAlpha.name : data.teamBeta.name}</strong></div>}</div>{data.currentStep.kind === 'result' ? <form className="result-form" onSubmit={(e) => { e.preventDefault(); result.mutate() }}><label>Outcome<select value={outcome} onChange={(e) => setOutcome(e.target.value as MapOutcome)}><option value="TeamAWin">{data.teamAlpha.name} wins</option><option value="TeamBWin">{data.teamBeta.name} wins</option><option value="Draw">Draw</option></select></label><button className="button team-action" disabled={result.isPending}>{result.isPending ? 'Recording…' : 'Record result'}</button>{result.isError && <p className="error-note">{result.error.message}</p>}</form> : data.currentStep.kind === 'complete' ? <div className="turn-footer"><p><Check />Series completed and bracket updated.</p></div> : <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled} className={selected === option.id ? 'selected' : ''} onClick={() => setSelected(option.id)}><span className={`role-chip ${option.role}`}>{option.role?.slice(0, 1).toUpperCase()}</span><strong>{option.name}</strong>{option.disabled ? <small>{option.disabledReason}</small> : <Check />}</button>)}</div><div className="turn-footer"><p><Shield />Draft commands use dedicated backend endpoints and server-side validation.</p><button className="button team-action" disabled={(data.currentStep.kind !== 'coin_toss' && !selected) || action.isPending} onClick={() => action.mutate()}>{action.isPending ? 'Confirming…' : data.currentStep.kind === 'coin_toss' ? 'Toss coin' : 'Confirm action'} <ChevronRight /></button></div>{action.isError && <p className="error-note">{action.error.message}</p>}</>}</section><aside className="match-sidebar"><MapTimeline series={data} /><AuditLog series={data} /></aside></div></div>
|
|
|
|
|
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
|
|
|
|
|
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>
|
|
|
|
|
: <><div className="draft-options" role="radiogroup" aria-label="Available draft choices">{data.options.map((option) => <button role="radio" aria-checked={selected === option.id} key={option.id} disabled={option.disabled || !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 }) {
|
|
|
|
|
@@ -662,7 +734,35 @@ function Scoreboard({ series }: { series: Series }) {
|
|
|
|
|
const betaCaptain = series.teamBeta.members.find(({ player }) => player.id === series.teamBeta.captainId)?.player.displayName
|
|
|
|
|
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 <><section className="scoreboard" aria-label="Current series score"><div className="score-team alpha"><span>Team Alpha</span><h2>{series.teamAlpha.name}</h2><small>{alphaCaptain ? `Captain · ${alphaCaptain}` : 'Captain not assigned'}</small></div><div className="series-score"><span>Best of 3</span><strong>{series.score.alpha}<i>:</i>{series.score.beta}</strong><Badge tone="live"><Radio />{series.status}</Badge></div><div className="score-team beta"><span>Team Beta</span><h2>{series.teamBeta.name}</h2><small>{betaCaptain ? `Captain · ${betaCaptain}` : 'Captain not assigned'}</small></div></section>{(canRenameAlpha || canRenameBeta) && <div className="team-name-controls">{canRenameAlpha && <TeamNameEditor team={series.teamAlpha} seriesId={series.id} />}{canRenameBeta && <TeamNameEditor team={series.teamBeta} seriesId={series.id} />}</div>}</>
|
|
|
|
|
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 · ${alphaCaptain}` : 'Captain not assigned'}</small></div><div className="series-score"><span>Best of 3</span><strong>{series.score.alpha}<i>:</i>{series.score.beta}</strong><Badge tone="live"><Radio />{series.status}</Badge></div><div className="score-team beta"><span>Team Beta</span><h2>{series.teamBeta.name}</h2><small>{betaCaptain ? `Captain · ${betaCaptain}` : 'Captain not assigned'}</small></div></section>{(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 }) {
|
|
|
|
|
@@ -682,25 +782,39 @@ function TeamNameEditor({ team, seriesId }: { team: Team; seriesId: string }) {
|
|
|
|
|
})
|
|
|
|
|
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>Auditable server actions</p></div></div>{series.audit.length === 0 ? <p className="empty-note">No actions yet.</p> : series.audit.slice(-4).reverse().map((a) => <div key={a.id}><span className="history-dot" /><p><strong>{a.summary}</strong><small>{a.actorName} · {new Date(a.createdAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</small></p></div>)}</section> }
|
|
|
|
|
function 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 })
|
|
|
|
|
if (series.isLoading) return <LoadingState />
|
|
|
|
|
if (!series.data) return <ErrorState message={series.error?.message} retry={() => void series.refetch()} />
|
|
|
|
|
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
|
|
|
|
|
return <div className="page spectator-page"><PageHeader eyebrow="Spectator mode" title={data.roundLabel} description="Read-only live view · Updates automatically" actions={<Badge tone="success"><span className="live-dot" />Connected</Badge>} /><Scoreboard series={data} /><section className="spectator-focus"><span className="eyebrow">{data.status}</span><h2>{data.currentStep.title}</h2><p>{data.currentStep.instruction}</p>{data.maps.at(-1)?.heroBans.length ? <div className="ban-display">{data.maps.at(-1)?.heroBans.map((ban, index) => <div key={`${ban.hero}-${index}`}><span>Ban {index + 1}</span><strong>{ban.hero}</strong><small>{ban.role}</small></div>)}</div> : null}</section><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
|
|
|
|
|
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: tournamentId } = bracketRoute.useParams()
|
|
|
|
|
const bracket = useQuery({ queryKey: ['tournament', tournamentId], queryFn: () => api.bracket(tournamentId), initialData: demoMode && tournamentId === demoBracket.id ? demoBracket : undefined })
|
|
|
|
|
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="/watch/$seriesId" params={{ seriesId: m.seriesId }}>Open series <ChevronRight /></Link>}</article>)}</div></section>)}</div><p className="bracket-help"><ChevronRight />Scroll horizontally to follow the bracket on smaller screens.</p></div>
|
|
|
|
|
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() {
|
|
|
|
|
|