More draft room fixes.

This commit is contained in:
Chris Parsons 2026-04-17 23:38:29 -07:00
parent 44ad954dfe
commit a4eaec865b
4 changed files with 346 additions and 294 deletions

View file

@ -1,12 +1,7 @@
import { useState } from "react";
import type { ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "~/components/ui/accordion";
import { cn } from "~/lib/utils";
interface DraftSidebarProps {
@ -26,17 +21,19 @@ export function DraftSidebar({
settingsSection,
className,
}: DraftSidebarProps) {
const [queueOpen, setQueueOpen] = useState(true);
const [picksOpen, setPicksOpen] = useState(true);
if (collapsed) {
return (
<div
className={cn(
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300 flex flex-col",
"w-12 hidden lg:block", // Hide completely on mobile when collapsed
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300 flex flex-col h-full",
"w-12 hidden lg:flex",
className
)}
>
<div className="flex-1" />
{/* Expand button at bottom to match hide button position */}
<div className="border-t border-border p-2 flex-shrink-0">
<Button
variant="ghost"
@ -65,45 +62,50 @@ export function DraftSidebar({
className={cn(
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300 flex flex-col",
"w-[300px]",
// Mobile: fixed overlay, Desktop: normal sidebar
"fixed inset-y-0 left-0 z-50 lg:relative lg:z-auto",
className
)}
>
<Accordion
type="multiple"
defaultValue={["queue", "recent-picks"]}
className="flex-1 overflow-hidden flex flex-col"
>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Queue Section */}
<AccordionItem value="queue" className="border-b flex-shrink-0">
<AccordionTrigger className="px-4 py-3 hover:no-underline bg-muted/50 hover:bg-muted">
<div className={cn("flex flex-col border-b", queueOpen ? "flex-1 min-h-0" : "flex-shrink-0")}>
<button
onClick={() => setQueueOpen((o) => !o)}
className="px-4 py-3 bg-muted/50 hover:bg-muted flex items-center justify-between w-full flex-shrink-0"
>
<h2 className="font-semibold text-sm">My Queue</h2>
</AccordionTrigger>
<AccordionContent className="max-h-[35vh] overflow-y-auto pb-0">
<ChevronDown className={cn("h-4 w-4 transition-transform", queueOpen && "rotate-180")} />
</button>
{queueOpen && (
<div className="flex-1 overflow-y-auto min-h-0">
{queueSection}
</AccordionContent>
</AccordionItem>
</div>
)}
</div>
{/* Recent Picks Section */}
<AccordionItem value="recent-picks" className="border-0">
<AccordionTrigger className="px-4 py-3 hover:no-underline bg-muted/50 hover:bg-muted">
{/* Picks Section */}
<div className={cn("flex flex-col", picksOpen ? "flex-1 min-h-0" : "flex-shrink-0")}>
<button
onClick={() => setPicksOpen((o) => !o)}
className="px-4 py-3 bg-muted/50 hover:bg-muted flex items-center justify-between w-full flex-shrink-0"
>
<h2 className="font-semibold text-sm">Picks</h2>
</AccordionTrigger>
<AccordionContent className="pb-0 overflow-y-auto max-h-[45vh]">
<ChevronDown className={cn("h-4 w-4 transition-transform", picksOpen && "rotate-180")} />
</button>
{picksOpen && (
<div className="flex-1 overflow-y-auto min-h-0">
{recentPicksSection}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
</div>
</div>
{/* Settings section (e.g. notification toggle) */}
{settingsSection && (
<div className="border-t border-border px-4 py-3 flex-shrink-0">
{settingsSection}
</div>
)}
{/* Collapse button at bottom */}
<div className="border-t border-border p-2 flex-shrink-0">
<Button
variant="ghost"

View file

@ -3,7 +3,7 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { Checkbox } from "~/components/ui/checkbox";
import { MiniDraftGrid } from "~/components/draft/MiniDraftGrid";
import { MiniDraftGrid, type MiniDraftGridProps } from "~/components/draft/MiniDraftGrid";
import {
Popover,
PopoverTrigger,
@ -133,32 +133,7 @@ interface AvailableParticipantsSectionProps {
};
}>;
participantRanks: Map<string, { overallRank: number; sportRank: number }>;
miniDraftGrid?: {
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>;
onForceAutopick?: (pickNumber: number, teamId: string) => void;
onForceManualPickOpen?: (pickNumber: number, teamId: string) => void;
onReplacePick?: (pickNumber: number, teamId: string) => void;
onRollbackToPick?: (pickNumber: number) => void;
};
miniDraftGrid?: MiniDraftGridProps;
searchQuery: string;
sportFilters: string[];
hideDrafted: boolean;
@ -293,24 +268,22 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</div>
)}
<div className="px-4 pt-4 pb-2 flex-shrink-0">
<div className="space-y-2">
<div className="flex flex-wrap gap-2 items-center">
<input
type="text"
placeholder="Search participants..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="w-full px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
className="flex-1 min-w-0 px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
/>
<div className="flex flex-wrap gap-2">
<div className="flex-1 min-w-[120px]">
<div className="md:hidden">
<div className="md:hidden shrink-0">
<Sheet>
<SheetTrigger asChild>
<Button
variant="outline"
aria-label={triggerAriaLabel}
className="w-full justify-between text-base font-normal"
className="justify-between text-base font-normal"
>
<span className="truncate">{triggerText}</span>
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
@ -345,13 +318,13 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</Sheet>
</div>
<div className="hidden md:block">
<div className="hidden md:block shrink-0">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-label={triggerAriaLabel}
className="w-full justify-between text-sm font-normal"
className="w-[160px] justify-between text-sm font-normal"
>
<span className="truncate">{triggerText}</span>
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
@ -382,9 +355,8 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</PopoverContent>
</Popover>
</div>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md shrink-0">
<Checkbox
checked={!hideDrafted}
onCheckedChange={(checked) => onHideDraftedChange(checked === false)}
@ -393,7 +365,7 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</label>
{hasTeam && eligibility && (
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md shrink-0">
<Checkbox
checked={!hideIneligible}
onCheckedChange={(checked) => onHideIneligibleChange(checked === false)}
@ -402,8 +374,6 @@ export const AvailableParticipantsSection = memo(function AvailableParticipantsS
</label>
)}
</div>
</div>
</div>
<div className={`hidden md:grid ${desktopGridClass} bg-muted border-b text-sm font-semibold flex-shrink-0 px-4`}>

View file

@ -1,4 +1,4 @@
import { memo, useMemo } from "react";
import { memo, useState, useEffect, useRef } from "react";
import { DraftPickCell } from "~/components/draft/DraftPickCell";
import type { SeasonStatus } from "~/models/season";
import {
@ -7,8 +7,17 @@ import {
ContextMenuItem,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
interface MiniDraftGridProps {
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;
@ -37,6 +46,8 @@ interface MiniDraftGridProps {
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;
@ -51,6 +62,8 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
currentPick,
currentRound,
ownerMap = {},
teamTimers = {},
autodraftStatus = {},
seasonStatus,
draftPaused,
onForceAutopick,
@ -58,49 +71,114 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
onReplacePick,
onRollbackToPick,
}: MiniDraftGridProps) {
const roundsToShow = useMemo(() => {
if (currentRound <= 1) return [0, 1];
return [currentRound - 2, currentRound - 1];
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);
useEffect(() => {
if (firstRowRef.current) {
setRowHeight(firstRowRef.current.getBoundingClientRect().height);
}
}, [draftGrid]);
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 className="overflow-x-auto">
<div className="inline-block min-w-full">
{/* Team header */}
<div className="flex gap-1.5 mb-1.5">
<div className="w-7 flex-shrink-0" />
{draftSlots.map((slot) => (
{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 text-muted-foreground">
{ownerMap[slot.team.id] || slot.team.name}
<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>
<div className="space-y-1.5">
{roundsToShow.map((roundIndex) => {
{/* 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;
const displayPicks = isEvenRound ? [...roundPicks].toReversed() : roundPicks;
return (
<div key={round} className="flex gap-1.5 items-stretch">
<div className="w-7 flex-shrink-0 flex items-center justify-center">
<span className="text-xs font-mono text-muted-foreground">R{round}</span>
</div>
<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 cellState = isPicked ? "picked" : isCurrent ? "current" : "upcoming";
const pickData = cell.pick
? {
participant: { name: cell.pick.participant.name },
@ -120,7 +198,7 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
pick={pickData}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
className="[&>div]:h-10 cursor-context-menu"
className="cursor-context-menu"
/>
</ContextMenuTrigger>
<ContextMenuContent>
@ -151,7 +229,7 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
pick={pickData}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
className="[&>div]:h-10 cursor-context-menu"
className="cursor-context-menu"
/>
</ContextMenuTrigger>
<ContextMenuContent>
@ -183,7 +261,6 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
pick={pickData}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
className="[&>div]:h-10"
/>
);
})}
@ -193,5 +270,6 @@ export const MiniDraftGrid = memo(function MiniDraftGrid({
</div>
</div>
</div>
</div>
);
});

View file

@ -539,7 +539,7 @@ export default function DraftRoom() {
const stored = localStorage.getItem("draftSidebarCollapsed");
return stored ? JSON.parse(stored) : false;
});
const [activeTab, setActiveTab] = useState<"participants" | "board" | "rosters" | "summary">("board");
const [activeTab, setActiveTab] = useState<"participants" | "board" | "rosters" | "summary">("participants");
const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "teams" | "controls">(
!userTeam && isCommissioner ? "board" : "available"
);
@ -1509,13 +1509,15 @@ export default function DraftRoom() {
currentPick,
currentRound,
ownerMap,
teamTimers,
autodraftStatus,
seasonStatus: season.status,
draftPaused: isPaused,
onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined,
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
}), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, season.status, isPaused, isCommissioner, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]);
}), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, season.status, isPaused, isCommissioner, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]);
const availableParticipantsSectionProps = useMemo(() => ({
participants: filteredParticipants,
@ -1738,7 +1740,7 @@ export default function DraftRoom() {
}
>
<TabsList>
<TabsTrigger value="participants">Available Participants</TabsTrigger>
<TabsTrigger value="participants">Participants</TabsTrigger>
<TabsTrigger value="board">Draft Board</TabsTrigger>
<TabsTrigger value="rosters">Rosters</TabsTrigger>
<TabsTrigger value="summary">Summary</TabsTrigger>
@ -1852,19 +1854,19 @@ export default function DraftRoom() {
/>
)}
<div className="flex-1 overflow-hidden">
{activeTab === "participants" && (
<div className="flex-1 overflow-hidden relative">
<div className={`absolute inset-0 ${activeTab === "participants" ? "" : "hidden"}`}>
<AvailableParticipantsSection {...availableParticipantsSectionProps} />
)}
{activeTab === "board" && (
</div>
<div className={`absolute inset-0 ${activeTab === "board" ? "" : "hidden"}`}>
<DraftGridSection {...draftGridSectionProps} />
)}
{activeTab === "rosters" && (
</div>
<div className={`absolute inset-0 ${activeTab === "rosters" ? "" : "hidden"}`}>
<TeamRosterView {...rosterViewProps} />
)}
{activeTab === "summary" && (
</div>
<div className={`absolute inset-0 ${activeTab === "summary" ? "" : "hidden"}`}>
<DraftSummaryView {...summaryViewProps} />
)}
</div>
</div>
</div>
)}