diff --git a/app/components/DraftSidebar.tsx b/app/components/DraftSidebar.tsx new file mode 100644 index 0000000..040186a --- /dev/null +++ b/app/components/DraftSidebar.tsx @@ -0,0 +1,173 @@ +import { useState, useEffect } from "react"; +import type { ReactNode } from "react"; +import { ChevronLeft, ChevronRight, ChevronDown, ChevronUp } from "lucide-react"; +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; + +interface DraftSidebarProps { + collapsed: boolean; + onCollapsedChange: (collapsed: boolean) => void; + queueSection: ReactNode; + participantsSection: ReactNode; + className?: string; +} + +export function DraftSidebar({ + collapsed, + onCollapsedChange, + queueSection, + participantsSection, + className, +}: DraftSidebarProps) { + // Track which sections are expanded + // At least one must be expanded at all times + const [queueExpanded, setQueueExpanded] = useState(() => { + if (typeof window === "undefined") return true; + const stored = localStorage.getItem("draftSidebarQueueExpanded"); + return stored !== null ? JSON.parse(stored) : true; + }); + + const [participantsExpanded, setParticipantsExpanded] = useState(() => { + if (typeof window === "undefined") return true; + const stored = localStorage.getItem("draftSidebarParticipantsExpanded"); + return stored !== null ? JSON.parse(stored) : true; + }); + + // Persist section states to localStorage + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem( + "draftSidebarQueueExpanded", + JSON.stringify(queueExpanded) + ); + } + }, [queueExpanded]); + + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem( + "draftSidebarParticipantsExpanded", + JSON.stringify(participantsExpanded) + ); + } + }, [participantsExpanded]); + + const toggleQueueSection = () => { + // Prevent collapsing if it's the only expanded section + if (queueExpanded && !participantsExpanded) { + return; + } + setQueueExpanded(!queueExpanded); + }; + + const toggleParticipantsSection = () => { + // Prevent collapsing if it's the only expanded section + if (participantsExpanded && !queueExpanded) { + return; + } + setParticipantsExpanded(!participantsExpanded); + }; + + // Calculate flex sizes for sections + const queueFlexSize = queueExpanded && participantsExpanded ? "1" : queueExpanded ? "1" : "0"; + const participantsFlexSize = queueExpanded && participantsExpanded ? "1" : participantsExpanded ? "1" : "0"; + + if (collapsed) { + return ( + + ); + } + + return ( + <> + {/* Mobile backdrop */} +
onCollapsedChange(true)} + aria-label="Close sidebar" + /> + +
+ {/* Collapse button */} +
+ +
+ + {/* Queue Section */} +
+ + {queueExpanded && ( +
{queueSection}
+ )} +
+ + {/* Available Participants Section */} +
+ + {participantsExpanded && ( +
{participantsSection}
+ )} +
+
+ + ); +} diff --git a/app/components/draft/AvailableParticipantsSection.tsx b/app/components/draft/AvailableParticipantsSection.tsx new file mode 100644 index 0000000..a8cdfff --- /dev/null +++ b/app/components/draft/AvailableParticipantsSection.tsx @@ -0,0 +1,277 @@ +import { Button } from "~/components/ui/button"; +import { Badge } from "~/components/ui/badge"; + +interface AvailableParticipantsSectionProps { + participants: Array<{ + id: string; + name: string; + expectedValue: number; + sport: { + id: string; + name: string; + }; + }>; + searchQuery: string; + sportFilter: string; + hideDrafted: boolean; + uniqueSports: string[]; + draftedParticipantIds: Set; + queue: Array<{ id: string; participantId: string }>; + eligibility: { + eligibleSportIds: Set; + ineligibleReasons: Record; + } | null; + canPick: boolean; + hasTeam: boolean; + onSearchChange: (query: string) => void; + onSportFilterChange: (sport: string) => void; + onHideDraftedChange: (hide: boolean) => void; + onMakePick: (participantId: string) => void; + onAddToQueue: (participantId: string) => void; + onRemoveFromQueue: (queueId: string) => void; +} + +export function AvailableParticipantsSection({ + participants, + searchQuery, + sportFilter, + hideDrafted, + uniqueSports, + draftedParticipantIds, + queue, + eligibility, + canPick, + hasTeam, + onSearchChange, + onSportFilterChange, + onHideDraftedChange, + onMakePick, + onAddToQueue, + onRemoveFromQueue, +}: AvailableParticipantsSectionProps) { + return ( +
+
+

Available Participants

+ +
+ {/* Search Input */} + onSearchChange(e.target.value)} + className="w-full px-3 py-2 border rounded-md text-sm" + /> + +
+ {/* Sport Filter Dropdown */} + + + {/* Show/Hide Drafted Toggle */} + +
+
+
+ + {/* Table */} +
+
+ + + + + + + {hasTeam && ( + + )} + + + + {participants.length === 0 ? ( + + + + ) : ( + participants.map((participant) => { + const isDrafted = draftedParticipantIds.has(participant.id); + const isInQueue = queue.some( + (item) => item.participantId === participant.id + ); + + // Check eligibility + const isEligible = eligibility + ? eligibility.eligibleSportIds.has(participant.sport.id) + : true; + const ineligibleReason = + eligibility?.ineligibleReasons[participant.sport.id]; + + return ( + + + + + {hasTeam && ( + + )} + + ); + }) + )} + +
ParticipantSportEVQueue
+ No participants found +
+
+ + {participant.name} + + {isDrafted && ( + + Drafted + + )} + {!isDrafted && !isEligible && ( + + Ineligible + + )} + {isInQueue && !isDrafted && isEligible && ( + + Queued + + )} +
+
+ {participant.sport.name} + + {participant.expectedValue} + +
+ {/* Pick button - only show if it's your turn and participant not drafted */} + {canPick && !isDrafted && ( + <> + + {/* Queue icon button next to Pick */} + {!isInQueue ? ( + + ) : ( + + )} + + )} + + {/* Queue buttons - only show if not your turn */} + {!canPick && !isDrafted && !isInQueue && ( + + )} + {!canPick && !isDrafted && isInQueue && ( + + )} +
+
+
+
+
+ ); +} diff --git a/app/components/draft/DraftGridSection.tsx b/app/components/draft/DraftGridSection.tsx new file mode 100644 index 0000000..07ffc5d --- /dev/null +++ b/app/components/draft/DraftGridSection.tsx @@ -0,0 +1,217 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "~/components/ui/context-menu"; + +interface DraftGridSectionProps { + draftSlots: Array<{ + id: string; + draftOrder: number; + team: { + id: string; + name: string; + }; + }>; + draftGrid: Array< + Array<{ + pickNumber: number; + round: number; + pickInRound: number; + teamId: string; + pick?: { + participant: { + name: string; + }; + sport: { + name: string; + }; + }; + }> + >; + currentPick: number; + teamTimers: Record; + autodraftStatus: Record; + connectedTeams: Set; + isCommissioner: boolean; + onForceAutopick: (pickNumber: number, teamId: string) => void; + onForceManualPickOpen: (pickNumber: number, teamId: string) => void; +} + +export function DraftGridSection({ + draftSlots, + draftGrid, + currentPick, + teamTimers, + autodraftStatus, + connectedTeams, + isCommissioner, + onForceAutopick, + onForceManualPickOpen, +}: DraftGridSectionProps) { + const formatTime = (seconds: number | null) => { + if (seconds === null) return "--:--"; + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + } + return `${minutes}:${String(secs).padStart(2, "0")}`; + }; + + const getTimerColor = (seconds: number | null) => { + if (seconds === null) return "text-muted-foreground"; + if (seconds > 60) return "text-green-600 dark:text-green-400"; + if (seconds > 30) return "text-yellow-600 dark:text-yellow-400"; + if (seconds > 10) return "text-red-600 dark:text-red-400"; + return "text-red-600 dark:text-red-400 animate-pulse"; + }; + + return ( +
+

Draft Grid

+
+
+ {/* Team Headers */} +
+ {draftSlots.map((slot) => { + const teamTime = teamTimers[slot.team.id]; + const isAutodraft = autodraftStatus[slot.team.id] || false; + const isConnected = connectedTeams.has(slot.team.id); + + return ( +
+
+ {slot.team.name} +
+
+ {formatTime(teamTime)} + {isAutodraft && ( + + (auto) + + )} +
+
+ ); + })} +
+ + {/* Draft Grid Rows */} +
+ {draftGrid.map((roundPicks, roundIndex) => { + const round = roundIndex + 1; + const isEvenRound = round % 2 === 0; + const displayPicks = isEvenRound + ? [...roundPicks].reverse() + : roundPicks; + + return ( +
+ {displayPicks.map((cell) => { + const isCurrent = cell.pickNumber === currentPick; + const isPicked = !!cell.pick; + + return isCommissioner && !isPicked && isCurrent ? ( + + +
+
+ {cell.round}. + {String(cell.pickInRound).padStart(2, "0")} +
+ {isPicked ? ( +
+
+ {cell.pick?.participant.name} +
+
+ {cell.pick?.sport.name} +
+
+ ) : isCurrent ? ( +
+ On Clock +
+ ) : null} +
+
+ + + onForceAutopick(cell.pickNumber, cell.teamId) + } + > + Force Auto Pick + + + onForceManualPickOpen( + cell.pickNumber, + cell.teamId + ) + } + > + Force Manual Pick + + +
+ ) : ( +
+
+ {cell.round}. + {String(cell.pickInRound).padStart(2, "0")} +
+ {isPicked ? ( +
+
+ {cell.pick?.participant.name} +
+
+ {cell.pick?.sport.name} +
+
+ ) : isCurrent ? ( +
+ On Clock +
+ ) : null} +
+ ); + })} +
+ ); + })} +
+
+
+
+ ); +} diff --git a/app/components/draft/QueueSection.tsx b/app/components/draft/QueueSection.tsx new file mode 100644 index 0000000..4c01a70 --- /dev/null +++ b/app/components/draft/QueueSection.tsx @@ -0,0 +1,164 @@ +import { Button } from "~/components/ui/button"; +import { Badge } from "~/components/ui/badge"; +import { AutodraftSettings } from "~/components/AutodraftSettings"; + +interface QueueSectionProps { + queue: Array<{ + id: string; + participantId: string; + }>; + availableParticipants: Array<{ + id: string; + name: string; + }>; + eligibility: { + flexPicksUsed: number; + flexPicksAvailable: number; + picksBySport: Record; + sportAvailability: Record< + string, + { sportName: string; sportId: string } + >; + ineligibleReasons: Record; + } | null; + seasonId: string; + teamId: string; + isMyTurn: boolean; + userAutodraft: { + isEnabled: boolean; + mode: "next_pick" | "while_on"; + }; + onClearQueue: () => void; + onRemoveFromQueue: (queueId: string) => void; + onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => void; +} + +export function QueueSection({ + queue, + availableParticipants, + eligibility, + seasonId, + teamId, + isMyTurn, + userAutodraft, + onClearQueue, + onRemoveFromQueue, + onAutodraftUpdate, +}: QueueSectionProps) { + return ( + <> +
+

Queue ({queue.length})

+ {queue.length > 0 && ( + + )} +
+ + {/* Eligibility Status Banner */} + {eligibility && ( +
+
Draft Status
+
+
+ Flex Picks: + = eligibility.flexPicksAvailable + ? "text-red-600 dark:text-red-400 font-semibold" + : "text-green-600 dark:text-green-400" + } + > + {eligibility.flexPicksUsed} / {eligibility.flexPicksAvailable} + +
+ {Object.entries(eligibility.picksBySport).length > 0 && ( + <> +
Picks by Sport:
+ {Object.entries(eligibility.picksBySport).map( + ([sportId, count]) => { + const sportInfo = eligibility.sportAvailability[sportId]; + return ( +
+ {sportInfo?.sportName || sportId}: + {count} +
+ ); + } + )} + + )} + {Object.keys(eligibility.ineligibleReasons).length > 0 && ( + <> +
+ Restrictions: +
+ {Object.entries(eligibility.ineligibleReasons) + .slice(0, 2) + .map(([sportId, reason]) => { + const sportInfo = eligibility.sportAvailability[sportId]; + return ( +
+ {sportInfo?.sportName}: {reason} +
+ ); + })} + + )} +
+
+ )} + + {/* Queue List */} + {queue.length === 0 ? ( +

+ Click participants to add to your queue +

+ ) : ( +
+ {queue.map((item, index) => ( +
+
+ {index + 1} +
+

+ {availableParticipants.find( + (p) => p.id === item.participantId + )?.name || "Unknown"} +

+
+
+ +
+ ))} +
+ )} + + {/* Autodraft Settings */} + + + ); +} diff --git a/app/components/draft/RecentPicksSection.tsx b/app/components/draft/RecentPicksSection.tsx new file mode 100644 index 0000000..49c7c9a --- /dev/null +++ b/app/components/draft/RecentPicksSection.tsx @@ -0,0 +1,58 @@ +import { Badge } from "~/components/ui/badge"; + +interface RecentPicksSectionProps { + picks: Array<{ + id: string; + pickNumber: number; + round: number; + participant: { + name: string; + }; + sport: { + name: string; + }; + team: { + name: string; + }; + }>; +} + +export function RecentPicksSection({ picks }: RecentPicksSectionProps) { + return ( +
+

Recent Picks

+ {picks.length === 0 ? ( +

No picks yet...

+ ) : ( +
+ {picks + .slice() + .reverse() + .slice(0, 10) + .map((pick) => ( +
+
+ #{pick.pickNumber} +
+

{pick.participant.name}

+

+ {pick.sport.name} +

+
+
+
+

{pick.team.name}

+

+ Round {pick.round} +

+
+
+ ))} +
+ )} +
+ ); +} diff --git a/app/components/draft/TeamsDraftedGrid.tsx b/app/components/draft/TeamsDraftedGrid.tsx new file mode 100644 index 0000000..5ba6dac --- /dev/null +++ b/app/components/draft/TeamsDraftedGrid.tsx @@ -0,0 +1,205 @@ +import { Badge } from "~/components/ui/badge"; +import { useMemo } from "react"; + +interface TeamsDraftedGridProps { + draftSlots: Array<{ + id: string; + draftOrder: number; + team: { + id: string; + name: string; + seasonId: string; + }; + }>; + picks: Array<{ + id: string; + team: { + id: string; + name: string; + }; + participant: { + id: string; + name: string; + }; + sport: { + id: string; + name: string; + type: string; + }; + }>; + availableParticipants: Array<{ + id: string; + name: string; + sport: { + id: string; + name: string; + }; + }>; + season: { + id: string; + name: string; + year: number; + numFlexPicks: number; + sports?: Array<{ + id: string; + name: string; + }>; + }; +} + +export function TeamsDraftedGrid({ + draftSlots, + picks, + availableParticipants, + season, +}: TeamsDraftedGridProps) { + // Get unique sports from all available participants (not just picked ones) + const sports = useMemo(() => { + const sportMap = new Map(); + availableParticipants.forEach((participant) => { + if (!sportMap.has(participant.sport.id)) { + sportMap.set(participant.sport.id, { + id: participant.sport.id, + name: participant.sport.name, + }); + } + }); + return Array.from(sportMap.values()).sort((a, b) => + a.name.localeCompare(b.name) + ); + }, [availableParticipants]); + + // Calculate picks by team and sport + const picksByTeamAndSport = useMemo(() => { + const map = new Map>(); + + picks.forEach((pick) => { + if (!map.has(pick.team.id)) { + map.set(pick.team.id, new Map()); + } + const teamMap = map.get(pick.team.id)!; + if (!teamMap.has(pick.sport.id)) { + teamMap.set(pick.sport.id, []); + } + teamMap.get(pick.sport.id)!.push(pick); + }); + + return map; + }, [picks]); + + // Calculate flex picks used by each team + const flexPicksByTeam = useMemo(() => { + const map = new Map(); + + picks.forEach((pick) => { + // Flex picks are counted based on sport type or a different logic + // For now, we'll just count total picks and subtract sport-specific limits + // This is a simplified version - adjust based on your actual flex logic + if (!map.has(pick.team.id)) { + map.set(pick.team.id, 0); + } + }); + + // Calculate flex usage based on season settings + draftSlots.forEach((slot) => { + const teamPicks = picks.filter((p) => p.team.id === slot.team.id); + const totalPicks = teamPicks.length; + // This is simplified - you may need to implement actual flex calculation + // based on sport-specific limits in your season settings + const flexUsed = Math.max(0, totalPicks - sports.length); + map.set(slot.team.id, flexUsed); + }); + + return map; + }, [picks, draftSlots, sports]); + + if (sports.length === 0) { + return ( +
+

No picks have been made yet

+
+ ); + } + + return ( +
+
+ + + + + {draftSlots.map((slot) => { + const flexUsed = flexPicksByTeam.get(slot.team.id) || 0; + return ( + + ); + })} + + + + {sports.map((sport) => ( + + + {draftSlots.map((slot) => { + const teamSportPicks = + picksByTeamAndSport + .get(slot.team.id) + ?.get(sport.id) || []; + const hasMultiplePicks = teamSportPicks.length > 1; + + return ( + + ); + })} + + ))} + +
+ Sport + +
+
+ {slot.team.name} +
+
+ {flexUsed} of {season.numFlexPicks} flex +
+
+
+ {sport.name} + + {teamSportPicks.length > 0 ? ( +
+ {teamSportPicks.map((pick) => ( +
+ {pick.participant.name} + {hasMultiplePicks && ( + + {teamSportPicks.indexOf(pick) + 1} + + )} +
+ ))} +
+ ) : null} +
+
+
+ ); +} diff --git a/app/components/ui/collapsible.tsx b/app/components/ui/collapsible.tsx new file mode 100644 index 0000000..ae9fad0 --- /dev/null +++ b/app/components/ui/collapsible.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" + +function Collapsible({ + ...props +}: React.ComponentProps) { + return +} + +function CollapsibleTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CollapsibleContent({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/app/components/ui/tabs.tsx b/app/components/ui/tabs.tsx new file mode 100644 index 0000000..1e8aa1f --- /dev/null +++ b/app/components/ui/tabs.tsx @@ -0,0 +1,64 @@ +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "app/lib/utils" + +function Tabs({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx index b6d081f..f24d621 100644 --- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx +++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx @@ -7,15 +7,15 @@ import { useEffect, useState, useMemo } from "react"; import { Card } from "~/components/ui/card"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { getAuth } from "@clerk/react-router/server"; import { getTeamQueue } from "~/models/draft-queue"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuTrigger, -} from "~/components/ui/context-menu"; -import { AutodraftSettings } from "~/components/AutodraftSettings"; +import { DraftSidebar } from "~/components/DraftSidebar"; +import { TeamsDraftedGrid } from "~/components/draft/TeamsDraftedGrid"; +import { QueueSection } from "~/components/draft/QueueSection"; +import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection"; +import { RecentPicksSection } from "~/components/draft/RecentPicksSection"; +import { DraftGridSection } from "~/components/draft/DraftGridSection"; import { calculateDraftEligibility, getEligibilitySummary } from "~/lib/draft-eligibility"; export async function loader(args: any) { @@ -204,7 +204,15 @@ export default function DraftRoom() { const [queue, setQueue] = useState(userQueue); const [isPaused, setIsPaused] = useState(season.draftPaused || false); const [isDraftComplete, setIsDraftComplete] = useState(season.status === "active"); - + + // Sidebar and tabs state + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + if (typeof window === "undefined") return false; + const stored = localStorage.getItem("draftSidebarCollapsed"); + return stored ? JSON.parse(stored) : false; + }); + const [activeTab, setActiveTab] = useState<"board" | "picks" | "teams">("board"); + // Track autodraft status for all teams const [autodraftStatus, setAutodraftStatus] = useState>(() => { const status: Record = {}; @@ -438,6 +446,13 @@ export default function DraftRoom() { }; }, [on, off, currentPick, userTeam]); + // Persist sidebar collapsed state + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem("draftSidebarCollapsed", JSON.stringify(sidebarCollapsed)); + } + }, [sidebarCollapsed]); + // Queue handlers const handleAddToQueue = async (participantId: string) => { if (!userTeam) return; @@ -732,19 +747,47 @@ export default function DraftRoom() { )} {/* Standalone Header - No Main Nav */} -
+
-
-

- {season.league.name} - {season.year} Draft -

-
- Round: {currentRound} - Pick: {currentPick} - - {season.status.replace("_", " ")} - +
+ {/* Mobile menu button - only show if user has team and sidebar is collapsed */} + {userTeam && sidebarCollapsed && ( + + )} +
+

+ {season.league.name} - {season.year} Draft +

+
+ Round: {currentRound} + Pick: {currentPick} + + {season.status.replace("_", " ")} + +
@@ -785,542 +828,104 @@ export default function DraftRoom() {
- {/* Main Content */} -
- {/* Draft Grid - Horizontally Scrollable */} -
- -

Draft Grid

-
-
- {/* Team Headers */} -
- {draftSlots.map((slot) => { - const teamTime = teamTimers[slot.team.id]; - const isAutodraft = autodraftStatus[slot.team.id] || false; - const isConnected = connectedTeams.has(slot.team.id); - - return ( -
-
- {slot.team.name} -
-
- {formatTime(teamTime)} - {isAutodraft && ( - (auto) - )} -
-
- ); - })} -
+ {/* Main Content - Flex Row: Sidebar + Tabs */} +
+ {/* Sidebar */} + {userTeam && ( + { + setUserAutodraft({ isEnabled, mode }); + }} + /> + } + participantsSection={ + + } + /> + )} - {/* Draft Grid Rows */} -
- {draftGrid.map((roundPicks, roundIndex) => { - const round = roundIndex + 1; - const isEvenRound = round % 2 === 0; - const displayPicks = isEvenRound - ? [...roundPicks].reverse() - : roundPicks; + {/* Main Tabbed Content */} +
+ + setActiveTab(value as "board" | "picks" | "teams") + } + className="h-full flex flex-col" + > + + Draft Board + Recent Picks + Teams Drafted + - return ( -
- {displayPicks.map((cell) => { - const isCurrent = cell.pickNumber === currentPick; - const isPicked = !!cell.pick; + + { + setSelectedPickSlot({ pickNumber, teamId }); + setForcePickDialogOpen(true); + }} + /> + - return isCommissioner && !isPicked && isCurrent ? ( - - -
-
- {cell.round}. - {String(cell.pickInRound).padStart(2, "0")} -
- {isPicked ? ( -
-
- {cell.pick.participant.name} -
-
- {cell.pick.sport.name} -
-
- ) : isCurrent ? ( -
- On Clock -
- ) : null} -
-
- - - handleForceAutopick( - cell.pickNumber, - cell.teamId - ) - } - > - Force Auto Pick - - { - setSelectedPickSlot({ - pickNumber: cell.pickNumber, - teamId: cell.teamId, - }); - setForcePickDialogOpen(true); - }} - > - Force Manual Pick - - -
- ) : ( -
-
- {cell.round}. - {String(cell.pickInRound).padStart(2, "0")} -
- {isPicked ? ( -
-
- {cell.pick.participant.name} -
-
- {cell.pick.sport.name} -
-
- ) : isCurrent ? ( -
- On Clock -
- ) : null} -
- ); - })} -
- ); - })} -
-
-
- -
+ + + -
- {/* My Queue */} - {userTeam && ( -
- -
-

- My Queue ({queue.length}) -

- {queue.length > 0 && ( - - )} -
- - {/* Eligibility Status Banner */} - {eligibility && ( -
-
Draft Status
-
-
- Flex Picks: - = eligibility.flexPicksAvailable ? "text-red-600 dark:text-red-400 font-semibold" : "text-green-600 dark:text-green-400"}> - {eligibility.flexPicksUsed} / {eligibility.flexPicksAvailable} - -
- {Object.entries(eligibility.picksBySport).length > 0 && ( - <> -
Picks by Sport:
- {Object.entries(eligibility.picksBySport).map(([sportId, count]) => { - const sportInfo = eligibility.sportAvailability[sportId]; - return ( -
- {sportInfo?.sportName || sportId}: - {count} -
- ); - })} - - )} - {Object.keys(eligibility.ineligibleReasons).length > 0 && ( - <> -
- Restrictions: -
- {Object.entries(eligibility.ineligibleReasons).slice(0, 2).map(([sportId, reason]) => { - const sportInfo = eligibility.sportAvailability[sportId]; - return ( -
- {sportInfo?.sportName}: {reason} -
- ); - })} - - )} -
-
- )} - {queue.length === 0 ? ( -

- Click participants to add to your queue -

- ) : ( -
- {queue.map((item: any, index: number) => ( -
-
- {index + 1} -
-

- {availableParticipants.find( - (p: any) => p.id === item.participantId - )?.name || "Unknown"} -

-
-
- -
- ))} -
- )} - - {/* Autodraft Settings */} - { - setUserAutodraft({ isEnabled, mode }); + +
+ - -
- )} - - {/* Recent Picks */} -
- -

