Add Discord event announcement functionality to backend
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / compose (push) Has been cancelled

This commit introduces a new Discord event announcer to the backend, allowing for event announcements via Discord. It includes the addition of new environment variables for Discord configuration in `.env.example` and `compose.yaml`. The `main.go` file has been updated to initialize the announcer, and a new `discord` package has been created, containing the announcer logic and tests. Additionally, the service layer has been modified to support bracket draft management, enhancing the overall event workflow. Integration tests have been updated to ensure proper functionality of the new features.
This commit is contained in:
2026-07-19 11:21:11 +03:00
parent b7a78b4384
commit ae19c03542
31 changed files with 1632 additions and 88 deletions

View File

@@ -21,6 +21,7 @@ import { LanguageSelector } from './i18n'
import { useLanguage } from './i18n-context'
import { rankLabel, rankOptions, toRankOrdinal, type RankOrdinal } from './ranks'
import { activeHeroBanMap } from './series-utils'
import { BracketEditor } from './components/bracket-editor/BracketEditor'
type RouterContext = { session: Session | null }
const isStaff = (role?: Session['account']['role']) => role === 'admin' || role === 'moderator'
@@ -86,6 +87,8 @@ function AppShell() {
void client.invalidateQueries({ queryKey: ['series', id] })
} else if (scope === 'tournament') {
void client.invalidateQueries({ queryKey: ['tournament'] })
} else if (scope === 'bracket' && id) {
void client.invalidateQueries({ queryKey: ['bracket-draft', id] })
} else if (scope === 'team') {
void client.invalidateQueries({ queryKey: ['teams'] })
void client.invalidateQueries({ queryKey: ['series'] })
@@ -475,9 +478,20 @@ function WorkflowControl({ eventId }: { eventId: string }) {
const roster = useQuery({
queryKey: ['roster', eventId],
queryFn: () => api.roster(eventId),
enabled: Boolean(event.data && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(event.data.workflowState)),
enabled: Boolean(event.data && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].includes(event.data.workflowState)),
retry: false,
})
const bracketDraft = useQuery({
queryKey: ['bracket-draft', eventId],
queryFn: () => api.bracketDraft(eventId),
enabled: event.data?.workflowState === 'BracketDraft',
retry: false,
})
const bracketTeams = useQuery({
queryKey: ['teams', eventId],
queryFn: () => api.teams(eventId),
enabled: event.data?.workflowState === 'BracketDraft',
})
const [candidates, setCandidates] = useState<Awaited<ReturnType<typeof api.generateWorkflowBalance>>['candidates']>([])
const [selectedCandidate, setSelectedCandidate] = useState(0)
const refresh = useCallback(() => {
@@ -510,6 +524,29 @@ function WorkflowControl({ eventId }: { eventId: string }) {
onSuccess: refresh,
onError: refresh,
})
const initializeBracket = useMutation({
mutationFn: () => api.initializeBracket(eventId, event.data!.version),
onSuccess: (draft) => {
client.setQueryData(['bracket-draft', eventId], draft)
refresh()
},
onError: refresh,
})
const saveBracket = useMutation({
mutationFn: (matches: Parameters<typeof api.updateBracket>[1]) => api.updateBracket(eventId, matches, bracketDraft.data!.version),
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
onError: () => void bracketDraft.refetch(),
})
const resetBracket = useMutation({
mutationFn: () => api.resetBracket(eventId, bracketDraft.data!.version),
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
onError: () => void bracketDraft.refetch(),
})
const confirmBracket = useMutation({
mutationFn: () => api.confirmBracket(eventId, bracketDraft.data!.version),
onSuccess: (draft) => client.setQueryData(['bracket-draft', eventId], draft),
onError: () => void bracketDraft.refetch(),
})
const start = useMutation({
mutationFn: () => api.startScrim(eventId, event.data!.version),
onSuccess: (result) => {
@@ -527,6 +564,7 @@ function WorkflowControl({ eventId }: { eventId: string }) {
onSuccess: (updated) => {
if (updated.workflowState === 'RegistrationClosed') setCandidates([])
if (updated.workflowState === 'Balancing') client.removeQueries({ queryKey: ['roster', eventId] })
if (updated.workflowState === 'RostersConfirmed') client.removeQueries({ queryKey: ['bracket-draft', eventId] })
refresh()
},
onError: refresh,
@@ -534,11 +572,11 @@ function WorkflowControl({ eventId }: { eventId: string }) {
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 ?? back.error
const error = close.error ?? generate.error ?? select.error ?? confirm.error ?? initializeBracket.error ?? saveBracket.error ?? resetBracket.error ?? confirmBracket.error ?? start.error ?? back.error ?? bracketDraft.error ?? bracketTeams.error
return <div className="workflow-control">
<section className="card workflow-progress">
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'] as const).map((step, index) => {
const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].indexOf(state)
{(['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'] as const).map((step, index) => {
const activeIndex = ['RegistrationOpen', 'RegistrationClosed', 'Balancing', 'RostersDraft', 'RostersConfirmed', 'BracketDraft', 'Live', 'Completed'].indexOf(state)
return <div className={index <= activeIndex ? 'done' : ''} key={step}><span>{index + 1}</span><strong>{step.replace(/([a-z])([A-Z])/g, '$1 $2')}</strong></div>
})}
</section>
@@ -547,9 +585,11 @@ function WorkflowControl({ eventId }: { eventId: string }) {
{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} allowCaptainAssignment={false} />)}</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 && ['RostersDraft', 'RostersConfirmed', 'Live', 'Completed'].includes(state) && <WorkflowRosterEditor eventId={eventId} roster={roster.data} players={players.data} locked={state !== 'RostersDraft'} onChanged={refresh} />}
{roster.data && ['RostersDraft', 'RostersConfirmed', 'BracketDraft', '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 === 'RostersConfirmed' && <WorkflowAction title="Configure bracket" description="Build the match order and connect winner or loser feeds before starting." button="Open bracket editor" pending={initializeBracket.isPending} onClick={() => initializeBracket.mutate()} />}
{state === 'BracketDraft' && (bracketDraft.isLoading || bracketTeams.isLoading) && <LoadingState label="Loading bracket editor…" />}
{state === 'BracketDraft' && bracketDraft.data && bracketTeams.data && <BracketEditor draft={bracketDraft.data} teams={bracketTeams.data} pending={saveBracket.isPending || resetBracket.isPending || confirmBracket.isPending || start.isPending} error={error?.message} onSave={(matches) => saveBracket.mutate(matches)} onReset={() => resetBracket.mutate()} onConfirm={() => confirmBracket.mutate()} onStart={() => start.mutate()} />}
{state === 'Live' && <section className="card workflow-action"><div><h2>Scrim is live</h2><p>Coin toss, bans, and map results are now available.</p></div>{event.data.activeSeriesId ? <Link className="button primary" to="/series/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Open series</Link> : <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link>}</section>}
{state === 'Completed' && <section className="card workflow-action"><div><h2>Scrim completed</h2><p>The final result and full audit trail are preserved.</p></div>{event.data.tournamentId ? <Link className="button primary" to="/bracket/$eventId" params={{ eventId }}>Open bracket</Link> : event.data.activeSeriesId ? <Link className="button primary" to="/watch/$seriesId" params={{ seriesId: event.data.activeSeriesId }}>Match history</Link> : null}</section>}
{error && <p className="error-note">{error.message}</p>}

View File

@@ -80,6 +80,21 @@ describe('backend DTO adapters', () => {
expect(adaptSeries(series).score).toEqual({ alpha: 0, beta: 1 })
expect(adaptSeries({ ...series, phase: 'CoinTossPending', results: null, maps: null, audit: null }).score).toEqual({ alpha: 0, beta: 0 })
expect(adaptTournament({ id: 't-1', eventId: 'event-1', name: 'Cup', winnerTeamId: '', teamIds: ['a', 'b'], rounds: [[{ ...series, phase: 'MapBan' }]] }).matches[0].status).toBe('live')
const graph = adaptTournament({
id: 'graph-1',
eventId: 'event-1',
name: 'Custom Cup',
winnerTeamId: '',
teamIds: ['a', 'b'],
version: 0,
rounds: [],
matches: [
{ id: 'm1', round: 0, order: 0, slotA: { kind: 'Team', teamId: 'a' }, slotB: { kind: 'Team', teamId: 'b' }, teamAId: 'a', teamBId: 'b', series },
{ id: 'm2', round: 1, order: 0, slotA: { kind: 'Loser', matchId: 'm1' }, slotB: { kind: 'Winner', matchId: 'm1' } },
],
})
expect(graph.matches[0].status).toBe('live')
expect(graph.matches[1]).toMatchObject({ teamAlpha: 'Loser of R1M1', teamBeta: 'Winner of R1M1' })
const heroDraft = adaptSeries({
...series,
phase: 'HeroBan',
@@ -149,4 +164,23 @@ describe('actual backend routes', () => {
body: JSON.stringify({ name: 'Ember Wolves' }),
}))
})
it('updates the versioned bracket graph', async () => {
const draft = {
eventId: 'event-1',
teamIds: ['a', 'b'],
matches: [{ id: 'm1', round: 0, order: 0, slotA: { kind: 'Team' as const, teamId: 'a' }, slotB: { kind: 'Team' as const, teamId: 'b' } }],
version: 2,
confirmed: false,
}
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify(draft), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}))
await api.updateBracket(draft.eventId, draft.matches, 1)
expect(fetchMock).toHaveBeenCalledWith('/api/events/event-1/bracket-draft', expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ matches: draft.matches, expectedVersion: 1 }),
}))
})
})

