Implement account management features in API, including endpoints for listing accounts and updating account roles. Introduce moderator role with associated permissions, and refactor access control checks to accommodate staff roles. Update database schema to support new role constraints and enhance frontend navigation for staff access.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState, type DragEvent, 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 Player, type PlayerRole, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
|
||||
type Player, type PlayerRole, type RawAccount, type RawRosterDraft, type Registration, type RsvpStatus, type Series, type Session, type Team,
|
||||
} from './api/client'
|
||||
import {
|
||||
demoBalanceCandidates, demoBracket, demoEvents, demoProfile,
|
||||
@@ -22,6 +22,7 @@ import { useLanguage } from './i18n-context'
|
||||
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
|
||||
|
||||
type RouterContext = { session: Session | null }
|
||||
const isStaff = (role?: Session['account']['role']) => role === 'admin' || role === 'moderator'
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false }, mutations: { retry: 0 } } })
|
||||
const rootRoute = createRootRouteWithContext<RouterContext>()({ component: Outlet })
|
||||
const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: '/login', component: LoginPage })
|
||||
@@ -39,7 +40,7 @@ const spectatorRoute = createRoute({ getParentRoute: () => protectedRoute, path:
|
||||
const bracketRoute = createRoute({ getParentRoute: () => protectedRoute, path: '/bracket/$eventId', component: BracketPage })
|
||||
const adminRoute = createRoute({
|
||||
getParentRoute: () => protectedRoute, path: '/admin', component: AdminPage,
|
||||
beforeLoad: ({ context }) => { if (context.session?.account.role !== 'admin') throw redirect({ to: '/events' }) },
|
||||
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,
|
||||
@@ -85,6 +86,9 @@ function AppShell() {
|
||||
} else if (message.topic === 'players') {
|
||||
void client.invalidateQueries({ queryKey: ['players'] })
|
||||
void client.invalidateQueries({ queryKey: ['profile'] })
|
||||
} else if (message.topic === 'accounts') {
|
||||
void client.invalidateQueries({ queryKey: ['session'] })
|
||||
void client.invalidateQueries({ queryKey: ['accounts'] })
|
||||
}
|
||||
}, setRealtimeStatus), [client])
|
||||
const logout = useMutation({
|
||||
@@ -95,8 +99,8 @@ function AppShell() {
|
||||
},
|
||||
})
|
||||
const initials = session.data?.account.displayName.slice(0, 2).toUpperCase() ?? 'MM'
|
||||
const navItems = session.data?.account.role === 'admin'
|
||||
? [...mainNavItems, { to: '/admin' as const, label: 'Admin', icon: Shield }]
|
||||
const navItems = isStaff(session.data?.account.role)
|
||||
? [...mainNavItems, { to: '/admin' as const, label: session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin', icon: Shield }]
|
||||
: mainNavItems
|
||||
return <div className="app-shell">
|
||||
<header className="topbar">
|
||||
@@ -123,7 +127,7 @@ function PageHeader({ eyebrow, title, description, actions }: { eyebrow: string;
|
||||
}
|
||||
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> }
|
||||
function ErrorState({ retry, message }: { retry?: () => void; message?: string }) { return <div className="state-card" role="alert"><CircleHelp /><h2>Couldn’t load this view</h2><p>{message || 'Your data is safe. Check your connection and try again.'}</p>{retry && <button className="button secondary" onClick={retry}>Try again</button>}</div> }
|
||||
|
||||
function EventsPage() {
|
||||
const [filter, setFilter] = useState<'upcoming' | 'all'>('upcoming')
|
||||
@@ -132,14 +136,26 @@ function EventsPage() {
|
||||
if (events.isError) return <ErrorState retry={() => void events.refetch()} />
|
||||
if (!events.data) return <LoadingState label="Loading events…" />
|
||||
const visible = filter === 'upcoming' ? events.data.filter((e) => e.status !== 'completed') : events.data
|
||||
const liveEvent = events.data.find((event) => event.workflowState === 'Live')
|
||||
return <div className="page">
|
||||
<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>}
|
||||
{liveEvent && <FeaturedLiveEvent event={liveEvent} />}
|
||||
<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.map((event) => <EventCard key={event.id} event={event} />)}</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function FeaturedLiveEvent({ event }: { event: MixEvent }) {
|
||||
const series = useQuery({
|
||||
queryKey: ['series', event.activeSeriesId],
|
||||
queryFn: () => api.series(event.activeSeriesId!),
|
||||
enabled: Boolean(event.activeSeriesId),
|
||||
initialData: demoMode && event.activeSeriesId === demoSeries.id ? demoSeries : 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>
|
||||
}
|
||||
|
||||
function EventCard({ event }: { event: MixEvent }) {
|
||||
const date = new Date(event.startsAt)
|
||||
const statusBadge = event.status === 'cancelled' ? <Badge tone="danger"><X />Cancelled</Badge>
|
||||
@@ -175,7 +191,7 @@ function EventPage() {
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -233,17 +249,32 @@ function ProfilePage() {
|
||||
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'
|
||||
type AdminTab = 'events' | 'participants' | 'balance' | 'moderators'
|
||||
function AdminPage() {
|
||||
const session = useQuery({ queryKey: ['session'], queryFn: api.session, staleTime: Infinity })
|
||||
const [tab, setTab] = useState<AdminTab>('events')
|
||||
const [createMode, setCreateMode] = useState(false)
|
||||
const events = useQuery({ queryKey: ['events'], queryFn: api.events, initialData: demoMode ? demoEvents : undefined })
|
||||
const [selectedId, setSelectedId] = useState<string>()
|
||||
const selected = events.data?.find((event) => event.id === selectedId) ?? events.data?.[0]
|
||||
useEffect(() => { if (!selectedId && events.data?.[0]) setSelectedId(events.data[0].id) }, [events.data, selectedId])
|
||||
if (events.isLoading) 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) }} onDeleted={() => { setSelectedId(undefined); setCreateMode(false) }} />}{tab === 'participants' && selected && <Participants eventId={selected.id} />}{tab === 'balance' && selected && <BalanceView eventId={selected.id} />}</div>
|
||||
if (events.isLoading || session.isLoading) return <LoadingState label="Loading admin control room…" />
|
||||
if (events.isError || session.isError) return <ErrorState retry={() => { void events.refetch(); void session.refetch() }} />
|
||||
const tabs: AdminTab[] = ['events', 'participants', 'balance', ...(session.data?.account.role === 'admin' ? ['moderators' as const] : [])]
|
||||
return <div className="page"><PageHeader eyebrow={session.data?.account.role === 'moderator' ? 'Moderation' : 'Admin control room'} title={tab === 'moderators' ? 'Moderator access' : createMode ? 'Create event' : selected?.title ?? 'No events yet'} description={tab === 'moderators' ? 'Grant moderation access to trusted Discord users.' : selected ? `Registration closes ${new Date(selected.registrationDeadline).toLocaleString()}.` : 'Create the first community event.'} actions={tab !== 'moderators' ? <>{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></> : undefined} /><div className="tabs" role="tablist">{tabs.map((item) => <button key={item} role="tab" aria-selected={tab === item} className={tab === item ? 'active' : ''} disabled={!selected && item !== 'events' && item !== 'moderators'} onClick={() => setTab(item)}>{item === 'events' ? 'Event setup' : item === 'participants' ? 'Participants' : item === 'balance' ? 'Balance teams' : 'Moderators'}</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} />}{tab === 'moderators' && session.data?.account.role === 'admin' && <ModeratorManagement />}</div>
|
||||
}
|
||||
|
||||
function ModeratorManagement() {
|
||||
const client = useQueryClient()
|
||||
const accounts = useQuery({ queryKey: ['accounts'], queryFn: api.accounts })
|
||||
const update = useMutation({
|
||||
mutationFn: ({ accountId, moderator }: { accountId: string; moderator: boolean }) => api.setModerator(accountId, moderator),
|
||||
onSuccess: (account) => client.setQueryData<RawAccount[]>(['accounts'], (current) => current?.map((item) => item.id === account.id ? account : item)),
|
||||
onError: () => void accounts.refetch(),
|
||||
})
|
||||
if (accounts.isLoading) return <LoadingState label="Loading accounts…" />
|
||||
if (accounts.isError || !accounts.data) return <ErrorState message={accounts.error?.message} retry={() => void accounts.refetch()} />
|
||||
return <section className="card moderator-panel"><div className="section-title"><div><h2>Moderators</h2><p>Moderators can manage events, participants, rosters, and live scrims. Only administrators can change this list.</p></div><Badge tone="blue">{accounts.data.filter((account) => account.role === 'moderator').length} active</Badge></div><div className="moderator-list">{accounts.data.map((account) => <div key={account.id}><span className="mini-avatar">{account.username.slice(0, 2).toUpperCase()}</span><span><strong>{account.username}</strong><small>Discord ID · {account.discordId}</small></span>{account.role === 'admin' ? <Badge tone="warning">Administrator</Badge> : <label className="moderator-toggle"><input type="checkbox" checked={account.role === 'moderator'} disabled={update.isPending} onChange={(event) => update.mutate({ accountId: account.id, moderator: event.target.checked })} /><span>{account.role === 'moderator' ? 'Moderator' : 'Player'}</span></label>}</div>)}</div>{update.isError && <p className="error-note">{update.error.message}</p>}</section>
|
||||
}
|
||||
|
||||
function toLocalInput(value: string) {
|
||||
@@ -253,7 +284,7 @@ function toLocalInput(value: string) {
|
||||
|
||||
function eventInput(event?: MixEvent): EventInput {
|
||||
const start = new Date(Date.now() + 86_400_000)
|
||||
const end = new Date(start.getTime() + 4 * 3_600_000)
|
||||
const end = new Date(start.getTime() + 2 * 3_600_000)
|
||||
const deadline = new Date(start.getTime() - 3_600_000)
|
||||
return {
|
||||
name: event?.title ?? '',
|
||||
@@ -305,10 +336,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 }
|
||||
const start = new Date(value)
|
||||
return {
|
||||
...current,
|
||||
startsAt: 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) => 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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -423,15 +463,27 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
||||
mutationFn: () => api.startScrim(eventId, event.data!.version),
|
||||
onSuccess: (result) => {
|
||||
refresh()
|
||||
if (result.series) void router.navigate({ to: '/series/$seriesId', params: { seriesId: result.series.id } })
|
||||
if (result.series) {
|
||||
client.setQueryData(['series', result.series.id], 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,
|
||||
})
|
||||
const back = useMutation({
|
||||
mutationFn: () => api.revertWorkflowStage(eventId, event.data!.version),
|
||||
onSuccess: (updated) => {
|
||||
if (updated.workflowState === 'RegistrationClosed') setCandidates([])
|
||||
if (updated.workflowState === 'Balancing') client.removeQueries({ queryKey: ['roster', eventId] })
|
||||
refresh()
|
||||
},
|
||||
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
|
||||
const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? start.error ?? back.error
|
||||
return <div className="workflow-control">
|
||||
<section className="card workflow-progress">
|
||||
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'] as const).map((step, index) => {
|
||||
@@ -439,11 +491,12 @@ function WorkflowControl({ eventId }: { eventId: string }) {
|
||||
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' && state !== 'Live' && state !== 'Completed' && state !== 'Cancelled' && <div className="workflow-back"><button className="button secondary" disabled={back.isPending} onClick={() => back.mutate()}>{back.isPending ? 'Going back…' : '← Back'}</button><small>Return to the previous setup stage</small></div>}
|
||||
{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} />}
|
||||
{roster.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(state) && <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>}
|
||||
@@ -456,22 +509,65 @@ function WorkflowAction({ title, description, button, pending, onClick }: { titl
|
||||
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>
|
||||
}
|
||||
|
||||
type RosterDrag = { kind: 'team'; teamId: string; playerId: string; role: 'Tank' | 'Damage' | 'Support' } | { kind: 'reserve'; playerId: string }
|
||||
|
||||
function WorkflowRosterEditor({ eventId, roster, players, locked, onChanged }: { eventId: string; roster: RawRosterDraft; players: Player[]; locked: boolean; onChanged: () => void }) {
|
||||
const language = useLanguage()
|
||||
const client = useQueryClient()
|
||||
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 }) => {
|
||||
mutationFn: (action:
|
||||
| { kind: 'swap'; teamAId: string; playerAId: string; teamBId: string; playerBId: string }
|
||||
| { kind: 'substitute'; teamId: string; outgoingPlayerId: string; reservePlayerId: string }
|
||||
| { kind: 'move'; fromTeamId: string; playerId: string; toTeamId: string; role: 'Tank' | 'Damage' | 'Support' }
|
||||
| { kind: 'place'; teamId: string; reservePlayerId: string; role: 'Tank' | 'Damage' | 'Support' }
|
||||
| { kind: 'remove'; teamId: string; playerId: 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 })
|
||||
if (action.kind === 'move') return api.moveRosterPlayer(eventId, { ...action, expectedVersion: roster.version })
|
||||
if (action.kind === 'place') return api.placeReservePlayer(eventId, { ...action, expectedVersion: roster.version })
|
||||
if (action.kind === 'remove') return api.removeRosterPlayer(eventId, { ...action, expectedVersion: roster.version })
|
||||
return api.setRosterCaptain(eventId, action.teamId, action.playerId, roster.version)
|
||||
},
|
||||
onSuccess: onChanged,
|
||||
onSuccess: (updated) => {
|
||||
client.setQueryData(['roster', eventId], updated)
|
||||
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>
|
||||
const startDrag = (event: DragEvent, payload: RosterDrag) => {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
event.dataTransfer.setData('application/x-mixmaker-roster', JSON.stringify(payload))
|
||||
}
|
||||
const readDrag = (event: DragEvent): RosterDrag | undefined => {
|
||||
try {
|
||||
return JSON.parse(event.dataTransfer.getData('application/x-mixmaker-roster')) as RosterDrag
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const dropOnSlot = (event: DragEvent, teamId: string, slot: RawRosterDraft['teams'][number]['slots'][number]) => {
|
||||
event.preventDefault()
|
||||
const source = readDrag(event)
|
||||
if (!source || locked || mutate.isPending) return
|
||||
if (source.kind === 'reserve') {
|
||||
mutate.mutate(slot.playerId
|
||||
? { kind: 'substitute', teamId, outgoingPlayerId: slot.playerId, reservePlayerId: source.playerId }
|
||||
: { kind: 'place', teamId, reservePlayerId: source.playerId, role: slot.role })
|
||||
} else if (source.playerId !== slot.playerId) {
|
||||
mutate.mutate(slot.playerId
|
||||
? { kind: 'swap', teamAId: source.teamId, playerAId: source.playerId, teamBId: teamId, playerBId: slot.playerId }
|
||||
: { kind: 'move', fromTeamId: source.teamId, playerId: source.playerId, toTeamId: teamId, role: slot.role })
|
||||
}
|
||||
}
|
||||
return <section className="card roster-editor">
|
||||
<div className="section-title"><div><h2>Roster editor</h2><p>Version {roster.version} · drag players between equal-role slots or through reserve.</p></div>{roster.confirmed && <Badge tone="success"><Check />Locked</Badge>}</div>
|
||||
<div className="roster-editor-body"><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.filter((slot) => slot.playerId).map((slot) => <option value={slot.playerId} key={slot.playerId}>{names.get(slot.playerId) ?? slot.playerId}</option>)}</select></label></header>{team.slots.map((slot, slotIndex) => <div className={`roster-slot ${slot.playerId ? '' : 'empty'}`} key={`${slot.role}-${slotIndex}`} onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => dropOnSlot(event, team.id, slot)}><span className={`role-chip ${slot.role.toLowerCase()}`}>{slot.role[0]}</span>{slot.playerId ? <><strong draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'team', teamId: team.id, playerId: slot.playerId, role: slot.role })}>{names.get(slot.playerId) ?? slot.playerId}</strong><small>{rankLabel(slot.rating, language)}</small>{!locked && <button className="icon-button slot-remove" type="button" aria-label={`Move ${names.get(slot.playerId) ?? slot.playerId} to reserve`} onClick={() => mutate.mutate({ kind: 'remove', teamId: team.id, playerId: slot.playerId })}><X /></button>}</> : <><strong>Empty</strong><small>{slot.role}</small></>}</div>)}</article>)}</div>
|
||||
<aside className="roster-reserve" onDragOver={(event) => { if (!locked) event.preventDefault() }} onDrop={(event) => { event.preventDefault(); const source = readDrag(event); if (!locked && source?.kind === 'team') mutate.mutate({ kind: 'remove', teamId: source.teamId, playerId: source.playerId }) }}><div><UsersRound /><span><strong>Reserve</strong><small>Drop a player here to remove them from a team</small></span></div><div className="reserve-list">{roster.reserve.length === 0 ? <p>Reserve is empty</p> : roster.reserve.map((id) => <button type="button" draggable={!locked} onDragStart={(event) => startDrag(event, { kind: 'reserve', playerId: id })} key={id}><span className="mini-avatar">{(names.get(id) ?? id).slice(0, 2).toUpperCase()}</span><strong>{names.get(id) ?? id}</strong></button>)}</div></aside>
|
||||
</div>
|
||||
{mutate.isError && <p className="error-note">{mutate.error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function LegacyBalanceView({ eventId }: { eventId: string }) {
|
||||
@@ -514,7 +610,7 @@ function LiveSeriesPage() {
|
||||
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'
|
||||
const actingTeamId = isStaff(session.data?.account.role)
|
||||
? data.teamAlpha.id
|
||||
: data.teamAlpha.captainId === session.data?.player.id ? data.teamAlpha.id
|
||||
: data.teamBeta.captainId === session.data?.player.id ? data.teamBeta.id : ''
|
||||
@@ -542,7 +638,7 @@ function LiveSeriesPage() {
|
||||
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.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>
|
||||
@@ -560,7 +656,7 @@ 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.isError || !series.data) return <ErrorState retry={() => void series.refetch()} />
|
||||
if (!series.data) return <ErrorState message={series.error?.message} 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>{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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user