Refactor series handling in backend to include player context in Toss, Ban, Pick, and Record actions. Update service methods to accept player parameters, enhancing authorization checks and ensuring proper team actions. Modify integration tests to reflect new method signatures and improve tournament hydration logic in the store. Enhance frontend components to support new series features and improve user experience with live match navigation.
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user