diff --git a/resources/js/pages/Stages/Edit.vue b/resources/js/pages/Stages/Edit.vue index 01f101e..e7c7bdc 100644 --- a/resources/js/pages/Stages/Edit.vue +++ b/resources/js/pages/Stages/Edit.vue @@ -128,16 +128,45 @@ const qualifierTotal = computed(() => { const qualifierTotalIsPowerOfTwo = computed(() => qualifierTotal.value >= 2 && (qualifierTotal.value & (qualifierTotal.value - 1)) === 0); -const entrantTokens = ref(((props.stage.config?.entrants as Entrant[] | undefined) ?? []).map(tokenOf)); +/** + * The bracket is a fixed set of slots (one per qualifier) that start empty + * and get a qualifier dropped into each. slots[2i] / slots[2i+1] are the two + * sides of round-1 match i, and the order of the matches is the bracket tree. + * We only mirror them into the saved entrants once every slot is filled — a + * half-built bracket isn't a valid one. + */ +const bracketSize = computed(() => (qualifierTotalIsPowerOfTwo.value ? qualifierTotal.value : 0)); + +function initialSlots(): (string | null)[] { + const saved = ((props.stage.config?.entrants as Entrant[] | undefined) ?? []).map(tokenOf); + + if (bracketSize.value === 0) { + return saved; + } + + return Array.from({ length: bracketSize.value }, (_, index) => saved[index] ?? null); +} + +const slots = ref<(string | null)[]>(initialSlots()); watch( - entrantTokens, - (tokens) => { - form.config.entrants = tokens.length > 0 ? tokens.map(entrantOf) : null; + slots, + (value) => { + const filled = value.length > 0 && value.every((token): token is string => token !== null); + form.config.entrants = filled ? (value as string[]).map(entrantOf) : null; }, { deep: true }, ); +const matchCount = computed(() => slots.value.length / 2); +const placedCount = computed(() => slots.value.filter((token) => token !== null).length); +const firstRoundLabel = computed(() => roundLabelFor(matchCount.value)); + +/** The two slot indices of a 1-based match number. */ +function matchSlots(match: number): [number, number] { + return [2 * (match - 1), 2 * (match - 1) + 1]; +} + /** * Classic template for even group counts with exactly 2 qualifiers and no * best-placed spots: 1A v 2B, 1C v 2D, …, then 1B v 2A, 1D v 2C, … so @@ -159,7 +188,9 @@ function applyClassicTemplate(): void { secondHalf.push(`group|${groups[i + 1]}|1`, `group|${groups[i]}|2`); } - entrantTokens.value = [...firstHalf, ...secondHalf]; + slots.value = [...firstHalf, ...secondHalf]; + selected.value = null; + selectedMatch.value = null; } /** @@ -196,13 +227,290 @@ function applySeededTemplate(): void { seeds = next; } - entrantTokens.value = seeds.map((seed) => seedList[seed - 1]); + slots.value = seeds.map((seed) => seedList[seed - 1]); + selected.value = null; + selectedMatch.value = null; } function clearEntrants(): void { - entrantTokens.value = []; + slots.value = Array.from({ length: bracketSize.value }, () => null); + selected.value = null; + selectedMatch.value = null; +} + +/** + * Full slot label, e.g. "Winner Group A" → "Winner A", "Best-placed #3" → + * "3rd #3". The group's "Group " prefix is dropped so chips stay compact. + */ +function slotLabel(token: string): string { + const parts = token.split('|'); + + if (parts[0] === 'best') { + return `3rd #${parts[1]}`; + } + + const group = parts[1].replace(/^Group\s+/i, ''); + const position = Number(parts[2]); + + if (position === 1) { + return `Winner ${group}`; + } + + if (position === 2) { + return `Runner-up ${group}`; + } + + const suffix = position % 10 === 3 && position % 100 !== 13 ? 'rd' : 'th'; + + return `${position}${suffix} ${group}`; +} + +type SlotKind = 'winner' | 'ru' | 'third' | 'other'; + +function kindForToken(token: string): SlotKind { + const parts = token.split('|'); + + if (parts[0] === 'best') { + return 'third'; + } + + const position = Number(parts[2]); + + return position === 1 ? 'winner' : position === 2 ? 'ru' : 'other'; +} + +function dotClass(kind: SlotKind): string { + return { winner: 'bg-primary', ru: 'bg-sky-500', third: 'bg-amber-500', other: 'bg-muted-foreground' }[kind]; +} + +/** Qualifier chips grouped by kind, marking the ones already on the bracket. */ +const paletteGroups = computed<{ label: string; chips: { token: string; label: string; kind: SlotKind; placed: boolean }[] }[]>(() => { + const placed = new Set(slots.value.filter((token): token is string => token !== null)); + const labels: Record = { + winner: 'Group winners', + ru: 'Runners-up', + third: 'Best-placed', + other: 'Other qualifiers', + }; + const grouped: Record = { winner: [], ru: [], third: [], other: [] }; + + for (const option of slotOptions.value) { + const kind = kindForToken(option.value); + grouped[kind].push({ token: option.value, label: slotLabel(option.value), kind, placed: placed.has(option.value) }); + } + + return (['winner', 'ru', 'third', 'other'] as SlotKind[]) + .filter((kind) => grouped[kind].length > 0) + .map((kind) => ({ label: labels[kind], chips: grouped[kind] })); +}); + +// ----- drag and drop (native, no dependency) ----- + +type DragState = { kind: 'chip'; token: string; from: number | null } | { kind: 'match'; index: number }; + +const drag = ref(null); +const overSlot = ref(null); +const dropMatch = ref<{ index: number; after: boolean } | null>(null); + +function assignSlot(token: string, index: number, from: number | null): void { + const next = [...slots.value]; + const occupant = next[index]; + next[index] = token; + + // Dragged out of another slot: leave the source empty, or swap when the + // target was occupied. + if (from !== null) { + next[from] = occupant && occupant !== token ? occupant : null; + } + + slots.value = next; +} + +function unassignSlot(index: number): void { + const next = [...slots.value]; + next[index] = null; + slots.value = next; +} + +/** Move round-1 match `from` to position `to`, sliding the rest along. */ +function moveMatch(from: number, to: number): void { + if (from === to) { + return; + } + + const pairs: [string | null, string | null][] = []; + for (let i = 0; i < slots.value.length; i += 2) { + pairs.push([slots.value[i], slots.value[i + 1]]); + } + + const [pair] = pairs.splice(from, 1); + pairs.splice(to, 0, pair); + slots.value = pairs.flat(); +} + +function onChipDragStart(event: DragEvent, token: string, from: number | null): void { + drag.value = { kind: 'chip', token, from }; + // Firefox only starts a drag once data is set; the value itself is unused. + event.dataTransfer?.setData('text/plain', token); + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + } +} + +function onMatchDragStart(event: DragEvent, index: number): void { + drag.value = { kind: 'match', index }; + event.dataTransfer?.setData('text/plain', `match:${index}`); + + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + } +} + +function onDragEnd(): void { + drag.value = null; + overSlot.value = null; + dropMatch.value = null; +} + +function onSlotDragOver(event: DragEvent, index: number): void { + if (drag.value?.kind !== 'chip') { + return; + } + + event.preventDefault(); + overSlot.value = index; +} + +function onSlotDrop(index: number): void { + if (drag.value?.kind !== 'chip') { + return; + } + + assignSlot(drag.value.token, index, drag.value.from); + onDragEnd(); +} + +function dropTargetAfter(event: DragEvent): boolean { + const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); + + return event.clientY > rect.top + rect.height / 2; +} + +function onMatchDragOver(event: DragEvent, index: number): void { + if (drag.value?.kind !== 'match') { + return; + } + + event.preventDefault(); + dropMatch.value = { index, after: dropTargetAfter(event) }; +} + +function onMatchDrop(event: DragEvent, index: number): void { + if (drag.value?.kind !== 'match') { + return; + } + + let to = index + (dropTargetAfter(event) ? 1 : 0); + + if (drag.value.index < to) { + to -= 1; + } + + moveMatch(drag.value.index, to); + onDragEnd(); +} + +// ----- tap to place (works on touch, where native drag never fires) ----- + +const selected = ref<{ token: string; from: number | null } | null>(null); + +function isSelected(token: string, from: number | null): boolean { + return selected.value?.token === token && selected.value.from === from; +} + +function onChipTap(token: string): void { + selected.value = isSelected(token, null) ? null : { token, from: null }; +} + +function onSlotTap(index: number): void { + if (selected.value) { + // Tapping the slot a chip was picked up from just cancels the pickup. + if (selected.value.from === index) { + selected.value = null; + + return; + } + + assignSlot(selected.value.token, index, selected.value.from); + selected.value = null; + + return; + } + + const token = slots.value[index]; + + if (token !== null) { + selected.value = { token, from: index }; + } +} + +// Tap a matchup's handle to pick it up, then another to drop it there. +const selectedMatch = ref(null); + +function onMatchHandleTap(index: number): void { + if (selectedMatch.value === null || selectedMatch.value === index) { + selectedMatch.value = selectedMatch.value === index ? null : index; + + return; + } + + const to = selectedMatch.value < index ? index - 1 : index; + moveMatch(selectedMatch.value, to); + selectedMatch.value = null; } +function roundLabelFor(gameCount: number): string { + const labels: Record = { 1: 'Final', 2: 'Semifinals', 4: 'Quarterfinals', 8: 'Round of 16', 16: 'Round of 32' }; + + return labels[gameCount] ?? `Round of ${gameCount * 2}`; +} + +function roundTag(gameCount: number, index: number): string { + const prefixes: Record = { 1: 'F', 2: 'SF', 4: 'QF', 8: 'R16', 16: 'M' }; + const prefix = prefixes[gameCount] ?? `R${gameCount * 2}`; + + return gameCount === 1 ? prefix : `${prefix}${index + 1}`; +} + +/** + * The bracket columns after round 1: each later round's games and which + * earlier games feed them (by tag — M1, QF1 …). Independent of who's placed, + * so the tree is visible while the admin is still filling slots. + */ +const derivedRounds = computed<{ label: string; games: { tag: string; feedA: string; feedB: string }[] }[]>(() => { + if (matchCount.value < 2 || (matchCount.value & (matchCount.value - 1)) !== 0) { + return []; + } + + let previousTags = Array.from({ length: matchCount.value }, (_, index) => `M${index + 1}`); + const rounds: { label: string; games: { tag: string; feedA: string; feedB: string }[] }[] = []; + + while (previousTags.length > 1) { + const gameCount = previousTags.length / 2; + const games = []; + + for (let j = 0; j < gameCount; j++) { + games.push({ tag: roundTag(gameCount, j), feedA: previousTags[2 * j], feedB: previousTags[2 * j + 1] }); + } + + rounds.push({ label: roundLabelFor(gameCount), games }); + previousTags = games.map((game) => game.tag); + } + + return rounds; +}); + const formatLabel = props.formats.find((f) => f.value === props.stage.format)?.label ?? props.stage.format; const pageBreadcrumbs = computed(() => [ @@ -229,7 +537,7 @@ function submit() { @@ -363,7 +759,7 @@ function submit() { -
+