feat: Implement draft room redesign with collapsible sidebar and tabbed content
- Added AvailableParticipantsSection for participant search and filtering. - Created DraftGridSection for displaying the draft grid with team timers and context menu. - Developed QueueSection for managing participant queue and autodraft settings. - Introduced RecentPicksSection to show recent draft picks. - Implemented TeamsDraftedGrid to visualize drafted participants by team and sport. - Added collapsible UI components for better layout management. - Established tabbed navigation for draft board, recent picks, and teams drafted. - Enhanced responsiveness for mobile and desktop views. - Updated styles for a cohesive design across components.
This commit is contained in:
parent
190dfb92ca
commit
2cd4096e70
12 changed files with 1819 additions and 548 deletions
173
app/components/DraftSidebar.tsx
Normal file
173
app/components/DraftSidebar.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300",
|
||||||
|
"w-12 hidden lg:block", // Hide completely on mobile when collapsed
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onCollapsedChange(false)}
|
||||||
|
className="absolute top-4 left-1/2 -translate-x-1/2"
|
||||||
|
aria-label="Expand sidebar"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||||
|
onClick={() => onCollapsedChange(true)}
|
||||||
|
aria-label="Close sidebar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex-shrink-0 bg-card border-r border-border transition-all duration-300 flex flex-col",
|
||||||
|
"w-[350px]",
|
||||||
|
// Mobile: fixed overlay, Desktop: normal sidebar
|
||||||
|
"fixed inset-y-0 left-0 z-50 lg:relative lg:z-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Collapse button */}
|
||||||
|
<div className="absolute top-4 right-2 z-20">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onCollapsedChange(true)}
|
||||||
|
aria-label="Collapse sidebar"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue Section */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col border-b border-border overflow-hidden"
|
||||||
|
style={{ flex: queueFlexSize }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={toggleQueueSection}
|
||||||
|
className="flex items-center justify-between px-4 py-3 pr-12 bg-muted/50 hover:bg-muted transition-colors border-b border-border"
|
||||||
|
disabled={queueExpanded && !participantsExpanded}
|
||||||
|
>
|
||||||
|
<h2 className="font-semibold text-sm">My Queue</h2>
|
||||||
|
{queueExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{queueExpanded && (
|
||||||
|
<div className="flex-1 overflow-y-auto">{queueSection}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Available Participants Section */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col overflow-hidden"
|
||||||
|
style={{ flex: participantsFlexSize }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={toggleParticipantsSection}
|
||||||
|
className="flex items-center justify-between px-4 py-3 bg-muted/50 hover:bg-muted transition-colors border-b border-border"
|
||||||
|
disabled={participantsExpanded && !queueExpanded}
|
||||||
|
>
|
||||||
|
<h2 className="font-semibold text-sm">Available Participants</h2>
|
||||||
|
{participantsExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{participantsExpanded && (
|
||||||
|
<div className="flex-1 overflow-y-auto">{participantsSection}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
277
app/components/draft/AvailableParticipantsSection.tsx
Normal file
277
app/components/draft/AvailableParticipantsSection.tsx
Normal file
|
|
@ -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<string>;
|
||||||
|
queue: Array<{ id: string; participantId: string }>;
|
||||||
|
eligibility: {
|
||||||
|
eligibleSportIds: Set<string>;
|
||||||
|
ineligibleReasons: Record<string, string>;
|
||||||
|
} | 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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="mb-4 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-semibold mb-3">Available Participants</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Search Input */}
|
||||||
|
<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-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{/* Sport Filter Dropdown */}
|
||||||
|
<select
|
||||||
|
value={sportFilter}
|
||||||
|
onChange={(e) => onSportFilterChange(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 border rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Sports</option>
|
||||||
|
{uniqueSports.map((sport) => (
|
||||||
|
<option key={sport} value={sport}>
|
||||||
|
{sport}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Show/Hide Drafted Toggle */}
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!hideDrafted}
|
||||||
|
onChange={(e) => onHideDraftedChange(!e.target.checked)}
|
||||||
|
className="rounded"
|
||||||
|
/>
|
||||||
|
<span>Show Drafted</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="border rounded-lg overflow-hidden flex-1 flex flex-col min-h-0">
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-3 font-semibold">Participant</th>
|
||||||
|
<th className="text-left p-3 font-semibold">Sport</th>
|
||||||
|
<th className="text-right p-3 font-semibold">EV</th>
|
||||||
|
{hasTeam && (
|
||||||
|
<th className="text-right p-3 font-semibold">Queue</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{participants.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={hasTeam ? 4 : 3}
|
||||||
|
className="text-center py-8 text-muted-foreground"
|
||||||
|
>
|
||||||
|
No participants found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
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 (
|
||||||
|
<tr
|
||||||
|
key={participant.id}
|
||||||
|
className={`border-t transition-colors ${
|
||||||
|
isDrafted
|
||||||
|
? "bg-muted/50 opacity-60"
|
||||||
|
: !isEligible
|
||||||
|
? "bg-red-50/30 dark:bg-red-950/20 opacity-75"
|
||||||
|
: "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
!isEligible && !isDrafted ? ineligibleReason : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
|
||||||
|
>
|
||||||
|
{participant.name}
|
||||||
|
</span>
|
||||||
|
{isDrafted && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
Drafted
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!isDrafted && !isEligible && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="text-xs"
|
||||||
|
title={ineligibleReason}
|
||||||
|
>
|
||||||
|
Ineligible
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{isInQueue && !isDrafted && isEligible && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
Queued
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-muted-foreground">
|
||||||
|
{participant.sport.name}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-right font-mono font-semibold">
|
||||||
|
{participant.expectedValue}
|
||||||
|
</td>
|
||||||
|
{hasTeam && (
|
||||||
|
<td className="p-3 text-right">
|
||||||
|
<div className="flex gap-2 justify-end items-center">
|
||||||
|
{/* Pick button - only show if it's your turn and participant not drafted */}
|
||||||
|
{canPick && !isDrafted && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onMakePick(participant.id)}
|
||||||
|
disabled={!isEligible}
|
||||||
|
title={
|
||||||
|
!isEligible ? ineligibleReason : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Pick
|
||||||
|
</Button>
|
||||||
|
{/* Queue icon button next to Pick */}
|
||||||
|
{!isInQueue ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAddToQueue(participant.id)}
|
||||||
|
title={
|
||||||
|
!isEligible
|
||||||
|
? ineligibleReason
|
||||||
|
: "Add to queue"
|
||||||
|
}
|
||||||
|
disabled={!isEligible}
|
||||||
|
>
|
||||||
|
<span className="text-lg">+</span>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const queueItem = queue.find(
|
||||||
|
(item) =>
|
||||||
|
item.participantId === participant.id
|
||||||
|
);
|
||||||
|
if (queueItem) {
|
||||||
|
onRemoveFromQueue(queueItem.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Remove from queue"
|
||||||
|
>
|
||||||
|
<span className="text-lg">✓</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Queue buttons - only show if not your turn */}
|
||||||
|
{!canPick && !isDrafted && !isInQueue && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAddToQueue(participant.id)}
|
||||||
|
disabled={!isEligible}
|
||||||
|
title={!isEligible ? ineligibleReason : undefined}
|
||||||
|
>
|
||||||
|
Add to Queue
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!canPick && !isDrafted && isInQueue && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const queueItem = queue.find(
|
||||||
|
(item) =>
|
||||||
|
item.participantId === participant.id
|
||||||
|
);
|
||||||
|
if (queueItem) {
|
||||||
|
onRemoveFromQueue(queueItem.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
217
app/components/draft/DraftGridSection.tsx
Normal file
217
app/components/draft/DraftGridSection.tsx
Normal file
|
|
@ -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<string, number>;
|
||||||
|
autodraftStatus: Record<string, boolean>;
|
||||||
|
connectedTeams: Set<string>;
|
||||||
|
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 (
|
||||||
|
<div className="h-full overflow-auto p-4">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Draft Grid</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Team Headers */}
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
{draftSlots.map((slot) => {
|
||||||
|
const teamTime = teamTimers[slot.team.id];
|
||||||
|
const isAutodraft = autodraftStatus[slot.team.id] || false;
|
||||||
|
const isConnected = connectedTeams.has(slot.team.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={slot.id} className="flex-1 min-w-32 text-center">
|
||||||
|
<div
|
||||||
|
className={`font-semibold text-sm truncate px-2 ${!isConnected ? "italic text-muted-foreground" : ""}`}
|
||||||
|
>
|
||||||
|
{slot.team.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-xs font-mono ${getTimerColor(teamTime)}`}
|
||||||
|
>
|
||||||
|
{formatTime(teamTime)}
|
||||||
|
{isAutodraft && (
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
(auto)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Draft Grid Rows */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{draftGrid.map((roundPicks, roundIndex) => {
|
||||||
|
const round = roundIndex + 1;
|
||||||
|
const isEvenRound = round % 2 === 0;
|
||||||
|
const displayPicks = isEvenRound
|
||||||
|
? [...roundPicks].reverse()
|
||||||
|
: roundPicks;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={roundIndex} className="flex gap-2">
|
||||||
|
{displayPicks.map((cell) => {
|
||||||
|
const isCurrent = cell.pickNumber === currentPick;
|
||||||
|
const isPicked = !!cell.pick;
|
||||||
|
|
||||||
|
return isCommissioner && !isPicked && isCurrent ? (
|
||||||
|
<ContextMenu key={cell.pickNumber}>
|
||||||
|
<ContextMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
className={`flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
|
||||||
|
isCurrent
|
||||||
|
? "border-blue-500 bg-blue-50 dark:bg-blue-950 shadow-lg"
|
||||||
|
: isPicked
|
||||||
|
? "border-green-500 bg-green-50 dark:bg-green-950"
|
||||||
|
: "border-gray-300 bg-white dark:bg-gray-900"
|
||||||
|
}`}
|
||||||
|
title={`Overall Pick #${cell.pickNumber}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-mono text-muted-foreground mb-1">
|
||||||
|
{cell.round}.
|
||||||
|
{String(cell.pickInRound).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
{isPicked ? (
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="font-semibold truncate">
|
||||||
|
{cell.pick?.participant.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground truncate">
|
||||||
|
{cell.pick?.sport.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isCurrent ? (
|
||||||
|
<div className="text-xs font-semibold text-blue-600 dark:text-blue-400">
|
||||||
|
On Clock
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</ContextMenuTrigger>
|
||||||
|
<ContextMenuContent>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onForceAutopick(cell.pickNumber, cell.teamId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Force Auto Pick
|
||||||
|
</ContextMenuItem>
|
||||||
|
<ContextMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
onForceManualPickOpen(
|
||||||
|
cell.pickNumber,
|
||||||
|
cell.teamId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Force Manual Pick
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={cell.pickNumber}
|
||||||
|
className={`flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
|
||||||
|
isCurrent
|
||||||
|
? "border-blue-500 bg-blue-50 dark:bg-blue-950 shadow-lg"
|
||||||
|
: isPicked
|
||||||
|
? "border-green-500 bg-green-50 dark:bg-green-950"
|
||||||
|
: "border-gray-300 bg-white dark:bg-gray-900"
|
||||||
|
}`}
|
||||||
|
title={`Overall Pick #${cell.pickNumber}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-mono text-muted-foreground mb-1">
|
||||||
|
{cell.round}.
|
||||||
|
{String(cell.pickInRound).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
{isPicked ? (
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="font-semibold truncate">
|
||||||
|
{cell.pick?.participant.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground truncate">
|
||||||
|
{cell.pick?.sport.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isCurrent ? (
|
||||||
|
<div className="text-xs font-semibold text-blue-600 dark:text-blue-400">
|
||||||
|
On Clock
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
app/components/draft/QueueSection.tsx
Normal file
164
app/components/draft/QueueSection.tsx
Normal file
|
|
@ -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<string, number>;
|
||||||
|
sportAvailability: Record<
|
||||||
|
string,
|
||||||
|
{ sportName: string; sportId: string }
|
||||||
|
>;
|
||||||
|
ineligibleReasons: Record<string, string>;
|
||||||
|
} | 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 (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">Queue ({queue.length})</h3>
|
||||||
|
{queue.length > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onClearQueue}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Eligibility Status Banner */}
|
||||||
|
{eligibility && (
|
||||||
|
<div className="mb-4 p-3 bg-muted/50 rounded-lg border">
|
||||||
|
<div className="text-sm font-semibold mb-2">Draft Status</div>
|
||||||
|
<div className="text-xs space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>Flex Picks:</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
eligibility.flexPicksUsed >= eligibility.flexPicksAvailable
|
||||||
|
? "text-red-600 dark:text-red-400 font-semibold"
|
||||||
|
: "text-green-600 dark:text-green-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{eligibility.flexPicksUsed} / {eligibility.flexPicksAvailable}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{Object.entries(eligibility.picksBySport).length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="font-semibold mt-2 mb-1">Picks by Sport:</div>
|
||||||
|
{Object.entries(eligibility.picksBySport).map(
|
||||||
|
([sportId, count]) => {
|
||||||
|
const sportInfo = eligibility.sportAvailability[sportId];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sportId}
|
||||||
|
className="flex justify-between items-center pl-2"
|
||||||
|
>
|
||||||
|
<span>{sportInfo?.sportName || sportId}:</span>
|
||||||
|
<span className="font-mono">{count}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{Object.keys(eligibility.ineligibleReasons).length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="font-semibold mt-2 mb-1 text-red-600 dark:text-red-400">
|
||||||
|
Restrictions:
|
||||||
|
</div>
|
||||||
|
{Object.entries(eligibility.ineligibleReasons)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map(([sportId, reason]) => {
|
||||||
|
const sportInfo = eligibility.sportAvailability[sportId];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sportId}
|
||||||
|
className="text-xs text-red-600 dark:text-red-400 pl-2"
|
||||||
|
>
|
||||||
|
{sportInfo?.sportName}: {reason}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Queue List */}
|
||||||
|
{queue.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm text-center py-8">
|
||||||
|
Click participants to add to your queue
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{queue.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="default">{index + 1}</Badge>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
{availableParticipants.find(
|
||||||
|
(p) => p.id === item.participantId
|
||||||
|
)?.name || "Unknown"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onRemoveFromQueue(item.id)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Autodraft Settings */}
|
||||||
|
<AutodraftSettings
|
||||||
|
seasonId={seasonId}
|
||||||
|
teamId={teamId}
|
||||||
|
isEnabled={userAutodraft.isEnabled}
|
||||||
|
mode={userAutodraft.mode}
|
||||||
|
isMyTurn={isMyTurn}
|
||||||
|
onUpdate={onAutodraftUpdate}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
app/components/draft/RecentPicksSection.tsx
Normal file
58
app/components/draft/RecentPicksSection.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="h-full flex flex-col p-4">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Recent Picks</h2>
|
||||||
|
{picks.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground text-sm">No picks yet...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 overflow-y-auto">
|
||||||
|
{picks
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.slice(0, 10)
|
||||||
|
.map((pick) => (
|
||||||
|
<div
|
||||||
|
key={pick.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant="outline">#{pick.pickNumber}</Badge>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{pick.participant.name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{pick.sport.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-medium">{pick.team.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Round {pick.round}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
app/components/draft/TeamsDraftedGrid.tsx
Normal file
205
app/components/draft/TeamsDraftedGrid.tsx
Normal file
|
|
@ -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<string, { id: string; name: string }>();
|
||||||
|
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<string, Map<string, typeof picks>>();
|
||||||
|
|
||||||
|
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<string, number>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center justify-center h-full p-8 text-muted-foreground">
|
||||||
|
<p>No picks have been made yet</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full overflow-auto">
|
||||||
|
<div className="inline-block min-w-full">
|
||||||
|
<table className="border-collapse border border-border">
|
||||||
|
<thead className="sticky top-0 bg-background z-10">
|
||||||
|
<tr>
|
||||||
|
<th className="border border-border p-2 text-left font-semibold min-w-[150px] bg-muted/50">
|
||||||
|
Sport
|
||||||
|
</th>
|
||||||
|
{draftSlots.map((slot) => {
|
||||||
|
const flexUsed = flexPicksByTeam.get(slot.team.id) || 0;
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={slot.id}
|
||||||
|
className="border border-border p-2 text-left font-semibold min-w-[180px] bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="font-semibold text-sm">
|
||||||
|
{slot.team.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground font-normal">
|
||||||
|
{flexUsed} of {season.numFlexPicks} flex
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sports.map((sport) => (
|
||||||
|
<tr key={sport.id}>
|
||||||
|
<td className="border border-border p-2 font-medium bg-muted/30">
|
||||||
|
{sport.name}
|
||||||
|
</td>
|
||||||
|
{draftSlots.map((slot) => {
|
||||||
|
const teamSportPicks =
|
||||||
|
picksByTeamAndSport
|
||||||
|
.get(slot.team.id)
|
||||||
|
?.get(sport.id) || [];
|
||||||
|
const hasMultiplePicks = teamSportPicks.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={`${slot.team.id}-${sport.id}`}
|
||||||
|
className={`border border-border p-2 ${
|
||||||
|
hasMultiplePicks
|
||||||
|
? "bg-accent/30 font-semibold"
|
||||||
|
: "bg-background"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{teamSportPicks.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{teamSportPicks.map((pick) => (
|
||||||
|
<div
|
||||||
|
key={pick.id}
|
||||||
|
className="text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<span>{pick.participant.name}</span>
|
||||||
|
{hasMultiplePicks && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs px-1 py-0"
|
||||||
|
>
|
||||||
|
{teamSportPicks.indexOf(pick) + 1}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
app/components/ui/collapsible.tsx
Normal file
33
app/components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
64
app/components/ui/tabs.tsx
Normal file
64
app/components/ui/tabs.tsx
Normal file
|
|
@ -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<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
|
|
@ -7,15 +7,15 @@ import { useEffect, useState, useMemo } from "react";
|
||||||
import { Card } from "~/components/ui/card";
|
import { Card } from "~/components/ui/card";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
import { getTeamQueue } from "~/models/draft-queue";
|
import { getTeamQueue } from "~/models/draft-queue";
|
||||||
import {
|
import { DraftSidebar } from "~/components/DraftSidebar";
|
||||||
ContextMenu,
|
import { TeamsDraftedGrid } from "~/components/draft/TeamsDraftedGrid";
|
||||||
ContextMenuContent,
|
import { QueueSection } from "~/components/draft/QueueSection";
|
||||||
ContextMenuItem,
|
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
||||||
ContextMenuTrigger,
|
import { RecentPicksSection } from "~/components/draft/RecentPicksSection";
|
||||||
} from "~/components/ui/context-menu";
|
import { DraftGridSection } from "~/components/draft/DraftGridSection";
|
||||||
import { AutodraftSettings } from "~/components/AutodraftSettings";
|
|
||||||
import { calculateDraftEligibility, getEligibilitySummary } from "~/lib/draft-eligibility";
|
import { calculateDraftEligibility, getEligibilitySummary } from "~/lib/draft-eligibility";
|
||||||
|
|
||||||
export async function loader(args: any) {
|
export async function loader(args: any) {
|
||||||
|
|
@ -205,6 +205,14 @@ export default function DraftRoom() {
|
||||||
const [isPaused, setIsPaused] = useState(season.draftPaused || false);
|
const [isPaused, setIsPaused] = useState(season.draftPaused || false);
|
||||||
const [isDraftComplete, setIsDraftComplete] = useState(season.status === "active");
|
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
|
// Track autodraft status for all teams
|
||||||
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, boolean>>(() => {
|
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, boolean>>(() => {
|
||||||
const status: Record<string, boolean> = {};
|
const status: Record<string, boolean> = {};
|
||||||
|
|
@ -438,6 +446,13 @@ export default function DraftRoom() {
|
||||||
};
|
};
|
||||||
}, [on, off, currentPick, userTeam]);
|
}, [on, off, currentPick, userTeam]);
|
||||||
|
|
||||||
|
// Persist sidebar collapsed state
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("draftSidebarCollapsed", JSON.stringify(sidebarCollapsed));
|
||||||
|
}
|
||||||
|
}, [sidebarCollapsed]);
|
||||||
|
|
||||||
// Queue handlers
|
// Queue handlers
|
||||||
const handleAddToQueue = async (participantId: string) => {
|
const handleAddToQueue = async (participantId: string) => {
|
||||||
if (!userTeam) return;
|
if (!userTeam) return;
|
||||||
|
|
@ -732,19 +747,47 @@ export default function DraftRoom() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Standalone Header - No Main Nav */}
|
{/* Standalone Header - No Main Nav */}
|
||||||
<div className="border-b bg-card">
|
<div className="border-b bg-card flex-shrink-0">
|
||||||
<div className="w-full px-4 py-4">
|
<div className="w-full px-4 py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold">
|
{/* Mobile menu button - only show if user has team and sidebar is collapsed */}
|
||||||
{season.league.name} - {season.year} Draft
|
{userTeam && sidebarCollapsed && (
|
||||||
</h1>
|
<Button
|
||||||
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
|
variant="ghost"
|
||||||
<span>Round: {currentRound}</span>
|
size="icon"
|
||||||
<span>Pick: {currentPick}</span>
|
onClick={() => setSidebarCollapsed(false)}
|
||||||
<span className="capitalize">
|
className="lg:hidden"
|
||||||
{season.status.replace("_", " ")}
|
aria-label="Open sidebar"
|
||||||
</span>
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">
|
||||||
|
{season.league.name} - {season.year} Draft
|
||||||
|
</h1>
|
||||||
|
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
|
||||||
|
<span>Round: {currentRound}</span>
|
||||||
|
<span>Pick: {currentPick}</span>
|
||||||
|
<span className="capitalize">
|
||||||
|
{season.status.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -785,542 +828,104 @@ export default function DraftRoom() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content - Flex Row: Sidebar + Tabs */}
|
||||||
<div className="w-full px-4 py-4">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Draft Grid - Horizontally Scrollable */}
|
{/* Sidebar */}
|
||||||
<div className="mb-6">
|
{userTeam && (
|
||||||
<Card className="p-4">
|
<DraftSidebar
|
||||||
<h2 className="text-xl font-semibold mb-4">Draft Grid</h2>
|
collapsed={sidebarCollapsed}
|
||||||
<div className="overflow-x-auto">
|
onCollapsedChange={setSidebarCollapsed}
|
||||||
<div className="w-full">
|
queueSection={
|
||||||
{/* Team Headers */}
|
<QueueSection
|
||||||
<div className="flex gap-2 mb-2">
|
queue={queue}
|
||||||
{draftSlots.map((slot) => {
|
availableParticipants={availableParticipants}
|
||||||
const teamTime = teamTimers[slot.team.id];
|
eligibility={eligibility}
|
||||||
const isAutodraft = autodraftStatus[slot.team.id] || false;
|
seasonId={season.id}
|
||||||
const isConnected = connectedTeams.has(slot.team.id);
|
teamId={userTeam.id}
|
||||||
|
isMyTurn={isMyTurn}
|
||||||
|
userAutodraft={userAutodraft}
|
||||||
|
onClearQueue={handleClearQueue}
|
||||||
|
onRemoveFromQueue={handleRemoveFromQueue}
|
||||||
|
onAutodraftUpdate={(isEnabled, mode) => {
|
||||||
|
setUserAutodraft({ isEnabled, mode });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
participantsSection={
|
||||||
|
<AvailableParticipantsSection
|
||||||
|
participants={filteredParticipants}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
sportFilter={sportFilter}
|
||||||
|
hideDrafted={hideDrafted}
|
||||||
|
uniqueSports={uniqueSports}
|
||||||
|
draftedParticipantIds={draftedParticipantIds}
|
||||||
|
queue={queue}
|
||||||
|
eligibility={eligibility}
|
||||||
|
canPick={canPick}
|
||||||
|
hasTeam={!!userTeam}
|
||||||
|
onSearchChange={setSearchQuery}
|
||||||
|
onSportFilterChange={setSportFilter}
|
||||||
|
onHideDraftedChange={setHideDrafted}
|
||||||
|
onMakePick={handleMakePick}
|
||||||
|
onAddToQueue={handleAddToQueue}
|
||||||
|
onRemoveFromQueue={handleRemoveFromQueue}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
{/* Main Tabbed Content */}
|
||||||
<div key={slot.id} className="flex-1 min-w-32 text-center">
|
<div className="flex-1 overflow-hidden">
|
||||||
<div className={`font-semibold text-sm truncate px-2 ${!isConnected ? "italic text-muted-foreground" : ""}`}>
|
<Tabs
|
||||||
{slot.team.name}
|
value={activeTab}
|
||||||
</div>
|
onValueChange={(value) =>
|
||||||
<div className={`text-xs font-mono ${getTimerColor(teamTime)}`}>
|
setActiveTab(value as "board" | "picks" | "teams")
|
||||||
{formatTime(teamTime)}
|
}
|
||||||
{isAutodraft && (
|
className="h-full flex flex-col"
|
||||||
<span className="ml-1 text-muted-foreground">(auto)</span>
|
>
|
||||||
)}
|
<TabsList className="mx-4 mt-4">
|
||||||
</div>
|
<TabsTrigger value="board">Draft Board</TabsTrigger>
|
||||||
</div>
|
<TabsTrigger value="picks">Recent Picks</TabsTrigger>
|
||||||
);
|
<TabsTrigger value="teams">Teams Drafted</TabsTrigger>
|
||||||
})}
|
</TabsList>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Draft Grid Rows */}
|
<TabsContent value="board" className="flex-1 overflow-hidden m-0">
|
||||||
<div className="space-y-2">
|
<DraftGridSection
|
||||||
{draftGrid.map((roundPicks, roundIndex) => {
|
draftSlots={draftSlots}
|
||||||
const round = roundIndex + 1;
|
draftGrid={draftGrid}
|
||||||
const isEvenRound = round % 2 === 0;
|
currentPick={currentPick}
|
||||||
const displayPicks = isEvenRound
|
teamTimers={teamTimers}
|
||||||
? [...roundPicks].reverse()
|
autodraftStatus={autodraftStatus}
|
||||||
: roundPicks;
|
connectedTeams={connectedTeams}
|
||||||
|
isCommissioner={isCommissioner}
|
||||||
|
onForceAutopick={handleForceAutopick}
|
||||||
|
onForceManualPickOpen={(pickNumber, teamId) => {
|
||||||
|
setSelectedPickSlot({ pickNumber, teamId });
|
||||||
|
setForcePickDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
return (
|
<TabsContent value="picks" className="flex-1 overflow-hidden m-0">
|
||||||
<div key={roundIndex} className="flex gap-2">
|
<RecentPicksSection picks={picks} />
|
||||||
{displayPicks.map((cell) => {
|
</TabsContent>
|
||||||
const isCurrent = cell.pickNumber === currentPick;
|
|
||||||
const isPicked = !!cell.pick;
|
|
||||||
|
|
||||||
return isCommissioner && !isPicked && isCurrent ? (
|
<TabsContent value="teams" className="flex-1 overflow-hidden m-0">
|
||||||
<ContextMenu key={cell.pickNumber}>
|
<div className="h-full p-4">
|
||||||
<ContextMenuTrigger asChild>
|
<TeamsDraftedGrid
|
||||||
<div
|
draftSlots={draftSlots}
|
||||||
className={`flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
|
picks={picks}
|
||||||
isCurrent
|
availableParticipants={availableParticipants}
|
||||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950 shadow-lg"
|
season={{
|
||||||
: isPicked
|
id: season.id,
|
||||||
? "border-green-500 bg-green-50 dark:bg-green-950"
|
name: `${season.year} Season`,
|
||||||
: "border-gray-300 bg-white dark:bg-gray-900"
|
year: season.year,
|
||||||
}`}
|
numFlexPicks: season.flexSpots,
|
||||||
title={`Overall Pick #${cell.pickNumber}`}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-mono text-muted-foreground mb-1">
|
|
||||||
{cell.round}.
|
|
||||||
{String(cell.pickInRound).padStart(2, "0")}
|
|
||||||
</div>
|
|
||||||
{isPicked ? (
|
|
||||||
<div className="text-xs">
|
|
||||||
<div className="font-semibold truncate">
|
|
||||||
{cell.pick.participant.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground truncate">
|
|
||||||
{cell.pick.sport.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : isCurrent ? (
|
|
||||||
<div className="text-xs font-semibold text-blue-600 dark:text-blue-400">
|
|
||||||
On Clock
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</ContextMenuTrigger>
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
handleForceAutopick(
|
|
||||||
cell.pickNumber,
|
|
||||||
cell.teamId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Force Auto Pick
|
|
||||||
</ContextMenuItem>
|
|
||||||
<ContextMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedPickSlot({
|
|
||||||
pickNumber: cell.pickNumber,
|
|
||||||
teamId: cell.teamId,
|
|
||||||
});
|
|
||||||
setForcePickDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Force Manual Pick
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
key={cell.pickNumber}
|
|
||||||
className={`flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
|
|
||||||
isCurrent
|
|
||||||
? "border-blue-500 bg-blue-50 dark:bg-blue-950 shadow-lg"
|
|
||||||
: isPicked
|
|
||||||
? "border-green-500 bg-green-50 dark:bg-green-950"
|
|
||||||
: "border-gray-300 bg-white dark:bg-gray-900"
|
|
||||||
}`}
|
|
||||||
title={`Overall Pick #${cell.pickNumber}`}
|
|
||||||
>
|
|
||||||
<div className="text-xs font-mono text-muted-foreground mb-1">
|
|
||||||
{cell.round}.
|
|
||||||
{String(cell.pickInRound).padStart(2, "0")}
|
|
||||||
</div>
|
|
||||||
{isPicked ? (
|
|
||||||
<div className="text-xs">
|
|
||||||
<div className="font-semibold truncate">
|
|
||||||
{cell.pick.participant.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground truncate">
|
|
||||||
{cell.pick.sport.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : isCurrent ? (
|
|
||||||
<div className="text-xs font-semibold text-blue-600 dark:text-blue-400">
|
|
||||||
On Clock
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
||||||
{/* My Queue */}
|
|
||||||
{userTeam && (
|
|
||||||
<div>
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h2 className="text-xl font-semibold">
|
|
||||||
My Queue ({queue.length})
|
|
||||||
</h2>
|
|
||||||
{queue.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClearQueue}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Eligibility Status Banner */}
|
|
||||||
{eligibility && (
|
|
||||||
<div className="mb-4 p-3 bg-muted/50 rounded-lg border">
|
|
||||||
<div className="text-sm font-semibold mb-2">Draft Status</div>
|
|
||||||
<div className="text-xs space-y-1">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Flex Picks:</span>
|
|
||||||
<span className={eligibility.flexPicksUsed >= eligibility.flexPicksAvailable ? "text-red-600 dark:text-red-400 font-semibold" : "text-green-600 dark:text-green-400"}>
|
|
||||||
{eligibility.flexPicksUsed} / {eligibility.flexPicksAvailable}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{Object.entries(eligibility.picksBySport).length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="font-semibold mt-2 mb-1">Picks by Sport:</div>
|
|
||||||
{Object.entries(eligibility.picksBySport).map(([sportId, count]) => {
|
|
||||||
const sportInfo = eligibility.sportAvailability[sportId];
|
|
||||||
return (
|
|
||||||
<div key={sportId} className="flex justify-between items-center pl-2">
|
|
||||||
<span>{sportInfo?.sportName || sportId}:</span>
|
|
||||||
<span className="font-mono">{count}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{Object.keys(eligibility.ineligibleReasons).length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="font-semibold mt-2 mb-1 text-red-600 dark:text-red-400">
|
|
||||||
Restrictions:
|
|
||||||
</div>
|
|
||||||
{Object.entries(eligibility.ineligibleReasons).slice(0, 2).map(([sportId, reason]) => {
|
|
||||||
const sportInfo = eligibility.sportAvailability[sportId];
|
|
||||||
return (
|
|
||||||
<div key={sportId} className="text-xs text-red-600 dark:text-red-400 pl-2">
|
|
||||||
{sportInfo?.sportName}: {reason}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{queue.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-sm text-center py-8">
|
|
||||||
Click participants to add to your queue
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
||||||
{queue.map((item: any, index: number) => (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge variant="default">{index + 1}</Badge>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-sm">
|
|
||||||
{availableParticipants.find(
|
|
||||||
(p: any) => p.id === item.participantId
|
|
||||||
)?.name || "Unknown"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleRemoveFromQueue(item.id)}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Autodraft Settings */}
|
|
||||||
<AutodraftSettings
|
|
||||||
seasonId={season.id}
|
|
||||||
teamId={userTeam.id}
|
|
||||||
isEnabled={userAutodraft.isEnabled}
|
|
||||||
mode={userAutodraft.mode}
|
|
||||||
isMyTurn={isMyTurn}
|
|
||||||
onUpdate={(isEnabled, mode) => {
|
|
||||||
setUserAutodraft({ isEnabled, mode });
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent Picks */}
|
|
||||||
<div className={userTeam ? "" : "lg:col-span-2"}>
|
|
||||||
<Card className="p-4">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Recent Picks</h2>
|
|
||||||
{picks.length === 0 ? (
|
|
||||||
<p className="text-muted-foreground text-sm">No picks yet...</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
||||||
{picks
|
|
||||||
.slice()
|
|
||||||
.reverse()
|
|
||||||
.slice(0, 10)
|
|
||||||
.map((pick: any) => (
|
|
||||||
<div
|
|
||||||
key={pick.id}
|
|
||||||
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Badge variant="outline">#{pick.pickNumber}</Badge>
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold">
|
|
||||||
{pick.participant.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{pick.sport.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="font-medium">{pick.team.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Round {pick.round}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column: Available Participants */}
|
|
||||||
<div>
|
|
||||||
<Card className="p-4">
|
|
||||||
<div className="mb-4">
|
|
||||||
<h2 className="text-xl font-semibold mb-3">
|
|
||||||
Available Participants
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* Search Input */}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search participants..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border rounded-md text-sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{/* Sport Filter Dropdown */}
|
|
||||||
<select
|
|
||||||
value={sportFilter}
|
|
||||||
onChange={(e) => setSportFilter(e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2 border rounded-md text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">All Sports</option>
|
|
||||||
{uniqueSports.map((sport) => (
|
|
||||||
<option key={sport} value={sport}>
|
|
||||||
{sport}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Show/Hide Drafted Toggle */}
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!hideDrafted}
|
|
||||||
onChange={(e) => setHideDrafted(!e.target.checked)}
|
|
||||||
className="rounded"
|
|
||||||
/>
|
|
||||||
<span>Show Drafted</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
{/* Table */}
|
</Tabs>
|
||||||
<div className="border rounded-lg overflow-hidden">
|
|
||||||
<div className="max-h-[500px] overflow-y-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-muted sticky top-0">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left p-3 font-semibold">
|
|
||||||
Participant
|
|
||||||
</th>
|
|
||||||
<th className="text-left p-3 font-semibold">Sport</th>
|
|
||||||
<th className="text-right p-3 font-semibold">EV</th>
|
|
||||||
{userTeam && (
|
|
||||||
<th className="text-right p-3 font-semibold">
|
|
||||||
Queue
|
|
||||||
</th>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredParticipants.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td
|
|
||||||
colSpan={3}
|
|
||||||
className="text-center py-8 text-muted-foreground"
|
|
||||||
>
|
|
||||||
No participants found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
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 (
|
|
||||||
<tr
|
|
||||||
key={participant.id}
|
|
||||||
className={`border-t transition-colors ${
|
|
||||||
isDrafted
|
|
||||||
? "bg-muted/50 opacity-60"
|
|
||||||
: !isEligible
|
|
||||||
? "bg-red-50/30 dark:bg-red-950/20 opacity-75"
|
|
||||||
: "hover:bg-muted/50"
|
|
||||||
}`}
|
|
||||||
title={!isEligible && !isDrafted ? ineligibleReason : undefined}
|
|
||||||
>
|
|
||||||
<td className="p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}>
|
|
||||||
{participant.name}
|
|
||||||
</span>
|
|
||||||
{isDrafted && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
Drafted
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{!isDrafted && !isEligible && (
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
className="text-xs"
|
|
||||||
title={ineligibleReason}
|
|
||||||
>
|
|
||||||
Ineligible
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{isInQueue && !isDrafted && isEligible && (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
Queued
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-muted-foreground">
|
|
||||||
{participant.sport.name}
|
|
||||||
</td>
|
|
||||||
<td className="p-3 text-right font-mono font-semibold">
|
|
||||||
{participant.expectedValue}
|
|
||||||
</td>
|
|
||||||
{userTeam && (
|
|
||||||
<td className="p-3 text-right">
|
|
||||||
<div className="flex gap-2 justify-end items-center">
|
|
||||||
{/* Pick button - only show if it's your turn and participant not drafted */}
|
|
||||||
{canPick && !isDrafted && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleMakePick(participant.id)
|
|
||||||
}
|
|
||||||
disabled={!isEligible}
|
|
||||||
title={!isEligible ? ineligibleReason : undefined}
|
|
||||||
>
|
|
||||||
Pick
|
|
||||||
</Button>
|
|
||||||
{/* Queue icon button next to Pick */}
|
|
||||||
{!isInQueue ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleAddToQueue(participant.id)
|
|
||||||
}
|
|
||||||
title={!isEligible ? ineligibleReason : "Add to queue"}
|
|
||||||
disabled={!isEligible}
|
|
||||||
>
|
|
||||||
<span className="text-lg">+</span>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const queueItem = queue.find(
|
|
||||||
(item: any) =>
|
|
||||||
item.participantId ===
|
|
||||||
participant.id
|
|
||||||
);
|
|
||||||
if (queueItem) {
|
|
||||||
handleRemoveFromQueue(
|
|
||||||
queueItem.id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Remove from queue"
|
|
||||||
>
|
|
||||||
<span className="text-lg">✓</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Queue buttons - only show if not your turn */}
|
|
||||||
{!canPick && !isDrafted && !isInQueue && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleAddToQueue(participant.id)
|
|
||||||
}
|
|
||||||
disabled={!isEligible}
|
|
||||||
title={!isEligible ? ineligibleReason : undefined}
|
|
||||||
>
|
|
||||||
Add to Queue
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!canPick && !isDrafted && isInQueue && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
const queueItem = queue.find(
|
|
||||||
(item: any) =>
|
|
||||||
item.participantId ===
|
|
||||||
participant.id
|
|
||||||
);
|
|
||||||
if (queueItem) {
|
|
||||||
handleRemoveFromQueue(queueItem.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
62
package-lock.json
generated
62
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"@clerk/react-router": "^2.1.0",
|
"@clerk/react-router": "^2.1.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@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-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
|
@ -18,6 +19,7 @@
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@react-router/express": "^7.7.1",
|
"@react-router/express": "^7.7.1",
|
||||||
"@react-router/node": "^7.7.1",
|
"@react-router/node": "^7.7.1",
|
||||||
"class-variance-authority": "^0.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": {
|
"node_modules/@radix-ui/react-collection": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
|
"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": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"@clerk/react-router": "^2.1.0",
|
"@clerk/react-router": "^2.1.0",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@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-context-menu": "^2.2.16",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.1.7",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
|
@ -33,6 +34,7 @@
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@react-router/express": "^7.7.1",
|
"@react-router/express": "^7.7.1",
|
||||||
"@react-router/node": "^7.7.1",
|
"@react-router/node": "^7.7.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|
|
||||||
411
plans/draft-room-redesign.md
Normal file
411
plans/draft-room-redesign.md
Normal file
|
|
@ -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)
|
||||||
Loading…
Add table
Reference in a new issue