oxlint enforces eqeqeq; replace != null with !== null && !== undefined. https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import { redirect, useLoaderData, useRevalidator, Link, useParams } from "react-router";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { ArrowLeft, ListOrdered, Users } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
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,
|
|
userTeam,
|
|
availableParticipants,
|
|
userQueue,
|
|
};
|
|
}
|
|
|
|
type QueueItem = { id: string; participantId: string };
|
|
|
|
export default function PreDraftRankings() {
|
|
const { season, userTeam, availableParticipants, userQueue } =
|
|
useLoaderData<typeof loader>();
|
|
const { leagueId } = useParams<{ leagueId: string }>();
|
|
const { revalidate } = useRevalidator();
|
|
|
|
const [localQueue, setLocalQueue] = useState<QueueItem[]>(userQueue);
|
|
|
|
// Sync local state when server data refreshes
|
|
useEffect(() => {
|
|
setLocalQueue(userQueue);
|
|
}, [userQueue]);
|
|
|
|
// Always-current ref so reorder callback stays stable (no localQueue dependency)
|
|
const queueRef = useRef(localQueue);
|
|
useEffect(() => {
|
|
queueRef.current = localQueue;
|
|
}, [localQueue]);
|
|
|
|
const queuedParticipantIds = useMemo(
|
|
() => new Set(localQueue.map((q) => q.participantId)),
|
|
[localQueue]
|
|
);
|
|
|
|
const handleAdd = useCallback(
|
|
async (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);
|
|
|
|
try {
|
|
const res = await fetch("/api/queue/add", { method: "POST", body: formData });
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
setLocalQueue((prev) => prev.filter((item) => item.id !== tempId));
|
|
toast.error(data.error || "Failed to add to rankings");
|
|
return;
|
|
}
|
|
revalidate(); // swap temp ID for the real server-assigned ID
|
|
} catch {
|
|
setLocalQueue((prev) => prev.filter((item) => item.id !== tempId));
|
|
toast.error("Network error — failed to add to rankings");
|
|
}
|
|
},
|
|
[queuedParticipantIds, season.id, userTeam.id, revalidate]
|
|
);
|
|
|
|
const handleRemove = useCallback(
|
|
async (queueId: string) => {
|
|
setLocalQueue((prev) => prev.filter((item) => item.id !== queueId));
|
|
|
|
const formData = new FormData();
|
|
formData.append("queueId", queueId);
|
|
formData.append("teamId", userTeam.id);
|
|
|
|
try {
|
|
const res = await fetch("/api/queue/remove", { method: "POST", body: formData });
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
toast.error(data.error || "Failed to remove from rankings");
|
|
revalidate();
|
|
}
|
|
} catch {
|
|
toast.error("Network error — failed to remove from rankings");
|
|
revalidate();
|
|
}
|
|
},
|
|
[userTeam.id, revalidate]
|
|
);
|
|
|
|
// Remove by participantId — used from the All Players list
|
|
const handleRemoveByParticipantId = useCallback(
|
|
(participantId: string) => {
|
|
const item = queueRef.current.find((q) => q.participantId === participantId);
|
|
if (item) handleRemove(item.id);
|
|
},
|
|
[handleRemove]
|
|
);
|
|
|
|
const handleReorder = useCallback(
|
|
async (participantIds: string[]) => {
|
|
const previousQueue = queueRef.current;
|
|
setLocalQueue((prev) => {
|
|
const byParticipantId = new Map(prev.map((item) => [item.participantId, item]));
|
|
return participantIds
|
|
.map((pid) => byParticipantId.get(pid))
|
|
.filter((item): item is QueueItem => item !== undefined);
|
|
});
|
|
|
|
const formData = new FormData();
|
|
formData.append("teamId", userTeam.id);
|
|
formData.append("seasonId", season.id);
|
|
formData.append("participantIds", JSON.stringify(participantIds));
|
|
|
|
try {
|
|
const res = await fetch("/api/queue/reorder", { method: "POST", body: formData });
|
|
const data = await res.json();
|
|
if (data.error) {
|
|
setLocalQueue(previousQueue);
|
|
toast.error(data.error || "Failed to reorder rankings");
|
|
}
|
|
} catch {
|
|
setLocalQueue(previousQueue);
|
|
toast.error("Network error — failed to reorder rankings");
|
|
}
|
|
},
|
|
[userTeam.id, season.id]
|
|
);
|
|
|
|
// ── Shared panel content ────────────────────────────────────────────────────
|
|
|
|
const allPlayersPanel = useMemo(
|
|
() => (
|
|
<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 && p.vorpValue !== undefined && (
|
|
<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>
|
|
),
|
|
[availableParticipants, queuedParticipantIds, handleAdd, handleRemoveByParticipantId]
|
|
);
|
|
|
|
const rankingsPanel = useMemo(
|
|
() => (
|
|
<>
|
|
<div className="rounded-lg border bg-card">
|
|
<QueueSection
|
|
queue={localQueue}
|
|
availableParticipants={availableParticipants}
|
|
canPick={false}
|
|
onRemoveFromQueue={handleRemove}
|
|
onReorder={handleReorder}
|
|
emptyMessage="Add players from All Players to set your ranking order."
|
|
/>
|
|
</div>
|
|
{localQueue.length > 0 && (
|
|
<p className="text-xs text-muted-foreground mt-2 text-center">
|
|
Drag to reorder · Autopick follows this order
|
|
</p>
|
|
)}
|
|
</>
|
|
),
|
|
[localQueue, availableParticipants, handleRemove, handleReorder]
|
|
);
|
|
|
|
// ── 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>
|
|
);
|
|
}
|