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.
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit is contained in:
2026-07-19 02:32:15 +03:00
parent 4b889fb8a0
commit 1a5a39baf0
18 changed files with 726 additions and 74 deletions

View File

@@ -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>Couldnt 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>Couldnt 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>
}

View File

@@ -51,6 +51,7 @@ describe('backend DTO adapters', () => {
expect(adaptEvent({ ...event, state: 'Cancelled' }).status).toBe('cancelled')
expect(adaptEvent({ ...event, state: 'Completed' }).status).toBe('completed')
expect(adaptEvent({ ...event, state: 'Live' }).status).toBe('live')
expect(adaptEvent({ ...event, state: 'RegistrationClosed', endsAt: '2020-01-01T00:00:00Z' }).status).toBe('balancing')
})
it('keeps corrected results out of the authoritative score', () => {
@@ -106,4 +107,17 @@ describe('actual backend routes', () => {
const rsvpCall = fetchMock.mock.calls.find(([url]) => url === '/api/events/event-1/rsvps/player-1')
expect(rsvpCall?.[1]?.body).toBe(JSON.stringify({ status: 'Going' }))
})
it('assigns moderators through the administrator-only route', async () => {
const account = { id: 'account-2', discordId: 'discord-2', username: 'Mod', avatarUrl: '', role: 'moderator', createdAt: '2026-07-01T00:00:00Z' }
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(account), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.setModerator(account.id, true)
expect(fetchMock).toHaveBeenCalledWith('/api/accounts/account-2/moderator', expect.objectContaining({
method: 'PATCH',
body: JSON.stringify({ moderator: true }),
}))
})
})

View File

