diff --git a/app/components/league/DraftInfoCard.tsx b/app/components/league/DraftInfoCard.tsx index 186f740..f22f1c9 100644 --- a/app/components/league/DraftInfoCard.tsx +++ b/app/components/league/DraftInfoCard.tsx @@ -21,6 +21,8 @@ export interface DraftInfoCardProps { draftDateTime?: string | Date | null; draftBoardHref: string; draftRoomHref: string; + /** Link to the pre-draft queue builder; shown only when draft order is not yet set and user has a team */ + queueBuilderHref?: string; /** 1-based draft position for the current user; omit or undefined to hide the panel */ userDraftPosition?: number; /** IANA timezone used to display the draft date and time. */ @@ -57,6 +59,7 @@ function DraftInfoCardVariant({ draftDateTime, draftTimezone, draftRoomHref, + queueBuilderHref, userDraftPosition, overnightPauseMode, overnightPauseStart, @@ -112,11 +115,15 @@ function DraftInfoCardVariant({

Draft Info

- {isDraftOrderSet && ( + {isDraftOrderSet ? ( - )} + ) : queueBuilderHref ? ( + + ) : null} diff --git a/app/routes.ts b/app/routes.ts index 9577079..2d055f1 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -20,6 +20,10 @@ export default [ "leagues/:leagueId/draft/:seasonId", "routes/leagues/$leagueId.draft.$seasonId.tsx" ), + route( + "leagues/:leagueId/draft-queue/:seasonId", + "routes/leagues/$leagueId.draft-queue.$seasonId.tsx" + ), route( "leagues/:leagueId/draft-board/:seasonId", "routes/leagues/$leagueId.draft-board.$seasonId.tsx" diff --git a/app/routes/leagues/$leagueId.draft-queue.$seasonId.tsx b/app/routes/leagues/$leagueId.draft-queue.$seasonId.tsx new file mode 100644 index 0000000..38a97e6 --- /dev/null +++ b/app/routes/leagues/$leagueId.draft-queue.$seasonId.tsx @@ -0,0 +1,227 @@ +import { redirect, useFetcher, useLoaderData, useRevalidator, Link } from "react-router"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { ArrowLeft, ListOrdered } from "lucide-react"; +import { auth } from "~/lib/auth.server"; +import { findSeasonWithTeamsAndLeague } from "~/models/season"; +import { getDraftParticipants } from "~/models/season-participant"; +import { getTeamQueue } from "~/models/draft-queue"; +import { QueueSection } from "~/components/draft/QueueSection"; +import { Button } from "~/components/ui/button"; +import { Badge } from "~/components/ui/badge"; +import type { Route } from "./+types/$leagueId.draft-queue.$seasonId"; + +export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { + return [{ title: `Pre-Draft Queue — ${data?.season?.league?.name ?? "League"} - Brackt` }]; +} + +export async function loader(args: Route.LoaderArgs) { + const { params } = args; + const { seasonId, leagueId } = params; + const session = await auth.api.getSession({ headers: args.request.headers }); + const userId = session?.user.id ?? null; + + if (!seasonId) { + throw new Response("Season ID is required", { status: 400 }); + } + + const season = await findSeasonWithTeamsAndLeague(seasonId); + + if (!season) { + throw new Response("Season not found", { status: 404 }); + } + + // Redirect if draft has started — send them to the appropriate page + if (season.status === "draft") { + return redirect(`/leagues/${leagueId}/draft/${seasonId}`); + } + if (season.status === "active" || season.status === "completed") { + return redirect(`/leagues/${leagueId}/draft-board/${seasonId}`); + } + + if (!userId) { + throw new Response("You must be logged in to build a queue", { status: 401 }); + } + + const userTeam = season.teams.find((t) => t.ownerId === userId); + if (!userTeam) { + throw new Response("You do not have a team in this season", { status: 403 }); + } + + const [availableParticipants, userQueue] = await Promise.all([ + getDraftParticipants(seasonId), + getTeamQueue(userTeam.id), + ]); + + return { + season, + leagueId, + userTeam, + availableParticipants, + userQueue, + }; +} + +type QueueItem = { id: string; participantId: string }; + +export default function PreDraftQueue() { + const { season, leagueId, userTeam, availableParticipants, userQueue } = + useLoaderData(); + const { revalidate } = useRevalidator(); + + const [localQueue, setLocalQueue] = useState(userQueue); + + // Sync local state when server data refreshes + useEffect(() => { + setLocalQueue(userQueue); + }, [userQueue]); + + const addFetcher = useFetcher<{ success: boolean }>(); + const removeFetcher = useFetcher(); + const reorderFetcher = useFetcher(); + + // After add completes, revalidate to replace the optimistic temp ID with the real one + useEffect(() => { + if (addFetcher.state === "idle" && addFetcher.data?.success) { + revalidate(); + } + }, [addFetcher.state, addFetcher.data, revalidate]); + + const queuedParticipantIds = useMemo( + () => new Set(localQueue.map((q) => q.participantId)), + [localQueue] + ); + + const handleAdd = useCallback( + (participantId: string) => { + if (queuedParticipantIds.has(participantId)) return; + // Optimistic update with a temporary ID + const tempId = `temp-${Date.now()}-${participantId}`; + setLocalQueue((prev) => [...prev, { id: tempId, participantId }]); + const formData = new FormData(); + formData.append("seasonId", season.id); + formData.append("teamId", userTeam.id); + formData.append("participantId", participantId); + addFetcher.submit(formData, { method: "POST", action: "/api/queue/add" }); + }, + [queuedParticipantIds, season.id, userTeam.id, addFetcher] + ); + + const handleRemove = useCallback( + (queueId: string) => { + setLocalQueue((prev) => prev.filter((item) => item.id !== queueId)); + const formData = new FormData(); + formData.append("queueId", queueId); + formData.append("teamId", userTeam.id); + removeFetcher.submit(formData, { method: "POST", action: "/api/queue/remove" }); + }, + [userTeam.id, removeFetcher] + ); + + const handleReorder = useCallback( + (participantIds: string[]) => { + setLocalQueue((prev) => { + const byParticipantId = new Map(prev.map((item) => [item.participantId, item])); + return participantIds + .map((pid) => byParticipantId.get(pid)) + .filter((item): item is (typeof prev)[number] => item !== undefined); + }); + const formData = new FormData(); + formData.append("teamId", userTeam.id); + formData.append("seasonId", season.id); + formData.append("participantIds", JSON.stringify(participantIds)); + reorderFetcher.submit(formData, { method: "POST", action: "/api/queue/reorder" }); + }, + [userTeam.id, season.id, reorderFetcher] + ); + + return ( +
+ {/* Header */} +
+ + + Back to {season.league.name} + +
+ +

Pre-Draft Queue

+
+

+ Rank the participants you want. Your queue will carry into the live draft and determine + your autopick order. +

+
+ + {/* Two-panel layout */} +
+ {/* Participant list */} +
+
+

All Participants

+ Sorted by VORP +
+
+ {availableParticipants.map((p) => { + const inQueue = queuedParticipantIds.has(p.id); + return ( +
+
+

{p.name}

+

{p.sport.name}

+
+ {p.vorpValue != null && ( + + {Number(p.vorpValue).toFixed(1)} + + )} + +
+ ); + })} + {availableParticipants.length === 0 && ( +

+ No participants have been added to this season yet. +

+ )} +
+
+ + {/* Queue panel */} +
+
+

Your Queue

+ {localQueue.length} +
+
+ +
+ {localQueue.length > 0 && ( +

+ Drag to reorder · Autopick follows this order +

+ )} +
+
+
+ ); +} diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index 86cd700..0a17ef5 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -210,6 +210,7 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { draftTimezone={currentUserTimezone ?? season.overnightPauseTimezone} draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`} draftRoomHref={`/leagues/${league.id}/draft/${season.id}`} + queueBuilderHref={myTeam && season.status === "pre_draft" ? `/leagues/${league.id}/draft-queue/${season.id}` : undefined} userDraftPosition={userDraftPosition} overnightPauseMode={season.overnightPauseMode} overnightPauseStart={season.overnightPauseStart}