Removes light mode entirely in favour of a permanent dark theme with a navy-tinted background and three signature accents (electric blue, amber/gold, coral) exposed as CSS custom properties and Tailwind utilities (bg-electric, text-amber-accent, text-coral-accent). - Set class="dark" on <html> and apply Clerk dark base theme - Rewrite app.css: single :root palette (oklch navy values), custom --electric / --amber-accent / --coral-accent variables, remove duplicate .dark block and light-mode bg-white/bg-gray-950 rule - Install @clerk/themes for Clerk dark modal support - Replace hardcoded Tailwind colors across 30+ files: - Draft grid cells: blue-50/blue-950 → electric/15, green-50/950 → emerald/10 - Timer: green-600/yellow-600/red-600 → emerald-400/amber-accent/coral-accent - Status badges: blue-50/green-50/gray-50 → electric/emerald/muted variants - Success messages: green-500/15 text-green-700 dark:text-green-400 → emerald-500/15 text-emerald-400 - Info cards: blue-50 dark:bg-blue-950 → electric/10 - Warning cards: yellow-500 → amber-accent variants - Medal/placement badges: yellow-500/orange-600 → amber-accent/coral-accent - Movement indicators: green-600/red-600 → emerald-400/coral-accent - Connection dots: green-500/red-500 → emerald-500/coral-accent - Remove dark:hidden/dark:block logo toggle in welcome.tsx (always dark) - Update DraftGrid test assertions to match new class names Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1095 lines
36 KiB
TypeScript
1095 lines
36 KiB
TypeScript
import { useLoaderData, Link } from "react-router";
|
|
import { eq, and, asc, desc, inArray } from "drizzle-orm";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
|
import { useEffect, useState, useMemo } from "react";
|
|
import { Card } from "~/components/ui/card";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
|
import { getAuth } from "@clerk/react-router/server";
|
|
import { getTeamQueue } from "~/models/draft-queue";
|
|
import { DraftSidebar } from "~/components/DraftSidebar";
|
|
import { TeamsDraftedGrid } from "~/components/draft/TeamsDraftedGrid";
|
|
import { QueueSection } from "~/components/draft/QueueSection";
|
|
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
|
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
|
|
import { DraftGridSection } from "~/components/draft/DraftGridSection";
|
|
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
|
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
|
import { toast } from "sonner";
|
|
|
|
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 ALL participants (both drafted and undrafted) - sorted by EV desc, then name
|
|
// Filtering will be done on the client side based on the "Show Drafted" toggle
|
|
let availableParticipants: any[] = [];
|
|
|
|
if (sportsSeasonIds.length > 0) {
|
|
availableParticipants = await db
|
|
.select({
|
|
id: schema.participants.id,
|
|
name: schema.participants.name,
|
|
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(
|
|
sportsSeasonIds.length === 1
|
|
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
|
|
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds)
|
|
)
|
|
.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) : [];
|
|
|
|
// Load draft timers for all teams
|
|
const timers = await db.query.draftTimers.findMany({
|
|
where: eq(schema.draftTimers.seasonId, seasonId),
|
|
});
|
|
|
|
// Load autodraft settings for all teams
|
|
const autodraftSettings = await db.query.autodraftSettings.findMany({
|
|
where: eq(schema.autodraftSettings.seasonId, seasonId),
|
|
});
|
|
|
|
// Load user's autodraft settings if they have a team
|
|
const userAutodraftSettings = userTeam
|
|
? await db.query.autodraftSettings.findFirst({
|
|
where: and(
|
|
eq(schema.autodraftSettings.seasonId, seasonId),
|
|
eq(schema.autodraftSettings.teamId, userTeam.id)
|
|
),
|
|
})
|
|
: null;
|
|
|
|
return {
|
|
season,
|
|
draftSlots,
|
|
draftPicks,
|
|
availableParticipants,
|
|
userTeam,
|
|
userQueue,
|
|
timers,
|
|
autodraftSettings,
|
|
userAutodraftSettings,
|
|
isCommissioner: !!isCommissioner,
|
|
currentUserId: userId,
|
|
};
|
|
}
|
|
|
|
export default function DraftRoom() {
|
|
const {
|
|
season,
|
|
draftSlots,
|
|
draftPicks,
|
|
availableParticipants,
|
|
userTeam,
|
|
userQueue,
|
|
timers,
|
|
autodraftSettings,
|
|
userAutodraftSettings,
|
|
isCommissioner,
|
|
} = useLoaderData<typeof loader>();
|
|
const { isConnected, connectionError, isReconnecting, on, off } = useDraftSocket(season.id, userTeam?.id);
|
|
const [picks, setPicks] = useState(draftPicks);
|
|
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [hideDrafted, setHideDrafted] = useState(true);
|
|
const [hideIneligible, setHideIneligible] = useState(true);
|
|
const [sportFilter, setSportFilter] = useState<string>("all");
|
|
const [queue, setQueue] = useState(userQueue);
|
|
const [isPaused, setIsPaused] = useState(season.draftPaused || false);
|
|
const [isDraftComplete, setIsDraftComplete] = useState(season.status === "active");
|
|
|
|
// Sidebar and tabs state
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
|
if (typeof window === "undefined") return false;
|
|
const stored = localStorage.getItem("draftSidebarCollapsed");
|
|
return stored ? JSON.parse(stored) : false;
|
|
});
|
|
const [activeTab, setActiveTab] = useState<"participants" | "board" | "teams">("participants");
|
|
|
|
// Track autodraft status for all teams
|
|
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, boolean>>(() => {
|
|
const status: Record<string, boolean> = {};
|
|
autodraftSettings.forEach((setting: any) => {
|
|
status[setting.teamId] = setting.isEnabled;
|
|
});
|
|
return status;
|
|
});
|
|
|
|
// Track connected teams
|
|
const [connectedTeams, setConnectedTeams] = useState<Set<string>>(new Set());
|
|
|
|
// Track user's autodraft settings
|
|
const [userAutodraft, setUserAutodraft] = useState({
|
|
isEnabled: userAutodraftSettings?.isEnabled || false,
|
|
mode: (userAutodraftSettings?.mode || "next_pick") as "next_pick" | "while_on",
|
|
});
|
|
|
|
const [teamTimers, setTeamTimers] = useState<Record<string, number>>(() => {
|
|
// Initialize with timers from loader data, or use initial time if draft hasn't started
|
|
const timersMap: Record<string, number> = {};
|
|
const initialTime = season.draftInitialTime || 120;
|
|
|
|
if (timers.length > 0) {
|
|
// Draft has started, use actual timers
|
|
timers.forEach((timer: any) => {
|
|
timersMap[timer.teamId] = timer.timeRemaining;
|
|
});
|
|
} else {
|
|
// Draft hasn't started, show initial time for all teams
|
|
draftSlots.forEach((slot) => {
|
|
timersMap[slot.team.id] = initialTime;
|
|
});
|
|
}
|
|
|
|
return timersMap;
|
|
});
|
|
const [forcePickDialogOpen, setForcePickDialogOpen] = useState(false);
|
|
const [selectedPickSlot, setSelectedPickSlot] = useState<{
|
|
pickNumber: number;
|
|
teamId: string;
|
|
} | null>(null);
|
|
|
|
// Calculate draft eligibility for current user's team
|
|
const eligibility = useMemo(() => {
|
|
if (!userTeam) return null;
|
|
|
|
// Transform picks data to match eligibility function signature
|
|
const transformedPicks = picks.map((p: any) => ({
|
|
teamId: p.team.id,
|
|
participant: {
|
|
id: p.participant.id,
|
|
sport: {
|
|
id: p.sport.id,
|
|
name: p.sport.name,
|
|
},
|
|
},
|
|
}));
|
|
|
|
const userTeamPicks = transformedPicks.filter(
|
|
(p: any) => p.teamId === userTeam.id
|
|
);
|
|
|
|
// Transform participants to match signature
|
|
const transformedParticipants = availableParticipants.map((p: any) => ({
|
|
id: p.id,
|
|
sport: {
|
|
id: p.sport.id,
|
|
name: p.sport.name,
|
|
},
|
|
}));
|
|
|
|
// Get unique sports from availableParticipants
|
|
const sportsMap = new Map();
|
|
availableParticipants.forEach((p: any) => {
|
|
if (!sportsMap.has(p.sport.id)) {
|
|
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
|
|
}
|
|
});
|
|
const seasonSportsData = Array.from(sportsMap.values());
|
|
|
|
return calculateDraftEligibility(
|
|
userTeam.id,
|
|
userTeamPicks,
|
|
transformedPicks,
|
|
transformedParticipants,
|
|
seasonSportsData,
|
|
season.draftRounds,
|
|
season.teams
|
|
);
|
|
}, [userTeam, picks, availableParticipants, season]);
|
|
|
|
// Calculate eligibility for force manual pick dialog (selected team)
|
|
const forcePickEligibility = useMemo(() => {
|
|
if (!selectedPickSlot) return null;
|
|
|
|
const transformedPicks = picks.map((p: any) => ({
|
|
teamId: p.team.id,
|
|
participant: {
|
|
id: p.participant.id,
|
|
sport: {
|
|
id: p.sport.id,
|
|
name: p.sport.name,
|
|
},
|
|
},
|
|
}));
|
|
|
|
const selectedTeamPicks = transformedPicks.filter(
|
|
(p: any) => p.teamId === selectedPickSlot.teamId
|
|
);
|
|
|
|
const transformedParticipants = availableParticipants.map((p: any) => ({
|
|
id: p.id,
|
|
sport: {
|
|
id: p.sport.id,
|
|
name: p.sport.name,
|
|
},
|
|
}));
|
|
|
|
const sportsMap = new Map();
|
|
availableParticipants.forEach((p: any) => {
|
|
if (!sportsMap.has(p.sport.id)) {
|
|
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
|
|
}
|
|
});
|
|
const seasonSportsData = Array.from(sportsMap.values());
|
|
|
|
return calculateDraftEligibility(
|
|
selectedPickSlot.teamId,
|
|
selectedTeamPicks,
|
|
transformedPicks,
|
|
transformedParticipants,
|
|
seasonSportsData,
|
|
season.draftRounds,
|
|
season.teams
|
|
);
|
|
}, [selectedPickSlot, picks, availableParticipants, season]);
|
|
|
|
// 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);
|
|
};
|
|
|
|
const handleTimerUpdate = (data: any) => {
|
|
// Update the specific team's timer
|
|
setTeamTimers((prev) => ({
|
|
...prev,
|
|
[data.teamId]: data.timeRemaining,
|
|
}));
|
|
};
|
|
|
|
const handleDraftPaused = () => {
|
|
setIsPaused(true);
|
|
};
|
|
|
|
const handleDraftResumed = () => {
|
|
setIsPaused(false);
|
|
};
|
|
|
|
const handleDraftCompleted = () => {
|
|
setIsDraftComplete(true);
|
|
};
|
|
|
|
const handleAutodraftUpdated = (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on" }) => {
|
|
console.log("Autodraft updated:", data);
|
|
setAutodraftStatus((prev) => ({
|
|
...prev,
|
|
[data.teamId]: data.isEnabled,
|
|
}));
|
|
|
|
// Update user's local state if it's their team
|
|
if (userTeam && data.teamId === userTeam.id) {
|
|
setUserAutodraft({
|
|
isEnabled: data.isEnabled,
|
|
mode: data.mode,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleTeamConnected = (data: { teamId: string }) => {
|
|
console.log("Team connected:", data.teamId);
|
|
setConnectedTeams((prev) => new Set(prev).add(data.teamId));
|
|
};
|
|
|
|
const handleTeamDisconnected = (data: { teamId: string }) => {
|
|
console.log("Team disconnected:", data.teamId);
|
|
setConnectedTeams((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(data.teamId);
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
const handleConnectedTeamsList = (data: { teamIds: string[] }) => {
|
|
console.log("Received connected teams list:", data.teamIds);
|
|
setConnectedTeams(new Set(data.teamIds));
|
|
};
|
|
|
|
const handleParticipantRemovedFromQueues = (data: { participantId: string }) => {
|
|
console.log("Participant removed from queues:", data.participantId);
|
|
setQueue((prev: any) =>
|
|
prev.filter((item: any) => item.participantId !== data.participantId)
|
|
);
|
|
};
|
|
|
|
on("pick-made", handlePickMade);
|
|
on("timer-update", handleTimerUpdate);
|
|
on("draft-paused", handleDraftPaused);
|
|
on("draft-resumed", handleDraftResumed);
|
|
on("draft-completed", handleDraftCompleted);
|
|
on("autodraft-updated", handleAutodraftUpdated);
|
|
on("team-connected", handleTeamConnected);
|
|
on("team-disconnected", handleTeamDisconnected);
|
|
on("connected-teams-list", handleConnectedTeamsList);
|
|
on("participant-removed-from-queues", handleParticipantRemovedFromQueues);
|
|
|
|
return () => {
|
|
off("pick-made", handlePickMade);
|
|
off("timer-update", handleTimerUpdate);
|
|
off("draft-paused", handleDraftPaused);
|
|
off("draft-resumed", handleDraftResumed);
|
|
off("draft-completed", handleDraftCompleted);
|
|
off("autodraft-updated", handleAutodraftUpdated);
|
|
off("team-connected", handleTeamConnected);
|
|
off("team-disconnected", handleTeamDisconnected);
|
|
off("connected-teams-list", handleConnectedTeamsList);
|
|
off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
|
|
};
|
|
}, [on, off, currentPick, userTeam]);
|
|
|
|
// Persist sidebar collapsed state
|
|
useEffect(() => {
|
|
if (typeof window !== "undefined") {
|
|
localStorage.setItem("draftSidebarCollapsed", JSON.stringify(sidebarCollapsed));
|
|
}
|
|
}, [sidebarCollapsed]);
|
|
|
|
// Queue handlers
|
|
const handleAddToQueue = async (participantId: string) => {
|
|
if (!userTeam) return;
|
|
|
|
// Check if participant is already in queue (client-side)
|
|
const alreadyInQueue = queue.some((item: any) => item.participantId === participantId);
|
|
if (alreadyInQueue) {
|
|
toast.error("This participant is already in your queue");
|
|
return;
|
|
}
|
|
|
|
// Optimistic update - create a temporary queue item
|
|
const tempQueueItem = {
|
|
id: `temp-${Date.now()}`, // Temporary ID
|
|
participantId,
|
|
queuePosition: queue.length + 1,
|
|
};
|
|
|
|
// Immediately update the UI
|
|
setQueue((prev: any) => [...prev, tempQueueItem]);
|
|
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", userTeam.id);
|
|
formData.append("participantId", participantId);
|
|
|
|
try {
|
|
const response = await fetch("/api/queue/add", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
// Replace temp item with real item from server
|
|
setQueue((prev: any) =>
|
|
prev.map((item: any) =>
|
|
item.id === tempQueueItem.id ? data.queueItem : item
|
|
)
|
|
);
|
|
} else {
|
|
// Revert the optimistic update on error
|
|
setQueue((prev: any) =>
|
|
prev.filter((item: any) => item.id !== tempQueueItem.id)
|
|
);
|
|
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to add to queue");
|
|
}
|
|
} catch (error) {
|
|
// Revert the optimistic update on network error
|
|
setQueue((prev: any) =>
|
|
prev.filter((item: any) => item.id !== tempQueueItem.id)
|
|
);
|
|
toast.error("Network error - failed to add to queue");
|
|
}
|
|
};
|
|
|
|
const handleRemoveFromQueue = async (queueId: string) => {
|
|
if (!userTeam) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append("queueId", queueId);
|
|
formData.append("teamId", userTeam.id);
|
|
|
|
const response = await fetch("/api/queue/remove", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setQueue(data.queue);
|
|
}
|
|
};
|
|
|
|
const handleReorderQueue = async (participantIds: string[]) => {
|
|
if (!userTeam) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append("teamId", userTeam.id);
|
|
formData.append("seasonId", season.id);
|
|
formData.append("participantIds", JSON.stringify(participantIds));
|
|
|
|
const response = await fetch("/api/queue/reorder", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setQueue(data.queue);
|
|
}
|
|
};
|
|
|
|
const handleStartDraft = async () => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
const response = await fetch("/api/draft/start", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
window.location.reload(); // Reload to get updated season status
|
|
}
|
|
};
|
|
|
|
const handlePauseDraft = async () => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
const response = await fetch("/api/draft/pause", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
setIsPaused(true);
|
|
}
|
|
};
|
|
|
|
const handleResumeDraft = async () => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
const response = await fetch("/api/draft/resume", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
setIsPaused(false);
|
|
}
|
|
};
|
|
|
|
const handleMakePick = async (participantId: string) => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("participantId", participantId);
|
|
|
|
const response = await fetch("/api/draft/make-pick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Pick will be broadcast via socket, no need to manually update
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.error || "Failed to make pick");
|
|
}
|
|
};
|
|
|
|
const handleForceAutopick = async (pickNumber: number, teamId: string) => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", teamId);
|
|
formData.append("pickNumber", pickNumber.toString());
|
|
|
|
const response = await fetch("/api/draft/force-autopick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Pick will be broadcast via socket
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.error || "Failed to force autopick");
|
|
}
|
|
};
|
|
|
|
const handleForceManualPick = async (participantId: string) => {
|
|
if (!selectedPickSlot) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", selectedPickSlot.teamId);
|
|
formData.append("participantId", participantId);
|
|
formData.append("pickNumber", selectedPickSlot.pickNumber.toString());
|
|
|
|
const response = await fetch("/api/draft/force-manual-pick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
if (response.ok) {
|
|
setForcePickDialogOpen(false);
|
|
setSelectedPickSlot(null);
|
|
} else {
|
|
const error = await response.json();
|
|
alert(error.error || "Failed to force manual pick");
|
|
}
|
|
};
|
|
|
|
// Calculate current round
|
|
const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1;
|
|
|
|
// Determine whose turn it is
|
|
const totalTeams = draftSlots.length;
|
|
const isSnakeDraft = true;
|
|
const isEvenRound = currentRound % 2 === 0;
|
|
let pickInRound = ((currentPick - 1) % totalTeams) + 1;
|
|
if (isSnakeDraft && isEvenRound) {
|
|
pickInRound = totalTeams - pickInRound + 1;
|
|
}
|
|
const currentDraftSlot = draftSlots.find(
|
|
(slot) => slot.draftOrder === pickInRound
|
|
);
|
|
const isMyTurn = !!(
|
|
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id
|
|
);
|
|
const canPick = isMyTurn && season.status === "draft"; // Only team owner on their turn
|
|
|
|
// 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();
|
|
|
|
// Filter participants based on search, sport, drafted status, and eligibility
|
|
const filteredParticipants = availableParticipants.filter(
|
|
(participant: any) => {
|
|
// Drafted filter - hide drafted participants by default
|
|
if (hideDrafted && draftedParticipantIds.has(participant.id)) {
|
|
return false;
|
|
}
|
|
// Eligibility filter - hide ineligible participants by default (if user has a team)
|
|
if (hideIneligible && eligibility) {
|
|
const isEligible = eligibility.eligibleSportIds.has(participant.sport.id);
|
|
if (!isEligible) {
|
|
return false;
|
|
}
|
|
}
|
|
// 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="h-screen bg-background flex flex-col overflow-hidden">
|
|
{/* Draft Completion Banner */}
|
|
{isDraftComplete && (
|
|
<div className="bg-emerald-500/20 border-b border-emerald-500/30 text-emerald-400 px-4 py-3 text-center font-semibold flex-shrink-0">
|
|
🎉 Draft Complete! The season is now active.
|
|
{season.league.isPublicDraftBoard && (
|
|
<>
|
|
{" "}
|
|
<a
|
|
href={`/leagues/${season.leagueId}/draft-board/${season.id}`}
|
|
className="underline hover:text-emerald-300"
|
|
>
|
|
View Draft Board
|
|
</a>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Standalone Header - No Main Nav */}
|
|
<div className="border-b bg-card flex-shrink-0">
|
|
<div className="w-full px-4 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{/* Mobile menu button - only show if user has team and sidebar is collapsed */}
|
|
{userTeam && sidebarCollapsed && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setSidebarCollapsed(false)}
|
|
className="lg:hidden"
|
|
aria-label="Open sidebar"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
|
</svg>
|
|
</Button>
|
|
)}
|
|
<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>
|
|
<div className="flex items-center gap-3">
|
|
{/* Commissioner Controls */}
|
|
{isCommissioner && season.status === "pre_draft" && (
|
|
<Button onClick={handleStartDraft}>Start Draft</Button>
|
|
)}
|
|
|
|
{isCommissioner && season.status === "draft" && (
|
|
<>
|
|
{isPaused ? (
|
|
<Button onClick={handleResumeDraft} variant="default">
|
|
Resume Draft
|
|
</Button>
|
|
) : (
|
|
<Button onClick={handlePauseDraft} variant="outline">
|
|
Pause Draft
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`w-3 h-3 rounded-full ${
|
|
isConnected ? "bg-emerald-500" : "bg-coral-accent"
|
|
}`}
|
|
/>
|
|
<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 - Flex Row: Sidebar + Tabs */}
|
|
<div className="flex-1 flex overflow-hidden">
|
|
{/* Sidebar */}
|
|
{userTeam && (
|
|
<DraftSidebar
|
|
collapsed={sidebarCollapsed}
|
|
onCollapsedChange={setSidebarCollapsed}
|
|
queueSection={
|
|
<QueueSection
|
|
queue={queue}
|
|
availableParticipants={availableParticipants}
|
|
eligibility={eligibility}
|
|
seasonId={season.id}
|
|
teamId={userTeam.id}
|
|
isMyTurn={isMyTurn}
|
|
userAutodraft={userAutodraft}
|
|
onRemoveFromQueue={handleRemoveFromQueue}
|
|
onAutodraftUpdate={(isEnabled, mode) => {
|
|
setUserAutodraft({ isEnabled, mode });
|
|
}}
|
|
onReorder={handleReorderQueue}
|
|
/>
|
|
}
|
|
recentPicksSection={
|
|
<SidebarRecentPicks picks={picks} />
|
|
}
|
|
/>
|
|
)}
|
|
|
|
{/* Main Tabbed Content */}
|
|
<div className="flex-1 overflow-hidden">
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(value) =>
|
|
setActiveTab(value as "participants" | "board" | "teams")
|
|
}
|
|
className="h-full flex flex-col"
|
|
>
|
|
<TabsList className="mx-4 mt-4">
|
|
<TabsTrigger value="participants">Available Participants</TabsTrigger>
|
|
<TabsTrigger value="board">Draft Board</TabsTrigger>
|
|
<TabsTrigger value="teams">Teams Drafted</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="participants" className="flex-1 overflow-hidden m-0">
|
|
<div className="h-full">
|
|
<AvailableParticipantsSection
|
|
participants={filteredParticipants}
|
|
searchQuery={searchQuery}
|
|
sportFilter={sportFilter}
|
|
hideDrafted={hideDrafted}
|
|
hideIneligible={hideIneligible}
|
|
uniqueSports={uniqueSports}
|
|
draftedParticipantIds={draftedParticipantIds}
|
|
queue={queue}
|
|
eligibility={eligibility}
|
|
canPick={canPick}
|
|
hasTeam={!!userTeam}
|
|
onSearchChange={setSearchQuery}
|
|
onSportFilterChange={setSportFilter}
|
|
onHideDraftedChange={setHideDrafted}
|
|
onHideIneligibleChange={setHideIneligible}
|
|
onMakePick={handleMakePick}
|
|
onAddToQueue={handleAddToQueue}
|
|
onRemoveFromQueue={handleRemoveFromQueue}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="board" className="flex-1 overflow-hidden m-0">
|
|
<DraftGridSection
|
|
draftSlots={draftSlots}
|
|
draftGrid={draftGrid}
|
|
currentPick={currentPick}
|
|
teamTimers={teamTimers}
|
|
autodraftStatus={autodraftStatus}
|
|
connectedTeams={connectedTeams}
|
|
isCommissioner={isCommissioner}
|
|
onForceAutopick={handleForceAutopick}
|
|
onForceManualPickOpen={(pickNumber, teamId) => {
|
|
setSelectedPickSlot({ pickNumber, teamId });
|
|
setForcePickDialogOpen(true);
|
|
}}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="teams" className="flex-1 overflow-hidden m-0">
|
|
<div className="h-full">
|
|
<TeamsDraftedGrid
|
|
draftSlots={draftSlots}
|
|
picks={picks}
|
|
availableParticipants={availableParticipants}
|
|
season={{
|
|
id: season.id,
|
|
name: `${season.year} Season`,
|
|
year: season.year,
|
|
numFlexPicks: season.flexSpots,
|
|
}}
|
|
/>
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Force Manual Pick Dialog */}
|
|
{forcePickDialogOpen && selectedPickSlot && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
onClick={() => setForcePickDialogOpen(false)}
|
|
>
|
|
<Card
|
|
className="w-full max-w-2xl max-h-[80vh] overflow-hidden"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="p-6">
|
|
<h2 className="text-2xl font-bold mb-4">Force Manual Pick</h2>
|
|
<p className="text-sm text-muted-foreground mb-4">
|
|
Select a participant to draft for Pick #
|
|
{selectedPickSlot.pickNumber}
|
|
</p>
|
|
|
|
{/* Search and Filter */}
|
|
<div className="flex gap-2 mb-4">
|
|
<input
|
|
type="text"
|
|
placeholder="Search participants..."
|
|
className="flex-1 px-3 py-2 border rounded-md"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
<select
|
|
className="px-3 py-2 border rounded-md"
|
|
value={sportFilter}
|
|
onChange={(e) => setSportFilter(e.target.value)}
|
|
>
|
|
<option value="all">All Sports</option>
|
|
{uniqueSports.map((sport) => (
|
|
<option key={sport} value={sport}>
|
|
{sport}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Participant List */}
|
|
<div className="max-h-96 overflow-y-auto border rounded-lg">
|
|
<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">Action</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredParticipants
|
|
.filter((p: any) => !draftedParticipantIds.has(p.id))
|
|
.map((participant: any) => {
|
|
// Check eligibility for force pick team
|
|
const isEligible = forcePickEligibility
|
|
? forcePickEligibility.eligibleSportIds.has(participant.sport.id)
|
|
: true;
|
|
const ineligibleReason = forcePickEligibility?.ineligibleReasons[participant.sport.id];
|
|
|
|
return (
|
|
<tr
|
|
key={participant.id}
|
|
className={`border-t ${
|
|
isEligible
|
|
? "hover:bg-muted/50"
|
|
: "bg-destructive/10 opacity-60"
|
|
}`}
|
|
title={!isEligible ? ineligibleReason : undefined}
|
|
>
|
|
<td className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`font-medium ${!isEligible ? "text-muted-foreground" : ""}`}>
|
|
{participant.name}
|
|
</span>
|
|
{!isEligible && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="text-xs"
|
|
title={ineligibleReason}
|
|
>
|
|
Ineligible
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="p-3 text-muted-foreground">
|
|
{participant.sport.name}
|
|
</td>
|
|
<td className="p-3 text-right">
|
|
<Button
|
|
size="sm"
|
|
onClick={() =>
|
|
handleForceManualPick(participant.id)
|
|
}
|
|
disabled={!isEligible}
|
|
title={!isEligible ? ineligibleReason : undefined}
|
|
>
|
|
Draft
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setForcePickDialogOpen(false)}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* Connection Overlay - blocks interaction until socket connects */}
|
|
<ConnectionOverlay
|
|
isConnected={isConnected}
|
|
isReconnecting={isReconnecting}
|
|
connectionError={connectionError}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|