@@ -1,6 +1,6 @@
import { toRankOrdinal, type RankOrdinal } from '../ranks'
export type AccountRole = 'admin' | 'player'
export type AccountRole = 'admin' | 'moderator' | 'player'
export type RsvpStatus = 'going' | 'maybe' | 'not_going'
export type PlayerRole = 'tank' | 'damage' | 'support'
export type TeamSide = 'alpha' | 'beta'
@@ -216,7 +216,6 @@ export function adaptSession(raw: RawSession): Session {
}
export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId?: string): MixEvent {
const now = Date.now()
const ends = new Date(raw.endsAt).getTime()
const deadline = new Date(raw.registrationDeadline).getTime()
const counts = { going: 0, maybe: 0, not_going: 0 }
rsvps.forEach((rsvp) => { counts[statusFromRaw(rsvp.status)]++ })
@@ -229,7 +228,7 @@ export function adaptEvent(raw: RawEvent, rsvps: RawRSVP[] = [], currentPlayerId
endsAt: raw.endsAt,
registrationDeadline: raw.registrationDeadline,
status: raw.state === 'Cancelled' ? 'cancelled'
: raw.state === 'Completed' || now > ends ? 'completed'
: raw.state === 'Completed' ? 'completed'
: raw.state === 'Live' ? 'live'
: raw.state === 'RegistrationOpen' && now <= deadline ? 'registration' : 'balancing',
rulesetName: 'Server ruleset',
@@ -474,6 +473,9 @@ export const api = {
}),
})),
players: async () => (await raw.players()).map(adaptPlayer),
accounts: () => request<RawAccount[]>('/accounts'),
setModerator: (accountId: string, moderator: boolean) =>
request<RawAccount>(`/accounts/${accountId}/moderator`, { method: 'PATCH', body: JSON.stringify({ moderator }) }),
events: async () => {
const [events, session] = await Promise.all([raw.events(), raw.session()])
return Promise.all(events.map(async (event) => {
@@ -535,6 +537,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ rulesetId, expectedVersion }),
})),
revertWorkflowStage: async (eventId: string, expectedVersion: number) =>
adaptEvent(await request<RawEvent>(`/events/${eventId}/workflow/back`, {
method: 'POST',
body: JSON.stringify({ expectedVersion }),
})),
generateWorkflowBalance: async (eventId: string, expectedVersion: number) => {
const [result, players] = await Promise.all([
request<{ event: RawEvent; candidates: RawBalanceCandidate[] }>(`/events/${eventId}/balance/generate`, {
@@ -557,6 +564,12 @@ export const api = {
}),
swapRoster: (eventId: string, input: { teamAId: string; playerAId: string; teamBId: string; playerBId: string; expectedVersion: number }) =>
request<RawRosterDraft>(`/events/${eventId}/roster/swap`, { method: 'POST', body: JSON.stringify(input) }),
moveRosterPlayer: (eventId: string, input: { fromTeamId: string; playerId: string; toTeamId: string; role: RawSlot['role']; expectedVersion: number }) =>
request<RawRosterDraft>(`/events/${eventId}/roster/move`, { method: 'POST', body: JSON.stringify(input) }),
placeReservePlayer: (eventId: string, input: { teamId: string; reservePlayerId: string; role: RawSlot['role']; expectedVersion: number }) =>
request<RawRosterDraft>(`/events/${eventId}/roster/place-reserve`, { method: 'POST', body: JSON.stringify(input) }),
removeRosterPlayer: (eventId: string, input: { teamId: string; playerId: string; expectedVersion: number }) =>
request<RawRosterDraft>(`/events/${eventId}/roster/remove`, { method: 'POST', body: JSON.stringify(input) }),
substituteRoster: (eventId: string, input: { teamId: string; outgoingPlayerId: string; reservePlayerId: string; expectedVersion: number }, emergency = false) =>
request<RawRosterDraft>(`/events/${eventId}/roster/${emergency ? 'emergency-substitute' : 'substitute'}`, { method: 'POST', body: JSON.stringify(input) }),
setRosterCaptain: (eventId: string, teamId: string, playerId: string, expectedVersion: number) =>
@@ -569,11 +582,13 @@ export const api = {
method: 'POST',
body: JSON.stringify({ expectedVersion, expectedRosterVersion }),
}),
startScrim: (eventId: string, expectedVersion: number) =>
request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
startScrim: async (eventId: string, expectedVersion: number) => {
const result = await request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
method: 'POST',
body: JSON.stringify({ expectedVersion }),
}),
})
return { ...result, series: result.series ? await adaptSeriesWithTeams(result.series) : undefined }
},
selectBalance: (eventId: string, candidate: BalanceCandidate) =>
request<{ status: string }>(`/events/${eventId}/teams`, {
method: 'PUT',

View File

@@ -6,6 +6,18 @@ const translations: Record<string, string> = {
'Live': 'Эфир',
'Bracket': 'Сетка',
'Admin': 'Админ',
'Moderation': 'Модерация',
'Moderators': 'Модераторы',
'Moderator': 'Модератор',
'Player': 'Игрок',
'Administrator': 'Администратор',
'Moderator access': 'Доступ модераторов',
'Grant moderation access to trusted Discord users.': 'Назначайте модераторами доверенных пользователей Discord.',
'Moderators can manage events, participants, rosters, and live scrims. Only administrators can change this list.': 'Модераторы могут управлять событиями, участниками, составами и скримами. Изменять этот список могут только администраторы.',
'Only administrators can change this list.': 'Только администраторы могут изменять этот список.',
'← Back': '← Назад',
'Going back…': 'Возвращаемся…',
'Return to the previous setup stage': 'Вернуться к предыдущему этапу настройки',
'Profile': 'Профиль',
'FAQ': 'FAQ',
'Language': 'Язык',
@@ -38,6 +50,7 @@ const translations: Record<string, string> = {
'Upcoming': 'Предстоящие',
'All': 'Все',
'Live now': 'Сейчас в эфире',
'The scrim has started': 'Скрим начался',
'Watch live': 'Смотреть',
'Coming up': 'Скоро',
'Filter events': 'Фильтровать события',

View File

@@ -74,6 +74,7 @@ main { min-height: calc(100vh - 72px); }
.segmented button.active { color: #fff; background: var(--surface-3); }
.featured-event { min-height: 180px; padding: 28px 32px; display: grid; grid-template-columns: 1.5fr 1fr auto; align-items: center; gap: 28px; border: 1px solid #46301f; border-radius: 14px; background: linear-gradient(115deg, rgba(53,28,13,.92), rgba(18,20,25,.96) 52%, rgba(11,31,45,.75)), repeating-linear-gradient(135deg,transparent 0 20px,rgba(255,255,255,.015) 20px 21px); overflow: hidden; position: relative; }
.featured-event.without-score { grid-template-columns: 1fr auto; }
.featured-event::after { content: ""; width: 240px; height: 240px; border-radius: 50%; border: 45px solid rgba(255,122,26,.06); position: absolute; right: 15%; top: -70px; pointer-events: none; }
.featured-event h2 { font-size: 26px; margin: 16px 0 6px; }
.featured-event p { color: var(--muted); font-size: 13px; }
@@ -234,14 +235,21 @@ textarea { min-height: 85px; resize: vertical; }
.workflow-progress div { min-width: 105px; display: flex; align-items: center; gap: 7px; color: #555e69; font-size: 9px; text-transform: uppercase; }
.workflow-progress span { width: 23px; height: 23px; display: grid; place-items: center; border: 1px solid #3a414b; border-radius: 50%; }
.workflow-progress div.done { color: var(--green); }.workflow-progress div.done span { border-color: #2d7654; background: #10271c; }
.workflow-back { display: flex; align-items: center; gap: 10px; margin: -5px 0 0; }.workflow-back small { color: var(--muted); }
.workflow-action { min-height: 105px; padding: 22px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.workflow-action h2 { font-size: 18px; }.workflow-action p { margin-top: 5px; color: var(--muted); font-size: 11px; }
.roster-editor { padding: 22px; }.roster-editor > .section-title { margin-top: 0; }
.roster-editor-body { display: grid; grid-template-columns: minmax(0,1fr) 220px; gap: 14px; align-items: stretch; }
.workflow-team { border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: #101319; }
.workflow-team header { padding: 14px; display: flex; align-items: end; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); }
.workflow-team header label { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 9px; }.workflow-team header select { padding: 7px; }
.roster-slot { min-height: 54px; padding: 8px 12px; display: grid; grid-template-columns: 24px minmax(100px,1fr) auto minmax(105px,auto) minmax(105px,auto); align-items: center; gap: 8px; border-bottom: 1px solid #22272e; font-size: 10px; }
.roster-slot > small { color: var(--muted); }.roster-slot select { max-width: 130px; padding: 7px; font-size: 9px; }
.roster-slot { min-height: 54px; padding: 8px 12px; display: grid; grid-template-columns: 24px minmax(100px,1fr) auto 30px; align-items: center; gap: 8px; border-bottom: 1px solid #22272e; font-size: 10px; transition: background .15s,border-color .15s; }
.roster-slot > small { color: var(--muted); }.roster-slot > strong[draggable="true"] { cursor: grab; }.roster-slot > strong[draggable="true"]:active { cursor: grabbing; }
.roster-slot.empty { border: 1px dashed #424a55; background: #15181d; }.roster-slot.empty > strong { color: #737b86; font-style: italic; }.slot-remove { width: 28px; height: 28px; }.slot-remove svg { width: 13px; }
.roster-reserve { min-height: 100%; padding: 14px; border: 1px dashed #3b424c; border-radius: 9px; background: #0d1015; }
.roster-reserve > div:first-child { display: flex; gap: 9px; align-items: center; margin-bottom: 13px; }.roster-reserve > div:first-child svg { width: 17px; color: var(--orange); }.roster-reserve span { display: grid; }.roster-reserve small,.reserve-list p { color: var(--muted); font-size: 9px; }
.reserve-list { display: grid; gap: 7px; }.reserve-list button { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); cursor: grab; text-align: left; }.reserve-list button:active { cursor: grabbing; }.mini-avatar { width: 25px; height: 25px; display: grid !important; place-items: center; border-radius: 5px; background: #253728; color: var(--green); font-size: 8px; }
.moderator-panel { padding: 22px; }.moderator-panel > .section-title { margin-top: 0; }.moderator-list { display: grid; gap: 7px; }.moderator-list > div { min-height: 58px; display: grid; grid-template-columns: 34px minmax(0,1fr) auto; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: #101319; }.moderator-list > div > span:nth-child(2) { display: grid; }.moderator-list small { color: var(--muted); }.moderator-toggle { display: flex; align-items: center; gap: 8px; color: var(--muted); cursor: pointer; }.moderator-toggle input { accent-color: var(--orange); }
.scoreboard { min-height: 150px; display: grid; grid-template-columns: 1fr 210px 1fr; align-items: center; border: 1px solid var(--border); background: linear-gradient(90deg,rgba(74,37,13,.35),rgba(16,19,24,.9) 42% 58%,rgba(10,45,70,.35)); border-radius: 12px; overflow: hidden; }
.score-team { padding: 25px 30px; }.score-team > span { color: var(--orange); font-size: 9px; text-transform: uppercase; letter-spacing: .18em; font-weight: 900; }.score-team h2 { margin: 5px 0; font-size: clamp(18px,2vw,28px); }.score-team small { color: var(--muted); }.score-team.beta { text-align: right; }.score-team.beta > span { color: var(--blue); }
@@ -301,7 +309,7 @@ textarea { min-height: 85px; resize: vertical; }
.teammate-grid { grid-template-columns: repeat(2,1fr); }
.settings-card,.admin-summary,.table-card { padding: 16px; }.field-grid,.guest-participant-fields { grid-template-columns: 1fr; }.participant-row { grid-template-columns: 34px 1fr 105px 36px; padding: 9px 0; }.mini-ratings { display: none; }
.candidate-list { grid-template-columns: 1fr; }.teams-grid { grid-template-columns: 1fr; }.balance-score { grid-template-columns: 1.4fr 1fr 1fr; padding: 15px; gap: 10px; }.reserve-bar,.sticky-action { align-items: stretch; flex-direction: column; }.sticky-action .button { width: 100%; }
.workflow-action { align-items: stretch; flex-direction: column; }.workflow-action .button { width: 100%; }.roster-slot { grid-template-columns: 24px 1fr auto; }.roster-slot select { grid-column: 2/-1; max-width: none; width: 100%; }
.workflow-action { align-items: stretch; flex-direction: column; }.workflow-action .button { width: 100%; }.roster-editor-body { grid-template-columns: 1fr; }.roster-slot { grid-template-columns: 24px 1fr auto 30px; }
.scoreboard { min-height: 120px; grid-template-columns: 1fr 90px 1fr; }.score-team { padding: 15px 10px; }.score-team h2 { font-size: 13px; }.score-team small { display: none; }.series-score strong { font-size: 30px; }.series-score .badge { display: none; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }
.result-form { grid-template-columns: 1fr; }