290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
|
|
import { memo, useState, useEffect, useRef } from "react";
|
||
|
|
import { DraftPickCell } from "~/components/draft/DraftPickCell";
|
||
|
|
import type { SeasonStatus } from "~/models/season";
|
||
|
|
import {
|
||
|
|
ContextMenu,
|
||
|
|
ContextMenuContent,
|
||
|
|
ContextMenuItem,
|
||
|
|
ContextMenuTrigger,
|
||
|
|
} from "~/components/ui/context-menu";
|
||
|
|
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
||
|
|
|
||
|
|
const ROW_GAP = 6; // gap-1.5
|
||
|
|
const ANIMATION_MS = 300;
|
||
|
|
|
||
|
|
function getRoundIndices(round: number): [number, number] {
|
||
|
|
if (round <= 2) return [0, 1];
|
||
|
|
return [round - 2, round - 1];
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface MiniDraftGridProps {
|
||
|
|
draftSlots: Array<{
|
||
|
|
id: string;
|
||
|
|
draftOrder: number;
|
||
|
|
team: {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
logoUrl?: string | null;
|
||
|
|
};
|
||
|
|
}>;
|
||
|
|
draftGrid: Array<
|
||
|
|
Array<{
|
||
|
|
pickNumber: number;
|
||
|
|
round: number;
|
||
|
|
pickInRound: number;
|
||
|
|
teamId: string;
|
||
|
|
pick?: {
|
||
|
|
participant: {
|
||
|
|
name: string;
|
||
|
|
};
|
||
|
|
sport: {
|
||
|
|
name: string;
|
||
|
|
};
|
||
|
|
};
|
||
|
|
}>
|
||
|
|
>;
|
||
|
|
currentPick: number;
|
||
|
|
currentRound: number;
|
||
|
|
ownerMap?: Record<string, string>;
|
||
|
|
teamTimers?: Record<string, number | undefined>;
|
||
|
|
autodraftStatus?: Record<string, { isEnabled: boolean }>;
|
||
|
|
seasonStatus?: SeasonStatus;
|
||
|
|
draftPaused?: boolean;
|
||
|
|
onForceAutopick?: (pickNumber: number, teamId: string) => void;
|
||
|
|
onForceManualPickOpen?: (pickNumber: number, teamId: string) => void;
|
||
|
|
onReplacePick?: (pickNumber: number, teamId: string) => void;
|
||
|
|
onRollbackToPick?: (pickNumber: number) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const MiniDraftGrid = memo(function MiniDraftGrid({
|
||
|
|
draftSlots,
|
||
|
|
draftGrid,
|
||
|
|
currentPick,
|
||
|
|
currentRound,
|
||
|
|
ownerMap = {},
|
||
|
|
teamTimers = {},
|
||
|
|
autodraftStatus = {},
|
||
|
|
seasonStatus,
|
||
|
|
draftPaused,
|
||
|
|
onForceAutopick,
|
||
|
|
onForceManualPickOpen,
|
||
|
|
onReplacePick,
|
||
|
|
onRollbackToPick,
|
||
|
|
}: MiniDraftGridProps) {
|
||
|
|
const [displayedIndices, setDisplayedIndices] = useState<[number, number]>(() => getRoundIndices(currentRound));
|
||
|
|
const [extraIndex, setExtraIndex] = useState<number | null>(null);
|
||
|
|
const [sliding, setSliding] = useState(false);
|
||
|
|
const animGenRef = useRef(0);
|
||
|
|
const prevRoundRef = useRef(currentRound);
|
||
|
|
const firstRowRef = useRef<HTMLDivElement>(null);
|
||
|
|
const [rowHeight, setRowHeight] = useState(0);
|
||
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||
|
|
const currentCellRef = useRef<HTMLDivElement>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (firstRowRef.current) {
|
||
|
|
setRowHeight(firstRowRef.current.getBoundingClientRect().height);
|
||
|
|
}
|
||
|
|
}, [draftGrid]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const container = scrollContainerRef.current;
|
||
|
|
const cell = currentCellRef.current;
|
||
|
|
if (!container || !cell) return;
|
||
|
|
const containerRect = container.getBoundingClientRect();
|
||
|
|
const cellRect = cell.getBoundingClientRect();
|
||
|
|
const scrollLeft = container.scrollLeft + cellRect.left - containerRect.left - (containerRect.width - cellRect.width) / 2;
|
||
|
|
container.scrollTo({ left: Math.max(0, scrollLeft), behavior: "smooth" });
|
||
|
|
}, [currentPick]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const prev = prevRoundRef.current;
|
||
|
|
prevRoundRef.current = currentRound;
|
||
|
|
|
||
|
|
const newIndices = getRoundIndices(currentRound);
|
||
|
|
const prevIndices = getRoundIndices(prev);
|
||
|
|
|
||
|
|
if (newIndices[0] === prevIndices[0]) return;
|
||
|
|
|
||
|
|
// Non-sequential jump or animation already running: snap and invalidate any in-flight animation
|
||
|
|
if (newIndices[0] !== prevIndices[0] + 1 || animGenRef.current > 0) {
|
||
|
|
animGenRef.current++; // invalidate any in-flight animation closure
|
||
|
|
setDisplayedIndices(newIndices);
|
||
|
|
setExtraIndex(null);
|
||
|
|
setSliding(false);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const gen = ++animGenRef.current;
|
||
|
|
setExtraIndex(newIndices[1]);
|
||
|
|
|
||
|
|
// Two rAFs: first lets React flush the extra row to DOM, second starts the CSS transition
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
requestAnimationFrame(() => {
|
||
|
|
setSliding(true);
|
||
|
|
|
||
|
|
setTimeout(() => {
|
||
|
|
if (animGenRef.current !== gen) return; // interrupted by snap
|
||
|
|
setDisplayedIndices(newIndices);
|
||
|
|
setExtraIndex(null);
|
||
|
|
setSliding(false);
|
||
|
|
animGenRef.current = 0;
|
||
|
|
}, ANIMATION_MS + 20);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}, [currentRound]);
|
||
|
|
|
||
|
|
if (draftGrid.length === 0) return null;
|
||
|
|
|
||
|
|
// h-14 (56px) + gap-1.5 (6px) fallback until first measurement
|
||
|
|
const effectiveRowHeight = rowHeight > 0 ? rowHeight : 56;
|
||
|
|
|
||
|
|
const roundIndicesToRender: number[] = extraIndex !== null
|
||
|
|
? [displayedIndices[0], displayedIndices[1], extraIndex]
|
||
|
|
: [displayedIndices[0], displayedIndices[1]];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div ref={scrollContainerRef} className="overflow-x-auto">
|
||
|
|
<div className="inline-block min-w-full">
|
||
|
|
{/* Team header */}
|
||
|
|
<div className="flex gap-1.5 mb-1.5">
|
||
|
|
{draftSlots.map((slot) => {
|
||
|
|
const teamTime = teamTimers[slot.team.id];
|
||
|
|
const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled ?? false;
|
||
|
|
return (
|
||
|
|
<div key={slot.id} className="flex-1 min-w-20 text-center">
|
||
|
|
<div className="text-xs font-medium truncate px-1 flex items-center justify-center gap-0.5">
|
||
|
|
{isAutodraft && (
|
||
|
|
<span className="inline-flex items-center justify-center h-3.5 w-3.5 text-[9px] font-bold text-white bg-black shrink-0">A</span>
|
||
|
|
)}
|
||
|
|
<span className="truncate">{ownerMap[slot.team.id] || slot.team.name}</span>
|
||
|
|
</div>
|
||
|
|
<div className={`text-xs font-mono px-1 ${getTimerColorClass(teamTime)}`}>
|
||
|
|
{formatClockTime(teamTime)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Rows — clipped to 2-row height, slides to reveal new round */}
|
||
|
|
<div style={{ height: `${effectiveRowHeight * 2 + ROW_GAP}px`, overflow: "hidden" }}>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
display: "flex",
|
||
|
|
flexDirection: "column",
|
||
|
|
gap: `${ROW_GAP}px`,
|
||
|
|
transform: sliding ? `translateY(-${effectiveRowHeight + ROW_GAP}px)` : "translateY(0)",
|
||
|
|
transition: sliding ? `transform ${ANIMATION_MS}ms ease-in-out` : "none",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{roundIndicesToRender.map((roundIndex) => {
|
||
|
|
if (roundIndex >= draftGrid.length) return null;
|
||
|
|
const roundPicks = draftGrid[roundIndex];
|
||
|
|
const round = roundIndex + 1;
|
||
|
|
const isEvenRound = round % 2 === 0;
|
||
|
|
const displayPicks = isEvenRound ? [...roundPicks].toReversed() : roundPicks;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div key={round} ref={roundIndex === displayedIndices[0] ? firstRowRef : undefined} className="flex gap-1.5 items-stretch flex-shrink-0">
|
||
|
|
{displayPicks.map((cell) => {
|
||
|
|
const isCurrent = cell.pickNumber === currentPick;
|
||
|
|
const isPicked = !!cell.pick;
|
||
|
|
const cellState = isPicked ? "picked" : isCurrent ? "current" : "upcoming";
|
||
|
|
const pickData = cell.pick
|
||
|
|
? {
|
||
|
|
participant: { name: cell.pick.participant.name },
|
||
|
|
sport: { name: cell.pick.sport.name },
|
||
|
|
}
|
||
|
|
: undefined;
|
||
|
|
|
||
|
|
if (!isPicked && isCurrent && (onForceAutopick || onForceManualPickOpen)) {
|
||
|
|
return (
|
||
|
|
<ContextMenu key={cell.pickNumber}>
|
||
|
|
<ContextMenuTrigger asChild>
|
||
|
|
<DraftPickCell
|
||
|
|
ref={isCurrent ? currentCellRef : undefined}
|
||
|
|
pickNumber={cell.pickNumber}
|
||
|
|
round={cell.round}
|
||
|
|
pickInRound={cell.pickInRound}
|
||
|
|
state={cellState}
|
||
|
|
pick={pickData}
|
||
|
|
seasonStatus={seasonStatus}
|
||
|
|
draftPaused={draftPaused}
|
||
|
|
className="cursor-context-menu"
|
||
|
|
/>
|
||
|
|
</ContextMenuTrigger>
|
||
|
|
<ContextMenuContent>
|
||
|
|
{onForceAutopick && (
|
||
|
|
<ContextMenuItem onClick={() => onForceAutopick(cell.pickNumber, cell.teamId)}>
|
||
|
|
Force Auto Pick
|
||
|
|
</ContextMenuItem>
|
||
|
|
)}
|
||
|
|
{onForceManualPickOpen && (
|
||
|
|
<ContextMenuItem onClick={() => onForceManualPickOpen(cell.pickNumber, cell.teamId)}>
|
||
|
|
Force Manual Pick
|
||
|
|
</ContextMenuItem>
|
||
|
|
)}
|
||
|
|
</ContextMenuContent>
|
||
|
|
</ContextMenu>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (isPicked && (onReplacePick || onRollbackToPick)) {
|
||
|
|
return (
|
||
|
|
<ContextMenu key={cell.pickNumber}>
|
||
|
|
<ContextMenuTrigger asChild>
|
||
|
|
<DraftPickCell
|
||
|
|
pickNumber={cell.pickNumber}
|
||
|
|
round={cell.round}
|
||
|
|
pickInRound={cell.pickInRound}
|
||
|
|
state={cellState}
|
||
|
|
pick={pickData}
|
||
|
|
seasonStatus={seasonStatus}
|
||
|
|
draftPaused={draftPaused}
|
||
|
|
className="cursor-context-menu"
|
||
|
|
/>
|
||
|
|
</ContextMenuTrigger>
|
||
|
|
<ContextMenuContent>
|
||
|
|
{onReplacePick && (
|
||
|
|
<ContextMenuItem onClick={() => onReplacePick(cell.pickNumber, cell.teamId)}>
|
||
|
|
Replace Pick
|
||
|
|
</ContextMenuItem>
|
||
|
|
)}
|
||
|
|
{onRollbackToPick && (
|
||
|
|
<ContextMenuItem
|
||
|
|
onClick={() => onRollbackToPick(cell.pickNumber)}
|
||
|
|
className="text-destructive focus:text-destructive"
|
||
|
|
>
|
||
|
|
Roll Back to This Pick
|
||
|
|
</ContextMenuItem>
|
||
|
|
)}
|
||
|
|
</ContextMenuContent>
|
||
|
|
</ContextMenu>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<DraftPickCell
|
||
|
|
key={cell.pickNumber}
|
||
|
|
ref={isCurrent ? currentCellRef : undefined}
|
||
|
|
pickNumber={cell.pickNumber}
|
||
|
|
round={cell.round}
|
||
|
|
pickInRound={cell.pickInRound}
|
||
|
|
state={cellState}
|
||
|
|
pick={pickData}
|
||
|
|
seasonStatus={seasonStatus}
|
||
|
|
draftPaused={draftPaused}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
});
|