View File

@@ -46,7 +46,7 @@ export interface MixEvent {
activeSeriesId?: string
tournamentId?: string
}
export type EventWorkflowState = 'RegistrationOpen' | 'RegistrationClosed' | 'Balancing' | 'RostersDraft' | 'RostersConfirmed' | 'Live' | 'Completed' | 'Cancelled'
export type EventWorkflowState = 'RegistrationOpen' | 'RegistrationClosed' | 'Balancing' | 'RostersDraft' | 'RostersConfirmed' | 'BracketDraft' | 'Live' | 'Completed' | 'Cancelled'
export interface TeamPlayer { player: Player; assignedRole: PlayerRole }
export interface Team {
id: string
@@ -122,6 +122,27 @@ export interface EventInput {
endsAt: string
registrationDeadline: string
}
export type BracketSlotSource =
| { kind: 'Team'; teamId: string; matchId?: never }
| { kind: 'Winner' | 'Loser'; matchId: string; teamId?: never }
export interface BracketDraftMatch {
id: string
round: number
order: number
slotA: BracketSlotSource
slotB: BracketSlotSource
seriesId?: string
teamAId?: string
teamBId?: string
winnerTeamId?: string
}
export interface BracketDraft {
eventId: string
teamIds: string[]
matches: BracketDraftMatch[]
version: number
confirmed: boolean
}
// Raw backend DTOs mirror Go's current camelCase JSON tags exactly.
export interface RawAccount {
@@ -174,7 +195,7 @@ export interface RawSeries {
export interface RawDraftAction { teamId: string; value: string; actorAccountId: string; at: string }
export interface RawTournament {
id: string; eventId: string; name: string; winnerTeamId: string
teamIds: string[]; rounds: RawSeries[][]
teamIds: string[]; rounds: RawSeries[][]; matches?: Array<BracketDraftMatch & { series?: RawSeries }>; version?: number
}
export interface RawRealtimeEvent { topic: string; data: unknown }
@@ -391,6 +412,37 @@ export function adaptSeries(raw: RawSeries, teams: Team[] = []): Series {
}
export function adaptTournament(raw: RawTournament, teams: Team[] = []): Bracket {
const names = new Map(teams.map((team) => [team.id, team.name]))
if (raw.matches?.length) {
const maxRound = Math.max(...raw.matches.map((match) => match.round))
const sourceName = (source: BracketSlotSource) => {
if (source.kind === 'Team') return names.get(source.teamId) ?? source.teamId
const upstream = raw.matches?.find((match) => match.id === source.matchId)
return `${source.kind} of ${upstream ? `R${upstream.round + 1}M${upstream.order + 1}` : 'match'}`
}
return {
id: raw.id,
title: raw.name,
rounds: Array.from({ length: maxRound + 1 }, (_, index) => index === maxRound ? 'Grand Final' : index === maxRound - 1 ? 'Semifinals' : `Round ${index + 1}`),
matches: raw.matches.map((match) => {
const adapted = match.series ? adaptSeries(match.series, teams) : undefined
return {
id: match.id,
round: match.round,
slot: match.order,
label: `Match ${match.order + 1}`,
teamAlpha: names.get(match.teamAId ?? '') ?? match.teamAId ?? sourceName(match.slotA),
teamBeta: names.get(match.teamBId ?? '') ?? match.teamBId ?? sourceName(match.slotB),
teamAlphaId: match.teamAId,
teamBetaId: match.teamBId,
scoreAlpha: adapted?.score.alpha,
scoreBeta: adapted?.score.beta,
winner: match.winnerTeamId ? (match.winnerTeamId === match.teamAId ? 'alpha' : 'beta') : undefined,
seriesId: match.seriesId ?? match.series?.id,
status: match.winnerTeamId ? 'completed' : match.series?.phase ? 'live' : match.teamAId && match.teamBId ? 'ready' : 'pending',
}
}),
}
}
return {
id: raw.id,
title: raw.name,
@@ -599,6 +651,15 @@ export const api = {
method: 'POST',
body: JSON.stringify({ expectedVersion, expectedRosterVersion }),
}),
bracketDraft: (eventId: string) => request<BracketDraft>(`/events/${eventId}/bracket-draft`),
initializeBracket: (eventId: string, expectedVersion: number) =>
request<BracketDraft>(`/events/${eventId}/bracket-draft/initialize`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }),
updateBracket: (eventId: string, matches: BracketDraftMatch[], expectedVersion: number) =>
request<BracketDraft>(`/events/${eventId}/bracket-draft`, { method: 'PUT', body: JSON.stringify({ matches, expectedVersion }) }),
resetBracket: (eventId: string, expectedVersion: number) =>
request<BracketDraft>(`/events/${eventId}/bracket-draft/reset`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }),
confirmBracket: (eventId: string, expectedVersion: number) =>
request<BracketDraft>(`/events/${eventId}/bracket-draft/confirm`, { method: 'POST', body: JSON.stringify({ expectedVersion }) }),
startScrim: async (eventId: string, expectedVersion: number) => {
const result = await request<{ event: RawEvent; series?: RawSeries; tournament?: RawTournament }>(`/events/${eventId}/start`, {
method: 'POST',

View File

@@ -1,6 +1,7 @@
import type {
BalanceCandidate,
Bracket,
BracketDraft,
MixEvent,
Player,
Registration,
@@ -294,3 +295,15 @@ export const demoBracket: Bracket = {
{ id: 'm3', round: 1, slot: 0, label: 'Grand Final', teamBeta: 'Neon Foxes', status: 'pending' },
],
}
export const demoBracketDraft: BracketDraft = {
eventId: 'event-3',
teamIds: ['team-alpha', 'team-beta', 'team-neon'],
version: 2,
confirmed: false,
matches: [
{ id: 'draft-m1', round: 0, order: 0, slotA: { kind: 'Team', teamId: 'team-alpha' }, slotB: { kind: 'Team', teamId: 'team-beta' } },
{ id: 'draft-m2', round: 1, order: 0, slotA: { kind: 'Loser', matchId: 'draft-m1' }, slotB: { kind: 'Team', teamId: 'team-neon' } },
{ id: 'draft-m3', round: 2, order: 0, slotA: { kind: 'Winner', matchId: 'draft-m1' }, slotB: { kind: 'Winner', matchId: 'draft-m2' } },
],
}

View File

@@ -0,0 +1,42 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { demoBalanceCandidates, demoBracketDraft } from '../../api/demo'
import { BracketEditor } from './BracketEditor'
describe('BracketEditor', () => {
afterEach(cleanup)
it('adds horizontal rounds and supports tap-to-place sources', () => {
const onSave = vi.fn()
render(<BracketEditor
draft={demoBracketDraft}
teams={demoBalanceCandidates[0].teams}
pending={false}
onSave={onSave}
onReset={vi.fn()}
onConfirm={vi.fn()}
onStart={vi.fn()}
/>)
fireEvent.click(screen.getByRole('button', { name: /add round/i }))
expect(onSave).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ round: 3 })]))
fireEvent.click(screen.getByRole('button', { name: demoBalanceCandidates[0].teams[0].name }))
fireEvent.click(screen.getAllByRole('button', { name: /team a source/i })[1])
expect(onSave).toHaveBeenLastCalledWith(expect.arrayContaining([
expect.objectContaining({ id: 'draft-m2', slotA: { kind: 'Team', teamId: demoBalanceCandidates[0].teams[0].id } }),
]))
})
it('keeps start locked until the graph is confirmed', () => {
render(<BracketEditor
draft={demoBracketDraft}
teams={demoBalanceCandidates[0].teams}
pending={false}
onSave={vi.fn()}
onReset={vi.fn()}
onConfirm={vi.fn()}
onStart={vi.fn()}
/>)
expect(screen.getByRole('button', { name: /start scrim/i })).toBeDisabled()
})
})

