Merge branch 'main' of https://github.com/Clippii/tssbot.web
This commit is contained in:
+115
-23
@@ -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 (
|
||||
<div className="bracket-viewport" onPointerDown={onPointerDown} ref={ref}>
|
||||
{children}
|
||||
<div className="bracket-fs-container" ref={containerRef}>
|
||||
<div className="bracket-viewport" onPointerDown={onPointerDown} ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
<button
|
||||
className="bracket-fs-btn"
|
||||
data-fs-btn=""
|
||||
onClick={toggleFs}
|
||||
title={fsActive ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
type="button"
|
||||
>
|
||||
{fsActive ? '✕' : '⛶'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5319,30 +5345,93 @@ function TournamentStandings({ standings }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TournamentMatchList({ sides, navigate }) {
|
||||
function TournamentListColumn({ column, navigate, highlight, onHover }) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{sides.map((side) => {
|
||||
const matches = side.columns.flatMap((column) => column.matches)
|
||||
return (
|
||||
<div key={side.key}>
|
||||
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-cyan">{side.label}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{matches.map((match) => (
|
||||
<TournamentMatchCard
|
||||
key={`${match.type_bracket}-${match.match_id}`}
|
||||
match={match}
|
||||
navigate={navigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className={`tournament-list-column${collapsed ? ' is-collapsed' : ''}`}>
|
||||
<button
|
||||
aria-expanded={!collapsed}
|
||||
className="tournament-list-round-btn"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
title={collapsed ? 'Expand round' : 'Collapse round'}
|
||||
type="button"
|
||||
>
|
||||
{column.label}
|
||||
<span aria-hidden="true" className={`tournament-list-round-caret${collapsed ? ' is-collapsed' : ''}`}>▾</span>
|
||||
</button>
|
||||
{collapsed ? null : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{column.matches.map((match) => {
|
||||
const traced = traceClass(teamsOf(match), highlight)
|
||||
return (
|
||||
<div className={`tournament-match-node${traced}`} key={`${match.type_bracket}-${match.match_id}`} style={{ position: 'relative' }}>
|
||||
<TournamentMatchCard match={match} navigate={navigate} onHover={onHover} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TournamentListSide({ side, navigate, highlight, onHover }) {
|
||||
const active = Boolean(highlight)
|
||||
return (
|
||||
<div className="bracket-side">
|
||||
<p className="mb-3 text-sm font-semibold uppercase tracking-wide text-fury-violet">
|
||||
{side.label}
|
||||
</p>
|
||||
<BracketViewport>
|
||||
<div className={`tournament-bracket-grid${active ? ' has-trace' : ''}`}>
|
||||
{side.columns.map((column) => (
|
||||
<TournamentListColumn column={column} highlight={highlight} key={column.id} navigate={navigate} onHover={onHover} />
|
||||
))}
|
||||
</div>
|
||||
</BracketViewport>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TournamentMatchList({ sides, navigate, highlight, onHover }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{sides.map((side) => (
|
||||
<TournamentListSide highlight={highlight} key={side.key} navigate={navigate} onHover={onHover} side={side} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
<div className="space-y-4">
|
||||
{bracketSides.length ? <h2 className="text-lg font-semibold">Group stage</h2> : null}
|
||||
{hasStandings ? <TournamentStandings standings={standings} /> : null}
|
||||
{listSides.length ? <TournamentMatchList sides={listSides} navigate={navigate} /> : null}
|
||||
{listSides.length ? <TournamentMatchList highlight={highlight} navigate={navigate} onHover={onHover} sides={listSides} /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user