Computing it before the return statement is the right place since it only depends on props, not loop variables. Removes the IIFE entirely and fixes the indentation of the map callback body. https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1
331 lines
13 KiB
TypeScript
331 lines
13 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 }>;
|
|
connectedTeams?: Set<string>;
|
|
seasonStatus?: SeasonStatus;
|
|
draftPaused?: boolean;
|
|
onAdjustTimeBankOpen?: (teamId: string) => void;
|
|
onSetAutodraftOpen?: (teamId: string) => void;
|
|
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 = {},
|
|
connectedTeams,
|
|
seasonStatus,
|
|
draftPaused,
|
|
onAdjustTimeBankOpen,
|
|
onSetAutodraftOpen,
|
|
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;
|
|
|
|
const hasHeaderMenu = !!(onAdjustTimeBankOpen || onSetAutodraftOpen);
|
|
|
|
// 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;
|
|
const isConnected = connectedTeams ? connectedTeams.has(slot.team.id) : true;
|
|
|
|
const headerContent = (
|
|
<>
|
|
<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${!isConnected ? " italic text-muted-foreground" : ""}`}>
|
|
{ownerMap[slot.team.id] || slot.team.name}
|
|
</span>
|
|
</div>
|
|
<div className={`text-xs font-mono px-1 ${getTimerColorClass(teamTime)}`}>
|
|
{formatClockTime(teamTime)}
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
if (hasHeaderMenu) {
|
|
return (
|
|
<ContextMenu key={slot.id}>
|
|
<ContextMenuTrigger asChild>
|
|
<div className="flex-1 min-w-20 text-center cursor-context-menu">
|
|
{headerContent}
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
{onAdjustTimeBankOpen && (
|
|
<ContextMenuItem onClick={() => onAdjustTimeBankOpen(slot.team.id)}>
|
|
Adjust Time Bank...
|
|
</ContextMenuItem>
|
|
)}
|
|
{onSetAutodraftOpen && (
|
|
<ContextMenuItem onClick={() => onSetAutodraftOpen(slot.team.id)}>
|
|
Set Autodraft...
|
|
</ContextMenuItem>
|
|
)}
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={slot.id} className="flex-1 min-w-20 text-center">
|
|
{headerContent}
|
|
</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>
|
|
);
|
|
});
|