* Add commish right-click context menus to MiniDraftGrid team headers Adds onAdjustTimeBankOpen and onSetAutodraftOpen props to MiniDraftGrid so the last-two-rounds display on the Participants tab shows the same "Adjust Time Bank…" / "Set Autodraft…" context menu on right-click that the full Draft Board already exposes. Also passes those callbacks from the route's miniDraftGrid useMemo so they are wired up for commissioners. https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1 * Address all code review issues for draft room context menus Layout bug: flex-1 min-w-20 was nested inside headerInner instead of the direct flex-child wrapper in MiniDraftGrid, breaking equal column sizing. Fixed by moving the classes to the outermost element in both the menu and non-menu paths (matching DraftGridSection's pattern). MiniDraftGrid improvements: - Hoist hasHeaderMenu constant above the draftSlots.map() loop - Add optional connectedTeams prop; disconnected teams render italic + muted-foreground, consistent with DraftGridSection - connectedTeams now wired through the route's miniDraftGrid useMemo DraftGridSection interface: - Make onForceAutopick and onForceManualPickOpen optional (?) to match every other commissioner callback; add null checks at all three call sites (context menu, mobile MoreVertical button, mobile Sheet) - Gate those two callbacks behind isCommissioner at the route level Tests (new files): - app/components/__tests__/MiniDraftGrid.test.tsx — covers layout classes, connected/disconnected styling, and all four context menu interactions - app/components/__tests__/DraftGridSection.test.tsx — covers all three context menu surfaces for both commissioner and non-commissioner roles Storybook: add CommissionerView and DisconnectedTeams stories to MiniDraftGrid.stories.tsx https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1 * Move hasHeaderMenu out of IIFE into component body Computing it before the return statement is the right place since it only depends on props, not loop variables. Removes the IIFE entirely and fixes the indentation of the map callback body. https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1 * Mock HTMLElement.scrollTo in test setup jsdom does not implement scrollTo on elements, causing any component that calls element.scrollTo() (e.g. MiniDraftGrid's scroll-to-current-cell effect) to throw in unit tests. https://claude.ai/code/session_017JCShLVs9xZ6FZmyrUFaE1 --------- Co-authored-by: Claude <noreply@anthropic.com>
1461 lines
52 KiB
TypeScript
1461 lines
52 KiB
TypeScript
import { useLoaderData, Link, useRevalidator } from "react-router";
|
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
|
import { logger } from "~/lib/logger";
|
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
|
import { auth } from "~/lib/auth.server";
|
|
import { useDraftRoomState, type QueueItem } from "~/hooks/useDraftRoomState";
|
|
import { useDraftAuthRecovery } from "~/hooks/useDraftAuthRecovery";
|
|
import { useDraftSocketEvents } from "~/hooks/useDraftSocketEvents";
|
|
import { RollbackConfirmDialog } from "~/components/draft/RollbackConfirmDialog";
|
|
import { TimeBankAdjustmentDialog } from "~/components/draft/TimeBankAdjustmentDialog";
|
|
import { CommissionerAutodraftDialog } from "~/components/draft/CommissionerAutodraftDialog";
|
|
import { CommissionerDraftControls } from "~/components/draft/CommissionerDraftControls";
|
|
import { getTeamQueue } from "~/models/draft-queue";
|
|
import { findSeasonWithTeamsAndLeague } from "~/models/season";
|
|
import { findDraftSlotsBySeasonId } from "~/models/draft-slot";
|
|
import { getDraftPicksForSeason } from "~/models/draft-pick";
|
|
import { getDraftParticipants } from "~/models/participant";
|
|
import { getSeasonTimers } from "~/models/draft-timer";
|
|
import { getSeasonAutodraftSettings } from "~/models/autodraft-settings";
|
|
import { hasCommissionerRecord } from "~/models/commissioner";
|
|
import logomarkUrl from "../../../public/logomark.svg?url";
|
|
import { buildOwnerMap } from "~/lib/owner-map";
|
|
import { DraftSidebar } from "~/components/DraftSidebar";
|
|
import { TeamRosterView } from "~/components/draft/TeamRosterView";
|
|
import { DraftSummaryView } from "~/components/draft/DraftSummaryView";
|
|
import { QueueSection } from "~/components/draft/QueueSection";
|
|
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
|
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
|
|
import { RecentPicksFeed } from "~/components/draft/RecentPicksFeed";
|
|
import { DraftGridSection } from "~/components/draft/DraftGridSection";
|
|
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
|
|
import { AuthRecoveryOverlay } from "~/components/draft/AuthRecoveryOverlay";
|
|
import { ParticipantSelectionDialog } from "~/components/draft/ParticipantSelectionDialog";
|
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
|
import { getTeamForPick, getProjectedPicks } from "~/lib/draft-order";
|
|
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
|
|
import { NotificationSettings } from "~/components/NotificationSettings";
|
|
import { AutodraftBadgeWithPopover } from "~/components/AutodraftSettings";
|
|
import { toast } from "sonner";
|
|
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
|
import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react";
|
|
import type { Route } from "./+types/$leagueId.draft.$seasonId";
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [{ title: `Draft — ${data?.season?.league?.name ?? "League"} - Brackt` }];
|
|
}
|
|
|
|
const MOBILE_TABS_BASE = [
|
|
{ id: "available" as const, label: "Participants", Icon: Users },
|
|
{ id: "board" as const, label: "Board", Icon: LayoutGrid },
|
|
{ id: "teams" as const, label: "Teams", Icon: ListChecks },
|
|
{ id: "controls" as const, label: "Controls", Icon: Settings },
|
|
];
|
|
|
|
const QUEUE_TAB = { id: "queue" as const, label: "Queue", Icon: ListOrdered };
|
|
|
|
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 });
|
|
}
|
|
|
|
const userTeam = userId
|
|
? season.teams.find((team) => team.ownerId === userId)
|
|
: undefined;
|
|
|
|
const isCommissioner = userId
|
|
? await hasCommissionerRecord(leagueId, userId)
|
|
: false;
|
|
|
|
if (!season.league.isPublicDraftBoard) {
|
|
if (!userId) {
|
|
throw new Response("You must be logged in to view the draft room", {
|
|
status: 401,
|
|
});
|
|
}
|
|
if (!userTeam && !isCommissioner) {
|
|
throw new Response("You do not have access to this draft room", {
|
|
status: 403,
|
|
});
|
|
}
|
|
}
|
|
|
|
const [draftSlots, draftPicks, availableParticipants, timers, autodraftSettings] =
|
|
await Promise.all([
|
|
findDraftSlotsBySeasonId(seasonId),
|
|
getDraftPicksForSeason(seasonId),
|
|
getDraftParticipants(seasonId),
|
|
getSeasonTimers(seasonId),
|
|
getSeasonAutodraftSettings(seasonId),
|
|
]);
|
|
|
|
const userQueue = userTeam
|
|
? await getTeamQueue(userTeam.id).catch((err) => {
|
|
logger.error("[DraftLoader] Failed to load queue, defaulting to empty:", err);
|
|
return [];
|
|
})
|
|
: [];
|
|
|
|
const userAutodraftSettings = userTeam
|
|
? (autodraftSettings.find((s) => s.teamId === userTeam.id) ?? null)
|
|
: null;
|
|
|
|
const ownerMap = await buildOwnerMap(draftSlots);
|
|
|
|
return {
|
|
season,
|
|
draftSlots,
|
|
draftPicks,
|
|
availableParticipants,
|
|
userTeam,
|
|
userQueue,
|
|
timers,
|
|
autodraftSettings,
|
|
userAutodraftSettings,
|
|
isCommissioner: !!isCommissioner,
|
|
currentUserId: userId,
|
|
ownerMap,
|
|
};
|
|
}
|
|
|
|
export default function DraftRoom() {
|
|
const {
|
|
season,
|
|
draftSlots,
|
|
draftPicks,
|
|
availableParticipants,
|
|
userTeam,
|
|
userQueue,
|
|
timers,
|
|
autodraftSettings,
|
|
userAutodraftSettings,
|
|
isCommissioner,
|
|
currentUserId,
|
|
ownerMap,
|
|
} = useLoaderData<typeof loader>();
|
|
const { revalidate, state: revalidatorState } = useRevalidator();
|
|
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
|
|
const {
|
|
permissionState: notificationsPermission,
|
|
enabled: notificationsEnabled,
|
|
setEnabled: setNotificationsEnabled,
|
|
mode: notificationsMode,
|
|
setMode: setNotificationsMode,
|
|
sendNotification,
|
|
} = useDraftNotifications(season.id, currentUserId ?? "");
|
|
|
|
// Use refs so the socket effect doesn't re-register all handlers when the user
|
|
// changes notification settings (which would change the sendNotification reference).
|
|
const sendNotificationRef = useRef(sendNotification);
|
|
useEffect(() => {
|
|
sendNotificationRef.current = sendNotification;
|
|
}, [sendNotification]);
|
|
const notificationsModeRef = useRef(notificationsMode);
|
|
useEffect(() => {
|
|
notificationsModeRef.current = notificationsMode;
|
|
}, [notificationsMode]);
|
|
|
|
const {
|
|
picks, setPicks,
|
|
currentPick, setCurrentPick,
|
|
searchQuery, setSearchQuery,
|
|
hideDrafted, setHideDrafted,
|
|
hideIneligible, setHideIneligible,
|
|
hideCompletedSports, setHideCompletedSports,
|
|
sportFilters, setSportFilters,
|
|
queue, setQueue,
|
|
isPaused, setIsPaused,
|
|
isDraftComplete, setIsDraftComplete,
|
|
sidebarCollapsed, setSidebarCollapsed,
|
|
activeTab, setActiveTab,
|
|
mobileTab, setMobileTab,
|
|
teamsSubTab, setTeamsSubTab,
|
|
boardSubTab, setBoardSubTab,
|
|
isMobile,
|
|
autodraftStatus, setAutodraftStatus,
|
|
connectedTeams, setConnectedTeams,
|
|
teamTimers, setTeamTimers,
|
|
forcePickDialogOpen, setForcePickDialogOpen,
|
|
selectedPickSlot, setSelectedPickSlot,
|
|
replacePickDialogOpen, setReplacePickDialogOpen,
|
|
replacePickSlot, setReplacePickSlot,
|
|
rollbackConfirmOpen, setRollbackConfirmOpen,
|
|
rollbackPickNumber, setRollbackPickNumber,
|
|
isRollingBack, setIsRollingBack,
|
|
timeBankDialogOpen, setTimeBankDialogOpen,
|
|
timeBankTeamId, setTimeBankTeamId,
|
|
commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen,
|
|
commissionerAutodraftTeamId, setCommissionerAutodraftTeamId,
|
|
timeBankAmount, setTimeBankAmount,
|
|
timeBankUnit, setTimeBankUnit,
|
|
timeBankDirection, setTimeBankDirection,
|
|
isAdjustingTimeBank, setIsAdjustingTimeBank,
|
|
} = useDraftRoomState({
|
|
draftPicks,
|
|
season,
|
|
userTeam,
|
|
isCommissioner,
|
|
autodraftSettings,
|
|
timers,
|
|
draftSlots,
|
|
userQueue,
|
|
});
|
|
|
|
const {
|
|
authDegraded,
|
|
authFetch,
|
|
userAutodraft,
|
|
setUserAutodraft,
|
|
userAutodraftRef,
|
|
isRevalidatingRef,
|
|
pendingPicksDuringRevalidationRef,
|
|
pendingQueueMutationsRef,
|
|
} = useDraftAuthRecovery({
|
|
reconnectCount,
|
|
revalidate,
|
|
revalidatorState,
|
|
currentUserId,
|
|
userAutodraftSettings,
|
|
draftPicks,
|
|
season,
|
|
userQueue,
|
|
timers,
|
|
autodraftSettings,
|
|
setPicks,
|
|
setCurrentPick,
|
|
setIsPaused,
|
|
setIsDraftComplete,
|
|
setQueue,
|
|
setTeamTimers,
|
|
setAutodraftStatus,
|
|
});
|
|
|
|
|
|
// Shared transforms for eligibility calculations
|
|
const transformedPicks = useMemo(
|
|
() =>
|
|
picks.map((p) => ({
|
|
teamId: p.team.id,
|
|
participant: {
|
|
id: p.participant.id,
|
|
sport: { id: p.sport.id, name: p.sport.name },
|
|
},
|
|
})),
|
|
[picks]
|
|
);
|
|
|
|
const transformedParticipants = useMemo(
|
|
() =>
|
|
availableParticipants.map((p) => ({
|
|
id: p.id,
|
|
sport: { id: p.sport.id, name: p.sport.name },
|
|
})),
|
|
[availableParticipants]
|
|
);
|
|
|
|
const seasonSportsData = useMemo(() => {
|
|
const sportsMap = new Map<string, { id: string; name: string }>();
|
|
availableParticipants.forEach((p) => {
|
|
if (!sportsMap.has(p.sport.id)) {
|
|
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
|
|
}
|
|
});
|
|
return Array.from(sportsMap.values()).toSorted((a, b) =>
|
|
a.name.localeCompare(b.name)
|
|
);
|
|
}, [availableParticipants]);
|
|
|
|
const participantRanks = useMemo(() => {
|
|
const ranks = new Map<string, { overallRank: number; sportRank: number }>();
|
|
const sportCounters = new Map<string, number>();
|
|
availableParticipants.forEach((p, i) => {
|
|
const sportIdx = (sportCounters.get(p.sport.id) ?? 0) + 1;
|
|
sportCounters.set(p.sport.id, sportIdx);
|
|
ranks.set(p.id, { overallRank: i + 1, sportRank: sportIdx });
|
|
});
|
|
return ranks;
|
|
}, [availableParticipants]);
|
|
|
|
// Calculate draft eligibility for current user's team
|
|
const eligibility = useMemo(() => {
|
|
if (!userTeam) return null;
|
|
const userTeamPicks = transformedPicks.filter(
|
|
(p) => p.teamId === userTeam.id
|
|
);
|
|
return calculateDraftEligibility(
|
|
userTeam.id,
|
|
userTeamPicks,
|
|
transformedPicks,
|
|
transformedParticipants,
|
|
seasonSportsData,
|
|
season.draftRounds,
|
|
season.teams
|
|
);
|
|
}, [userTeam, transformedPicks, transformedParticipants, seasonSportsData, season]);
|
|
|
|
// Calculate eligibility for force manual pick dialog (selected team)
|
|
const forcePickEligibility = useMemo(() => {
|
|
if (!selectedPickSlot) return null;
|
|
const selectedTeamPicks = transformedPicks.filter(
|
|
(p) => p.teamId === selectedPickSlot.teamId
|
|
);
|
|
return calculateDraftEligibility(
|
|
selectedPickSlot.teamId,
|
|
selectedTeamPicks,
|
|
transformedPicks,
|
|
transformedParticipants,
|
|
seasonSportsData,
|
|
season.draftRounds,
|
|
season.teams
|
|
);
|
|
}, [selectedPickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]);
|
|
|
|
// Calculate eligibility for replace pick dialog — exclude the old pick so its slot is free
|
|
const replacePickEligibility = useMemo(() => {
|
|
if (!replacePickSlot) return null;
|
|
const allPicksExcluding = transformedPicks.filter(
|
|
(p) => p.participant.id !== replacePickSlot.oldParticipantId
|
|
);
|
|
const selectedTeamPicks = allPicksExcluding.filter(
|
|
(p) => p.teamId === replacePickSlot.teamId
|
|
);
|
|
return calculateDraftEligibility(
|
|
replacePickSlot.teamId,
|
|
selectedTeamPicks,
|
|
allPicksExcluding,
|
|
transformedParticipants,
|
|
seasonSportsData,
|
|
season.draftRounds,
|
|
season.teams
|
|
);
|
|
}, [replacePickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]);
|
|
|
|
useDraftSocketEvents({
|
|
on,
|
|
off,
|
|
socketVersion,
|
|
userTeam,
|
|
draftSlots,
|
|
leagueName: season.league.name,
|
|
isRevalidatingRef,
|
|
pendingPicksDuringRevalidationRef,
|
|
pendingQueueMutationsRef,
|
|
userAutodraftRef,
|
|
sendNotificationRef,
|
|
notificationsModeRef,
|
|
setPicks,
|
|
setCurrentPick,
|
|
setIsPaused,
|
|
setIsDraftComplete,
|
|
setAutodraftStatus,
|
|
setUserAutodraft,
|
|
setConnectedTeams,
|
|
setQueue,
|
|
setTeamTimers,
|
|
});
|
|
|
|
// Persist sidebar collapsed state
|
|
useEffect(() => {
|
|
if (typeof window !== "undefined") {
|
|
localStorage.setItem("draftSidebarCollapsed", JSON.stringify(sidebarCollapsed));
|
|
}
|
|
}, [sidebarCollapsed]);
|
|
|
|
// Queue handlers
|
|
const handleAddToQueue = useCallback(async (participantId: string) => {
|
|
if (!userTeam) return;
|
|
|
|
// Check if participant is already in queue (client-side)
|
|
const alreadyInQueue = queue.some((item) => item.participantId === participantId);
|
|
if (alreadyInQueue) {
|
|
toast.error("This participant is already in your queue");
|
|
return;
|
|
}
|
|
|
|
// Optimistic update - create a temporary queue item
|
|
const now = new Date();
|
|
const tempQueueItem: QueueItem = {
|
|
id: `temp-${Date.now()}`,
|
|
participantId,
|
|
queuePosition: queue.length + 1,
|
|
seasonId: season.id,
|
|
teamId: userTeam.id,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
// Immediately update the UI
|
|
setQueue((prev) => [...prev, tempQueueItem]);
|
|
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", userTeam.id);
|
|
formData.append("participantId", participantId);
|
|
|
|
pendingQueueMutationsRef.current++;
|
|
try {
|
|
const response = await authFetch("/api/queue/add", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) { setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id)); return; }
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
// Replace temp item with real item from server
|
|
setQueue((prev) =>
|
|
prev.map((item) =>
|
|
item.id === tempQueueItem.id ? data.queueItem : item
|
|
)
|
|
);
|
|
} else {
|
|
// Revert the optimistic update on error
|
|
setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id));
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to add to queue");
|
|
}
|
|
} catch {
|
|
// Revert the optimistic update on network error
|
|
setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id));
|
|
toast.error("Network error - failed to add to queue");
|
|
} finally {
|
|
pendingQueueMutationsRef.current--;
|
|
}
|
|
// queue is read directly (not via functional updater) for the duplicate check and
|
|
// optimistic position, so it must be a dep. This is fine: AvailableParticipantsSection
|
|
// already re-renders when queue changes (it receives queue as a prop).
|
|
}, [userTeam, queue, season.id, authFetch, setQueue, pendingQueueMutationsRef]);
|
|
|
|
const handleRemoveFromQueue = useCallback(async (queueId: string) => {
|
|
if (!userTeam) return;
|
|
|
|
// Optimistically remove the item immediately
|
|
let previousQueue: QueueItem[] = [];
|
|
setQueue((prev) => {
|
|
previousQueue = prev;
|
|
return prev.filter((item) => item.id !== queueId);
|
|
});
|
|
|
|
const formData = new FormData();
|
|
formData.append("queueId", queueId);
|
|
formData.append("teamId", userTeam.id);
|
|
|
|
pendingQueueMutationsRef.current++;
|
|
try {
|
|
const response = await authFetch("/api/queue/remove", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) { setQueue(previousQueue); return; }
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setQueue(data.queue);
|
|
} else {
|
|
setQueue(previousQueue);
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to remove from queue");
|
|
}
|
|
} catch {
|
|
setQueue(previousQueue);
|
|
toast.error("Network error - failed to remove from queue");
|
|
} finally {
|
|
pendingQueueMutationsRef.current--;
|
|
}
|
|
}, [userTeam, authFetch, setQueue, pendingQueueMutationsRef]);
|
|
|
|
const handleReorderQueue = useCallback(async (participantIds: string[]) => {
|
|
if (!userTeam) return;
|
|
|
|
// Optimistically reorder the queue immediately
|
|
let previousQueue: QueueItem[] = [];
|
|
setQueue((prev) => {
|
|
previousQueue = prev;
|
|
return participantIds
|
|
.map((pid) => prev.find((item) => item.participantId === 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));
|
|
|
|
pendingQueueMutationsRef.current++;
|
|
try {
|
|
const response = await authFetch("/api/queue/reorder", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) { setQueue(previousQueue); return; }
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setQueue(data.queue);
|
|
} else {
|
|
const error = await response.json();
|
|
setQueue(previousQueue);
|
|
toast.error(error.error || "Failed to reorder queue");
|
|
}
|
|
} catch {
|
|
setQueue(previousQueue);
|
|
toast.error("Network error - failed to reorder queue");
|
|
} finally {
|
|
pendingQueueMutationsRef.current--;
|
|
}
|
|
}, [userTeam, season.id, authFetch, setQueue, pendingQueueMutationsRef]);
|
|
|
|
const handleStartDraft = async () => {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
const response = await authFetch("/api/draft/start", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
revalidate();
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to start draft");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to start draft");
|
|
}
|
|
};
|
|
|
|
const handlePauseDraft = async () => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
try {
|
|
const response = await authFetch("/api/draft/pause", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
setIsPaused(true);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to pause draft");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to pause draft");
|
|
}
|
|
};
|
|
|
|
const handleResumeDraft = async () => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
try {
|
|
const response = await authFetch("/api/draft/resume", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
setIsPaused(false);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to resume draft");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to resume draft");
|
|
}
|
|
};
|
|
|
|
const handleMakePick = useCallback(async (participantId: string) => {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("participantId", participantId);
|
|
|
|
const response = await authFetch("/api/draft/make-pick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to make pick");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to make pick");
|
|
}
|
|
}, [season.id, authFetch]);
|
|
|
|
const handleForceAutopick = useCallback(async (pickNumber: number, teamId: string) => {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", teamId);
|
|
formData.append("pickNumber", pickNumber.toString());
|
|
|
|
const response = await authFetch("/api/draft/force-autopick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to force autopick");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to force autopick");
|
|
}
|
|
}, [season.id, authFetch]);
|
|
|
|
const handleForceManualPick = async (participantId: string) => {
|
|
if (!selectedPickSlot) return;
|
|
|
|
try {
|
|
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 authFetch("/api/draft/force-manual-pick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
setForcePickDialogOpen(false);
|
|
setSelectedPickSlot(null);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to force manual pick");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to force manual pick");
|
|
}
|
|
};
|
|
|
|
const handleReplacePickOpen = useCallback((pickNumber: number, teamId: string) => {
|
|
const pick = picks.find((p) => p.pickNumber === pickNumber);
|
|
if (!pick) {
|
|
toast.error("Pick not found — try refreshing the page");
|
|
return;
|
|
}
|
|
setReplacePickSlot({ pickNumber, teamId, oldParticipantId: pick.participant.id });
|
|
setReplacePickDialogOpen(true);
|
|
// picks is read directly to find the pick being replaced.
|
|
}, [picks, setReplacePickSlot, setReplacePickDialogOpen]);
|
|
|
|
const handleReplacePick = async (participantId: string) => {
|
|
if (!replacePickSlot) return;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", replacePickSlot.teamId);
|
|
formData.append("participantId", participantId);
|
|
formData.append("pickNumber", replacePickSlot.pickNumber.toString());
|
|
|
|
const response = await authFetch("/api/draft/replace-pick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
setReplacePickDialogOpen(false);
|
|
setReplacePickSlot(null);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to replace pick");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to replace pick");
|
|
}
|
|
};
|
|
|
|
const handleRollbackToPick = useCallback((pickNumber: number) => {
|
|
setRollbackPickNumber(pickNumber);
|
|
setRollbackConfirmOpen(true);
|
|
}, [setRollbackPickNumber, setRollbackConfirmOpen]);
|
|
|
|
const handleConfirmRollback = async () => {
|
|
if (!rollbackPickNumber || isRollingBack) return;
|
|
|
|
setIsRollingBack(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("pickNumber", rollbackPickNumber.toString());
|
|
|
|
const response = await authFetch("/api/draft/rollback", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
setIsRollingBack(false);
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
setRollbackConfirmOpen(false);
|
|
setRollbackPickNumber(null);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to roll back draft");
|
|
}
|
|
} catch {
|
|
setIsRollingBack(false);
|
|
toast.error("Network error - failed to roll back draft");
|
|
}
|
|
};
|
|
|
|
const handleAdjustTimeBankOpen = useCallback((teamId: string) => {
|
|
setTimeBankTeamId(teamId);
|
|
setTimeBankAmount("1");
|
|
setTimeBankUnit("minutes");
|
|
setTimeBankDirection("add");
|
|
setTimeBankDialogOpen(true);
|
|
}, [setTimeBankTeamId, setTimeBankAmount, setTimeBankUnit, setTimeBankDirection, setTimeBankDialogOpen]);
|
|
|
|
const handleSetAutodraftOpen = useCallback((teamId: string) => {
|
|
setCommissionerAutodraftTeamId(teamId);
|
|
setCommissionerAutodraftDialogOpen(true);
|
|
}, [setCommissionerAutodraftTeamId, setCommissionerAutodraftDialogOpen]);
|
|
|
|
const handleCommissionerAutodraftUpdate = useCallback(() => {
|
|
setCommissionerAutodraftDialogOpen(false);
|
|
setCommissionerAutodraftTeamId(null);
|
|
}, [setCommissionerAutodraftDialogOpen, setCommissionerAutodraftTeamId]);
|
|
|
|
const handleConfirmAdjustTimeBank = async () => {
|
|
if (!timeBankTeamId || isAdjustingTimeBank) return;
|
|
|
|
const amount = parseFloat(timeBankAmount);
|
|
if (isNaN(amount) || amount <= 0) {
|
|
toast.error("Please enter a valid positive amount");
|
|
return;
|
|
}
|
|
|
|
const unitMultipliers = { seconds: 1, minutes: 60, hours: 3600 };
|
|
const totalSeconds = Math.round(amount * unitMultipliers[timeBankUnit]);
|
|
|
|
if (totalSeconds === 0) {
|
|
toast.error("Adjustment rounds to 0 seconds — please enter a larger value");
|
|
return;
|
|
}
|
|
|
|
const adjustment = timeBankDirection === "add" ? totalSeconds : -totalSeconds;
|
|
|
|
setIsAdjustingTimeBank(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", timeBankTeamId);
|
|
formData.append("adjustment", adjustment.toString());
|
|
|
|
const response = await authFetch("/api/draft/adjust-time-bank", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setTeamTimers((prev) => ({ ...prev, [timeBankTeamId]: data.timeRemaining }));
|
|
const teamName = draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name;
|
|
toast.success(`Time bank adjusted for ${teamName}`);
|
|
setTimeBankDialogOpen(false);
|
|
setTimeBankTeamId(null);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to adjust time bank");
|
|
}
|
|
} catch {
|
|
toast.error("Network error — failed to adjust time bank");
|
|
} finally {
|
|
setIsAdjustingTimeBank(false);
|
|
}
|
|
};
|
|
|
|
const handleForceManualPickOpen = useCallback((pickNumber: number, teamId: string) => {
|
|
setSelectedPickSlot({ pickNumber, teamId });
|
|
setForcePickDialogOpen(true);
|
|
}, [setSelectedPickSlot, setForcePickDialogOpen]);
|
|
|
|
// Calculate current round
|
|
const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1;
|
|
|
|
// Determine whose turn it is
|
|
const currentDraftSlot = useMemo(
|
|
() => getTeamForPick(currentPick, draftSlots),
|
|
[currentPick, draftSlots]
|
|
);
|
|
const isMyTurn = !!(
|
|
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id
|
|
);
|
|
const canPick = isMyTurn && season.status === "draft" && !isPaused; // Only team owner on their turn when draft is active
|
|
|
|
const currentDraftSlotOwnerName = currentDraftSlot ? ownerMap[currentDraftSlot.team.id] : undefined;
|
|
const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined;
|
|
const currentClockColor = getTimerColorClass(currentClockTime);
|
|
|
|
// Generate snake draft grid structure
|
|
const draftGrid = useMemo(() => {
|
|
const teamCount = draftSlots.length;
|
|
const rounds = season.draftRounds;
|
|
const pickMap = new Map(picks.map((p) => [p.pickNumber, p]));
|
|
const grid: Array<
|
|
Array<{
|
|
pickNumber: number;
|
|
round: number;
|
|
pickInRound: number;
|
|
teamId: string;
|
|
pick?: (typeof draftPicks)[number];
|
|
}>
|
|
> = [];
|
|
|
|
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;
|
|
|
|
const pick = pickMap.get(pickNumber);
|
|
|
|
roundPicks.push({ pickNumber, round, pickInRound, teamId, pick });
|
|
}
|
|
grid.push(roundPicks);
|
|
}
|
|
|
|
return grid;
|
|
}, [picks, draftSlots, season.draftRounds]);
|
|
|
|
const projectedPicks = useMemo(() => {
|
|
if (!userTeam || season.status !== "draft" || isDraftComplete) return [];
|
|
return getProjectedPicks({
|
|
draftSlots,
|
|
userTeamId: userTeam.id,
|
|
currentPick,
|
|
totalRounds: season.draftRounds,
|
|
existingPicks: picks.map((p) => ({
|
|
pickNumber: p.pickNumber,
|
|
teamId: p.team.id,
|
|
})),
|
|
});
|
|
}, [userTeam, season.status, isDraftComplete, draftSlots, currentPick, season.draftRounds, picks]);
|
|
|
|
// Get drafted participant IDs for filtering
|
|
const draftedParticipantIds = useMemo(
|
|
() => new Set(picks.map((p) => p.participant.id)),
|
|
[picks]
|
|
);
|
|
|
|
// Sport IDs where the current user's team has already made at least one pick
|
|
const userDraftedSportIds = useMemo(() => {
|
|
if (!userTeam) return new Set<string>();
|
|
return new Set(
|
|
picks
|
|
.filter((p) => p.team.id === userTeam.id)
|
|
.map((p) => p.sport.id)
|
|
);
|
|
}, [picks, userTeam]);
|
|
|
|
// Sport names where the current user's team has already made at least one pick
|
|
const userDraftedSportNames = useMemo(() => {
|
|
if (!userTeam) return new Set<string>();
|
|
return new Set(
|
|
picks
|
|
.filter((p) => p.team.id === userTeam.id)
|
|
.map((p) => p.sport.name)
|
|
);
|
|
}, [picks, userTeam]);
|
|
|
|
// Get unique sports for filter dropdown
|
|
const uniqueSports = useMemo(
|
|
() =>
|
|
Array.from(
|
|
new Set(availableParticipants.map((p) => p.sport.name))
|
|
).sort(),
|
|
[availableParticipants]
|
|
);
|
|
|
|
// Filter participants based on search, sport, drafted status, and eligibility
|
|
const filteredParticipants = useMemo(() => {
|
|
const sportFilterSet = new Set(sportFilters);
|
|
return availableParticipants.filter((participant) => {
|
|
// 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;
|
|
}
|
|
}
|
|
// Hide completed sports filter - hide participants from sports already drafted by user's team
|
|
if (hideCompletedSports && userDraftedSportIds.has(participant.sport.id)) {
|
|
return false;
|
|
}
|
|
// Search filter
|
|
if (
|
|
searchQuery &&
|
|
!participant.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
) {
|
|
return false;
|
|
}
|
|
// Sport filter
|
|
if (sportFilterSet.size > 0 && !sportFilterSet.has(participant.sport.name)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}, [availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters]);
|
|
|
|
// Shared component props — defined once to avoid duplication between desktop and mobile layouts
|
|
const handleHideCompletedSportsChange = useCallback((hide: boolean) => {
|
|
setHideCompletedSports(hide);
|
|
if (hide) {
|
|
setSportFilters((prev) => prev.filter((s) => !userDraftedSportNames.has(s)));
|
|
}
|
|
}, [userDraftedSportNames, setHideCompletedSports, setSportFilters]);
|
|
|
|
const miniDraftGrid = useMemo(() => ({
|
|
draftSlots,
|
|
draftGrid,
|
|
currentPick,
|
|
currentRound,
|
|
ownerMap,
|
|
teamTimers,
|
|
autodraftStatus,
|
|
connectedTeams,
|
|
seasonStatus: season.status,
|
|
draftPaused: isPaused,
|
|
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
|
|
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
|
|
onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
|
|
onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined,
|
|
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
|
|
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
|
|
}), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, connectedTeams, season.status, isPaused, isCommissioner, handleAdjustTimeBankOpen, handleSetAutodraftOpen, handleForceAutopick, handleForceManualPickOpen, handleReplacePickOpen, handleRollbackToPick, isDraftComplete]);
|
|
|
|
const availableParticipantsSectionProps = useMemo(() => ({
|
|
participants: filteredParticipants,
|
|
participantRanks,
|
|
miniDraftGrid,
|
|
searchQuery,
|
|
sportFilters,
|
|
hideDrafted,
|
|
hideIneligible,
|
|
uniqueSports,
|
|
draftedParticipantIds,
|
|
queue,
|
|
eligibility,
|
|
canPick,
|
|
hasTeam: !!userTeam,
|
|
onSearchChange: setSearchQuery,
|
|
onSportFiltersChange: setSportFilters,
|
|
onHideDraftedChange: setHideDrafted,
|
|
onHideIneligibleChange: setHideIneligible,
|
|
hideCompletedSports,
|
|
userDraftedSportNames,
|
|
onHideCompletedSportsChange: handleHideCompletedSportsChange,
|
|
onMakePick: handleMakePick,
|
|
onAddToQueue: handleAddToQueue,
|
|
onRemoveFromQueue: handleRemoveFromQueue,
|
|
projectedPicks,
|
|
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue, setSearchQuery, setSportFilters, setHideDrafted, setHideIneligible, projectedPicks]);
|
|
|
|
const draftGridSectionProps = {
|
|
draftSlots,
|
|
draftGrid,
|
|
currentPick,
|
|
teamTimers,
|
|
autodraftStatus,
|
|
connectedTeams,
|
|
isCommissioner,
|
|
ownerMap,
|
|
seasonStatus: season.status,
|
|
draftPaused: isPaused,
|
|
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
|
|
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
|
|
onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
|
|
onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined,
|
|
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
|
|
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
|
|
};
|
|
|
|
const summaryViewProps = {
|
|
draftSlots,
|
|
picks,
|
|
sports: seasonSportsData,
|
|
ownerMap,
|
|
totalRounds: season.draftRounds,
|
|
};
|
|
|
|
const rosterViewProps = {
|
|
draftSlots,
|
|
picks,
|
|
sports: seasonSportsData,
|
|
ownerMap,
|
|
totalRounds: season.draftRounds,
|
|
};
|
|
|
|
const handleAutodraftUpdate = useCallback(
|
|
(isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => {
|
|
setUserAutodraft({ isEnabled, mode, queueOnly });
|
|
},
|
|
[setUserAutodraft]
|
|
);
|
|
|
|
const queueSectionProps = userTeam
|
|
? {
|
|
queue,
|
|
availableParticipants,
|
|
canPick,
|
|
onRemoveFromQueue: handleRemoveFromQueue,
|
|
onReorder: handleReorderQueue,
|
|
onMakePick: handleMakePick,
|
|
}
|
|
: null;
|
|
|
|
const mobileTabs = userTeam
|
|
? [MOBILE_TABS_BASE[0], QUEUE_TAB, ...MOBILE_TABS_BASE.slice(1)]
|
|
: MOBILE_TABS_BASE;
|
|
const mobileColCount = mobileTabs.length;
|
|
|
|
return (
|
|
<div className="h-dvh 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 x1="3" y1="6" x2="21" y2="6" />
|
|
<line x1="3" y1="18" x2="21" y2="18" />
|
|
</svg>
|
|
</Button>
|
|
)}
|
|
<Link to="/">
|
|
<img src={logomarkUrl} alt="Brackt" className="h-8" />
|
|
</Link>
|
|
<h1 className="text-lg md:text-xl font-bold">
|
|
Draft Room
|
|
</h1>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="hidden md:flex items-center gap-2 relative">
|
|
<span className="text-xs text-muted-foreground">Pick Notifications</span>
|
|
<NotificationSettings
|
|
enabled={notificationsEnabled}
|
|
onEnabledChange={setNotificationsEnabled}
|
|
mode={notificationsMode}
|
|
onModeChange={setNotificationsMode}
|
|
permissionState={notificationsPermission}
|
|
switchOnly
|
|
/>
|
|
</div>
|
|
{/* Commissioner Controls - hidden on mobile (shown in Controls tab) */}
|
|
{isCommissioner && (
|
|
<div className="hidden md:flex">
|
|
<CommissionerDraftControls
|
|
seasonStatus={season.status}
|
|
isDraftComplete={isDraftComplete}
|
|
isPaused={isPaused}
|
|
onStart={handleStartDraft}
|
|
onPause={handlePauseDraft}
|
|
onResume={handleResumeDraft}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<Button variant="outline" asChild className="hidden md:flex">
|
|
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile On Clock bar */}
|
|
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
|
|
<div className={`md:hidden flex-shrink-0 flex items-center justify-between px-4 py-2 border-b ${
|
|
isMyTurn
|
|
? "bg-electric/25 border-electric"
|
|
: "bg-muted/30"
|
|
}`}>
|
|
<div>
|
|
<div className={`text-xs font-bold uppercase tracking-wider ${isMyTurn ? "text-electric" : "text-muted-foreground"}`}>
|
|
{isMyTurn ? "Your Turn!" : "On the Clock"}
|
|
</div>
|
|
<div className="text-sm font-bold">
|
|
{currentDraftSlotOwnerName ?? currentDraftSlot.team.name}
|
|
</div>
|
|
{currentDraftSlotOwnerName && (
|
|
<div className="text-xs text-muted-foreground">{currentDraftSlot.team.name}</div>
|
|
)}
|
|
</div>
|
|
{isPaused ? (
|
|
<span className="text-sm font-bold text-amber-accent">Paused</span>
|
|
) : (
|
|
<span className={`text-xl font-mono font-bold tabular-nums ${currentClockColor}`}>
|
|
{formatClockTime(currentClockTime)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop Tab Navigation Row */}
|
|
<div className={`hidden md:flex flex-shrink-0 items-center justify-between px-4 py-2 border-b transition-all duration-300 ${
|
|
isMyTurn && season.status === "draft" && !isDraftComplete
|
|
? "bg-electric/25 border-electric shadow-[0_0_24px_0_rgb(0_200_255_/_0.18)]"
|
|
: "bg-card"
|
|
}`}>
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(value) =>
|
|
setActiveTab(value as "participants" | "board" | "rosters" | "summary")
|
|
}
|
|
>
|
|
<TabsList>
|
|
<TabsTrigger value="participants">Participants</TabsTrigger>
|
|
<TabsTrigger value="board">Draft Board</TabsTrigger>
|
|
<TabsTrigger value="rosters">Rosters</TabsTrigger>
|
|
<TabsTrigger value="summary">Summary</TabsTrigger>
|
|
</TabsList>
|
|
</Tabs>
|
|
{userTeam && (
|
|
<AutodraftBadgeWithPopover
|
|
seasonId={season.id}
|
|
teamId={userTeam.id}
|
|
isEnabled={userAutodraft.isEnabled}
|
|
mode={userAutodraft.mode}
|
|
queueOnly={userAutodraft.queueOnly}
|
|
isMyTurn={isMyTurn}
|
|
onUpdate={handleAutodraftUpdate}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Main Content — single layout tree based on isMobile to avoid duplicate component instances */}
|
|
<div className="flex-1 overflow-hidden">
|
|
{isMobile ? (
|
|
<div className="flex h-full overflow-hidden flex-col">
|
|
{mobileTab === "available" && (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
<div className="flex-shrink-0">
|
|
<RecentPicksFeed picks={picks} />
|
|
</div>
|
|
<div className="flex-1 min-h-0 overflow-hidden">
|
|
<AvailableParticipantsSection {...availableParticipantsSectionProps} miniDraftGrid={undefined} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{mobileTab === "queue" && (
|
|
<div className="h-full overflow-y-auto">
|
|
{queueSectionProps ? (
|
|
<QueueSection {...queueSectionProps} />
|
|
) : (
|
|
<p className="text-muted-foreground text-sm text-center py-8 px-4">
|
|
Join a team to manage your queue
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{mobileTab === "board" && (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
<div className="flex-shrink-0 flex gap-1 p-2 border-b">
|
|
<button
|
|
onClick={() => setBoardSubTab("board")}
|
|
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
|
|
boardSubTab === "board"
|
|
? "bg-accent text-accent-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
Board
|
|
</button>
|
|
<button
|
|
onClick={() => setBoardSubTab("pick-list")}
|
|
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
|
|
boardSubTab === "pick-list"
|
|
? "bg-accent text-accent-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
Pick List
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-hidden">
|
|
{boardSubTab !== "pick-list" ? (
|
|
<DraftGridSection {...draftGridSectionProps} />
|
|
) : (
|
|
<div className="h-full overflow-y-auto p-4">
|
|
<SidebarRecentPicks picks={picks} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{mobileTab === "teams" && (
|
|
<div className="flex flex-col h-full overflow-hidden">
|
|
<div className="flex-shrink-0 flex gap-1 p-2 border-b">
|
|
<button
|
|
onClick={() => setTeamsSubTab("rosters")}
|
|
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
|
|
teamsSubTab === "rosters"
|
|
? "bg-accent text-accent-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
Roster
|
|
</button>
|
|
<button
|
|
onClick={() => setTeamsSubTab("summary")}
|
|
className={`flex-1 py-1.5 text-sm font-medium rounded transition-colors ${
|
|
teamsSubTab === "summary"
|
|
? "bg-accent text-accent-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
Summary
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-hidden">
|
|
{teamsSubTab === "rosters" ? (
|
|
<TeamRosterView {...rosterViewProps} />
|
|
) : (
|
|
<DraftSummaryView {...summaryViewProps} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{mobileTab === "controls" && (
|
|
<div className="h-full overflow-y-auto p-4 space-y-6">
|
|
{isCommissioner && season.status === "pre_draft" && (
|
|
<Button className="w-full min-h-[48px]" onClick={handleStartDraft}>
|
|
Start Draft
|
|
</Button>
|
|
)}
|
|
{isCommissioner && season.status === "draft" && !isDraftComplete && (
|
|
isPaused ? (
|
|
<Button className="w-full min-h-[48px]" onClick={handleResumeDraft}>
|
|
Resume Draft
|
|
</Button>
|
|
) : (
|
|
<Button className="w-full min-h-[48px]" variant="outline" onClick={handlePauseDraft}>
|
|
Pause Draft
|
|
</Button>
|
|
)
|
|
)}
|
|
|
|
<Button variant="outline" asChild className="w-full min-h-[48px]">
|
|
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full overflow-hidden">
|
|
{userTeam && queueSectionProps && (
|
|
<DraftSidebar
|
|
collapsed={sidebarCollapsed}
|
|
onCollapsedChange={setSidebarCollapsed}
|
|
queueSection={<QueueSection {...queueSectionProps} />}
|
|
recentPicksSection={<SidebarRecentPicks picks={picks} />}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-hidden relative">
|
|
<div className={`absolute inset-0 ${activeTab === "participants" ? "" : "hidden"}`}>
|
|
<AvailableParticipantsSection {...availableParticipantsSectionProps} />
|
|
</div>
|
|
<div className={`absolute inset-0 ${activeTab === "board" ? "" : "hidden"}`}>
|
|
<DraftGridSection {...draftGridSectionProps} />
|
|
</div>
|
|
<div className={`absolute inset-0 ${activeTab === "rosters" ? "" : "hidden"}`}>
|
|
<TeamRosterView {...rosterViewProps} />
|
|
</div>
|
|
<div className={`absolute inset-0 ${activeTab === "summary" ? "" : "hidden"}`}>
|
|
<DraftSummaryView {...summaryViewProps} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile Bottom Nav */}
|
|
<nav className={`md:hidden flex-shrink-0 border-t bg-card grid`} style={{ gridTemplateColumns: `repeat(${mobileColCount}, 1fr)` }}>
|
|
{mobileTabs.map((tab) => {
|
|
const showTurnIndicator =
|
|
(tab.id === "available" || tab.id === "queue") &&
|
|
isMyTurn &&
|
|
season.status === "draft" &&
|
|
!isDraftComplete;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setMobileTab(tab.id as typeof mobileTab)}
|
|
className={`relative flex flex-col items-center justify-center py-2 gap-0.5 min-h-[56px] transition-colors ${
|
|
mobileTab === tab.id ? "text-electric" : "text-muted-foreground"
|
|
}`}
|
|
>
|
|
<div className="relative">
|
|
<tab.Icon className="h-5 w-5" />
|
|
{showTurnIndicator && (
|
|
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-electric" />
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] font-medium">{tab.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<ParticipantSelectionDialog
|
|
open={forcePickDialogOpen && !!selectedPickSlot}
|
|
onOpenChange={(open) => {
|
|
setForcePickDialogOpen(open);
|
|
if (!open) setSelectedPickSlot(null);
|
|
}}
|
|
title="Force Manual Pick"
|
|
description={`Select a participant to draft for Pick #${selectedPickSlot?.pickNumber}`}
|
|
participants={availableParticipants}
|
|
draftedParticipantIds={draftedParticipantIds}
|
|
eligibility={forcePickEligibility}
|
|
uniqueSports={uniqueSports}
|
|
onSelect={handleForceManualPick}
|
|
/>
|
|
|
|
<ParticipantSelectionDialog
|
|
open={replacePickDialogOpen && !!replacePickSlot}
|
|
onOpenChange={(open) => {
|
|
setReplacePickDialogOpen(open);
|
|
if (!open) setReplacePickSlot(null);
|
|
}}
|
|
title="Replace Pick"
|
|
description={`Select a participant to replace the current pick at slot #${replacePickSlot?.pickNumber}`}
|
|
participants={availableParticipants}
|
|
draftedParticipantIds={draftedParticipantIds}
|
|
allowParticipantId={replacePickSlot?.oldParticipantId}
|
|
currentParticipantId={replacePickSlot?.oldParticipantId}
|
|
eligibility={replacePickEligibility}
|
|
uniqueSports={uniqueSports}
|
|
onSelect={handleReplacePick}
|
|
/>
|
|
|
|
|
|
{/* Rollback Confirmation Dialog */}
|
|
<RollbackConfirmDialog
|
|
open={rollbackConfirmOpen}
|
|
onOpenChange={(open) => { setRollbackConfirmOpen(open); if (!open) setRollbackPickNumber(null); }}
|
|
pickNumber={rollbackPickNumber}
|
|
picksCount={rollbackPickNumber !== null ? picks.filter((p) => p.pickNumber >= rollbackPickNumber).length : 0}
|
|
isRollingBack={isRollingBack}
|
|
onConfirm={handleConfirmRollback}
|
|
/>
|
|
|
|
{/* Time Bank Adjustment Dialog */}
|
|
<TimeBankAdjustmentDialog
|
|
open={timeBankDialogOpen}
|
|
onOpenChange={(open) => { setTimeBankDialogOpen(open); if (!open) setTimeBankTeamId(null); }}
|
|
teamName={draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name}
|
|
amount={timeBankAmount}
|
|
onAmountChange={setTimeBankAmount}
|
|
unit={timeBankUnit}
|
|
onUnitChange={setTimeBankUnit}
|
|
direction={timeBankDirection}
|
|
onDirectionChange={setTimeBankDirection}
|
|
isAdjusting={isAdjustingTimeBank}
|
|
onConfirm={handleConfirmAdjustTimeBank}
|
|
/>
|
|
|
|
{/* Commissioner Autodraft Override Dialog */}
|
|
<CommissionerAutodraftDialog
|
|
open={commissionerAutodraftDialogOpen}
|
|
onOpenChange={(open) => { setCommissionerAutodraftDialogOpen(open); if (!open) setCommissionerAutodraftTeamId(null); }}
|
|
teamId={commissionerAutodraftTeamId}
|
|
teamName={draftSlots.find((s) => s.team.id === commissionerAutodraftTeamId)?.team.name}
|
|
seasonId={season.id}
|
|
isEnabled={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.isEnabled ?? false}
|
|
mode={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.mode ?? "next_pick"}
|
|
queueOnly={autodraftStatus[commissionerAutodraftTeamId ?? ""]?.queueOnly ?? false}
|
|
onUpdate={handleCommissionerAutodraftUpdate}
|
|
/>
|
|
|
|
{/* Connection Overlay - blocks interaction until socket connects */}
|
|
<ConnectionOverlay
|
|
isConnected={isConnected}
|
|
isReconnecting={isReconnecting}
|
|
connectionError={connectionError}
|
|
/>
|
|
|
|
{/* Auth Recovery Overlay - blocks interaction when session expires */}
|
|
<AuthRecoveryOverlay isAuthDegraded={authDegraded} />
|
|
</div>
|
|
);
|
|
}
|