173 lines
5.9 KiB
TypeScript
173 lines
5.9 KiB
TypeScript
|
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||
|
|
import { Button } from "~/components/ui/button";
|
||
|
|
import { useRoundTransition } from "~/hooks/useRoundTransition";
|
||
|
|
import {
|
||
|
|
TreeColumns,
|
||
|
|
BracketMatchSlot,
|
||
|
|
SLOT_WIDTH,
|
||
|
|
LABEL_HEIGHT,
|
||
|
|
DESIRED_CARD_HEIGHT,
|
||
|
|
CARD_GAP,
|
||
|
|
MAX_CARD_HEIGHT,
|
||
|
|
type BracketMatch,
|
||
|
|
type BracketOwnership,
|
||
|
|
} from "./BracketTreeView";
|
||
|
|
|
||
|
|
interface BracketTreePaginatedProps {
|
||
|
|
rounds: string[];
|
||
|
|
matchesByRound: Map<string, BracketMatch[]>;
|
||
|
|
ownershipMap: Map<string, BracketOwnership>;
|
||
|
|
userParticipantIds: Set<string>;
|
||
|
|
/** Index of the first scoring round — default page starts here */
|
||
|
|
firstScoringRoundIdx?: number;
|
||
|
|
thirdPlaceRound?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function BracketTreePaginated({
|
||
|
|
rounds,
|
||
|
|
matchesByRound,
|
||
|
|
ownershipMap,
|
||
|
|
userParticipantIds,
|
||
|
|
firstScoringRoundIdx,
|
||
|
|
thirdPlaceRound,
|
||
|
|
}: BracketTreePaginatedProps) {
|
||
|
|
const mainRounds = thirdPlaceRound ? rounds.filter((r) => r !== thirdPlaceRound) : rounds;
|
||
|
|
const thirdPlaceMatch = thirdPlaceRound ? (matchesByRound.get(thirdPlaceRound) ?? [])[0] : undefined;
|
||
|
|
|
||
|
|
const defaultPage = Math.max(
|
||
|
|
0,
|
||
|
|
Math.min(
|
||
|
|
firstScoringRoundIdx !== undefined
|
||
|
|
? Math.max(0, firstScoringRoundIdx - 1)
|
||
|
|
: mainRounds.length - 2,
|
||
|
|
mainRounds.length - 2,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
const { page, anim, stripRef, navigate, handleTransitionEnd } = useRoundTransition(
|
||
|
|
mainRounds.length - 2,
|
||
|
|
defaultPage,
|
||
|
|
);
|
||
|
|
|
||
|
|
const targetPage = anim ? anim.toPage : page;
|
||
|
|
const labelRounds = mainRounds.slice(targetPage, targetPage + 2);
|
||
|
|
const label = labelRounds[1] ? `${labelRounds[0]} → ${labelRounds[1]}` : labelRounds[0];
|
||
|
|
|
||
|
|
const calcHeight = (p: number) => {
|
||
|
|
const rs = mainRounds.slice(p, p + 2);
|
||
|
|
const max = Math.max(...rs.map((r) => matchesByRound.get(r)?.length ?? 0), 1);
|
||
|
|
return max * (DESIRED_CARD_HEIGHT + CARD_GAP);
|
||
|
|
};
|
||
|
|
|
||
|
|
const pageHeight = calcHeight(page);
|
||
|
|
const animFromHeight = anim ? calcHeight(anim.fromPage) : pageHeight;
|
||
|
|
const animToHeight = anim ? calcHeight(anim.toPage) : pageHeight;
|
||
|
|
|
||
|
|
const visibleRounds = mainRounds.slice(page, page + 2);
|
||
|
|
const fromRounds = anim ? mainRounds.slice(anim.fromPage, anim.fromPage + 2) : visibleRounds;
|
||
|
|
const toRounds = anim ? mainRounds.slice(anim.toPage, anim.toPage + 2) : visibleRounds;
|
||
|
|
|
||
|
|
let leftRounds: string[];
|
||
|
|
let rightRounds: string[] = [];
|
||
|
|
let leftHeight: number;
|
||
|
|
let rightHeight = 0;
|
||
|
|
let settlingTransition = false;
|
||
|
|
if (anim?.phase === "sliding") {
|
||
|
|
leftRounds = anim.dir === "right" ? fromRounds : toRounds;
|
||
|
|
rightRounds = anim.dir === "right" ? toRounds : fromRounds;
|
||
|
|
leftHeight = anim.dir === "right" ? animFromHeight : animToHeight;
|
||
|
|
rightHeight = anim.dir === "right" ? animToHeight : animFromHeight;
|
||
|
|
} else if (anim?.phase === "settling") {
|
||
|
|
leftRounds = toRounds;
|
||
|
|
leftHeight = animToHeight;
|
||
|
|
settlingTransition = true;
|
||
|
|
} else {
|
||
|
|
leftRounds = visibleRounds;
|
||
|
|
leftHeight = pageHeight;
|
||
|
|
}
|
||
|
|
|
||
|
|
const containerMinHeight = anim?.phase === "settling" ? animToHeight : animFromHeight;
|
||
|
|
const initialX = anim?.phase === "sliding" && anim.dir === "left" ? -SLOT_WIDTH : 0;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
<div className="flex items-center gap-2 mb-3">
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
onClick={() => navigate(page - 1)}
|
||
|
|
disabled={page === 0 || !!anim}
|
||
|
|
className="h-7 w-7 shrink-0"
|
||
|
|
aria-label="Previous rounds"
|
||
|
|
>
|
||
|
|
<ChevronLeft className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<span className="flex-1 text-center text-xs font-medium text-muted-foreground uppercase tracking-wide truncate">
|
||
|
|
{label}
|
||
|
|
</span>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
onClick={() => navigate(page + 1)}
|
||
|
|
disabled={page + 2 >= mainRounds.length || !!anim}
|
||
|
|
className="h-7 w-7 shrink-0"
|
||
|
|
aria-label="Next rounds"
|
||
|
|
>
|
||
|
|
<ChevronRight className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div style={{ width: SLOT_WIDTH, overflow: "hidden", minHeight: containerMinHeight + LABEL_HEIGHT + 2, transition: settlingTransition ? "min-height 500ms ease" : undefined }}>
|
||
|
|
<div
|
||
|
|
ref={anim?.phase === "sliding" ? stripRef : undefined}
|
||
|
|
style={{
|
||
|
|
display: "flex",
|
||
|
|
width: anim?.phase === "sliding" ? SLOT_WIDTH * 2 : SLOT_WIDTH,
|
||
|
|
transform: anim?.phase === "sliding" ? `translateX(${initialX}px)` : undefined,
|
||
|
|
}}
|
||
|
|
onTransitionEnd={handleTransitionEnd}
|
||
|
|
>
|
||
|
|
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||
|
|
<TreeColumns
|
||
|
|
visibleRounds={leftRounds}
|
||
|
|
matchesByRound={matchesByRound}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
bracketHeight={leftHeight}
|
||
|
|
transitionDuration={settlingTransition ? 500 : undefined}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
{anim?.phase === "sliding" && (
|
||
|
|
<div style={{ width: SLOT_WIDTH, flexShrink: 0 }}>
|
||
|
|
<TreeColumns
|
||
|
|
visibleRounds={rightRounds}
|
||
|
|
matchesByRound={matchesByRound}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
bracketHeight={rightHeight}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{thirdPlaceMatch && (
|
||
|
|
<div style={{ marginTop: 20, width: SLOT_WIDTH }}>
|
||
|
|
<div
|
||
|
|
className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground text-center"
|
||
|
|
style={{ height: LABEL_HEIGHT, display: "flex", alignItems: "center", justifyContent: "center" }}
|
||
|
|
>
|
||
|
|
3rd Place
|
||
|
|
</div>
|
||
|
|
<BracketMatchSlot
|
||
|
|
match={thirdPlaceMatch}
|
||
|
|
slotHeight={Math.min(DESIRED_CARD_HEIGHT, MAX_CARD_HEIGHT)}
|
||
|
|
ownershipMap={ownershipMap}
|
||
|
|
userParticipantIds={userParticipantIds}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|