brackt/app/components/scoring/Cs2TournamentBracket.tsx

200 lines
7.2 KiB
TypeScript
Raw Normal View History

import { useState } from "react";
import type { SeasonMatch, MatchSubGame } from "~/models/season-match";
import { PlayoffBracket, type Match as PlayoffMatch, type TeamOwnership } from "./PlayoffBracket";
type MatchWithRelations = SeasonMatch & {
participant1: { id: string; name: string } | null;
participant2: { id: string; name: string } | null;
winner: { id: string; name: string } | null;
subGames: MatchSubGame[];
};
interface Cs2TournamentBracketProps {
swissMatches: MatchWithRelations[];
playoffMatches: PlayoffMatch[];
playoffRounds: string[];
bracketTemplateId?: string | null;
teamOwnerships?: TeamOwnership[];
userParticipantIds?: string[];
}
const STATUS_BADGE: Record<string, string> = {
scheduled: "bg-muted text-muted-foreground text-xs px-2 py-0.5 rounded",
in_progress: "bg-amber-500/15 text-amber-400 border border-amber-500/30 text-xs px-2 py-0.5 rounded",
complete: "bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 text-xs px-2 py-0.5 rounded",
canceled: "bg-destructive/15 text-destructive text-xs px-2 py-0.5 rounded",
postponed: "bg-muted text-muted-foreground text-xs px-2 py-0.5 rounded italic",
};
function MapScores({ subGames, p1Id, p2Id }: { subGames: MatchSubGame[]; p1Id: string | null; p2Id: string | null }) {
if (subGames.length === 0) return null;
return (
<div className="flex gap-1 mt-1 flex-wrap">
{subGames.map((g) => (
<span key={g.id} className="text-xs text-muted-foreground border border-border rounded px-1.5 py-0.5">
{g.gameLabel && <span className="font-medium text-foreground/70 mr-1">{g.gameLabel}</span>}
<span className={g.winnerId === p1Id ? "text-foreground font-semibold" : ""}>{g.participant1Score ?? "-"}</span>
{" "}
<span className={g.winnerId === p2Id ? "text-foreground font-semibold" : ""}>{g.participant2Score ?? "-"}</span>
</span>
))}
</div>
);
}
function MatchCard({ match }: { match: MatchWithRelations }) {
const p1 = match.participant1;
const p2 = match.participant2;
const winnerId = match.winnerId;
const isComplete = match.status === "complete";
return (
<div className="border border-border rounded-lg p-3 bg-card">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isComplete && winnerId !== p1?.id ? "text-muted-foreground line-through decoration-1" : ""}`}>
{p1?.name ?? "TBD"}
{isComplete && (
<span className="ml-2 text-xs tabular-nums">{match.participant1Score ?? ""}</span>
)}
</div>
<div className={`text-sm font-medium truncate mt-1 ${isComplete && winnerId !== p2?.id ? "text-muted-foreground line-through decoration-1" : ""}`}>
{p2?.name ?? "TBD"}
{isComplete && (
<span className="ml-2 text-xs tabular-nums">{match.participant2Score ?? ""}</span>
)}
</div>
<MapScores subGames={match.subGames} p1Id={p1?.id ?? null} p2Id={p2?.id ?? null} />
</div>
<div className="shrink-0">
<span className={STATUS_BADGE[match.status] ?? STATUS_BADGE.scheduled}>
{match.status === "in_progress" && (
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 mr-1 animate-pulse" />
)}
{match.status === "complete" ? "Final" : match.status === "in_progress" ? "Live" : match.status === "scheduled" ? "Upcoming" : match.status}
</span>
</div>
</div>
</div>
);
}
function SwissStageTab({ stage: _stage, matches }: { stage: number; matches: MatchWithRelations[] }) {
if (matches.length === 0) {
return <p className="text-sm text-muted-foreground py-4">No matches for this stage yet.</p>;
}
// Group matches by round
const rounds = new Map<number, MatchWithRelations[]>();
for (const m of matches) {
const r = m.matchRound ?? 0;
let roundGroup = rounds.get(r);
if (!roundGroup) {
roundGroup = [];
rounds.set(r, roundGroup);
}
roundGroup.push(m);
}
const sortedRounds = [...rounds.entries()].toSorted(([a], [b]) => a - b);
return (
<div className="space-y-6">
{sortedRounds.map(([round, roundMatches]) => (
<div key={round}>
{round > 0 && (
<h4 className="text-sm font-semibold text-muted-foreground mb-3 uppercase tracking-wide">
Round {round}
</h4>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{roundMatches.map((m) => (
<MatchCard key={m.id} match={m} />
))}
</div>
</div>
))}
</div>
);
}
export function Cs2TournamentBracket({
swissMatches,
playoffMatches,
playoffRounds,
bracketTemplateId,
teamOwnerships,
userParticipantIds,
}: Cs2TournamentBracketProps) {
const stages = [1, 2, 3].filter((s) => swissMatches.some((m) => m.matchStage === s));
const hasBracket = playoffMatches.length > 0;
type Tab = "swiss-1" | "swiss-2" | "swiss-3" | "bracket";
const availableTabs: Tab[] = [
...(stages.includes(1) ? (["swiss-1"] as Tab[]) : []),
...(stages.includes(2) ? (["swiss-2"] as Tab[]) : []),
...(stages.includes(3) ? (["swiss-3"] as Tab[]) : []),
...(hasBracket ? (["bracket"] as Tab[]) : []),
];
const [activeTab, setActiveTab] = useState<Tab>(availableTabs[0] ?? "swiss-1");
const tabLabel: Record<Tab, string> = {
"swiss-1": "Opening Stage",
"swiss-2": "Challengers Stage",
"swiss-3": "Legends Stage",
bracket: "Champions Stage",
};
if (availableTabs.length === 0) {
return (
<div className="text-center text-muted-foreground py-12 text-sm">
No match data available yet.
</div>
);
}
return (
<div className="space-y-4">
{/* Tab bar */}
<div className="flex gap-1 border-b border-border overflow-x-auto pb-0">
{availableTabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium whitespace-nowrap border-b-2 transition-colors ${
activeTab === tab
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tabLabel[tab]}
</button>
))}
</div>
{/* Tab content */}
<div className="pt-2">
{activeTab === "swiss-1" && (
<SwissStageTab stage={1} matches={swissMatches.filter((m) => m.matchStage === 1)} />
)}
{activeTab === "swiss-2" && (
<SwissStageTab stage={2} matches={swissMatches.filter((m) => m.matchStage === 2)} />
)}
{activeTab === "swiss-3" && (
<SwissStageTab stage={3} matches={swissMatches.filter((m) => m.matchStage === 3)} />
)}
{activeTab === "bracket" && hasBracket && (
<PlayoffBracket
matches={playoffMatches}
rounds={playoffRounds}
bracketTemplateId={bracketTemplateId}
teamOwnerships={teamOwnerships}
userParticipantIds={userParticipantIds}
/>
)}
</div>
</div>
);
}