|
|
|
@@ -1,4 +1,4 @@
|
|
|
|
import { useCallback, useEffect, useMemo, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type FormEvent, type ReactNode } from 'react'
|
|
|
|
import {
|
|
|
|
import {
|
|
|
|
CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
|
|
|
|
CalendarDays, Check, ChevronLeft, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
|
|
|
|
Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound,
|
|
|
|
Menu, Radio, RefreshCw, Search, Shield, Sparkles, Swords, Trophy, UserRound,
|
|
|
|
@@ -21,6 +21,7 @@ import { LanguageSelector } from './i18n'
|
|
|
|
import { useLanguage } from './i18n-context'
|
|
|
|
import { useLanguage } from './i18n-context'
|
|
|
|
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
|
|
|
|
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
|
|
|
|
import { activeHeroBanMap } from './series-utils'
|
|
|
|
import { activeHeroBanMap } from './series-utils'
|
|
|
|
|
|
|
|
import { playTurnSound, primeTurnSound, shouldNotifyTurn, type TurnSnapshot } from './turn-notification'
|
|
|
|
import { BracketEditor } from './components/bracket-editor/BracketEditor'
|
|
|
|
import { BracketEditor } from './components/bracket-editor/BracketEditor'
|
|
|
|
|
|
|
|
|
|
|
|
type RouterContext = { session: Session | null }
|
|
|
|
type RouterContext = { session: Session | null }
|
|
|
|
@@ -74,6 +75,14 @@ function AppShell() {
|
|
|
|
const [realtimeStatus, setRealtimeStatus] = useState<'connected' | 'reconnecting'>('reconnecting')
|
|
|
|
const [realtimeStatus, setRealtimeStatus] = useState<'connected' | 'reconnecting'>('reconnecting')
|
|
|
|
const client = useQueryClient()
|
|
|
|
const client = useQueryClient()
|
|
|
|
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
|
|
|
|
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) => {
|
|
|
|
useEffect(() => subscribeToEvents('*', (message) => {
|
|
|
|
const [scope, id] = message.topic.split(':', 2)
|
|
|
|
const [scope, id] = message.topic.split(':', 2)
|
|
|
|
if (scope === 'event' && id) {
|
|
|
|
if (scope === 'event' && id) {
|
|
|
|
@@ -190,6 +199,7 @@ function EventPage() {
|
|
|
|
const demoEvent = demoEvents.find((event) => event.id === eventId)
|
|
|
|
const demoEvent = demoEvents.find((event) => event.id === eventId)
|
|
|
|
const eventQuery = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId), initialData: demoMode ? demoEvent : undefined })
|
|
|
|
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 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 session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
|
|
|
|
const rsvpMutation = useMutation({
|
|
|
|
const rsvpMutation = useMutation({
|
|
|
|
mutationFn: (status: RsvpStatus) => api.setRsvp(eventId, session.data!.player.id, status),
|
|
|
|
mutationFn: (status: RsvpStatus) => api.setRsvp(eventId, session.data!.player.id, status),
|
|
|
|
@@ -211,7 +221,7 @@ function EventPage() {
|
|
|
|
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">
|
|
|
|
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>
|
|
|
|
<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>
|
|
|
|
<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>
|
|
|
|
</div>{teams.data && teams.data.length > 0 && <section className="event-teams"><div className="section-title"><div><h2>Balanced teams</h2><p>Confirmed match rosters are always available here.</p></div><Swords /></div><div className="teams-grid">{teams.data.map((team) => <PublicTeamCard key={team.id} team={team} />)}</div></section>}<section className="card public-roster"><div className="section-title"><div><h2>Registered players</h2><p>Everyone can see who responded and their current status.</p></div><UsersRound /></div>{attendees.length === 0 ? <p className="empty-note">No responses yet.</p> : <div className="public-roster-grid">{attendees.map((registration) => <div className="public-roster-player" key={registration.id}><span className="mini-avatar">{registration.player.displayName.slice(0, 2).toUpperCase()}</span><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() {
|
|
|
|
function LiveEntryPage() {
|
|
|
|
@@ -705,6 +715,10 @@ function TeamCard({ team, eventId, allowCaptainAssignment = true }: { team: Team
|
|
|
|
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div>{allowCaptainAssignment && <label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label>}</article>
|
|
|
|
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><strong className="team-rank">{rankLabel(team.averageRating, language)}<small> avg</small></strong></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong><span>{rankLabel(player.ratings[assignedRole], language)}</span></div>)}</div>{allowCaptainAssignment && <label className="captain-select"><span><Trophy />Team captain</span><select value={captain} disabled={assign.isPending} onChange={(e) => { setCaptain(e.target.value); assign.mutate(e.target.value) }}><option value="">Select from roster</option>{team.members.map(({ player }) => <option value={player.id} key={player.id}>{player.displayName}</option>)}</select>{assign.isError && <small className="error-note">{assign.error.message}</small>}</label>}</article>
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function PublicTeamCard({ team }: { team: Team }) {
|
|
|
|
|
|
|
|
return <article className={`card team-card team-${team.side}`}><header><div><span>Team {team.side === 'alpha' ? 'A' : 'B'}</span><h3>{team.name}</h3></div><Badge tone={team.side === 'alpha' ? 'warning' : 'blue'}>{team.members.length}/5</Badge></header><div className="team-members">{team.members.map(({ player, assignedRole }) => <div key={player.id}><span className={`role-chip ${assignedRole}`}>{assignedRole === 'tank' ? 'T' : assignedRole === 'damage' ? 'D' : 'S'}</span><strong>{player.displayName}</strong>{player.id === team.captainId && <Badge tone="neutral">Captain</Badge>}</div>)}</div></article>
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function LiveSeriesPage() {
|
|
|
|
function LiveSeriesPage() {
|
|
|
|
const { seriesId } = liveRoute.useParams()
|
|
|
|
const { seriesId } = liveRoute.useParams()
|
|
|
|
const client = useQueryClient()
|
|
|
|
const client = useQueryClient()
|
|
|
|
@@ -714,7 +728,19 @@ function LiveSeriesPage() {
|
|
|
|
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
|
|
|
|
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
|
|
|
|
const [heroRoleFilter, setHeroRoleFilter] = useState<'all' | PlayerRole>('all')
|
|
|
|
const [heroRoleFilter, setHeroRoleFilter] = useState<'all' | PlayerRole>('all')
|
|
|
|
const [heroSearch, setHeroSearch] = useState('')
|
|
|
|
const [heroSearch, setHeroSearch] = useState('')
|
|
|
|
|
|
|
|
const previousTurn = useRef<(TurnSnapshot & { seriesId: string }) | undefined>(undefined)
|
|
|
|
const participant = Boolean(series.data && session.data && [series.data.teamAlpha, series.data.teamBeta].some((team) => team.members.some(({ player }) => player.id === session.data?.player.id)))
|
|
|
|
const participant = Boolean(series.data && session.data && [series.data.teamAlpha, series.data.teamBeta].some((team) => team.members.some(({ player }) => player.id === session.data?.player.id)))
|
|
|
|
|
|
|
|
const playerSide = series.data && session.data
|
|
|
|
|
|
|
|
? series.data.teamAlpha.members.some(({ player }) => player.id === session.data?.player.id) ? 'alpha'
|
|
|
|
|
|
|
|
: series.data.teamBeta.members.some(({ player }) => player.id === session.data?.player.id) ? 'beta' : undefined
|
|
|
|
|
|
|
|
: undefined
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
if (!series.data) return
|
|
|
|
|
|
|
|
const current = { seriesId, activeTeam: series.data.currentStep.activeTeam, version: series.data.version }
|
|
|
|
|
|
|
|
const previous = previousTurn.current?.seriesId === seriesId ? previousTurn.current : undefined
|
|
|
|
|
|
|
|
if (shouldNotifyTurn(previous, current, playerSide)) playTurnSound()
|
|
|
|
|
|
|
|
previousTurn.current = current
|
|
|
|
|
|
|
|
}, [playerSide, series.data, seriesId])
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
if (series.data && session.data && !participant && !isStaff(session.data.account.role)) {
|
|
|
|
if (series.data && session.data && !participant && !isStaff(session.data.account.role)) {
|
|
|
|
void router.navigate({ to: '/watch/$seriesId', params: { seriesId }, replace: true })
|
|
|
|
void router.navigate({ to: '/watch/$seriesId', params: { seriesId }, replace: true })
|
|
|
|
|