View File

@@ -0,0 +1,93 @@
import { useState, type DragEvent } from 'react'
import { Check, ChevronRight, Plus, RefreshCw, Trash2, Trophy } from 'lucide-react'
import type { BracketDraft, BracketDraftMatch, BracketSlotSource, Team } from '../../api/client'
const dragType = 'application/x-mixmaker-bracket-source'
function sourceLabel(source: BracketSlotSource, teams: Map<string, string>, matches: Map<string, BracketDraftMatch>) {
if (source.kind === 'Team') return teams.get(source.teamId) ?? 'Empty slot'
const match = matches.get(source.matchId)
return `${source.kind} of ${match ? `R${match.round + 1}M${match.order + 1}` : 'unknown match'}`
}
function readSource(event: DragEvent): BracketSlotSource | undefined {
try {
return JSON.parse(event.dataTransfer.getData(dragType)) as BracketSlotSource
} catch {
return undefined
}
}
export function BracketEditor({
draft, teams, pending, error, onSave, onReset, onConfirm, onStart,
}: {
draft: BracketDraft
teams: Team[]
pending: boolean
error?: string
onSave: (matches: BracketDraftMatch[]) => void
onReset: () => void
onConfirm: () => void
onStart: () => void
}) {
const [selectedSource, setSelectedSource] = useState<BracketSlotSource>()
const teamNames = new Map(teams.map((team) => [team.id, team.name]))
const byID = new Map(draft.matches.map((match) => [match.id, match]))
const maxRound = Math.max(0, ...draft.matches.map((match) => match.round))
const rounds = Array.from({ length: maxRound + 1 }, (_, round) => draft.matches.filter((match) => match.round === round).sort((a, b) => a.order - b.order))
const choose = (source: BracketSlotSource) => setSelectedSource(source)
const startDrag = (event: DragEvent, source: BracketSlotSource) => {
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData(dragType, JSON.stringify(source))
}
const place = (matchID: string, slot: 'slotA' | 'slotB', source?: BracketSlotSource) => {
const value = source ?? selectedSource
if (!value || pending) return
onSave(draft.matches.map((match) => match.id === matchID ? { ...match, [slot]: value, seriesId: undefined, teamAId: undefined, teamBId: undefined, winnerTeamId: undefined } : match))
setSelectedSource(undefined)
}
const addMatch = (round: number) => {
const order = rounds[round]?.length ?? 0
onSave([...draft.matches, {
id: `${draft.eventId}-custom-${crypto.randomUUID()}`,
round,
order,
slotA: { kind: 'Team', teamId: '' },
slotB: { kind: 'Team', teamId: '' },
}])
}
const removeMatch = (id: string) => {
const remaining = draft.matches.filter((match) => match.id !== id).map((match) => ({
...match,
slotA: match.slotA.kind !== 'Team' && match.slotA.matchId === id ? { kind: 'Team' as const, teamId: '' } : match.slotA,
slotB: match.slotB.kind !== 'Team' && match.slotB.matchId === id ? { kind: 'Team' as const, teamId: '' } : match.slotB,
}))
const byRound = new Map<number, BracketDraftMatch[]>()
for (const match of remaining) byRound.set(match.round, [...(byRound.get(match.round) ?? []), match])
onSave(remaining.map((match) => ({
...match,
order: [...(byRound.get(match.round) ?? [])].sort((a, b) => a.order - b.order).findIndex((candidate) => candidate.id === match.id),
})))
}
const addRound = () => addMatch(maxRound + 1)
return <section className="bracket-editor">
<div className="bracket-editor-toolbar">
<div><span className="eyebrow">Bracket editor</span><h2>Build the match graph</h2><p>Drag a team, winner, or loser into either match slot.</p></div>
<div><button className="button secondary" disabled={pending} onClick={onReset}><RefreshCw />Reset template</button><button className="button secondary" disabled={pending} onClick={addRound}><Plus />Add round</button></div>
</div>
<div className="bracket-source-palette">
<div><strong>Teams</strong>{teams.map((team) => <button type="button" draggable onDragStart={(event) => startDrag(event, { kind: 'Team', teamId: team.id })} className={selectedSource?.kind === 'Team' && selectedSource.teamId === team.id ? 'selected' : ''} key={team.id} onClick={() => choose({ kind: 'Team', teamId: team.id })}>{team.name}</button>)}</div>
<div><strong>Dynamic feeds</strong>{draft.matches.map((match) => <span key={match.id}><button type="button" draggable onDragStart={(event) => startDrag(event, { kind: 'Winner', matchId: match.id })} onClick={() => choose({ kind: 'Winner', matchId: match.id })}><Trophy />Winner R{match.round + 1}M{match.order + 1}</button><button type="button" draggable onDragStart={(event) => startDrag(event, { kind: 'Loser', matchId: match.id })} onClick={() => choose({ kind: 'Loser', matchId: match.id })}>Loser R{match.round + 1}M{match.order + 1}</button></span>)}</div>
</div>
<div className="bracket-editor-scroll">
{rounds.map((matches, round) => <section className="bracket-editor-round" key={round}><header><div><span>Round {round + 1}</span><h3>{round === maxRound ? 'Final column' : `Stage ${round + 1}`}</h3></div><button className="icon-button" disabled={pending} aria-label={`Add match to round ${round + 1}`} onClick={() => addMatch(round)}><Plus /></button></header>{matches.map((match) => <article className="bracket-editor-match" key={match.id}><div className="match-label"><span>R{round + 1}M{match.order + 1}</span><button className="icon-button" aria-label={`Delete R${round + 1}M${match.order + 1}`} disabled={pending || draft.matches.length === 1} onClick={() => removeMatch(match.id)}><Trash2 /></button></div>{(['slotA', 'slotB'] as const).map((slot) => <button type="button" className={`bracket-slot-drop ${match[slot].kind === 'Team' && !match[slot].teamId ? 'empty' : ''}`} key={slot} onDragOver={(event) => event.preventDefault()} onDrop={(event) => place(match.id, slot, readSource(event))} onClick={() => place(match.id, slot)}><small>{slot === 'slotA' ? 'Team A source' : 'Team B source'}</small><strong>{sourceLabel(match[slot], teamNames, byID)}</strong></button>)}</article>)}</section>)}
</div>
<div className="bracket-editor-footer"><div>{selectedSource && <p>Selected: <strong>{sourceLabel(selectedSource, teamNames, byID)}</strong>. Tap a slot to place it.</p>}{error && <p className="error-note">{error}</p>}</div><div>{draft.confirmed ? <BadgeConfirmed /> : <button className="button secondary" disabled={pending} onClick={onConfirm}><Check />Confirm bracket</button>}<button className="button primary" disabled={pending || !draft.confirmed} onClick={onStart}>Start scrim <ChevronRight /></button></div></div>
</section>
}
function BadgeConfirmed() {
return <span className="bracket-confirmed"><Check />Bracket confirmed</span>
}

