feat: implement real-time draft room with commissioner controls and socket events
This commit is contained in:
parent
c820dfbdad
commit
d93812315d
9 changed files with 786 additions and 12 deletions
107
app/components/draft/CommissionerControls.tsx
Normal file
107
app/components/draft/CommissionerControls.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { Play, Pause, FastForward } from "lucide-react";
|
||||||
|
|
||||||
|
interface CommissionerControlsProps {
|
||||||
|
isDraftStarted: boolean;
|
||||||
|
isDraftPaused: boolean;
|
||||||
|
isDraftComplete: boolean;
|
||||||
|
currentTeamName: string;
|
||||||
|
onStartDraft: () => void;
|
||||||
|
onPauseDraft: () => void;
|
||||||
|
onResumeDraft: () => void;
|
||||||
|
onForcePick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommissionerControls({
|
||||||
|
isDraftStarted,
|
||||||
|
isDraftPaused,
|
||||||
|
isDraftComplete,
|
||||||
|
currentTeamName,
|
||||||
|
onStartDraft,
|
||||||
|
onPauseDraft,
|
||||||
|
onResumeDraft,
|
||||||
|
onForcePick,
|
||||||
|
}: CommissionerControlsProps) {
|
||||||
|
if (isDraftComplete) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-green-500/10 p-4 text-center">
|
||||||
|
<p className="font-medium text-green-600 dark:text-green-400">
|
||||||
|
Draft Complete
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDraftStarted) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The draft has not started yet. Click below to begin the draft.
|
||||||
|
</p>
|
||||||
|
<Button onClick={onStartDraft} className="w-full" size="lg">
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Start Draft
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Draft Status */}
|
||||||
|
<div className="rounded-lg border bg-muted/50 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Draft Status:</span>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-semibold ${
|
||||||
|
isDraftPaused
|
||||||
|
? "text-yellow-600 dark:text-yellow-400"
|
||||||
|
: "text-green-600 dark:text-green-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isDraftPaused ? "Paused" : "Active"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Current Pick:</span>
|
||||||
|
<span className="text-sm text-muted-foreground">{currentTeamName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control Buttons */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{isDraftPaused ? (
|
||||||
|
<Button onClick={onResumeDraft} className="w-full" variant="default">
|
||||||
|
<Play className="mr-2 h-4 w-4" />
|
||||||
|
Resume Draft
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={onPauseDraft} className="w-full" variant="outline">
|
||||||
|
<Pause className="mr-2 h-4 w-4" />
|
||||||
|
Pause Draft
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={onForcePick}
|
||||||
|
className="w-full"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isDraftPaused}
|
||||||
|
>
|
||||||
|
<FastForward className="mr-2 h-4 w-4" />
|
||||||
|
Force Pick for {currentTeamName}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Help Text */}
|
||||||
|
<div className="rounded-lg bg-muted/30 p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<strong>Force Pick:</strong> Automatically selects the top player from the
|
||||||
|
current team's queue, or the highest EV available player if queue is empty.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ export default [
|
||||||
route("leagues/:leagueId/draft", "routes/leagues/$leagueId.draft.tsx"),
|
route("leagues/:leagueId/draft", "routes/leagues/$leagueId.draft.tsx"),
|
||||||
route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"),
|
route("teams/:teamId/settings", "routes/teams/$teamId.settings.tsx"),
|
||||||
route("api/webhooks/clerk", "routes/api/webhooks/clerk.ts"),
|
route("api/webhooks/clerk", "routes/api/webhooks/clerk.ts"),
|
||||||
|
route("api/draft-actions", "routes/api/draft-actions.ts"),
|
||||||
route("user-profile", "routes/user-profile.tsx"),
|
route("user-profile", "routes/user-profile.tsx"),
|
||||||
route("how-to-play", "routes/how-to-play.tsx"),
|
route("how-to-play", "routes/how-to-play.tsx"),
|
||||||
|
|
||||||
|
|
|
||||||
95
app/routes/api/draft-actions.ts
Normal file
95
app/routes/api/draft-actions.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { data, type ActionFunctionArgs } from "react-router";
|
||||||
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
|
import {
|
||||||
|
addToQueue,
|
||||||
|
removeFromQueue,
|
||||||
|
reorderQueueWithSeason,
|
||||||
|
findTeamById,
|
||||||
|
getTeamQueue,
|
||||||
|
} from "~/models";
|
||||||
|
|
||||||
|
export async function action(args: ActionFunctionArgs) {
|
||||||
|
const { userId } = await getAuth(args);
|
||||||
|
if (!userId) {
|
||||||
|
return data({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { request } = args;
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const actionType = formData.get("action");
|
||||||
|
const teamId = formData.get("teamId");
|
||||||
|
|
||||||
|
if (!teamId || typeof teamId !== "string") {
|
||||||
|
return data({ error: "Team ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user owns this team
|
||||||
|
const team = await findTeamById(teamId);
|
||||||
|
if (!team || team.ownerId !== userId) {
|
||||||
|
return data({ error: "You do not own this team" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (actionType) {
|
||||||
|
case "add-to-queue": {
|
||||||
|
const participantId = formData.get("participantId");
|
||||||
|
const seasonId = formData.get("seasonId");
|
||||||
|
|
||||||
|
if (!participantId || typeof participantId !== "string") {
|
||||||
|
return data({ error: "Participant ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!seasonId || typeof seasonId !== "string") {
|
||||||
|
return data({ error: "Season ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current queue to determine position
|
||||||
|
const currentQueue = await getTeamQueue(teamId);
|
||||||
|
const queuePosition = currentQueue.length + 1;
|
||||||
|
|
||||||
|
await addToQueue({
|
||||||
|
seasonId,
|
||||||
|
teamId,
|
||||||
|
participantId,
|
||||||
|
queuePosition,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "remove-from-queue": {
|
||||||
|
const queueItemId = formData.get("queueItemId");
|
||||||
|
|
||||||
|
if (!queueItemId || typeof queueItemId !== "string") {
|
||||||
|
return data({ error: "Queue item ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeFromQueue(queueItemId);
|
||||||
|
return data({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "reorder-queue": {
|
||||||
|
const participantIds = formData.get("participantIds");
|
||||||
|
const seasonId = formData.get("seasonId");
|
||||||
|
|
||||||
|
if (!participantIds || typeof participantIds !== "string") {
|
||||||
|
return data({ error: "Participant IDs are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!seasonId || typeof seasonId !== "string") {
|
||||||
|
return data({ error: "Season ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const idsArray = JSON.parse(participantIds);
|
||||||
|
await reorderQueueWithSeason(seasonId, teamId, idsArray);
|
||||||
|
|
||||||
|
return data({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return data({ error: "Invalid action" }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Draft action error:", error);
|
||||||
|
return data({ error: "Failed to perform action" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
203
app/routes/api/draft-commissioner.ts
Normal file
203
app/routes/api/draft-commissioner.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
import { data, type ActionFunctionArgs } from "react-router";
|
||||||
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
|
import {
|
||||||
|
findSeasonById,
|
||||||
|
updateSeason,
|
||||||
|
updateSeasonStatus,
|
||||||
|
findTeamsBySeasonId,
|
||||||
|
isCommissioner,
|
||||||
|
createDraftPick,
|
||||||
|
initializeDraftTimers,
|
||||||
|
updateTeamTimer,
|
||||||
|
getSeasonTimers,
|
||||||
|
autoPickForTeam,
|
||||||
|
calculatePickInfo,
|
||||||
|
removeFromQueue,
|
||||||
|
getTeamQueue,
|
||||||
|
} from "~/models";
|
||||||
|
import { getSocketIO } from "../../../server/socket.js";
|
||||||
|
|
||||||
|
export async function action(args: ActionFunctionArgs) {
|
||||||
|
const { userId } = await getAuth(args);
|
||||||
|
if (!userId) {
|
||||||
|
return data({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { request } = args;
|
||||||
|
const formData = await request.formData();
|
||||||
|
const actionType = formData.get("action");
|
||||||
|
const seasonId = formData.get("seasonId");
|
||||||
|
|
||||||
|
if (!seasonId || typeof seasonId !== "string") {
|
||||||
|
return data({ error: "Season ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get season
|
||||||
|
const season = await findSeasonById(seasonId);
|
||||||
|
if (!season) {
|
||||||
|
return data({ error: "Season not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user is commissioner
|
||||||
|
const isUserCommissioner = await isCommissioner(season.leagueId, userId);
|
||||||
|
if (!isUserCommissioner) {
|
||||||
|
return data({ error: "Only commissioners can perform this action" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const io = getSocketIO();
|
||||||
|
|
||||||
|
switch (actionType) {
|
||||||
|
case "start-draft": {
|
||||||
|
// Check if draft already started
|
||||||
|
if (season.draftStartedAt) {
|
||||||
|
return data({ error: "Draft has already started" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get teams
|
||||||
|
const teams = await findTeamsBySeasonId(seasonId);
|
||||||
|
|
||||||
|
// Initialize timers
|
||||||
|
await initializeDraftTimers(seasonId, teams, season.draftInitialTime || 120);
|
||||||
|
|
||||||
|
// Update season
|
||||||
|
await updateSeason(seasonId, {
|
||||||
|
draftStartedAt: new Date(),
|
||||||
|
currentPickNumber: 1,
|
||||||
|
draftPaused: false,
|
||||||
|
});
|
||||||
|
await updateSeasonStatus(seasonId, "draft");
|
||||||
|
|
||||||
|
// Get updated timers
|
||||||
|
const timers = await getSeasonTimers(seasonId);
|
||||||
|
|
||||||
|
// Broadcast to all clients
|
||||||
|
io.to(`draft-${seasonId}`).emit("draft-started", {
|
||||||
|
seasonId,
|
||||||
|
startedAt: new Date(),
|
||||||
|
timers,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "pause-draft": {
|
||||||
|
const pause = formData.get("pause") === "true";
|
||||||
|
|
||||||
|
// Check if draft has started
|
||||||
|
if (!season.draftStartedAt) {
|
||||||
|
return data({ error: "Draft has not started yet" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update season
|
||||||
|
await updateSeason(seasonId, { draftPaused: pause });
|
||||||
|
|
||||||
|
// Broadcast to all clients
|
||||||
|
io.to(`draft-${seasonId}`).emit("draft-paused", {
|
||||||
|
seasonId,
|
||||||
|
isPaused: pause,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
case "force-pick": {
|
||||||
|
const teamId = formData.get("teamId");
|
||||||
|
|
||||||
|
if (!teamId || typeof teamId !== "string") {
|
||||||
|
return data({ error: "Team ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if draft has started
|
||||||
|
if (!season.draftStartedAt) {
|
||||||
|
return data({ error: "Draft has not started" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if draft is complete
|
||||||
|
const teams = await findTeamsBySeasonId(seasonId);
|
||||||
|
const totalPicks = season.draftRounds * teams.length;
|
||||||
|
const currentPickNumber = season.currentPickNumber || 1;
|
||||||
|
|
||||||
|
if (currentPickNumber > totalPicks) {
|
||||||
|
return data({ error: "Draft is already complete" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-pick for the team
|
||||||
|
const participantId = await autoPickForTeam(seasonId, teamId);
|
||||||
|
|
||||||
|
if (!participantId) {
|
||||||
|
return data({ error: "No available participants to pick" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate pick info
|
||||||
|
const { round, pickInRound } = calculatePickInfo(currentPickNumber, teams.length);
|
||||||
|
|
||||||
|
// Create the draft pick
|
||||||
|
const pick = await createDraftPick({
|
||||||
|
seasonId,
|
||||||
|
teamId,
|
||||||
|
participantId,
|
||||||
|
pickNumber: currentPickNumber,
|
||||||
|
round,
|
||||||
|
pickInRound,
|
||||||
|
pickedByUserId: userId,
|
||||||
|
pickedByType: "commissioner",
|
||||||
|
timeUsed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from queue if it was queued
|
||||||
|
const queue = await getTeamQueue(teamId);
|
||||||
|
const queueItem = queue.find((item: any) => item.participantId === participantId);
|
||||||
|
if (queueItem) {
|
||||||
|
await removeFromQueue(queueItem.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update season to next pick
|
||||||
|
const nextPickNumber = currentPickNumber + 1;
|
||||||
|
await updateSeason(seasonId, { currentPickNumber: nextPickNumber });
|
||||||
|
|
||||||
|
// Add increment time to current team's timer
|
||||||
|
if (season.draftIncrementTime) {
|
||||||
|
const timers = await getSeasonTimers(seasonId);
|
||||||
|
const currentTimer = timers.find((t: any) => t.teamId === teamId);
|
||||||
|
if (currentTimer) {
|
||||||
|
const newTime = currentTimer.timeRemaining + season.draftIncrementTime;
|
||||||
|
await updateTeamTimer(teamId, newTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if draft is complete
|
||||||
|
if (nextPickNumber > totalPicks) {
|
||||||
|
await updateSeasonStatus(seasonId, "active");
|
||||||
|
io.to(`draft-${seasonId}`).emit("draft-completed", {
|
||||||
|
seasonId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast pick to all clients in the room
|
||||||
|
io.to(`draft-${seasonId}`).emit("pick-made", {
|
||||||
|
pick,
|
||||||
|
nextPickNumber,
|
||||||
|
isDraftComplete: nextPickNumber > totalPicks,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit queue update to team owner
|
||||||
|
const updatedQueue = await getTeamQueue(teamId);
|
||||||
|
io.to(`draft-${seasonId}`).emit("queue-updated", {
|
||||||
|
teamId,
|
||||||
|
queue: updatedQueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data({ success: true, pick });
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return data({ error: "Invalid action" }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Commissioner action error:", error);
|
||||||
|
return data({
|
||||||
|
error: error instanceof Error ? error.message : "Failed to perform action"
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
175
app/routes/api/draft-pick.ts
Normal file
175
app/routes/api/draft-pick.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { data, type ActionFunctionArgs } from "react-router";
|
||||||
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
|
import {
|
||||||
|
findSeasonById,
|
||||||
|
updateSeason,
|
||||||
|
updateSeasonStatus,
|
||||||
|
findTeamsBySeasonId,
|
||||||
|
isCommissioner,
|
||||||
|
createDraftPick,
|
||||||
|
isParticipantDrafted,
|
||||||
|
updateTeamTimer,
|
||||||
|
getSeasonTimers,
|
||||||
|
calculatePickInfo,
|
||||||
|
getTeamForPick,
|
||||||
|
findDraftSlotsBySeasonId,
|
||||||
|
removeFromQueue,
|
||||||
|
getTeamQueue,
|
||||||
|
} from "~/models";
|
||||||
|
import { getSocketIO } from "../../../server/socket.js";
|
||||||
|
|
||||||
|
export async function action(args: ActionFunctionArgs) {
|
||||||
|
const { userId } = await getAuth(args);
|
||||||
|
if (!userId) {
|
||||||
|
return data({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { request } = args;
|
||||||
|
const formData = await request.formData();
|
||||||
|
|
||||||
|
const seasonId = formData.get("seasonId");
|
||||||
|
const teamId = formData.get("teamId");
|
||||||
|
const participantId = formData.get("participantId");
|
||||||
|
|
||||||
|
if (!seasonId || typeof seasonId !== "string") {
|
||||||
|
return data({ error: "Season ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!teamId || typeof teamId !== "string") {
|
||||||
|
return data({ error: "Team ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!participantId || typeof participantId !== "string") {
|
||||||
|
return data({ error: "Participant ID is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get season
|
||||||
|
const season = await findSeasonById(seasonId);
|
||||||
|
if (!season) {
|
||||||
|
return data({ error: "Season not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if draft has started
|
||||||
|
if (!season.draftStartedAt) {
|
||||||
|
return data({ error: "Draft has not started" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if draft is paused
|
||||||
|
if (season.draftPaused) {
|
||||||
|
return data({ error: "Draft is currently paused" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if draft is complete
|
||||||
|
const teams = await findTeamsBySeasonId(seasonId);
|
||||||
|
const totalPicks = season.draftRounds * teams.length;
|
||||||
|
const currentPickNumber = season.currentPickNumber || 1;
|
||||||
|
|
||||||
|
if (currentPickNumber > totalPicks) {
|
||||||
|
return data({ error: "Draft is already complete" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get draft slots to determine current team
|
||||||
|
const draftSlots = await findDraftSlotsBySeasonId(seasonId);
|
||||||
|
const draftOrder = draftSlots.map((slot: any) => ({
|
||||||
|
teamId: slot.teamId,
|
||||||
|
draftOrder: slot.draftOrder,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const currentTeamId = getTeamForPick(currentPickNumber, draftOrder);
|
||||||
|
|
||||||
|
if (!currentTeamId) {
|
||||||
|
return data({ error: "Could not determine current team" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's the correct team's turn
|
||||||
|
if (teamId !== currentTeamId) {
|
||||||
|
return data({ error: "It is not this team's turn" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user has permission (team owner or commissioner)
|
||||||
|
const team = teams.find((t: any) => t.id === teamId);
|
||||||
|
const isTeamOwner = team?.ownerId === userId;
|
||||||
|
const isUserCommissioner = await isCommissioner(season.leagueId, userId);
|
||||||
|
|
||||||
|
if (!isTeamOwner && !isUserCommissioner) {
|
||||||
|
return data({ error: "You do not have permission to make this pick" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if participant is already drafted
|
||||||
|
const isDrafted = await isParticipantDrafted(seasonId, participantId);
|
||||||
|
if (isDrafted) {
|
||||||
|
return data({ error: "This participant has already been drafted" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate pick info
|
||||||
|
const { round, pickInRound } = calculatePickInfo(currentPickNumber, teams.length);
|
||||||
|
|
||||||
|
// Determine picked by type
|
||||||
|
const pickedByType = isUserCommissioner && !isTeamOwner ? "commissioner" : "owner";
|
||||||
|
|
||||||
|
// Create the draft pick
|
||||||
|
const pick = await createDraftPick({
|
||||||
|
seasonId,
|
||||||
|
teamId,
|
||||||
|
participantId,
|
||||||
|
pickNumber: currentPickNumber,
|
||||||
|
round,
|
||||||
|
pickInRound,
|
||||||
|
pickedByUserId: userId,
|
||||||
|
pickedByType,
|
||||||
|
timeUsed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove from queue if it was queued
|
||||||
|
const queue = await getTeamQueue(teamId);
|
||||||
|
const queueItem = queue.find((item: any) => item.participantId === participantId);
|
||||||
|
if (queueItem) {
|
||||||
|
await removeFromQueue(queueItem.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update season to next pick
|
||||||
|
const nextPickNumber = currentPickNumber + 1;
|
||||||
|
await updateSeason(seasonId, { currentPickNumber: nextPickNumber });
|
||||||
|
|
||||||
|
// Add increment time to current team's timer
|
||||||
|
if (season.draftIncrementTime) {
|
||||||
|
const timers = await getSeasonTimers(seasonId);
|
||||||
|
const currentTimer = timers.find((t: any) => t.teamId === teamId);
|
||||||
|
if (currentTimer) {
|
||||||
|
const newTime = currentTimer.timeRemaining + season.draftIncrementTime;
|
||||||
|
await updateTeamTimer(teamId, newTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if draft is complete
|
||||||
|
const io = getSocketIO();
|
||||||
|
if (nextPickNumber > totalPicks) {
|
||||||
|
await updateSeasonStatus(seasonId, "active");
|
||||||
|
io.to(`draft-${seasonId}`).emit("draft-completed", {
|
||||||
|
seasonId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast pick to all clients in the room
|
||||||
|
io.to(`draft-${seasonId}`).emit("pick-made", {
|
||||||
|
pick,
|
||||||
|
nextPickNumber,
|
||||||
|
isDraftComplete: nextPickNumber > totalPicks,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit queue update to team owner
|
||||||
|
const updatedQueue = await getTeamQueue(teamId);
|
||||||
|
io.to(`draft-${seasonId}`).emit("queue-updated", {
|
||||||
|
teamId,
|
||||||
|
queue: updatedQueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data({ success: true, pick });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error making pick:", error);
|
||||||
|
return data({
|
||||||
|
error: error instanceof Error ? error.message : "Failed to make pick",
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { data, redirect } from "react-router";
|
import { data, redirect } from "react-router";
|
||||||
import type { LoaderFunctionArgs } from "react-router";
|
import type { LoaderFunctionArgs } from "react-router";
|
||||||
import { getAuth } from "@clerk/react-router/server";
|
import { getAuth } from "@clerk/react-router/server";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
findLeagueById,
|
findLeagueById,
|
||||||
findCurrentSeason,
|
findCurrentSeason,
|
||||||
|
|
@ -11,10 +12,13 @@ import {
|
||||||
getSeasonTimers,
|
getSeasonTimers,
|
||||||
findSeasonWithSportsSeasons,
|
findSeasonWithSportsSeasons,
|
||||||
getTeamQueue,
|
getTeamQueue,
|
||||||
|
getTeamForPick,
|
||||||
} from "~/models";
|
} from "~/models";
|
||||||
import { DraftGrid } from "~/components/draft/DraftGrid";
|
import { DraftGrid } from "~/components/draft/DraftGrid";
|
||||||
import { PlayerList } from "~/components/draft/PlayerList";
|
import { PlayerList } from "~/components/draft/PlayerList";
|
||||||
import { DraftQueue } from "~/components/draft/DraftQueue";
|
import { DraftQueue } from "~/components/draft/DraftQueue";
|
||||||
|
import { CommissionerControls } from "~/components/draft/CommissionerControls";
|
||||||
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
||||||
|
|
||||||
export async function loader(args: LoaderFunctionArgs) {
|
export async function loader(args: LoaderFunctionArgs) {
|
||||||
const { userId } = await getAuth(args);
|
const { userId } = await getAuth(args);
|
||||||
|
|
@ -114,17 +118,173 @@ export default function DraftRoom({ loaderData }: { loaderData: Awaited<ReturnTy
|
||||||
league,
|
league,
|
||||||
season,
|
season,
|
||||||
teams,
|
teams,
|
||||||
draftPicks,
|
draftPicks: initialDraftPicks,
|
||||||
participants,
|
participants,
|
||||||
queueItems,
|
queueItems: initialQueueItems,
|
||||||
timers,
|
timers: initialTimers,
|
||||||
isCommissioner,
|
isCommissioner,
|
||||||
userTeam,
|
userTeam,
|
||||||
currentPickNumber,
|
currentPickNumber: initialPickNumber,
|
||||||
totalPicks,
|
totalPicks,
|
||||||
isDraftComplete,
|
isDraftComplete: initialIsDraftComplete,
|
||||||
|
draftSlots,
|
||||||
} = loaderData;
|
} = loaderData;
|
||||||
|
|
||||||
|
// Local state for real-time updates
|
||||||
|
const [draftPicks, setDraftPicks] = useState(initialDraftPicks);
|
||||||
|
const [queueItems, setQueueItems] = useState(initialQueueItems);
|
||||||
|
const [timers, setTimers] = useState(initialTimers);
|
||||||
|
const [currentPickNumber, setCurrentPickNumber] = useState(initialPickNumber);
|
||||||
|
const [isDraftComplete, setIsDraftComplete] = useState(initialIsDraftComplete);
|
||||||
|
const [isDraftStarted, setIsDraftStarted] = useState(!!season.draftStartedAt);
|
||||||
|
const [isDraftPaused, setIsDraftPaused] = useState(season.draftPaused || false);
|
||||||
|
|
||||||
|
// Socket connection
|
||||||
|
const { on, off } = useDraftSocket(season.id);
|
||||||
|
|
||||||
|
// Calculate current team
|
||||||
|
const draftOrder = draftSlots.map((slot: any) => ({
|
||||||
|
teamId: slot.teamId,
|
||||||
|
draftOrder: slot.draftOrder,
|
||||||
|
}));
|
||||||
|
const currentTeamId = getTeamForPick(currentPickNumber, draftOrder);
|
||||||
|
const currentTeam = teams.find((t: any) => t.id === currentTeamId);
|
||||||
|
const currentTeamName = currentTeam?.name || "Unknown";
|
||||||
|
|
||||||
|
// Socket event handlers
|
||||||
|
useEffect(() => {
|
||||||
|
// Listen for pick made
|
||||||
|
const handlePickMade = (data: any) => {
|
||||||
|
setDraftPicks((prev: any) => [...prev, data.pick]);
|
||||||
|
setCurrentPickNumber(data.nextPickNumber);
|
||||||
|
setIsDraftComplete(data.isDraftComplete);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for draft started
|
||||||
|
const handleDraftStarted = (data: any) => {
|
||||||
|
setIsDraftStarted(true);
|
||||||
|
setIsDraftPaused(false);
|
||||||
|
setTimers(data.timers);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for draft paused
|
||||||
|
const handleDraftPaused = (data: any) => {
|
||||||
|
setIsDraftPaused(data.isPaused);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for draft completed
|
||||||
|
const handleDraftCompleted = () => {
|
||||||
|
setIsDraftComplete(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for queue updates
|
||||||
|
const handleQueueUpdated = (data: any) => {
|
||||||
|
if (userTeam && data.teamId === userTeam.id) {
|
||||||
|
setQueueItems(data.queue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
on("pick-made", handlePickMade);
|
||||||
|
on("draft-started", handleDraftStarted);
|
||||||
|
on("draft-paused", handleDraftPaused);
|
||||||
|
on("draft-completed", handleDraftCompleted);
|
||||||
|
on("queue-updated", handleQueueUpdated);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
off("pick-made", handlePickMade);
|
||||||
|
off("draft-started", handleDraftStarted);
|
||||||
|
off("draft-paused", handleDraftPaused);
|
||||||
|
off("draft-completed", handleDraftCompleted);
|
||||||
|
off("queue-updated", handleQueueUpdated);
|
||||||
|
};
|
||||||
|
}, [on, off, userTeam]);
|
||||||
|
|
||||||
|
// Commissioner action handlers
|
||||||
|
const handleStartDraft = async () => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "start-draft");
|
||||||
|
formData.append("seasonId", season.id);
|
||||||
|
|
||||||
|
const response = await fetch("/api/draft-commissioner", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error("Failed to start draft:", error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error starting draft:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePauseDraft = async () => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "pause-draft");
|
||||||
|
formData.append("seasonId", season.id);
|
||||||
|
formData.append("pause", "true");
|
||||||
|
|
||||||
|
const response = await fetch("/api/draft-commissioner", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error("Failed to pause draft:", error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error pausing draft:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResumeDraft = async () => {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "pause-draft");
|
||||||
|
formData.append("seasonId", season.id);
|
||||||
|
formData.append("pause", "false");
|
||||||
|
|
||||||
|
const response = await fetch("/api/draft-commissioner", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error("Failed to resume draft:", error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resuming draft:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleForcePick = async () => {
|
||||||
|
if (!currentTeamId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("action", "force-pick");
|
||||||
|
formData.append("seasonId", season.id);
|
||||||
|
formData.append("teamId", currentTeamId);
|
||||||
|
|
||||||
|
const response = await fetch("/api/draft-commissioner", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
console.error("Failed to force pick:", error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error forcing pick:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -213,9 +373,16 @@ export default function DraftRoom({ loaderData }: { loaderData: Awaited<ReturnTy
|
||||||
{isCommissioner ? (
|
{isCommissioner ? (
|
||||||
<div className="rounded-lg border bg-card p-6">
|
<div className="rounded-lg border bg-card p-6">
|
||||||
<h2 className="mb-4 text-xl font-bold">Commissioner Controls</h2>
|
<h2 className="mb-4 text-xl font-bold">Commissioner Controls</h2>
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
<CommissionerControls
|
||||||
Controls will be implemented in Phase 9
|
isDraftStarted={isDraftStarted}
|
||||||
</div>
|
isDraftPaused={isDraftPaused}
|
||||||
|
isDraftComplete={isDraftComplete}
|
||||||
|
currentTeamName={currentTeamName}
|
||||||
|
onStartDraft={handleStartDraft}
|
||||||
|
onPauseDraft={handlePauseDraft}
|
||||||
|
onResumeDraft={handleResumeDraft}
|
||||||
|
onForcePick={handleForcePick}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border bg-card p-6">
|
<div className="rounded-lg border bg-card p-6">
|
||||||
|
|
@ -229,6 +396,10 @@ export default function DraftRoom({ loaderData }: { loaderData: Awaited<ReturnTy
|
||||||
<span className="text-muted-foreground">Draft Rounds:</span>
|
<span className="text-muted-foreground">Draft Rounds:</span>
|
||||||
<span className="ml-2 font-medium">{season.draftRounds}</span>
|
<span className="ml-2 font-medium">{season.draftRounds}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">Current Team:</span>
|
||||||
|
<span className="ml-2 font-medium">{currentTeamName}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -32,13 +32,29 @@ export function setupSocketIO(httpServer: HTTPServer): SocketServer {
|
||||||
console.log(`Socket ${socket.id} left draft room: ${roomName}`);
|
console.log(`Socket ${socket.id} left draft room: ${roomName}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Draft Actions - Phase 9
|
||||||
|
// These events will be handled by API routes and then broadcast back via socket
|
||||||
|
// The client will call API routes which will then use getSocketIO() to broadcast
|
||||||
|
socket.on("make-pick", (_data) => {
|
||||||
|
console.log("make-pick event received, should use API route instead");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("commissioner-start-draft", (_data) => {
|
||||||
|
console.log("commissioner-start-draft event received, should use API route instead");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("commissioner-pause-draft", (_data) => {
|
||||||
|
console.log("commissioner-pause-draft event received, should use API route instead");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("commissioner-force-pick", (_data) => {
|
||||||
|
console.log("commissioner-force-pick event received, should use API route instead");
|
||||||
|
});
|
||||||
|
|
||||||
// Handle disconnection
|
// Handle disconnection
|
||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
console.log(`Client disconnected: ${socket.id}`);
|
console.log(`Client disconnected: ${socket.id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draft-specific events will be added in Phase 9
|
|
||||||
// For now, we're just setting up the infrastructure
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return io;
|
return io;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
"server/**/*.ts",
|
"server/**/*.ts",
|
||||||
"vite.config.ts",
|
"vite.config.ts",
|
||||||
"database/**/*.ts",
|
"database/**/*.ts",
|
||||||
"app/contexts/**/*.ts"
|
"app/contexts/**/*.ts",
|
||||||
|
"app/models/**/*.ts"
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,9 @@ export default defineConfig(({ isSsrBuild }) => ({
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"~/database": "/database",
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue