* Add pre-draft queue builder so users can rank players before draft order is set Creates a new /leagues/:leagueId/draft-queue/:seasonId page that lets team owners browse participants by VORP and build their autopick queue during the pre_draft phase. The queue uses the existing draftQueue table so it carries seamlessly into the live draft room. Adds a "Build Your Queue" button to DraftInfoCard that shows only when draft order has not been set yet (replaced by "Enter Draft Room" once it is). https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG * Improve pre-draft rankings UX: mobile tabs, add/remove toggle, rename On mobile, show tabs ("All Players" / "My Rankings") instead of a stacked layout that buries the queue below a long participant list. On the All Players list, the button now toggles between Add and Remove so users never need to switch tabs just to drop someone. Desktop keeps the side-by-side panel layout. Also renames the feature throughout from "queue builder" to "pre-draft rankings" and the DraftInfoCard button to "Set Pre-Draft Rankings". https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG * Fix all code review issues in pre-draft rankings feature - Replace useFetcher with direct fetch() for add/remove/reorder so rapid clicks no longer cancel in-flight requests - Add toast.error() on all failure paths (matching live draft room pattern) and revert optimistic state when operations fail - Add useRef to give handleReorder a stable reference without a localQueue closure dependency, preventing unnecessary QueueSection re-renders - Convert allPlayersPanel and rankingsPanel to useMemo - Remove leagueId from loader return; read from useParams() instead - Extract queueBuilderHref to a variable in the league home component - Add emptyMessage prop to QueueSection with a correct message for the pre-draft context ("Add players from All Players...") - Add draft-queue-access.test.ts covering all loader access control paths: 401/403 errors, status-based redirects, and redirect URL correctness https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG * Fix lint: use !== null check instead of != null oxlint enforces eqeqeq; replace != null with !== null && !== undefined. https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG --------- Co-authored-by: Claude <noreply@anthropic.com>
199 lines
5.4 KiB
TypeScript
199 lines
5.4 KiB
TypeScript
import { memo, useCallback, useMemo } from "react";
|
||
import { GripVertical } from "lucide-react";
|
||
import { Button } from "~/components/ui/button";
|
||
import { Badge } from "~/components/ui/badge";
|
||
import {
|
||
DndContext,
|
||
closestCenter,
|
||
KeyboardSensor,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
} from "@dnd-kit/core";
|
||
import type { DragEndEvent } from "@dnd-kit/core";
|
||
import {
|
||
arrayMove,
|
||
SortableContext,
|
||
sortableKeyboardCoordinates,
|
||
useSortable,
|
||
verticalListSortingStrategy,
|
||
} from "@dnd-kit/sortable";
|
||
import { CSS } from "@dnd-kit/utilities";
|
||
|
||
interface QueueSectionProps {
|
||
queue: Array<{
|
||
id: string;
|
||
participantId: string;
|
||
}>;
|
||
availableParticipants: Array<{
|
||
id: string;
|
||
name: string;
|
||
sport: { name: string };
|
||
}>;
|
||
canPick: boolean;
|
||
onRemoveFromQueue: (queueId: string) => void;
|
||
onReorder: (participantIds: string[]) => void;
|
||
onMakePick?: (participantId: string) => void;
|
||
emptyMessage?: string;
|
||
}
|
||
|
||
// Sortable queue item component
|
||
const SortableQueueItem = memo(function SortableQueueItem({
|
||
item,
|
||
index,
|
||
participantName,
|
||
sportName,
|
||
canPick,
|
||
onRemove,
|
||
onDraft,
|
||
}: {
|
||
item: { id: string; participantId: string };
|
||
index: number;
|
||
participantName: string;
|
||
sportName?: string;
|
||
canPick: boolean;
|
||
onRemove: (queueId: string) => void;
|
||
onDraft?: (participantId: string) => void;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
setActivatorNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id: item.id });
|
||
|
||
const style = {
|
||
transform: CSS.Transform.toString(transform),
|
||
transition,
|
||
opacity: isDragging ? 0.5 : 1,
|
||
};
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={style}
|
||
{...attributes}
|
||
className={`p-2 rounded-lg ${
|
||
canPick ? "bg-electric/10 border border-electric/40" : "bg-muted"
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<div
|
||
ref={setActivatorNodeRef}
|
||
{...listeners}
|
||
className="w-10 flex items-center gap-2 flex-shrink-0 cursor-grab active:cursor-grabbing touch-none"
|
||
>
|
||
<GripVertical className="w-4 h-4 text-muted-foreground" />
|
||
<Badge variant="default" className="text-xs">{index + 1}</Badge>
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="font-semibold text-sm truncate">{participantName}</p>
|
||
{sportName && <p className="text-xs text-muted-foreground">{sportName}</p>}
|
||
</div>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-7 w-7 flex-shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||
onClick={() => onRemove(item.id)}
|
||
title="Remove from queue"
|
||
>
|
||
<span className="text-lg">×</span>
|
||
</Button>
|
||
</div>
|
||
{canPick && onDraft && (
|
||
<div className="mt-1.5 pl-10">
|
||
<Button
|
||
variant="default"
|
||
size="sm"
|
||
className="h-7 text-xs w-full bg-electric text-background hover:bg-electric/90"
|
||
onClick={() => onDraft(item.participantId)}
|
||
>
|
||
Draft
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|
||
|
||
export const QueueSection = memo(function QueueSection({
|
||
queue,
|
||
availableParticipants,
|
||
canPick,
|
||
onRemoveFromQueue,
|
||
onReorder,
|
||
onMakePick,
|
||
emptyMessage = "Click participants in Available to add to your queue",
|
||
}: QueueSectionProps) {
|
||
const sensors = useSensors(
|
||
useSensor(PointerSensor, {
|
||
activationConstraint: { distance: 8 },
|
||
}),
|
||
useSensor(KeyboardSensor, {
|
||
coordinateGetter: sortableKeyboardCoordinates,
|
||
})
|
||
);
|
||
|
||
const participantMap = useMemo(
|
||
() => new Map(availableParticipants.map((p) => [p.id, p])),
|
||
[availableParticipants]
|
||
);
|
||
|
||
const queueIds = useMemo(() => queue.map((item) => item.id), [queue]);
|
||
|
||
const handleDragEnd = useCallback(
|
||
(event: DragEndEvent) => {
|
||
const { active, over } = event;
|
||
if (over && active.id !== over.id) {
|
||
const oldIndex = queue.findIndex((item) => item.id === active.id);
|
||
const newIndex = queue.findIndex((item) => item.id === over.id);
|
||
const reorderedQueue = arrayMove(queue, oldIndex, newIndex);
|
||
onReorder(reorderedQueue.map((item) => item.participantId));
|
||
}
|
||
},
|
||
[queue, onReorder]
|
||
);
|
||
|
||
return (
|
||
<div className="p-4">
|
||
{/* Queue List */}
|
||
{queue.length === 0 ? (
|
||
<p className="text-muted-foreground text-sm text-center py-8">
|
||
{emptyMessage}
|
||
</p>
|
||
) : (
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragEnd={handleDragEnd}
|
||
>
|
||
<SortableContext
|
||
items={queueIds}
|
||
strategy={verticalListSortingStrategy}
|
||
>
|
||
<div className="space-y-1.5">
|
||
{queue.map((item, index) => {
|
||
const participant = participantMap.get(item.participantId);
|
||
return (
|
||
<SortableQueueItem
|
||
key={item.id}
|
||
item={item}
|
||
index={index}
|
||
participantName={participant?.name ?? "Unknown"}
|
||
sportName={participant?.sport.name}
|
||
canPick={canPick}
|
||
onRemove={onRemoveFromQueue}
|
||
onDraft={onMakePick}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</SortableContext>
|
||
</DndContext>
|
||
)}
|
||
</div>
|
||
);
|
||
});
|