View File

@@ -249,6 +249,24 @@ const translations: Record<string, string> = {
'Confirm rosters': 'Подтвердить составы',
'Every team must have a valid 1/2/2 lineup and a captain.': 'В каждой команде должен быть состав 1/2/2 и капитан.',
'Start scrim': 'Начать скрим',
'Bracket Draft': 'Редактор сетки',
'Bracket editor': 'Редактор сетки',
'Configure bracket': 'Настроить сетку',
'Build the match order and connect winner or loser feeds before starting.': 'Настройте порядок матчей и переходы победителей или проигравших.',
'Open bracket editor': 'Открыть редактор сетки',
'Loading bracket editor…': 'Загрузка редактора сетки…',
'Build the match graph': 'Соберите граф матчей',
'Drag a team, winner, or loser into either match slot.': 'Перетащите команду, победителя или проигравшего в слот матча.',
'Reset template': 'Сбросить шаблон',
'Add round': 'Добавить раунд',
'Teams': 'Команды',
'Dynamic feeds': 'Динамические переходы',
'Final column': 'Финальная колонка',
'Team A source': 'Источник команды A',
'Team B source': 'Источник команды B',
'Empty slot': 'Пустой слот',
'Confirm bracket': 'Подтвердить сетку',
'Bracket confirmed': 'Сетка подтверждена',
'This atomically creates the Bo3 series or tournament bracket.': 'Серия Bo3 или турнирная сетка будут созданы атомарно.',
'Scrim is live': 'Скрим начался',
'Coin toss, bans, and map results are now available.': 'Теперь доступны жеребьёвка, баны и результаты карт.',

View File

@@ -232,7 +232,7 @@ textarea { min-height: 85px; resize: vertical; }
.reserve-bar { margin-top: 12px; padding: 16px 18px; display: flex; align-items: center; justify-content: space-between; gap: 15px; }.reserve-bar > div { display: flex; align-items: center; gap: 9px; }.reserve-bar span { display: flex; flex-direction: column; }.reserve-bar small { color: var(--muted); margin-top: 3px; font-size: 9px; }
.sticky-action { margin-top: 12px; padding: 14px 0; display: flex; align-items: center; justify-content: flex-end; gap: 18px; }.sticky-action p { color: var(--muted); font-size: 10px; }
.workflow-control { display: grid; gap: 14px; }
.workflow-progress { padding: 16px; display: grid; grid-template-columns: repeat(7,1fr); gap: 6px; overflow-x: auto; }
.workflow-progress { padding: 16px; display: grid; grid-template-columns: repeat(8,1fr); gap: 6px; overflow-x: auto; }
.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; }
@@ -251,6 +251,7 @@ textarea { min-height: 85px; resize: vertical; }
.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); }
.bracket-editor { display: grid; gap: 12px; padding: 20px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }.bracket-editor-toolbar,.bracket-editor-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }.bracket-editor-toolbar h2 { margin: 5px 0; }.bracket-editor-toolbar p,.bracket-editor-footer p { color: var(--muted); font-size: 10px; }.bracket-editor-toolbar > div:last-child,.bracket-editor-footer > div:last-child { display: flex; align-items: center; gap: 8px; }.bracket-source-palette { display: grid; grid-template-columns: minmax(220px,1fr) 2fr; gap: 10px; padding: 12px; border: 1px dashed #3d454f; border-radius: 9px; background: #0d1014; }.bracket-source-palette > div { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; }.bracket-source-palette > div > strong { width: 100%; color: var(--muted); font-size: 9px; text-transform: uppercase; }.bracket-source-palette button { padding: 7px 9px; display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--border); border-radius: 6px; background: #151920; color: var(--text); cursor: grab; font-size: 9px; }.bracket-source-palette button.selected { border-color: var(--orange); background: var(--orange-soft); }.bracket-source-palette button svg { width: 12px; }.bracket-source-palette span { display: contents; }.bracket-editor-scroll { min-height: 390px; padding: 20px; display: flex; align-items: stretch; gap: 70px; overflow-x: auto; border: 1px solid var(--border); border-radius: 9px; background: radial-gradient(circle at 50% 50%,#171b22,transparent 55%),#0d1014; }.bracket-editor-round { min-width: 280px; display: flex; flex-direction: column; gap: 18px; }.bracket-editor-round > header { display: flex; align-items: center; justify-content: space-between; }.bracket-editor-round > header span { color: var(--orange); font-size: 8px; text-transform: uppercase; }.bracket-editor-round h3 { margin-top: 4px; font-size: 16px; }.bracket-editor-match { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); position: relative; }.bracket-editor-match::after { content: ""; width: 70px; height: 1px; position: absolute; right: -71px; top: 50%; background: #303640; }.bracket-editor-round:last-child .bracket-editor-match::after { display: none; }.bracket-editor-match .match-label button { width: 24px; height: 24px; }.bracket-slot-drop { width: 100%; min-height: 54px; padding: 9px 12px; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; gap: 4px; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--text); text-align: left; }.bracket-slot-drop:last-child { border-bottom: 0; }.bracket-slot-drop small { color: var(--muted); font-size: 8px; }.bracket-slot-drop.empty { border: 1px dashed #65452e; background: rgba(255,122,26,.04); }.bracket-confirmed { display: inline-flex; align-items: center; gap: 5px; color: var(--green); font-size: 10px; }.bracket-confirmed svg { width: 15px; }
.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; }
.tournament-strip { margin-bottom: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; background: #0d1014; }.tournament-strip > header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }.tournament-strip > header span,.tournament-strip > header a { display: flex; align-items: center; gap: 6px; font-size: 10px; }.tournament-strip > header svg { width: 15px; color: var(--orange); }.tournament-strip-body { display: grid; grid-template-columns: 34px minmax(0,1fr) 34px; align-items: center; gap: 8px; }.mini-bracket { display: flex; gap: 12px; overflow-x: auto; padding: 2px; }.mini-bracket > div { min-width: 175px; display: grid; gap: 5px; }.mini-bracket small { color: var(--muted); text-transform: uppercase; font-size: 8px; }.mini-match { display: grid; gap: 3px; padding: 7px 9px; border: 1px solid var(--border); border-radius: 6px; color: var(--muted); font-size: 9px; }.mini-match span { display: flex; justify-content: space-between; gap: 8px; }.mini-match.current { border-color: var(--orange); background: var(--orange-soft); color: var(--text); }.mini-match.live:not(.current) { border-color: #285c43; }.match-arrow { width: 34px; height: 44px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 7px; background: var(--panel); color: var(--text); }.match-arrow.disabled { opacity: .3; }.tournament-strip > p { margin: 8px 42px 0; color: var(--muted); font-size: 9px; }
@@ -319,6 +320,7 @@ textarea { min-height: 85px; resize: vertical; }
.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; }
.team-name-controls { grid-template-columns: 1fr; }.team-name-editor { align-items: stretch; flex-direction: column; }
.turn-header { padding: 20px; }.turn-header h2 { font-size: 23px; }.hero-filter-tools { padding: 14px 14px 0; }.draft-options { grid-template-columns: repeat(2,1fr); padding: 14px; }.turn-footer { align-items: stretch; flex-direction: column; }.turn-footer .button { width: 100%; }
.bracket-editor { padding: 14px; }.bracket-editor-toolbar,.bracket-editor-footer { align-items: stretch; flex-direction: column; }.bracket-editor-toolbar > div:last-child,.bracket-editor-footer > div:last-child { width: 100%; }.bracket-editor-toolbar .button,.bracket-editor-footer .button { flex: 1; }.bracket-source-palette { grid-template-columns: 1fr; }.bracket-editor-scroll { padding: 14px; gap: 45px; }.bracket-editor-round { min-width: 245px; }.bracket-editor-match::after { width: 45px; right: -46px; }
.result-form { grid-template-columns: 1fr; }
.ban-display { grid-template-columns: repeat(2,1fr); }.spectator-focus { padding: 24px 14px; }.spectator-grid { grid-template-columns: 1fr; }
.bracket-scroll { padding: 25px; gap: 70px; }.bracket-match::after { width: 70px; right: -71px; }