518 lines
19 KiB
TypeScript
518 lines
19 KiB
TypeScript
import { useLoaderData, Link } from "react-router";
|
|
import { eq, and, asc, desc, notInArray, inArray } from "drizzle-orm";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
|
import { useEffect, useState } from "react";
|
|
import { Card } from "~/components/ui/card";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Button } from "~/components/ui/button";
|
|
import { getAuth } from "@clerk/react-router/server";
|
|
import { getTeamQueue } from "~/models/draft-queue";
|
|
|
|
export async function loader(args: any) {
|
|
const { params } = args;
|
|
const { seasonId, leagueId } = params;
|
|
const auth = await getAuth(args);
|
|
const userId = (auth as any).userId as string | null;
|
|
|
|
if (!seasonId) {
|
|
throw new Response("Season ID is required", { status: 400 });
|
|
}
|
|
|
|
if (!userId) {
|
|
throw new Response("You must be logged in to view the draft room", { status: 401 });
|
|
}
|
|
|
|
const db = database();
|
|
|
|
// Get season details
|
|
const season = await db.query.seasons.findFirst({
|
|
where: eq(schema.seasons.id, seasonId),
|
|
with: {
|
|
league: true,
|
|
teams: true,
|
|
},
|
|
});
|
|
|
|
if (!season) {
|
|
throw new Response("Season not found", { status: 404 });
|
|
}
|
|
|
|
// Verify user access: must be team owner or commissioner
|
|
const userTeam = season.teams.find((team: any) => team.ownerId === userId);
|
|
const isCommissioner = await db.query.commissioners.findFirst({
|
|
where: and(
|
|
eq(schema.commissioners.leagueId, leagueId),
|
|
eq(schema.commissioners.userId, userId)
|
|
),
|
|
});
|
|
|
|
if (!userTeam && !isCommissioner) {
|
|
throw new Response("You do not have access to this draft room", { status: 403 });
|
|
}
|
|
|
|
// Get draft slots (draft order) - using select instead of query builder
|
|
const draftSlots = await db
|
|
.select({
|
|
id: schema.draftSlots.id,
|
|
draftOrder: schema.draftSlots.draftOrder,
|
|
team: schema.teams,
|
|
})
|
|
.from(schema.draftSlots)
|
|
.innerJoin(schema.teams, eq(schema.draftSlots.teamId, schema.teams.id))
|
|
.where(eq(schema.draftSlots.seasonId, seasonId))
|
|
.orderBy(asc(schema.draftSlots.draftOrder));
|
|
|
|
// Get all draft picks - using select instead of query builder
|
|
const draftPicks = await db
|
|
.select({
|
|
id: schema.draftPicks.id,
|
|
pickNumber: schema.draftPicks.pickNumber,
|
|
round: schema.draftPicks.round,
|
|
pickInRound: schema.draftPicks.pickInRound,
|
|
timeUsed: schema.draftPicks.timeUsed,
|
|
team: schema.teams,
|
|
participant: schema.participants,
|
|
sport: schema.sports,
|
|
})
|
|
.from(schema.draftPicks)
|
|
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
|
|
.innerJoin(schema.participants, eq(schema.draftPicks.participantId, schema.participants.id))
|
|
.innerJoin(schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id))
|
|
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
|
|
.where(eq(schema.draftPicks.seasonId, seasonId))
|
|
.orderBy(asc(schema.draftPicks.pickNumber));
|
|
|
|
// Get sports seasons for this season
|
|
const seasonSports = await db.query.seasonSports.findMany({
|
|
where: eq(schema.seasonSports.seasonId, seasonId),
|
|
});
|
|
|
|
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
|
|
|
|
// Get available participants (not yet picked) - sorted by EV desc, then name
|
|
const pickedParticipantIds = draftPicks.map((p) => p.participant.id);
|
|
|
|
let availableParticipants: any[] = [];
|
|
|
|
if (sportsSeasonIds.length > 0) {
|
|
availableParticipants = await db
|
|
.select({
|
|
id: schema.participants.id,
|
|
name: schema.participants.name,
|
|
expectedValue: schema.participants.expectedValue,
|
|
sport: schema.sports,
|
|
})
|
|
.from(schema.participants)
|
|
.innerJoin(schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id))
|
|
.innerJoin(schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id))
|
|
.where(
|
|
and(
|
|
sportsSeasonIds.length === 1
|
|
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
|
|
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds),
|
|
pickedParticipantIds.length > 0
|
|
? notInArray(schema.participants.id, pickedParticipantIds)
|
|
: undefined
|
|
)
|
|
)
|
|
.orderBy(desc(schema.participants.expectedValue), asc(schema.participants.name));
|
|
}
|
|
|
|
// Load user's team queue if they have a team
|
|
const userQueue = userTeam ? await getTeamQueue(userTeam.id) : [];
|
|
|
|
return {
|
|
season,
|
|
draftSlots,
|
|
draftPicks,
|
|
availableParticipants,
|
|
userTeam,
|
|
userQueue,
|
|
isCommissioner: !!isCommissioner,
|
|
currentUserId: userId,
|
|
};
|
|
}
|
|
|
|
export default function DraftRoom() {
|
|
const { season, draftSlots, draftPicks, availableParticipants, userTeam: _userTeam, isCommissioner: _isCommissioner } =
|
|
useLoaderData<typeof loader>();
|
|
const { isConnected, on, off } = useDraftSocket(season.id);
|
|
const [picks, setPicks] = useState(draftPicks);
|
|
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [hideDrafted, setHideDrafted] = useState(true);
|
|
const [sportFilter, setSportFilter] = useState<string>("all");
|
|
|
|
// Listen for new picks from other users
|
|
useEffect(() => {
|
|
const handlePickMade = (data: any) => {
|
|
console.log("Pick made:", data);
|
|
setPicks((prev: any) => [...prev, data.pick]);
|
|
setCurrentPick(data.nextPickNumber);
|
|
};
|
|
|
|
on("pick-made", handlePickMade);
|
|
|
|
return () => {
|
|
off("pick-made", handlePickMade);
|
|
};
|
|
}, [on, off]);
|
|
|
|
// Calculate current round
|
|
const currentRound = Math.ceil(currentPick / draftSlots.length);
|
|
|
|
// Generate snake draft grid structure
|
|
const generateDraftGrid = () => {
|
|
const teamCount = draftSlots.length;
|
|
const rounds = season.draftRounds;
|
|
const grid: Array<Array<{ pickNumber: number; round: number; pickInRound: number; teamId: string; pick?: any }>> = [];
|
|
|
|
for (let round = 1; round <= rounds; round++) {
|
|
const roundPicks = [];
|
|
const isOddRound = round % 2 === 1;
|
|
|
|
for (let i = 0; i < teamCount; i++) {
|
|
const pickInRound = i + 1;
|
|
const teamIndex = isOddRound ? i : teamCount - 1 - i;
|
|
const pickNumber = (round - 1) * teamCount + pickInRound;
|
|
const teamId = draftSlots[teamIndex]?.team.id;
|
|
|
|
// Find if this pick has been made
|
|
const pick = picks.find((p: any) => p.pickNumber === pickNumber);
|
|
|
|
roundPicks.push({
|
|
pickNumber,
|
|
round,
|
|
pickInRound,
|
|
teamId,
|
|
pick,
|
|
});
|
|
}
|
|
grid.push(roundPicks);
|
|
}
|
|
|
|
return grid;
|
|
};
|
|
|
|
const draftGrid = generateDraftGrid();
|
|
|
|
// Get drafted participant IDs for filtering
|
|
const draftedParticipantIds = new Set(picks.map((p: any) => p.participant.id));
|
|
|
|
// Get unique sports for filter dropdown
|
|
const uniqueSports = Array.from(
|
|
new Set(availableParticipants.map((p: any) => p.sport.name))
|
|
).sort();
|
|
|
|
// Get all participants (including drafted) for the show/hide drafted toggle
|
|
const allParticipants = [...availableParticipants];
|
|
|
|
// Add drafted participants if we're showing them
|
|
if (!hideDrafted) {
|
|
picks.forEach((pick: any) => {
|
|
if (!allParticipants.find((p: any) => p.id === pick.participant.id)) {
|
|
allParticipants.push({
|
|
id: pick.participant.id,
|
|
name: pick.participant.name,
|
|
expectedValue: pick.participant.expectedValue || 0,
|
|
sport: pick.sport,
|
|
});
|
|
}
|
|
});
|
|
// Re-sort after adding drafted players
|
|
allParticipants.sort((a, b) => {
|
|
if (b.expectedValue !== a.expectedValue) {
|
|
return b.expectedValue - a.expectedValue;
|
|
}
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
}
|
|
|
|
// Filter participants based on search, sport, and drafted status
|
|
const filteredParticipants = allParticipants.filter((participant: any) => {
|
|
// Search filter
|
|
if (searchQuery && !participant.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
|
return false;
|
|
}
|
|
// Sport filter
|
|
if (sportFilter !== "all" && participant.sport.name !== sportFilter) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
{/* Standalone Header - No Main Nav */}
|
|
<div className="border-b bg-card">
|
|
<div className="w-full px-4 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<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 className="flex items-center gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`w-3 h-3 rounded-full ${
|
|
isConnected ? "bg-green-500" : "bg-red-500"
|
|
}`}
|
|
/>
|
|
<span className="text-sm font-medium">
|
|
{isConnected ? "Live" : "Disconnected"}
|
|
</span>
|
|
</div>
|
|
<Button variant="outline" asChild>
|
|
<Link to={`/leagues/${season.leagueId}`}>
|
|
Exit Draft Room
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="w-full px-4 py-4">
|
|
{/* Draft Grid - Horizontally Scrollable */}
|
|
<div className="mb-6">
|
|
<Card className="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) => (
|
|
<div
|
|
key={slot.id}
|
|
className="flex-1 min-w-32 text-center"
|
|
>
|
|
<div className="font-semibold text-sm truncate px-2">
|
|
{slot.team.name}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
Pick {slot.draftOrder}
|
|
</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 (
|
|
<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-2 gap-6">
|
|
{/* Recent Picks */}
|
|
<div>
|
|
<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 ({filteredParticipants.length})
|
|
</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>
|
|
|
|
{/* Table */}
|
|
<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>
|
|
</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);
|
|
|
|
return (
|
|
<tr
|
|
key={participant.id}
|
|
className={`border-t transition-colors ${
|
|
isDrafted
|
|
? "bg-muted/50 opacity-60 cursor-not-allowed"
|
|
: "hover:bg-muted/50 cursor-pointer"
|
|
}`}
|
|
onClick={() => {
|
|
if (!isDrafted) {
|
|
// TODO: Add to queue functionality (Phase 7)
|
|
console.log("Add to queue:", participant.name);
|
|
}
|
|
}}
|
|
>
|
|
<td className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{participant.name}</span>
|
|
{isDrafted && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Drafted
|
|
</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>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|