diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7a67560..29e5cc7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5065,11 +5065,26 @@ function noOpponentTooltip(match, roundLabel) { // threshold pans (and suppresses the click). Move/up live on the window so a fast // drag keeps panning even when the cursor leaves the canvas. function BracketViewport({ children }) { + const containerRef = useRef(null) const ref = useRef(null) + const [fsActive, setFsActive] = useState(false) + + useEffect(() => { + const handler = () => setFsActive(Boolean(document.fullscreenElement)) + document.addEventListener('fullscreenchange', handler) + return () => document.removeEventListener('fullscreenchange', handler) + }, []) + + const toggleFs = (event) => { + event.stopPropagation() + if (document.fullscreenElement) document.exitFullscreen() + else containerRef.current?.requestFullscreen() + } const onPointerDown = (event) => { const el = ref.current if (!el || event.button !== 0) return + if (event.target.closest('[data-fs-btn]')) return const start = { x: event.clientX, y: event.clientY, sl: el.scrollLeft, st: el.scrollTop, moved: false } bracketPan.dragged = false @@ -5102,8 +5117,19 @@ function BracketViewport({ children }) { } return ( -
- {children} +
+
+ {children} +
+
) } @@ -5319,30 +5345,93 @@ function TournamentStandings({ standings }) { ) } -function TournamentMatchList({ sides, navigate }) { +function TournamentListColumn({ column, navigate, highlight, onHover }) { + const [collapsed, setCollapsed] = useState(false) return ( -
- {sides.map((side) => { - const matches = side.columns.flatMap((column) => column.matches) - return ( -
-

{side.label}

-
- {matches.map((match) => ( - - ))} -
-
- ) - })} +
+ + {collapsed ? null : ( +
+ {column.matches.map((match) => { + const traced = traceClass(teamsOf(match), highlight) + return ( +
+ +
+ ) + })} +
+ )}
) } +function TournamentListSide({ side, navigate, highlight, onHover }) { + const active = Boolean(highlight) + return ( +
+

+ {side.label} +

+ +
+ {side.columns.map((column) => ( + + ))} +
+
+
+ ) +} + +function TournamentMatchList({ sides, navigate, highlight, onHover }) { + return ( +
+ {sides.map((side) => ( + + ))} +
+ ) +} + +// Swiss tournaments often have round: null on every match because the bot doesn't +// populate that field. Matches ARE returned in round order (sorted by time_start / +// match_id), so we can infer round boundaries: floor(uniqueTeams / 2) matches per +// round. Only kicks in when a list side has exactly one column (all-null rounds). +function inferSwissRounds(sides) { + return sides.map((side) => { + if (side.kind !== 'list' || side.columns.length !== 1) return side + const allMatches = side.columns[0].matches + if (!allMatches.length) return side + const teams = new Set() + allMatches.forEach((m) => { + if (m.team_a_name) teams.add(m.team_a_name) + if (m.team_b_name) teams.add(m.team_b_name) + }) + const matchesPerRound = Math.floor(teams.size / 2) + if (matchesPerRound <= 0 || allMatches.length <= matchesPerRound) return side + const columns = [] + for (let i = 0; i < allMatches.length; i += matchesPerRound) { + const roundNum = columns.length + 1 + columns.push({ + id: `${side.key}:inferred:${roundNum}`, + label: `Round ${roundNum}`, + matches: allMatches.slice(i, i + matchesPerRound), + }) + } + return { ...side, columns } + }) +} + function TournamentDetailPage({ tournamentId, navigate }) { const [state, setState] = useState({ status: 'loading', data: null, error: null }) @@ -5365,7 +5454,10 @@ function TournamentDetailPage({ tournamentId, navigate }) { const data = state.data const matches = useMemo(() => data?.matches || [], [data]) const format = useMemo(() => tournamentFormatMeta(data?.format, matches), [data?.format, matches]) - const { sides } = useMemo(() => buildBracket(matches), [matches]) + const { sides } = useMemo(() => { + const result = buildBracket(matches) + return { ...result, sides: inferSwissRounds(result.sides) } + }, [matches]) const bracketSides = sides.filter((side) => side.kind === 'bracket') const listSides = sides.filter((side) => side.kind === 'list') const standings = data?.standings || [] @@ -5425,7 +5517,7 @@ function TournamentDetailPage({ tournamentId, navigate }) {
{bracketSides.length ?

Group stage

: null} {hasStandings ? : null} - {listSides.length ? : null} + {listSides.length ? : null}
) : null} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5617017..c5e99ed 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -884,6 +884,101 @@ h3 { margin-left: calc(50% - 42.5vw); } +.bracket-fs-container { + position: relative; +} + +.bracket-fs-container:fullscreen { + display: flex; + flex-direction: column; + background-color: var(--color-bg); +} + +.bracket-fs-container:fullscreen .bracket-viewport { + flex: 1; + max-height: none; +} + +.bracket-fs-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + z-index: 10; + padding: 0.25rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: 0.375rem; + background: color-mix(in srgb, var(--color-surface) 85%, transparent); + font-size: 0.875rem; + font-weight: 600; + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s; + line-height: 1; +} + +.bracket-fs-btn:hover { + color: var(--color-text); +} + +/* List-style column inside the bracket viewport (Swiss / group rounds). + Label sits on the left as a vertical strip; cards fill the right. */ +.tournament-list-column { + position: relative; + z-index: 1; + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 1rem; + min-width: 440px; +} + +.tournament-list-column.is-collapsed { + min-width: 0; +} + +.tournament-list-round-btn { + writing-mode: vertical-lr; + align-self: stretch; + display: flex; + align-items: center; + justify-content: center; + padding: 0.5rem 0.375rem; + border-right: 1px solid var(--color-border); + font-size: 0.65rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + white-space: nowrap; +} + +.tournament-list-round-btn:hover { + color: var(--color-text); + border-color: var(--color-text-muted); +} + +/* Caret resets to horizontal so it renders upright inside the vertical label. */ +.tournament-list-round-caret { + writing-mode: horizontal-tb; + display: inline-block; + font-size: 1rem; + line-height: 1; + transition: transform 0.18s ease; + margin-top: 0.4rem; +} + +.tournament-list-round-caret.is-collapsed { + transform: rotate(-90deg); +} + +/* Divider between round columns. */ +.tournament-list-column:not(:last-child) { + border-right: 1px solid var(--color-border); + padding-right: 1rem; +} + .bracket-caret { display: inline-block; font-size: 1.35rem; diff --git a/vite.config.js b/vite.config.js index a38239b..b84914c 100644 --- a/vite.config.js +++ b/vite.config.js @@ -28,6 +28,9 @@ function isAllowedApiUrl(req) { if (req.method !== 'GET' && req.method !== 'HEAD') return false + if (url.pathname === '/api/tss/tournaments') return true + if (/^\/api\/tss\/tournaments\/[^/]+$/.test(url.pathname)) return true + if (url.pathname === '/api/tss/leaderboard/teams') { const keys = [...params.keys()] const limit = Number(params.get('limit') || 100)