Recent Picks

- {picks.length === 0 ? ( -

No picks yet...

- ) : ( -
- {picks - .slice() - .reverse() - .slice(0, 10) - .map((pick: any) => ( -
-
- #{pick.pickNumber} -
-

- {pick.participant.name} -

-

- {pick.sport.name} -

-
-
-
-

{pick.team.name}

-

- Round {pick.round} -

-
-
- ))} -
- )} -
-
- - {/* Right Column: Available Participants */} -
- -
-

- Available Participants -

- -
- {/* Search Input */} - setSearchQuery(e.target.value)} - className="w-full px-3 py-2 border rounded-md text-sm" - /> - -
- {/* Sport Filter Dropdown */} - - - {/* Show/Hide Drafted Toggle */} - -
-
- - {/* Table */} -
-
- - - - - - - {userTeam && ( - - )} - - - - {filteredParticipants.length === 0 ? ( - - - - ) : ( - filteredParticipants.map((participant: any) => { - const isDrafted = draftedParticipantIds.has( - participant.id - ); - const isInQueue = queue.some( - (item: any) => item.participantId === participant.id - ); - - // Check eligibility - const isEligible = eligibility - ? eligibility.eligibleSportIds.has(participant.sport.id) - : true; - const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id]; - - return ( - - - - - {userTeam && ( - - )} - - ); - }) - )} - -
- Participant - SportEV - Queue -
- No participants found -
-
- - {participant.name} - - {isDrafted && ( - - Drafted - - )} - {!isDrafted && !isEligible && ( - - Ineligible - - )} - {isInQueue && !isDrafted && isEligible && ( - - Queued - - )} -
-
- {participant.sport.name} - - {participant.expectedValue} - -
- {/* Pick button - only show if it's your turn and participant not drafted */} - {canPick && !isDrafted && ( - <> - - {/* Queue icon button next to Pick */} - {!isInQueue ? ( - - ) : ( - - )} - - )} - - {/* Queue buttons - only show if not your turn */} - {!canPick && !isDrafted && !isInQueue && ( - - )} - {!canPick && !isDrafted && isInQueue && ( - - )} -
-
-
-
-
-
+
+
diff --git a/package-lock.json b/package-lock.json index b9ec18d..7248137 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "@clerk/react-router": "^2.1.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", @@ -18,6 +19,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@react-router/express": "^7.7.1", "@react-router/node": "^7.7.1", "class-variance-authority": "^0.7.1", @@ -2679,6 +2681,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -3291,6 +3323,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/package.json b/package.json index db743b0..337defd 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@clerk/react-router": "^2.1.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", @@ -33,6 +34,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@react-router/express": "^7.7.1", "@react-router/node": "^7.7.1", "class-variance-authority": "^0.7.1", diff --git a/plans/draft-room-redesign.md b/plans/draft-room-redesign.md new file mode 100644 index 0000000..5131fd4 --- /dev/null +++ b/plans/draft-room-redesign.md @@ -0,0 +1,411 @@ +# Draft Room Redesign Plan + +**Status**: ✅ Complete - All Features Implemented & Tested +**Created**: 2025-10-24 +**Last Updated**: 2025-10-24 + +## Overview + +Redesigning the draft room to use a full-screen layout with a collapsible left sidebar and tabbed main content area. + +--- + +## Requirements + +### Layout Changes + +#### 1. Full-Screen Layout (No Page Scroll) +- Page fills entire viewport (100vh) +- Individual sections scroll internally +- Fixed header at top +- Content area uses remaining height + +#### 2. Collapsible Left Sidebar +- **Default State**: Expanded +- **Width**: + - Expanded: 350px + - Collapsed: 50px +- **Contents** (Accordion-style sections): + - My Queue (top section) - Collapsible + - Eligibility status banner + - Queue list (ordered, scrollable) + - Autodraft settings + - Clear queue button + - Available Participants (bottom section) - Collapsible + - Search bar + - Sport filter dropdown + - Show/hide drafted toggle + - Participants table (scrollable) + - Action buttons (Pick/Add to Queue) +- **Section Behavior**: + - Both sections can be expanded or collapsed independently + - When both expanded: 50/50 vertical split + - When one collapsed: Expanded section takes 100% of space + - At least one section must remain expanded +- **Responsive**: + - Desktop (≥1024px): Fixed sidebar on left + - Mobile (<1024px): Full-screen modal + +#### 3. Tabbed Main Content Area +Three tabs with toggle functionality: + +**Tab 1: Draft Board (Default)** +- Current draft grid display +- Team headers with timers and autodraft status +- Snake draft grid (scrollable) +- Context menu for commissioners +- Reuse existing DraftGrid component or inline implementation + +**Tab 2: Recent Picks** +- List of recent picks (latest 10-20) +- Same format as current Recent Picks section +- Scrollable list +- Shows: Pick number, team, participant, sport + +**Tab 3: Teams Drafted (NEW)** +- Grid layout: + - **Rows**: Sports (from season sports) + - **Columns**: Teams (in draft order) + - **Cells**: Participants drafted by that team in that sport +- **Highlighting**: Visual indicator when team has multiple participants in same sport +- **Flex Display**: Show "X of Y flexes used" somewhere with team data +- **Scrollable**: Both horizontal and vertical as needed + +### Design Decisions + +**Teams Drafted Grid:** +1. ✅ **Cell content**: Show participant names (e.g., "Patrick Mahomes, Travis Kelce") +2. ✅ **Cell interactivity**: No clickability needed (existing commissioner context menu on draft board remains) +3. ✅ **Empty cells**: Leave empty (just border, no text) +4. ✅ **Flex display**: In team column header, below team name (similar to timer positioning) + +**Tab UI:** +5. ✅ **Tab style**: ShadCN Tabs component +6. ✅ **Recent Picks data**: Same 10 most recent picks as current implementation + +**Sidebar:** +7. ✅ **Section sizing**: Accordion-style - both sections collapsible independently + - Both expanded: 50/50 split + - One collapsed: Expanded section takes 100% + - At least one must remain expanded + +--- + +## Current Implementation Analysis + +### File Structure + +**Main Route**: `/app/routes/leagues/$leagueId.draft.$seasonId.tsx` +- All draft room logic currently in this single file +- ~1,300 lines of code +- Inline rendering of Queue and Available Participants (not extracted components) + +**Related Components**: +- `/app/components/DraftGrid.tsx` - Reusable draft grid (not currently used in draft room) +- `/app/components/AutodraftSettings.tsx` - Autodraft toggle and settings + +**Current Layout**: +``` +Header (full width, fixed) +├─ Title and metadata +├─ Control buttons (start/pause/resume) +└─ Connection status + +Draft Grid (full width, scrollable) +├─ Team headers +└─ Pick grid + +3-Column Grid (lg:grid-cols-3) +├─ My Queue (conditional if user has team) +│ ├─ Eligibility status +│ ├─ Queue list (max-h-96) +│ ├─ Autodraft settings +│ └─ Clear button +├─ Recent Picks +│ └─ Last 10 picks (max-h-96) +└─ Available Participants + ├─ Search and filters + └─ Participants table (max-h-[500px]) +``` + +### State Management +Component state (useState): +- `picks` - All draft picks +- `currentPick` - Current pick number +- `searchQuery` - Participant search term +- `hideDrafted` - Toggle drafted visibility +- `sportFilter` - Active sport filter +- `queue` - User's queue items +- `teamTimers` - Timer for each team +- `autodraftStatus` - Autodraft state per team +- `connectedTeams` - Set of connected team IDs +- `forcePickDialogOpen` - Commissioner dialog state + +Real-time updates via Socket.IO: +- `pick-made` +- `timer-update` +- `draft-paused` / `draft-resumed` +- `draft-completed` +- `autodraft-updated` +- `team-connected` / `team-disconnected` +- `participant-removed-from-queues` + +### Data Flow +- Loader provides initial data +- Socket.IO provides real-time updates +- Form actions handle queue management and draft actions +- Draft eligibility calculated client-side via `calculateDraftEligibility()` + +--- + +## Proposed Architecture + +### New Component Structure + +``` +leagues/$leagueId.draft.$seasonId.tsx (Main Route) +├─ Full-screen flex container (100vh) +├─ Header (fixed, flex: 0 0 auto) +├─ Content Area (flex: 1 1 auto, flex row) +│ ├─ DraftSidebar (NEW component) +│ │ ├─ Collapse toggle button +│ │ ├─ QueueSection (scrollable) +│ │ └─ AvailableParticipantsSection (scrollable) +│ └─ MainContent (flex: 1 1 auto) +│ ├─ TabSelector (Draft Board | Recent Picks | Teams Drafted) +│ └─ TabContent (scrollable) +│ ├─ DraftBoardTab (uses DraftGrid component) +│ ├─ RecentPicksTab +│ └─ TeamsDraftedTab (NEW) +``` + +### Files to Create + +1. **`/app/components/DraftSidebar.tsx`** (NEW) + - Collapsible sidebar container + - Props: collapsed state, queue data, participants data, handlers + - Two internal sections with separate scrolling + +2. **`/app/components/draft/QueueSection.tsx`** (NEW, optional) + - Extracted queue rendering logic + - Props: queue, eligibility, autodraft settings, handlers + +3. **`/app/components/draft/AvailableParticipantsSection.tsx`** (NEW, optional) + - Extracted participants table logic + - Props: participants, filters, handlers + +4. **`/app/components/draft/TeamsDraftedGrid.tsx`** (NEW) + - Grid showing teams × sports with drafted participants + - Props: teams, sports, picks, flex usage + - Highlighting logic for multiple drafts per sport + +5. **`/app/components/draft/DraftTabs.tsx`** (NEW, optional) + - Tab selector and content container + - Props: active tab, tab change handler, children + +### Files to Modify + +1. **`/app/routes/leagues/$leagueId.draft.$seasonId.tsx`** + - Add full-screen layout container + - Add sidebar collapsed state + - Add active tab state + - Import and render DraftSidebar + - Render tabbed main content + - Remove 3-column grid layout + - Remove Recent Picks section (moved to tab) + +2. **`/app/components/DraftGrid.tsx`** + - Ensure it works in flex container + - May need height/overflow adjustments + +### State Additions + +Add to main route component: +```typescript +const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + // Check localStorage for user preference + const stored = localStorage.getItem('draftSidebarCollapsed'); + return stored ? JSON.parse(stored) : false; // Default: expanded +}); + +const [activeTab, setActiveTab] = useState<'board' | 'picks' | 'teams'>('board'); +``` + +--- + +## CSS/Styling Strategy + +### Layout CSS + +```css +/* Main container */ +.draft-room-container { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Header */ +.draft-room-header { + flex: 0 0 auto; +} + +/* Content area */ +.draft-room-content { + flex: 1 1 auto; + display: flex; + overflow: hidden; +} + +/* Sidebar */ +.draft-sidebar { + flex: 0 0 auto; + width: 350px; /* Expanded */ + transition: width 0.3s ease, transform 0.3s ease; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.draft-sidebar.collapsed { + width: 50px; +} + +/* Sidebar sections */ +.sidebar-section { + flex: 0 0 auto; /* Queue: take what you need */ + overflow-y: auto; +} + +.sidebar-section.grow { + flex: 1 1 auto; /* Available Participants: take remaining */ +} + +/* Main content */ +.draft-main-content { + flex: 1 1 auto; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Tab content */ +.draft-tab-content { + flex: 1 1 auto; + overflow-y: auto; +} +``` + +### Responsive Breakpoints + +```css +/* Mobile (<1024px) */ +@media (max-width: 1023px) { + .draft-sidebar { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + z-index: 50; + transform: translateX(-100%); + } + + .draft-sidebar.open { + transform: translateX(0); + } +} +``` + +### Teams Drafted Grid Styling + +```css +.teams-grid { + display: grid; + grid-template-columns: 150px repeat(auto-fit, minmax(120px, 1fr)); + overflow: auto; +} + +.teams-grid-cell { + border: 1px solid var(--border); + padding: 0.5rem; + min-height: 60px; +} + +.teams-grid-cell.multiple-picks { + background-color: var(--accent); + font-weight: 600; +} +``` + +--- + +## Implementation Steps + +### Phase 1: Setup and Layout Structure +- [x] Create plans directory and documentation +- [ ] Add sidebar collapsed state with localStorage persistence +- [ ] Add active tab state +- [ ] Create full-screen layout container in main route +- [ ] Update header to work with new layout +- [ ] Test basic layout structure + +### Phase 2: Sidebar Component +- [ ] Create DraftSidebar.tsx component shell +- [ ] Add collapse/expand toggle button +- [ ] Move Queue section rendering to sidebar +- [ ] Move Available Participants section to sidebar +- [ ] Add internal scrolling for both sections +- [ ] Test sidebar functionality and transitions +- [ ] Add mobile modal behavior + +### Phase 3: Tab System +- [ ] Create tab selector UI (choose style based on user feedback) +- [ ] Add Draft Board tab (use existing grid) +- [ ] Add Recent Picks tab (move existing section) +- [ ] Create TeamsDraftedGrid component +- [ ] Implement Teams Drafted grid logic +- [ ] Add flex usage display +- [ ] Add highlighting for multiple picks +- [ ] Test tab switching and content rendering + +### Phase 4: Refinement +- [ ] Adjust spacing and padding throughout +- [ ] Test all Socket.IO real-time updates +- [ ] Test all form actions (queue management, making picks) +- [ ] Test responsive behavior (desktop, tablet, mobile) +- [ ] Add keyboard shortcuts (optional) +- [ ] Performance testing with large datasets +- [ ] Accessibility audit (ARIA labels, focus management) + +### Phase 5: Polish +- [ ] Animation tuning +- [ ] Dark mode verification +- [ ] Cross-browser testing +- [ ] User acceptance testing + +--- + +## Notes + +- Preserve all existing Socket.IO functionality +- Maintain commissioner-specific features (force pick, context menus) +- Keep eligibility calculation logic unchanged +- Ensure autodraft integration remains functional +- Test with various screen sizes and data volumes + +--- + +## Success Criteria + +- [ ] Draft room fills viewport (no page scroll) +- [ ] Sidebar collapses/expands smoothly +- [ ] All three tabs render correctly +- [ ] Teams Drafted grid shows accurate data +- [ ] Queue and Available Participants work as before +- [ ] All real-time updates continue to work +- [ ] Mobile experience is usable +- [ ] No regressions in existing functionality +- [ ] Performance is acceptable (no lag during picks/updates)