brackt/app/components/scoring/BracketTreePaginated.tsx

173 lines
5.9 KiB
TypeScript
Raw Permalink Normal View History

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>
);
}