117 lines
4.2 KiB
TypeScript
117 lines
4.2 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Text, ScrollArea, Box } from "@mantine/core";
|
|
import { BracketData } from "../types";
|
|
import { Bracket } from "./bracket";
|
|
import MatchDock from "./match-dock";
|
|
import useAppShellHeight from "@/hooks/use-appshell-height";
|
|
import { Match } from "@/features/matches/types";
|
|
import styles from "./styles.module.css";
|
|
|
|
interface BracketViewProps {
|
|
bracket: BracketData;
|
|
showControls?: boolean;
|
|
groupConfig?: {
|
|
num_groups: number;
|
|
advance_per_group: number;
|
|
};
|
|
renderMatch?: (match: Match) => React.ReactNode;
|
|
bottomOffset?: number;
|
|
nextUpMatchId?: string;
|
|
}
|
|
|
|
const BracketView: React.FC<BracketViewProps> = ({ bracket, showControls, groupConfig, renderMatch, bottomOffset, nextUpMatchId }) => {
|
|
const height = useAppShellHeight();
|
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
const hasAutoScrolled = useRef(false);
|
|
const orders = useMemo(() => {
|
|
const map: Record<number, number> = {};
|
|
bracket.winners.flat().forEach(match => map[match.lid] = match.order);
|
|
bracket.losers.flat().forEach(match => map[match.lid] = match.order);
|
|
return map;
|
|
}, [bracket.winners, bracket.losers]);
|
|
|
|
const [selectedLid, setSelectedLid] = useState<number | null>(null);
|
|
const handleMatchTap = useCallback((match: Match) => {
|
|
setSelectedLid((prev) => (prev === match.lid ? null : match.lid));
|
|
}, []);
|
|
const closeDock = useCallback(() => setSelectedLid(null), []);
|
|
|
|
const selectedMatch = useMemo(() => {
|
|
if (selectedLid == null) return null;
|
|
return (
|
|
[...bracket.winners.flat(), ...bracket.losers.flat()].find(
|
|
(match) => match.lid === selectedLid
|
|
) ?? null
|
|
);
|
|
}, [bracket, selectedLid]);
|
|
|
|
useEffect(() => {
|
|
if (hasAutoScrolled.current) return;
|
|
|
|
const matches = [...bracket.winners.flat(), ...bracket.losers.flat()].filter(
|
|
(match) => !match.bye
|
|
);
|
|
const target = nextUpMatchId !== undefined
|
|
? matches.find((match) => match.id === nextUpMatchId)
|
|
: matches.find((match) => match.status === "started") ??
|
|
matches.find((match) => match.status === "ready");
|
|
if (!target) return;
|
|
|
|
const viewport = viewportRef.current;
|
|
const element = viewport?.querySelector(`[data-match-lid="${target.lid}"]`);
|
|
if (!viewport || !element) return;
|
|
|
|
hasAutoScrolled.current = true;
|
|
|
|
const viewportRect = viewport.getBoundingClientRect();
|
|
const elementRect = element.getBoundingClientRect();
|
|
const left =
|
|
elementRect.left - viewportRect.left + viewport.scrollLeft - 40;
|
|
const top =
|
|
elementRect.top -
|
|
viewportRect.top +
|
|
viewport.scrollTop -
|
|
(viewport.clientHeight - elementRect.height) / 2;
|
|
|
|
viewport.scrollTo({
|
|
left: Math.max(0, left),
|
|
top: Math.max(0, top),
|
|
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
? "auto"
|
|
: "smooth",
|
|
});
|
|
}, [bracket, nextUpMatchId]);
|
|
|
|
return <Box pos="relative">
|
|
<ScrollArea
|
|
viewportRef={viewportRef}
|
|
h={`calc(${height})`}
|
|
className={styles["bracket-container"]}
|
|
style={{
|
|
backgroundImage: `radial-gradient(circle, var(--mantine-color-default-border) 1px, transparent 1px)`,
|
|
backgroundSize: "16px 16px",
|
|
backgroundPosition: "0 0, 8px 8px",
|
|
}}
|
|
>
|
|
<div>
|
|
<Text fw={600} size="md" m={16}>
|
|
Winners Bracket
|
|
</Text>
|
|
<Bracket rounds={bracket.winners} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} nextUpMatchId={nextUpMatchId} />
|
|
</div>
|
|
{bracket.losers && bracket.losers.length > 0 && bracket.losers.some(round => round.length > 0) && (
|
|
<div>
|
|
<Text fw={600} size="md" m={16}>
|
|
Losers Bracket
|
|
</Text>
|
|
<Bracket rounds={bracket.losers} orders={orders} showControls={showControls} groupConfig={groupConfig} renderMatch={renderMatch} onMatchTap={handleMatchTap} selectedMatchLid={selectedLid} nextUpMatchId={nextUpMatchId} />
|
|
</div>
|
|
)}
|
|
{bottomOffset ? <div style={{ height: bottomOffset }} /> : null}
|
|
</ScrollArea>
|
|
<MatchDock match={selectedMatch} onClose={closeDock} />
|
|
</Box>
|
|
};
|
|
|
|
export default BracketView;
|