brackt/app/routes/leagues/$leagueId.draft-queue.$seasonId.tsx
Claude fb392c4fdd
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
2026-05-15 23:45:34 +00:00

285 lines
10 KiB
TypeScript

import { redirect, useFetcher, useLoaderData, useRevalidator, Link } from "react-router";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ArrowLeft, ListOrdered, Users } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import type { Route } from "./+types/$leagueId.draft-queue.$seasonId";
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Pre-Draft Rankings — ${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 set pre-draft rankings", { 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 PreDraftRankings() {
const { season, leagueId, userTeam, availableParticipants, userQueue } =
useLoaderData<typeof loader>();
const { revalidate } = useRevalidator();
const [localQueue, setLocalQueue] = useState<QueueItem[]>(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;
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]
);
// Remove by participantId — used from the All Players list
const handleRemoveByParticipantId = useCallback(
(participantId: string) => {
const item = localQueue.find((q) => q.participantId === participantId);
if (item) handleRemove(item.id);
},
[localQueue, handleRemove]
);
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]
);
// ── Shared panel content ────────────────────────────────────────────────────
const allPlayersPanel = (
<div className="space-y-1">
{availableParticipants.map((p) => {
const inQueue = queuedParticipantIds.has(p.id);
return (
<div
key={p.id}
className="flex items-center gap-3 rounded-lg bg-muted/50 px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{p.name}</p>
<p className="text-xs text-muted-foreground">{p.sport.name}</p>
</div>
{p.vorpValue != null && (
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{Number(p.vorpValue).toFixed(1)}
</span>
)}
{inQueue ? (
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveByParticipantId(p.id)}
className="h-7 text-xs shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
>
Remove
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleAdd(p.id)}
className="h-7 text-xs shrink-0"
>
+ Add
</Button>
)}
</div>
);
})}
{availableParticipants.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-12">
No participants have been added to this season yet.
</p>
)}
</div>
);
const rankingsPanel = (
<>
<div className="rounded-lg border bg-card">
<QueueSection
queue={localQueue}
availableParticipants={availableParticipants}
canPick={false}
onRemoveFromQueue={handleRemove}
onReorder={handleReorder}
/>
</div>
{localQueue.length > 0 && (
<p className="text-xs text-muted-foreground mt-2 text-center">
Drag to reorder · Autopick follows this order
</p>
)}
</>
);
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div className="container mx-auto py-8 px-4">
{/* Header */}
<div className="mb-6">
<Link
to={`/leagues/${leagueId}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground mb-3"
>
<ArrowLeft className="h-4 w-4" />
Back to {season.league.name}
</Link>
<div className="flex items-center gap-2 mb-1">
<ListOrdered className="h-6 w-6" />
<h1 className="text-2xl font-bold">Pre-Draft Rankings</h1>
</div>
<p className="text-sm text-muted-foreground">
Rank the players you want. Your rankings carry into the live draft as your autopick
priority order.
</p>
</div>
{/* Mobile: tabs */}
<div className="lg:hidden">
<Tabs defaultValue="players">
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger value="players" className="flex items-center gap-1.5 text-xs">
<Users className="h-4 w-4" />
All Players
</TabsTrigger>
<TabsTrigger value="rankings" className="flex items-center gap-1.5 text-xs">
<ListOrdered className="h-4 w-4" />
My Rankings
{localQueue.length > 0 && (
<Badge variant="secondary" className="ml-1 h-4 px-1 text-[10px]">
{localQueue.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="players">
<div className="flex items-center justify-between mb-3">
<span className="text-xs text-muted-foreground">Sorted by VORP</span>
</div>
{allPlayersPanel}
</TabsContent>
<TabsContent value="rankings">{rankingsPanel}</TabsContent>
</Tabs>
</div>
{/* Desktop: side-by-side */}
<div className="hidden lg:grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold">All Players</h2>
<span className="text-xs text-muted-foreground">Sorted by VORP</span>
</div>
{allPlayersPanel}
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold">My Rankings</h2>
<Badge variant="secondary">{localQueue.length}</Badge>
</div>
{rankingsPanel}
</div>
</div>
</div>
);
}