Add event management features to API, including event cancellation, deletion, registration closure, and workflow balance generation. Introduce new roster management endpoints and enhance participant handling with avoided player preferences. Update database schema and service logic to support these changes.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import {
|
||||
CalendarDays, Check, ChevronRight, CircleHelp, Clock3, Gamepad2, ListFilter, LogOut,
|
||||
Menu, Radio, RefreshCw, Shield, Sparkles, Swords, Trophy, UserRound,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@tanstack/react-router'
|
||||
import {
|
||||
api, subscribeToEvents, type EventInput, type MapOutcome, type MixEvent,
|
||||
type PlayerRole, type Registration, type RsvpStatus, type Series, type Session, type Team,
|
||||
type Player, type PlayerRole, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
|
||||
} from './api/client'
|
||||
import {
|
||||
demoBalanceCandidates, demoBracket, demoEvents, demoProfile,
|
||||
@@ -31,6 +31,7 @@ const protectedRoute = createRoute({
|
||||
})
|
||||
const indexRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/', beforeLoad: () => { throw redirect({ to: '/events' }) } })
|
||||
const profileRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/profile', component: ProfilePage })
|
||||
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 liveRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/series/$seriesId', component: LiveSeriesPage })
|
||||
@@ -41,13 +42,14 @@ const adminRoute = createRoute({
|
||||
beforeLoad: ({ context }) => { if (context.session?.account.role !== 'admin') throw redirect({ to: '/events' }) },
|
||||
})
|
||||
const routeTree = rootRoute.addChildren([loginRoute, protectedRoute.addChildren([
|
||||
indexRoute, profileRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
|
||||
indexRoute, profileRoute, faqRoute, eventsRoute, eventRoute, liveRoute, spectatorRoute, bracketRoute, adminRoute,
|
||||
])])
|
||||
const router = createRouter({ routeTree, context: { session: null } })
|
||||
declare module '@tanstack/react-router' { interface Register { router: typeof router } }
|
||||
|
||||
const mainNavItems = [
|
||||
{ to: '/events', label: 'Events', icon: CalendarDays },
|
||||
{ to: '/faq', label: 'FAQ', icon: CircleHelp },
|
||||
...(demoMode ? [
|
||||
{ to: '/series/$seriesId' as const, params: { seriesId: 'series-1' }, label: 'Live', icon: Radio },
|
||||
{ to: '/bracket/$eventId' as const, params: { eventId: 'bracket-1' }, label: 'Bracket', icon: Trophy },
|
||||
@@ -60,7 +62,31 @@ function Brand() {
|
||||
|
||||
function AppShell() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [realtimeStatus, setRealtimeStatus] = useState<'connected' | 'reconnecting'>('reconnecting')
|
||||
const client = useQueryClient()
|
||||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
|
||||
useEffect(() => subscribeToEvents('*', (message) => {
|
||||
const [scope, id] = message.topic.split(':', 2)
|
||||
if (scope === 'event' && id) {
|
||||
void client.invalidateQueries({ queryKey: ['event', id] })
|
||||
void client.invalidateQueries({ queryKey: ['registrations', id] })
|
||||
void client.invalidateQueries({ queryKey: ['roster', id] })
|
||||
void client.invalidateQueries({ queryKey: ['teams', id] })
|
||||
void client.invalidateQueries({ queryKey: ['tournament', id] })
|
||||
void client.invalidateQueries({ queryKey: ['events'] })
|
||||
} else if (scope === 'series' && id) {
|
||||
void client.invalidateQueries({ queryKey: ['series', id] })
|
||||
} else if (scope === 'tournament') {
|
||||
void client.invalidateQueries({ queryKey: ['tournament'] })
|
||||
} else if (scope === 'team') {
|
||||
void client.invalidateQueries({ queryKey: ['teams'] })
|
||||
} else if (message.topic === 'events') {
|
||||
void client.invalidateQueries({ queryKey: ['events'] })
|
||||
} else if (message.topic === 'players') {
|
||||
void client.invalidateQueries({ queryKey: ['players'] })
|
||||
void client.invalidateQueries({ queryKey: ['profile'] })
|
||||
}
|
||||
}, setRealtimeStatus), [client])
|
||||
const logout = useMutation({
|
||||
mutationFn: api.logout,
|
||||
onSuccess: () => {
|
||||
@@ -76,7 +102,7 @@ function AppShell() {
|
||||
<header className="topbar">
|
||||
<Brand />
|
||||
<nav className="desktop-nav" aria-label="Primary navigation">{navItems.map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} activeProps={{ className: 'active' }}><Icon />{item.label}</Link>)}</nav>
|
||||
<div className="account"><LanguageSelector /><span className="live-dot" title="Realtime connected" /><Link to="/profile" className="avatar" aria-label="Open profile">{initials}</Link><button className="icon-button" onClick={() => logout.mutate()} aria-label="Sign out" title="Sign out"><LogOut /></button><button className="icon-button mobile-menu" onClick={() => setOpen(!open)} aria-label="Toggle navigation" aria-expanded={open}>{open ? <X /> : <Menu />}</button></div>
|
||||
<div className="account"><LanguageSelector /><span className={`live-dot ${realtimeStatus}`} title={realtimeStatus === 'connected' ? 'Realtime connected' : 'Realtime reconnecting'} /><Link to="/profile" className="avatar" aria-label="Open profile">{initials}</Link><button className="icon-button" onClick={() => logout.mutate()} aria-label="Sign out" title="Sign out"><LogOut /></button><button className="icon-button mobile-menu" onClick={() => setOpen(!open)} aria-label="Toggle navigation" aria-expanded={open}>{open ? <X /> : <Menu />}</button></div>
|
||||
</header>
|
||||
{open && <nav className="mobile-nav" aria-label="Mobile navigation">{[...navItems, { to: '/profile' as const, label: 'Profile', icon: UserRound }].map(({ icon: Icon, ...item }) => <Link key={item.label} {...item} onClick={() => setOpen(false)}><Icon />{item.label}</Link>)}</nav>}
|
||||
<main><Outlet /></main>
|
||||
@@ -95,7 +121,7 @@ function LoginPage() {
|
||||
function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string; title: string; description?: string; actions?: ReactNode }) {
|
||||
return <header className="page-header"><div><span className="eyebrow">{eyebrow}</span><h1>{title}</h1>{description && <p>{description}</p>}</div>{actions && <div className="page-actions">{actions}</div>}</header>
|
||||
}
|
||||
function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
|
||||
function Badge({ children, tone = 'neutral' }: { children: ReactNode; tone?: 'success' | 'warning' | 'live' | 'danger' | 'neutral' | 'blue' }) { return <span className={`badge badge-${tone}`}>{children}</span> }
|
||||
function LoadingState({ label = 'Loading match data…' }: { label?: string }) { return <div className="state-card" role="status"><RefreshCw className="spin" /><h2>{label}</h2><p>Syncing with the server.</p></div> }
|
||||
function ErrorState({ retry }: { retry?: () => void }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldn’t load this view</h2><p>Your data is safe. Check your connection and try again.</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
|
||||
|
||||
@@ -110,13 +136,17 @@ function EventsPage() {
|
||||
<PageHeader eyebrow="Scrim schedule" title="Your events" description="Join a mix, check the roster, and get match-ready." actions={<div className="segmented"><button className={filter === 'upcoming' ? 'active' : ''} onClick={() => setFilter('upcoming')}>Upcoming</button><button className={filter === 'all' ? 'active' : ''} onClick={() => setFilter('all')}>All</button></div>} />
|
||||
{demoMode && <section className="featured-event"><div><Badge tone="live"><Radio />Live now</Badge><h2>{demoEvents[2].title}</h2><p>Semifinal 1 · Map 2 draft in progress</p></div><div className="featured-score"><span>EMBER</span><strong>1<i>:</i>0</strong><span>AZURE</span></div><Link className="button primary" to="/watch/$seriesId" params={{ seriesId: 'series-1' }}>Watch live <ChevronRight /></Link></section>}
|
||||
<div className="section-title"><div><h2>Coming up</h2><p>{visible.length} scheduled events</p></div><button className="icon-button" aria-label="Filter events"><ListFilter /></button></div>
|
||||
{visible.length === 0 ? <div className="state-card"><CalendarDays /><h2>No events yet</h2><p>New community nights will appear here.</p></div> : <div className="event-grid">{visible.filter((e) => e.status !== 'live').map((event) => <EventCard key={event.id} event={event} />)}</div>}
|
||||
{visible.length === 0 ? <div className="state-card"><CalendarDays /><h2>No events yet</h2><p>New community nights will appear here.</p></div> : <div className="event-grid">{visible.map((event) => <EventCard key={event.id} event={event} />)}</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function EventCard({ event }: { event: MixEvent }) {
|
||||
const date = new Date(event.startsAt)
|
||||
return <article className="card event-card"><div className="date-block"><strong>{date.toLocaleDateString(undefined, { day: '2-digit' })}</strong><span>{date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}</span></div><div className="event-card-body"><div className="event-meta"><Badge tone={event.myRsvp === 'going' ? 'success' : 'warning'}>{event.myRsvp === 'going' ? <Check /> : <Clock3 />}{event.myRsvp === 'going' ? 'Going' : 'Maybe'}</Badge><span>{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span></div><h3>{event.title}</h3><p>{event.description}</p><div className="event-footer"><span><UsersRound />{event.counts.going} going</span><Link to="/events/$eventId" params={{ eventId: event.id }}>View event <ChevronRight /></Link></div></div></article>
|
||||
const statusBadge = event.status === 'cancelled' ? <Badge tone="danger"><X />Cancelled</Badge>
|
||||
: event.status === 'completed' ? <Badge tone="success"><Check />Completed</Badge>
|
||||
: event.status === 'live' ? <Badge tone="live"><Radio />LIVE</Badge>
|
||||
: <Badge tone={event.myRsvp === 'going' ? 'success' : 'warning'}>{event.myRsvp === 'going' ? <Check /> : <Clock3 />}{event.myRsvp === 'going' ? 'Going' : 'Maybe'}</Badge>
|
||||
return <Link className={`card event-card status-${event.status}`} to="/events/$eventId" params={{ eventId: event.id }} aria-label={`Open ${event.title}`}><div className="date-block"><strong>{date.toLocaleDateString(undefined, { day: '2-digit' })}</strong><span>{date.toLocaleDateString(undefined, { month: 'short' }).toUpperCase()}</span></div><div className="event-card-body"><div className="event-meta">{statusBadge}<span>{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</span></div><h3>{event.title}</h3><p>{event.description}</p><div className="event-footer"><span><UsersRound />{event.counts.going} going</span><span className="event-open">View event <ChevronRight /></span></div></div></Link>
|
||||
}
|
||||
|
||||
function EventPage() {
|
||||
@@ -134,24 +164,34 @@ function EventPage() {
|
||||
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
|
||||
},
|
||||
})
|
||||
useEffect(() => subscribeToEvents(`event:${eventId}`, () => {
|
||||
void client.invalidateQueries({ queryKey: ['event', eventId] })
|
||||
void client.invalidateQueries({ queryKey: ['registrations', eventId] })
|
||||
}), [client, eventId])
|
||||
const labels: Record<RsvpStatus, string> = { going: 'Going', maybe: 'Maybe', not_going: 'Can’t go' }
|
||||
if (eventQuery.isLoading || registrations.isLoading || session.isLoading) return <LoadingState label="Loading event…" />
|
||||
if (eventQuery.isError || registrations.isError || session.isError || !eventQuery.data || !session.data) return <ErrorState retry={() => { void eventQuery.refetch(); void registrations.refetch(); void session.refetch() }} />
|
||||
const event = eventQuery.data
|
||||
const rsvp = registrations.data?.find((registration) => registration.player.id === session.data.player.id)?.status ?? event.myRsvp
|
||||
const counts = registrations.data?.reduce((total, registration) => ({ ...total, [registration.status]: total[registration.status] + 1 }), { going: 0, maybe: 0, not_going: 0 }) ?? event.counts
|
||||
const attendanceTotal = Object.values(counts).reduce((total, count) => total + count, 0)
|
||||
const statusOrder: Record<RsvpStatus, number> = { going: 0, maybe: 1, not_going: 2 }
|
||||
const attendees = [...(registrations.data ?? [])].sort((a, b) => statusOrder[a.status] - statusOrder[b.status] || a.player.displayName.localeCompare(b.player.displayName))
|
||||
return <div className="page"><PageHeader eyebrow="Event details" title={event.title} description={event.description} actions={<Badge tone="success">Registration open</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>Your latest response is sent to 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} 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>{Object.values(counts).reduce((a, b) => a + b, 0)} responses</p></div><UsersRound /></div><div className="attendance-bar"><span /><span /><span /></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>{session.data.account.role === 'admin' && <Link className="button secondary full" to="/admin">Manage participants <ChevronRight /></Link>}</aside>
|
||||
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">
|
||||
<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>{session.data.account.role === 'admin' && <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 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>
|
||||
<section className="card faq-card"><span>02</span><div><h2>How does registration work?</h2><p>Choose Going, Maybe, or Can’t go before registration closes. Only Going players enter balancing. After an admin closes registration, normal RSVP changes are locked.</p></div></section>
|
||||
<section className="card faq-card"><span>03</span><div><h2>What happens before the scrim?</h2><p>An admin generates balance candidates, chooses one, may swap players of the same role or use the reserve, assigns captains, confirms the rosters, and starts the scrim.</p></div></section>
|
||||
<section className="card faq-card"><span>04</span><div><h2>How is the first map selected?</h2><p>The initial coin toss decides which team bans first. Teams alternate map bans until one map remains. That map becomes the first map of the series.</p></div></section>
|
||||
<section className="card faq-card"><span>05</span><div><h2>Who picks later maps?</h2><p>After a captain or admin records a map result, the team that lost that map immediately chooses the next map from the next ruleset pool. Already played maps are unavailable. After a draw, the initial coin-toss loser receives the pick.</p></div></section>
|
||||
<section className="card faq-card"><span>06</span><div><h2>How do hero bans work?</h2><p>Before every map, teams make two bans each in alternating order. The initial coin-toss loser bans first. A team’s two bans must use different roles, and a team cannot repeat its own hero ban during the same series. It may ban a hero previously banned by the opponent.</p></div></section>
|
||||
<section className="card faq-card"><span>07</span><div><h2>How does Bo3 scoring work?</h2><p>The first team to win two maps wins the series. A captain selects the result after each map; admins can correct mistakes. Tournament winners advance automatically.</p></div></section>
|
||||
<section className="card faq-card"><span>08</span><div><h2>What can captains do?</h2><p>Captains can perform actions for their team: map bans, later-map picks, and hero bans. Admins can perform or correct any authoritative action. Every command is versioned and recorded in the match history.</p></div></section>
|
||||
</div></div>
|
||||
}
|
||||
|
||||
function ProfilePage() {
|
||||
const client = useQueryClient()
|
||||
const language = useLanguage()
|
||||
@@ -161,15 +201,24 @@ function ProfilePage() {
|
||||
const [ratings, setRatings] = useState<Record<PlayerRole, RankOrdinal>>({ tank: 20, damage: 20, support: 20 })
|
||||
const [preferredRoles, setPreferredRoles] = useState<PlayerRole[]>([])
|
||||
const [preferredPlayerIds, setPreferredPlayerIds] = useState<string[]>([])
|
||||
const [avoidedPlayerIds, setAvoidedPlayerIds] = useState<string[]>([])
|
||||
useEffect(() => {
|
||||
if (profile.data) {
|
||||
setRatings({ tank: profile.data.ratings.tank, damage: profile.data.ratings.damage, support: profile.data.ratings.support })
|
||||
setPreferredRoles(profile.data.preferredRoles)
|
||||
setPreferredPlayerIds(profile.data.preferredPlayerIds)
|
||||
setAvoidedPlayerIds(profile.data.avoidedPlayerIds)
|
||||
}
|
||||
}, [profile.data])
|
||||
useEffect(() => {
|
||||
if (players.data) {
|
||||
const eligible = new Set(players.data.filter((player) => Boolean(player.accountId)).map((player) => player.id))
|
||||
setPreferredPlayerIds((current) => current.filter((id) => eligible.has(id)))
|
||||
setAvoidedPlayerIds((current) => current.filter((id) => eligible.has(id)))
|
||||
}
|
||||
}, [players.data])
|
||||
const update = useMutation({
|
||||
mutationFn: () => api.updateRatings(profile.data!.displayName, ratings, preferredRoles, preferredPlayerIds),
|
||||
mutationFn: () => api.updateRatings(profile.data!.displayName, ratings, preferredRoles, preferredPlayerIds, avoidedPlayerIds),
|
||||
onSuccess: (player) => {
|
||||
client.setQueryData(['profile'], player)
|
||||
void client.invalidateQueries({ queryKey: ['session'] })
|
||||
@@ -180,7 +229,8 @@ function ProfilePage() {
|
||||
if (!profile.data || !players.data) return <LoadingState label="Loading profile…" />
|
||||
const roles: Array<{ key: PlayerRole; label: string; hint: string }> = [{ key: 'tank', label: 'Tank', hint: 'Space, pressure, and frontline' }, { key: 'damage', label: 'Damage', hint: 'Eliminations and map control' }, { key: 'support', label: 'Support', hint: 'Sustain, utility, and tempo' }]
|
||||
const submit = (e: FormEvent) => { e.preventDefault(); update.mutate() }
|
||||
return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{players.data.filter((player) => player.id !== profile.data.id).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
|
||||
const eligiblePlayers = players.data.filter((player) => player.id !== profile.data.id && Boolean(player.accountId))
|
||||
return <div className="page narrow"><PageHeader eyebrow="Player profile" title="Set your competitive ranks" description="Your role ranks help the server build fair, role-locked teams." /><section className="profile-banner card"><div className="large-avatar">{profile.data.displayName.slice(0, 2).toUpperCase()}</div><div><h2>{profile.data.displayName}</h2><p>{profile.data.battleTag}</p><Badge tone="blue"><Gamepad2 />Connected via Discord</Badge></div></section><form className="card ratings-form" onSubmit={submit}><div className="section-title"><div><h2>Competitive ranks</h2><p>Select your current Overwatch rank for each role.</p></div><span>Updated {new Date(profile.data.ratings.updatedAt).toLocaleDateString()}</span></div>{roles.map((role) => <label className={`rating-row role-${role.key}`} key={role.key}><div><span className="role-icon">{role.key === 'tank' ? <Shield /> : role.key === 'damage' ? <Zap /> : <Sparkles />}</span><span><strong>{role.label}</strong><small>{role.hint}</small></span></div><select id={`rating-${role.key}`} className="rank-select" value={ratings[role.key]} onChange={(e) => setRatings({ ...ratings, [role.key]: toRankOrdinal(Number(e.target.value)) })}>{options.map((option) => <option value={option.value} key={option.value}>{option.label}</option>)}</select></label>)}<section className="preference-section"><div><h2>Preferred roles</h2><p>Choose every role you enjoy playing.</p></div><div className="preference-chips">{roles.map((role) => <button type="button" aria-pressed={preferredRoles.includes(role.key)} className={preferredRoles.includes(role.key) ? 'active' : ''} key={role.key} onClick={() => setPreferredRoles((current) => current.includes(role.key) ? current.filter((item) => item !== role.key) : [...current, role.key])}>{role.label}</button>)}</div></section><section className="preference-section"><div><h2>Preferred teammates</h2><p>Choose up to 3 players. The balancer treats these as preferences, not guarantees.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !avoidedPlayerIds.includes(player.id)).map((player) => { const selected = preferredPlayerIds.includes(player.id); const disabled = !selected && preferredPlayerIds.length >= 3; return <label className={selected ? 'selected' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setPreferredPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><section className="preference-section avoid-section"><div><h2>Avoided teammates</h2><p>Choose up to 3 players. This is a soft preference, so balance can still place you together when necessary.</p></div><div className="teammate-grid">{eligiblePlayers.filter((player) => !preferredPlayerIds.includes(player.id)).map((player) => { const selected = avoidedPlayerIds.includes(player.id); const disabled = !selected && avoidedPlayerIds.length >= 3; return <label className={selected ? 'selected avoided' : ''} key={player.id}><input type="checkbox" checked={selected} disabled={disabled} onChange={() => setAvoidedPlayerIds((current) => selected ? current.filter((id) => id !== player.id) : [...current, player.id])} /><span className="mini-avatar">{player.displayName.slice(0, 2).toUpperCase()}</span><strong>{player.displayName}</strong></label> })}</div></section><div className="form-footer"><p><Shield />Only you can edit your ranks and preferences.{update.isError && ` ${update.error.message}`}</p><button className="button primary" type="submit" disabled={update.isPending}>{update.isPending ? 'Saving…' : update.isSuccess ? <><Check />Saved</> : 'Save profile'}</button></div></form></div>
|
||||
}
|
||||
|
||||
type AdminTab = 'events' | 'participants' | 'balance'
|
||||
@@ -193,7 +243,7 @@ function AdminPage() {
|
||||
useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
|
||||
if (events.isLoading) return <LoadingState label="Loading admin control room…" />
|
||||
if (events.isError) return <ErrorState retry={() => void events.refetch()} />
|
||||
return <div className="page"><PageHeader eyebrow="Admin control room" title={createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={<>{events.data && events.data.length > 0 && <select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select>}<button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></>} /><div className="tabs" role="tablist">{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : 'Balance teams'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}</div>
|
||||
return <div className="page"><PageHeader eyebrow="Admin control room" title={createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={<>{events.data && events.data.length > 0 && <select aria-label="Selected event" value={selected?.id ?? ''} onChange={(e) => { setSelectedId(e.target.value); setCreateMode(false) }}>{events.data.map((event) => <option value={event.id} key={event.id}>{event.title}</option>)}</select>}<button className="button secondary" onClick={() => { setCreateMode(true); setTab('events') }}><Sparkles />New event</button></>} /><div className="tabs" role="tablist">{(['events', 'participants', 'balance'] as AdminTab[]).map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : 'Balance teams'}</button>)}</div>{tab === 'events' && <EventSetup event={createMode ? undefined : selected} onCreated={(event) => { setCreateMode(false); setSelectedId(event.id) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}</div>
|
||||
}
|
||||
|
||||
function toLocalInput(value: string) {
|
||||
@@ -214,8 +264,9 @@ function eventInput(event?: MixEvent): EventInput {
|
||||
}
|
||||
}
|
||||
|
||||
function EventSetup({ event, onCreated }: { event?: MixEvent; onCreated: (event: MixEvent) => void }) {
|
||||
function EventSetup({ event, onCreated, onDeleted }: { event?: MixEvent; onCreated: (event: MixEvent) => void; onDeleted: () => void }) {
|
||||
const client = useQueryClient()
|
||||
const language = useLanguage()
|
||||
const initialForm = useMemo(() => eventInput(event), [event])
|
||||
const [form, setForm] = useState<EventInput>(initialForm)
|
||||
useEffect(() => setForm(initialForm), [initialForm])
|
||||
@@ -229,8 +280,40 @@ function EventSetup({ event, onCreated }: { event?: MixEvent; onCreated: (event:
|
||||
onCreated(saved)
|
||||
},
|
||||
})
|
||||
const cancel = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!event) return Promise.reject(new Error('Event is required'))
|
||||
if (demoMode) return Promise.resolve({ ...event, status: 'cancelled' as const, workflowState: 'Cancelled' as const, version: event.version + 1 })
|
||||
return api.cancelEvent(event.id, event.version)
|
||||
},
|
||||
onSuccess: (cancelled) => {
|
||||
client.setQueryData<MixEvent[]>(['events'], (current) => current?.map((item) => item.id === cancelled.id ? cancelled : item))
|
||||
void client.invalidateQueries({ queryKey: ['events'] })
|
||||
void client.invalidateQueries({ queryKey: ['event', cancelled.id] })
|
||||
onCreated(cancelled)
|
||||
},
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!event) return
|
||||
if (!demoMode) await api.deleteEvent(event.id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
client.setQueryData<MixEvent[]>(['events'], (current) => current?.filter((item) => item.id !== event?.id))
|
||||
void client.invalidateQueries({ queryKey: ['events'] })
|
||||
onDeleted()
|
||||
},
|
||||
})
|
||||
const set = (key: keyof EventInput, value: string) => setForm((current) => ({ ...current, [key]: value }))
|
||||
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 && <Badge tone="success">Published</Badge>}</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) => set('startsAt', 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><label>Description<textarea value={form.description} onChange={(e) => set('description', e.target.value)} /></label><div className="form-footer"><p>{save.isError ? save.error.message : 'Times are sent to the server in UTC.'}</p><button className="button primary" disabled={save.isPending}>{save.isPending ? 'Saving…' : event ? 'Save changes' : 'Create event'}</button></div></form><aside className="card admin-summary"><h2>Server authority</h2><div className="readiness-score"><Shield /><span>Validation runs on the backend</span></div><ul><li><Check />UTC scheduling</li><li><Check />Audited admin changes</li><li><Check />Registration deadline rules</li></ul></aside></div>
|
||||
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) => set('startsAt', 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>
|
||||
<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>
|
||||
<aside className="card admin-summary"><h2>Server authority</h2><div className="readiness-score"><Shield /><span>Validation runs on the backend</span></div><ul><li><Check />UTC scheduling</li><li><Check />Audited admin changes</li><li><Check />Registration deadline rules</li></ul></aside>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Participants({ eventId }: { eventId: string }) {
|
||||
@@ -254,6 +337,7 @@ function Participants({ eventId }: { eventId: string }) {
|
||||
ratings: { ...ratings, updatedAt: now },
|
||||
preferredRoles: [],
|
||||
preferredPlayerIds: [],
|
||||
avoidedPlayerIds: [],
|
||||
},
|
||||
status,
|
||||
updatedAt: now,
|
||||
@@ -275,13 +359,122 @@ function Participants({ eventId }: { eventId: string }) {
|
||||
void client.invalidateQueries({ queryKey: ['events'] })
|
||||
},
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: (playerId: string) => demoMode ? Promise.resolve() : api.removeParticipant(eventId, playerId),
|
||||
onSuccess: (_, playerId) => {
|
||||
client.setQueryData<Registration[]>(['registrations', eventId], (current) => current?.filter((registration) => registration.player.id !== playerId) ?? [])
|
||||
if (!demoMode) void client.invalidateQueries({ queryKey: ['events'] })
|
||||
},
|
||||
})
|
||||
if (rows.isLoading) return <LoadingState label="Loading participants…" />
|
||||
if (rows.isError || !rows.data) return <ErrorState retry={() => void rows.refetch()} />
|
||||
const names = new Map(rows.data.map((registration) => [registration.player.id, registration.player.displayName]))
|
||||
return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><strong>{r.player.displayName}</strong><span>{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}</>}{r.changedBy?.source === 'admin' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></div>)}</div>{override.isError && <p className="error-note">{override.error.message}</p>}</section>
|
||||
return <section className="card table-card"><form className="guest-participant-form" onSubmit={(event) => { event.preventDefault(); create.mutate() }}><div><h2>Add participant</h2><p>Create a guest player when they cannot sign in with Discord.</p></div><div className="guest-participant-fields"><label>Nickname<input required maxLength={80} value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>{(['tank', 'damage', 'support'] as PlayerRole[]).map((role) => <label key={role}>{role === 'tank' ? 'Tank rank' : role === 'damage' ? 'Damage rank' : 'Support rank'}<select value={ratings[role]} onChange={(event) => setRatings((current) => ({ ...current, [role]: toRankOrdinal(Number(event.target.value)) }))}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>)}<label>Status<select value={status} onChange={(event) => setStatus(event.target.value as RsvpStatus)}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select></label><button className="button primary" disabled={create.isPending || !displayName.trim()}>{create.isPending ? 'Adding…' : 'Add participant'}</button></div>{create.isError && <p className="error-note">{create.error.message}</p>}</form><div className="section-title"><div><h2>Participant responses</h2><p>Admin overrides are attributed and audited.</p></div><div className="count-pills"><Badge tone="success">{rows.data.filter((r) => r.status === 'going').length} going</Badge><Badge tone="warning">{rows.data.filter((r) => r.status === 'maybe').length} maybe</Badge></div></div><div className="participant-list">{rows.data.map((r) => <div className="participant-row" key={r.id}><div className="mini-avatar">{r.player.displayName.slice(0, 2).toUpperCase()}</div><div className="participant-name"><strong>{r.player.displayName}</strong><span>{r.player.preferredRoles.map((role) => role[0].toUpperCase() + role.slice(1)).join(' · ') || 'No preferred roles'}{r.player.preferredPlayerIds.length > 0 && <> · Preferred with {r.player.preferredPlayerIds.map((id) => names.get(id) ?? id.slice(0, 6)).join(', ')}</>}{r.changedBy?.source === 'admin' && <> · <em>set by {r.changedBy.displayName}</em></>}</span></div><div className="mini-ratings"><span>T · {rankLabel(r.player.ratings.tank, language)}</span><span>D · {rankLabel(r.player.ratings.damage, language)}</span><span>S · {rankLabel(r.player.ratings.support, language)}</span></div><select disabled={override.isPending || remove.isPending} aria-label={`RSVP for ${r.player.displayName}`} value={r.status} onChange={(e) => override.mutate({ playerId: r.player.id, status: e.target.value as RsvpStatus })}><option value="going">Going</option><option value="maybe">Maybe</option><option value="not_going">Not going</option></select><button className="icon-button remove-participant" type="button" disabled={remove.isPending} aria-label={`Remove ${r.player.displayName} from event`} title="Remove from event" onClick={() => remove.mutate(r.player.id)}><X /></button></div>)}</div>{(override.isError || remove.isError) && <p className="error-note">{override.error?.message ?? remove.error?.message}</p>}</section>
|
||||
}
|
||||
|
||||
function BalanceView({ eventId }: { eventId: string }) {
|
||||
if (demoMode) return <LegacyBalanceView eventId={eventId} />
|
||||
return <WorkflowControl eventId={eventId} />
|
||||
}
|
||||
|
||||
function WorkflowControl({ eventId }: { eventId: string }) {
|
||||
const client = useQueryClient()
|
||||
const event = useQuery({ queryKey: ['event', eventId], queryFn: () => api.event(eventId) })
|
||||
const players = useQuery({ queryKey: ['players'], queryFn: api.players })
|
||||
const roster = useQuery({
|
||||
queryKey: ['roster', eventId],
|
||||
queryFn: () => api.roster(eventId),
|
||||
enabled: Boolean(event.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(event.data.workflowState)),
|
||||
retry: false,
|
||||
})
|
||||
const [candidates, setCandidates] = useState<Awaited<ReturnType<typeof api.generateWorkflowBalance>>['candidates']>([])
|
||||
const [selectedCandidate, setSelectedCandidate] = useState(0)
|
||||
const refresh = useCallback(() => {
|
||||
void client.invalidateQueries({ queryKey: ['event', eventId] })
|
||||
void client.invalidateQueries({ queryKey: ['events'] })
|
||||
void client.invalidateQueries({ queryKey: ['roster', eventId] })
|
||||
void client.invalidateQueries({ queryKey: ['teams', eventId] })
|
||||
}, [client, eventId])
|
||||
const close = useMutation({
|
||||
mutationFn: () => api.closeRegistration(eventId, event.data!.rulesetId, event.data!.version),
|
||||
onSuccess: refresh,
|
||||
onError: refresh,
|
||||
})
|
||||
const generate = useMutation({
|
||||
mutationFn: () => api.generateWorkflowBalance(eventId, event.data!.version),
|
||||
onSuccess: (result) => {
|
||||
setCandidates(result.candidates)
|
||||
setSelectedCandidate(0)
|
||||
refresh()
|
||||
},
|
||||
onError: refresh,
|
||||
})
|
||||
const select = useMutation({
|
||||
mutationFn: () => api.selectWorkflowBalance(eventId, candidates[selectedCandidate], event.data!.version),
|
||||
onSuccess: refresh,
|
||||
onError: refresh,
|
||||
})
|
||||
const confirm = useMutation({
|
||||
mutationFn: () => api.confirmRosters(eventId, event.data!.version, roster.data!.version),
|
||||
onSuccess: refresh,
|
||||
onError: refresh,
|
||||
})
|
||||
const start = useMutation({
|
||||
mutationFn: () => api.startScrim(eventId, event.data!.version),
|
||||
onSuccess: (result) => {
|
||||
refresh()
|
||||
if (result.series) void router.navigate({ to: '/series/$seriesId', params: { seriesId: result.series.id } })
|
||||
else if (result.tournament) void router.navigate({ to: '/bracket/$eventId', params: { eventId } })
|
||||
},
|
||||
onError: refresh,
|
||||
})
|
||||
if (event.isLoading || players.isLoading) return <LoadingState label="Loading scrim workflow…" />
|
||||
if (event.isError || players.isError || !event.data || !players.data) return <ErrorState retry={() => { void event.refetch(); void players.refetch() }} />
|
||||
const state = event.data.workflowState
|
||||
const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? start.error
|
||||
return <div className="workflow-control">
|
||||
<section className="card workflow-progress">
|
||||
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'] as const).map((step, index) => {
|
||||
const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].indexOf(state)
|
||||
return <div className={index <= activeIndex ? 'done' : ''} key={step}><span>{index + 1}</span><strong>{step.replace(/([a-z])([A-Z])/g, '$1 $2')}</strong></div>
|
||||
})}
|
||||
</section>
|
||||
{state === 'RegistrationOpen' && <WorkflowAction title="Close registration" description="Freeze the Going participant list before balancing." button="Close registration" pending={close.isPending} onClick={() => close.mutate()} />}
|
||||
{state === 'RegistrationClosed' && <WorkflowAction title="Generate fair teams" description="The optimizer considers ranks, preferred roles, and preferred teammates." button="Generate balance" pending={generate.isPending} onClick={() => generate.mutate()} />}
|
||||
{state === 'Balancing' && candidates.length === 0 && <WorkflowAction title="Generate balance candidates" description="Generate again after a refresh to choose one of the server candidates." button="Generate candidates" pending={generate.isPending} onClick={() => generate.mutate()} />}
|
||||
{state === 'Balancing' && candidates.length > 0 && <div className="balance-layout"><aside className="candidate-list">{candidates.map((candidate, index) => <button className={`candidate-card ${selectedCandidate === index ? 'active' : ''}`} key={candidate.id} onClick={() => setSelectedCandidate(index)}><span className="candidate-rank">0{index + 1}</span><span><strong>Score {candidate.score}</strong><small>{candidate.explanations[0]}</small></span><ChevronRight /></button>)}</aside><section><div className="teams-grid">{candidates[selectedCandidate].teams.map((team) => <TeamCard key={team.id} team={team} eventId={eventId} />)}</div><div className="sticky-action"><p>Selecting creates a versioned roster draft.</p><button className="button primary" disabled={select.isPending} onClick={() => select.mutate()}>Use this balance</button></div></section></div>}
|
||||
{roster.data && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
|
||||
{state === 'RostersDraft' && roster.data && <WorkflowAction title="Confirm rosters" description="Every team must have a valid 1/2/2 lineup and a captain." button="Confirm rosters" pending={confirm.isPending} onClick={() => confirm.mutate()} />}
|
||||
{state === 'RostersConfirmed' && <WorkflowAction title="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>}
|
||||
{error && <p className="error-note">{error.message}</p>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function WorkflowAction({ title, description, button, pending, onClick }: { title: string; description: string; button: string; pending: boolean; onClick: () => void }) {
|
||||
return <section className="card workflow-action"><div><h2>{title}</h2><p>{description}</p></div><button className="button primary" disabled={pending} onClick={onClick}>{pending ? 'Working…' : button}</button></section>
|
||||
}
|
||||
|
||||
function WorkflowRosterEditor({ eventId, roster, players, locked, onChanged }: { eventId: string; roster: RawRosterDraft; players: Player[]; locked: boolean; onChanged: () => void }) {
|
||||
const language = useLanguage()
|
||||
const names = new Map(players.map((player) => [player.id, player.displayName]))
|
||||
const mutate = useMutation({
|
||||
mutationFn: (action: { kind: 'swap'; teamAId: string; playerAId: string; teamBId: string; playerBId: string } | { kind: 'substitute'; teamId: string; outgoingPlayerId: string; reservePlayerId: string } | { kind: 'captain'; teamId: string; playerId: string }) => {
|
||||
if (action.kind === 'swap') return api.swapRoster(eventId, { ...action, expectedVersion: roster.version })
|
||||
if (action.kind === 'substitute') return api.substituteRoster(eventId, { ...action, expectedVersion: roster.version })
|
||||
return api.setRosterCaptain(eventId, action.teamId, action.playerId, roster.version)
|
||||
},
|
||||
onSuccess: onChanged,
|
||||
onError: onChanged,
|
||||
})
|
||||
return <section className="card roster-editor"><div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · swaps are restricted to the same role.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div><div className="teams-grid">{roster.teams.map((team) => <article className="workflow-team" key={team.id}><header><h3>{team.name}</h3><label>Captain<select disabled={locked || mutate.isPending} value={team.captainPlayerId} onChange={(event) => mutate.mutate({ kind: 'captain', teamId: team.id, playerId: event.target.value })}><option value="">Choose captain</option>{team.slots.map((slot) => <option value={slot.playerId} key={slot.playerId}>{names.get(slot.playerId) ?? slot.playerId}</option>)}</select></label></header>{team.slots.map((slot) => {
|
||||
const swapOptions = roster.teams.flatMap((other) => other.id === team.id ? [] : other.slots.filter((candidate) => candidate.role === slot.role).map((candidate) => ({ ...candidate, teamId: other.id })))
|
||||
return <div className="roster-slot" key={slot.playerId}><span className={`role-chip ${slot.role.toLowerCase()}`}>{slot.role[0]}</span><strong>{names.get(slot.playerId) ?? slot.playerId}</strong><small>{rankLabel(slot.rating, language)}</small>{!locked && <><select aria-label={`Swap ${names.get(slot.playerId)}`} defaultValue="" disabled={mutate.isPending} onChange={(event) => { const [teamBId, playerBId] = event.target.value.split(':'); if (playerBId) mutate.mutate({ kind: 'swap', teamAId: team.id, playerAId: slot.playerId, teamBId, playerBId }) }}><option value="">Swap…</option>{swapOptions.map((candidate) => <option key={candidate.playerId} value={`${candidate.teamId}:${candidate.playerId}`}>{names.get(candidate.playerId)}</option>)}</select>{roster.reserve.length > 0 && <select aria-label={`Substitute ${names.get(slot.playerId)}`} defaultValue="" disabled={mutate.isPending} onChange={(event) => event.target.value && mutate.mutate({ kind: 'substitute', teamId: team.id, outgoingPlayerId: slot.playerId, reservePlayerId: event.target.value })}><option value="">Reserve…</option>{roster.reserve.map((id) => <option value={id} key={id}>{names.get(id) ?? id}</option>)}</select>}</>}</div>
|
||||
})}</article>)}</div>{roster.reserve.length > 0 && <div className="reserve-bar"><strong>Reserve</strong>{roster.reserve.map((id) => <Badge key={id}>{names.get(id) ?? id}</Badge>)}</div>}{mutate.isError && <p className="error-note">{mutate.error.message}</p>}</section>
|
||||
}
|
||||
|
||||
function LegacyBalanceView({ eventId }: { eventId: string }) {
|
||||
const client = useQueryClient()
|
||||
const existingTeams = useQuery({ queryKey: ['teams', eventId], queryFn: () => api.teams(eventId), initialData: demoMode ? demoBalanceCandidates[0].teams : undefined })
|
||||
const generate = useMutation({ mutationFn: () => api.generateBalance(eventId) })
|
||||
@@ -314,24 +507,45 @@ function LiveSeriesPage() {
|
||||
const { seriesId } = liveRoute.useParams()
|
||||
const client = useQueryClient()
|
||||
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
|
||||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, initialData: demoMode ? demoSession : undefined })
|
||||
const [selected, setSelected] = useState<string>()
|
||||
const [mapName, setMapName] = useState('')
|
||||
const [outcome, setOutcome] = useState<MapOutcome>('TeamAWin')
|
||||
const result = useMutation({
|
||||
mutationFn: () => api.recordResult(seriesId, mapName, outcome, series.data!.version),
|
||||
mutationFn: () => {
|
||||
const data = series.data!
|
||||
if (demoMode) return api.recordResult(seriesId, `Map ${data.maps.length + 1}`, outcome, data.version)
|
||||
const actingTeamId = session.data?.account.role === 'admin'
|
||||
? data.teamAlpha.id
|
||||
: data.teamAlpha.captainId === session.data?.player.id ? data.teamAlpha.id
|
||||
: data.teamBeta.captainId === session.data?.player.id ? data.teamBeta.id : ''
|
||||
return api.recordSeriesMap(seriesId, outcome, actingTeamId, data.version)
|
||||
},
|
||||
onSuccess: (updated) => {
|
||||
client.setQueryData(['series', seriesId], updated)
|
||||
setMapName('')
|
||||
},
|
||||
onError: () => void series.refetch(),
|
||||
})
|
||||
useEffect(() => subscribeToEvents(`series:${seriesId}`, () => {
|
||||
void client.invalidateQueries({ queryKey: ['series', seriesId] })
|
||||
}), [client, seriesId])
|
||||
if (series.isLoading) return <LoadingState />
|
||||
if (series.isError) return <ErrorState retry={() => void series.refetch()} />
|
||||
if (!series.data) return <LoadingState />
|
||||
const action = useMutation({
|
||||
mutationFn: () => {
|
||||
const data = series.data!
|
||||
const teamId = data.currentStep.activeTeam === 'beta' ? data.teamBeta.id : data.teamAlpha.id
|
||||
if (data.currentStep.kind === 'coin_toss') return api.tossSeriesCoin(seriesId, `${Date.now()}-${crypto.randomUUID()}`, teamId, data.version)
|
||||
if (data.currentStep.kind === 'map_ban') return api.banSeriesMap(seriesId, teamId, selected!, data.version)
|
||||
if (data.currentStep.kind === 'map_pick') return api.pickSeriesMap(seriesId, teamId, selected!, data.version)
|
||||
if (data.currentStep.kind === 'hero_ban') return api.banSeriesHero(seriesId, teamId, selected!, data.version)
|
||||
return Promise.resolve(data)
|
||||
},
|
||||
onSuccess: (updated) => {
|
||||
client.setQueryData(['series', seriesId], updated)
|
||||
setSelected(undefined)
|
||||
},
|
||||
onError: () => void series.refetch(),
|
||||
})
|
||||
if (series.isLoading || session.isLoading) return <LoadingState />
|
||||
if (series.isError || session.isError) return <ErrorState 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>Map name<input required value={mapName} onChange={(e) => setMapName(e.target.value)} placeholder="e.g. Lijiang Tower" /></label><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> : <><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={!selected}>Confirm action <ChevronRight /></button></div></>}</section><aside className="match-sidebar"><MapTimeline series={data} /><AuditLog series={data} /></aside></div></div>
|
||||
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>
|
||||
}
|
||||
|
||||
function Scoreboard({ series }: { series: Series }) {
|
||||
@@ -344,13 +558,11 @@ function AuditLog({ series }: { series: Series }) { return <section className="c
|
||||
|
||||
function SpectatorPage() {
|
||||
const { seriesId } = spectatorRoute.useParams()
|
||||
const client = useQueryClient()
|
||||
const series = useQuery({ queryKey: ['series', seriesId], queryFn: () => api.series(seriesId), initialData: demoMode && seriesId === demoSeries.id ? demoSeries : undefined })
|
||||
useEffect(() => subscribeToEvents(`series:${seriesId}`, () => void client.invalidateQueries({ queryKey: ['series', seriesId] })), [client, seriesId])
|
||||
if (series.isLoading) return <LoadingState />
|
||||
if (series.isError || !series.data) return <ErrorState retry={() => void series.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>{demoMode && data.currentStep.kind === 'hero_ban' && <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>}</section><div className="spectator-grid"><MapTimeline series={data} /><AuditLog series={data} /></div></div>
|
||||
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>
|
||||
}
|
||||
|
||||
function BracketPage() {
|
||||
|
||||
Reference in New Issue
Block a user