2131 lines
80 KiB
TypeScript
2131 lines
80 KiB
TypeScript
import { useLoaderData, Link, useRevalidator } from "react-router";
|
|
import { useAuth } from "@clerk/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 { logger } from "~/lib/logger";
|
|
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
|
import { getAuth } from "@clerk/react-router/server";
|
|
import { getTeamQueue } from "~/models/draft-queue";
|
|
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 } from "~/lib/draft-order";
|
|
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
|
|
import { useMediaQuery } from "~/hooks/useMediaQuery";
|
|
import { NotificationSettings } from "~/components/NotificationSettings";
|
|
import { AutodraftSettings, getAutodraftLabel, 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` }];
|
|
}
|
|
|
|
type QueueItem = typeof schema.draftQueue.$inferSelect;
|
|
|
|
type AutodraftStatusEntry = {
|
|
isEnabled: boolean;
|
|
mode: "next_pick" | "while_on";
|
|
queueOnly: boolean;
|
|
};
|
|
|
|
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 { userId } = await getAuth(args);
|
|
|
|
if (!seasonId) {
|
|
throw new Response("Season ID is required", { status: 400 });
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
// Resolve user's team and commissioner status (null userId means neither)
|
|
const userTeam = userId
|
|
? season.teams.find((team) => team.ownerId === userId)
|
|
: undefined;
|
|
|
|
const isCommissioner = userId
|
|
? await db.query.commissioners.findFirst({
|
|
where: and(
|
|
eq(schema.commissioners.leagueId, leagueId),
|
|
eq(schema.commissioners.userId, userId)
|
|
),
|
|
})
|
|
: null;
|
|
|
|
// Access control: if draft board is not public, require authentication and membership
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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
|
|
const availableParticipants = sportsSeasonIds.length > 0
|
|
? await db
|
|
.select({
|
|
id: schema.participants.id,
|
|
name: schema.participants.name,
|
|
vorpValue: schema.participants.vorpValue,
|
|
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.vorpValue),
|
|
asc(schema.participants.name)
|
|
)
|
|
: [];
|
|
|
|
// Load user's team queue if they have a team
|
|
const userQueue = userTeam
|
|
? await getTeamQueue(userTeam.id).catch((err) => {
|
|
logger.error("[DraftLoader] Failed to load queue, defaulting to empty:", err);
|
|
return [];
|
|
})
|
|
: [];
|
|
|
|
// 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),
|
|
});
|
|
|
|
// Derive user's autodraft settings from the already-loaded list
|
|
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 { userId: clerkUserId, getToken } = useAuth();
|
|
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]);
|
|
|
|
// Track current autodraft state for detecting server-initiated auto-disable
|
|
const [userAutodraft, setUserAutodraft] = useState({
|
|
isEnabled: userAutodraftSettings?.isEnabled || false,
|
|
mode: (userAutodraftSettings?.mode || "next_pick") as "next_pick" | "while_on",
|
|
queueOnly: userAutodraftSettings?.queueOnly || false,
|
|
});
|
|
const userAutodraftRef = useRef(userAutodraft);
|
|
useEffect(() => {
|
|
userAutodraftRef.current = userAutodraft;
|
|
}, [userAutodraft]);
|
|
|
|
// Re-fetch loader data after each reconnect so stale picks/timers are refreshed.
|
|
// Schedule a delayed retry so that if the first attempt fails (common on mobile
|
|
// where the network may not be fully restored yet), we get another chance.
|
|
const revalidationRetryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => {
|
|
if (reconnectCount > 0) {
|
|
revalidate();
|
|
// Clear any pending retry from a previous reconnect
|
|
if (revalidationRetryRef.current) {
|
|
clearTimeout(revalidationRetryRef.current);
|
|
}
|
|
revalidationRetryRef.current = setTimeout(() => {
|
|
revalidate();
|
|
revalidationRetryRef.current = null;
|
|
}, 3000);
|
|
}
|
|
return () => {
|
|
if (revalidationRetryRef.current) {
|
|
clearTimeout(revalidationRetryRef.current);
|
|
}
|
|
};
|
|
}, [reconnectCount, revalidate]);
|
|
|
|
// Guard against Clerk JWT expiry race: if the loader ran without a userId
|
|
// (expired short-lived JWT) but the Clerk client SDK has a valid session,
|
|
// revalidate so the loader re-runs with a fresh token and returns correct
|
|
// userTeam/userQueue data. This can happen repeatedly on mobile when the
|
|
// user backgrounds and foregrounds the app, so we don't limit it to a
|
|
// single attempt. Instead we debounce to avoid rapid-fire revalidations.
|
|
//
|
|
// Cap at 3 attempts to avoid an infinite loop if the server consistently
|
|
// fails to resolve a userId (e.g. clock skew, misconfigured Clerk keys).
|
|
// After 3 failures we fall through to the authDegraded overlay.
|
|
const authRecoveryTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
const authRecoveryAttemptsRef = useRef(0);
|
|
const MAX_AUTH_RECOVERY_ATTEMPTS = 3;
|
|
useEffect(() => {
|
|
if (clerkUserId && !currentUserId) {
|
|
if (authRecoveryAttemptsRef.current >= MAX_AUTH_RECOVERY_ATTEMPTS) {
|
|
setAuthDegraded(true);
|
|
return;
|
|
}
|
|
// Small debounce so we don't fire multiple times in quick succession
|
|
// (e.g. visibility change + socket reconnect both updating deps).
|
|
clearTimeout(authRecoveryTimerRef.current);
|
|
authRecoveryTimerRef.current = setTimeout(() => {
|
|
authRecoveryAttemptsRef.current += 1;
|
|
revalidate();
|
|
}, 100);
|
|
} else if (currentUserId) {
|
|
// Auth recovered — reset the counter for the next background cycle.
|
|
authRecoveryAttemptsRef.current = 0;
|
|
}
|
|
return () => clearTimeout(authRecoveryTimerRef.current);
|
|
}, [clerkUserId, currentUserId, revalidate]);
|
|
|
|
// Auth session protection: proactively refresh the Clerk JWT to prevent
|
|
// silent logout during long-running draft sessions.
|
|
const [authDegraded, setAuthDegraded] = useState(false);
|
|
|
|
// Fetch wrapper that detects 401 responses and shows the auth recovery
|
|
// overlay instead of letting each handler deal with it individually.
|
|
// Returns null on 401 so callers can bail with `if (!response) return;`.
|
|
const authFetch = useCallback(async (url: string, init?: RequestInit): Promise<Response | null> => {
|
|
const response = await fetch(url, init);
|
|
if (response.status === 401) {
|
|
setAuthDegraded(true);
|
|
return null;
|
|
}
|
|
return response;
|
|
}, []);
|
|
|
|
// Refresh the Clerk JWT when the tab becomes visible again. Browsers
|
|
// throttle/suspend intervals in background tabs, so the SDK's automatic
|
|
// 60-second refresh may not have run. Getting a fresh token here prevents
|
|
// the next user action from hitting a 401. After a successful refresh we
|
|
// also revalidate the loader so it re-runs with the fresh token — without
|
|
// this, userTeam/userQueue stay stale from the previous (possibly
|
|
// unauthenticated) loader run.
|
|
useEffect(() => {
|
|
// Nothing to do once auth is fully degraded — avoid re-registering a
|
|
// listener that would immediately exit on every future visibility change.
|
|
if (authDegraded) return;
|
|
|
|
let aborted = false;
|
|
let inFlight = false;
|
|
|
|
const handleVisibilityChange = async () => {
|
|
// Ignore hide events, and skip if another invocation is already running
|
|
// (rapid tab switching) or this effect has been cleaned up.
|
|
if (document.visibilityState !== "visible" || !clerkUserId || inFlight) return;
|
|
inFlight = true;
|
|
try {
|
|
const token = await getToken({ skipCache: true });
|
|
if (aborted) return;
|
|
if (token !== null) {
|
|
// Token refreshed successfully. Only revalidate if the loader is
|
|
// missing auth (currentUserId is null) — if the loader already has
|
|
// a valid user there's no stale state to fix and the extra round
|
|
// trip just adds unnecessary server load.
|
|
if (!currentUserId) {
|
|
revalidate();
|
|
}
|
|
} else {
|
|
// Retry after a longer delay — mobile networks may take a few
|
|
// seconds to fully wake up after background suspension.
|
|
await new Promise((r) => setTimeout(r, 2500));
|
|
if (aborted) return;
|
|
const retryToken = await getToken({ skipCache: true });
|
|
if (aborted) return;
|
|
if (retryToken !== null) {
|
|
revalidate();
|
|
} else {
|
|
setAuthDegraded(true);
|
|
}
|
|
}
|
|
} catch {
|
|
try {
|
|
await new Promise((r) => setTimeout(r, 2500));
|
|
if (aborted) return;
|
|
const retryToken = await getToken({ skipCache: true });
|
|
if (aborted) return;
|
|
if (retryToken !== null) {
|
|
revalidate();
|
|
} else {
|
|
setAuthDegraded(true);
|
|
}
|
|
} catch {
|
|
if (!aborted) setAuthDegraded(true);
|
|
}
|
|
} finally {
|
|
inFlight = false;
|
|
}
|
|
};
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
return () => {
|
|
aborted = true;
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
};
|
|
}, [clerkUserId, currentUserId, getToken, authDegraded, revalidate]);
|
|
|
|
// Track revalidation lifecycle to sync local state from fresh loader data.
|
|
//
|
|
// The problem: `picks` and `currentPick` are local state initialized once
|
|
// from useLoaderData(). When revalidate() runs after a reconnect, React
|
|
// Router re-fetches `draftPicks`/`season` — but those updates never flow
|
|
// back into the local useState copies automatically.
|
|
//
|
|
// Race condition: a `pick-made` socket event that arrives *during* the
|
|
// revalidation window would normally be applied to the stale prev state and
|
|
// then overwritten when we sync from the DB snapshot. We prevent that by
|
|
// buffering live picks while revalidation is in flight and merging them
|
|
// (deduplicated by ID) after the DB snapshot lands.
|
|
const revalidatorStateRef = useRef(revalidatorState);
|
|
const isRevalidatingRef = useRef(false);
|
|
const pendingPicksDuringRevalidationRef = useRef<(typeof draftPicks)[number][]>([]);
|
|
// Track the draftPicks reference when revalidation starts. If it hasn't
|
|
// changed by the time revalidation completes, the loader didn't return new
|
|
// data (e.g. network failure) and we should NOT overwrite state that may
|
|
// have been freshly set by draft-state-sync.
|
|
const draftPicksAtRevalidationStartRef = useRef(draftPicks);
|
|
// Same pattern for userQueue: only sync from loader if the reference changed,
|
|
// meaning the fetch actually returned fresh data. Prevents overwriting
|
|
// socket-applied queue changes (queue-updated, participant-removed-from-queues)
|
|
// with stale loader data when a revalidation fetch fails.
|
|
const userQueueAtRevalidationStartRef = useRef(userQueue);
|
|
// Tracks how many queue mutations are in-flight from this window. While > 0,
|
|
// incoming queue-updated socket events are ignored so that the server echo
|
|
// doesn't clobber a more-recent optimistic update (e.g. rapid drag-reorders).
|
|
// The initiating window uses the HTTP response as the authoritative state;
|
|
// other windows use the socket event.
|
|
const pendingQueueMutationsRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
const prev = revalidatorStateRef.current;
|
|
revalidatorStateRef.current = revalidatorState;
|
|
|
|
if (prev !== "loading" && revalidatorState === "loading") {
|
|
// Revalidation just started — buffer any live picks to avoid loss
|
|
isRevalidatingRef.current = true;
|
|
pendingPicksDuringRevalidationRef.current = [];
|
|
draftPicksAtRevalidationStartRef.current = draftPicks;
|
|
userQueueAtRevalidationStartRef.current = userQueue;
|
|
} else if (prev === "loading" && revalidatorState === "idle") {
|
|
// Revalidation just completed — merge DB snapshot with buffered picks
|
|
isRevalidatingRef.current = false;
|
|
|
|
// If draftPicks is the same reference as when revalidation started, the
|
|
// loader didn't actually update (likely a failed fetch). Skip the sync
|
|
// to avoid overwriting fresh draft-state-sync data with stale state.
|
|
if (draftPicks === draftPicksAtRevalidationStartRef.current) {
|
|
pendingPicksDuringRevalidationRef.current = [];
|
|
return;
|
|
}
|
|
|
|
const dbPickIds = new Set(draftPicks.map((p) => p.id));
|
|
const missedPicks = pendingPicksDuringRevalidationRef.current.filter(
|
|
(p) => !dbPickIds.has(p.id)
|
|
);
|
|
pendingPicksDuringRevalidationRef.current = [];
|
|
|
|
setPicks([...draftPicks, ...missedPicks]);
|
|
setCurrentPick(season.currentPickNumber || 1);
|
|
setIsPaused(season.draftPaused || false);
|
|
setIsDraftComplete(
|
|
season.status === "active" || season.status === "completed"
|
|
);
|
|
// Sync the queue if the loader returned fresh data for it. Using reference
|
|
// equality (same pattern as draftPicks above): drizzle always returns a new
|
|
// array on a successful fetch, so a changed reference means the fetch
|
|
// succeeded and we can trust the value. A same reference means the fetch
|
|
// failed and we must NOT overwrite socket-applied changes (queue-updated,
|
|
// participant-removed-from-queues) with stale data.
|
|
//
|
|
// Guard: skip if the loader ran unauthenticated (currentUserId null) to avoid
|
|
// overwriting with an empty userQueue returned for an expired JWT session.
|
|
if (currentUserId && userQueue !== userQueueAtRevalidationStartRef.current) {
|
|
setQueue(userQueue);
|
|
}
|
|
// Sync timers — the server-side timer state may have changed while
|
|
// we were disconnected (time bank adjustments, new timers, etc.)
|
|
if (timers.length > 0) {
|
|
setTeamTimers((currentTimers) => {
|
|
const updated = { ...currentTimers };
|
|
timers.forEach((timer) => {
|
|
updated[timer.teamId] = timer.timeRemaining;
|
|
});
|
|
return updated;
|
|
});
|
|
}
|
|
// Sync autodraft settings — teams may have toggled autodraft while
|
|
// we were away.
|
|
setAutodraftStatus(() => {
|
|
const status: Record<string, AutodraftStatusEntry> = {};
|
|
autodraftSettings.forEach((setting) => {
|
|
status[setting.teamId] = {
|
|
isEnabled: setting.isEnabled,
|
|
mode: setting.mode,
|
|
queueOnly: setting.queueOnly,
|
|
};
|
|
});
|
|
return status;
|
|
});
|
|
}
|
|
}, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings, currentUserId]);
|
|
|
|
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 [hideCompletedSports, setHideCompletedSports] = useState(false);
|
|
const [sportFilters, setSportFilters] = useState<string[]>([]);
|
|
const [queue, setQueue] = useState<QueueItem[]>(userQueue);
|
|
const [isPaused, setIsPaused] = useState(season.draftPaused || false);
|
|
const [isDraftComplete, setIsDraftComplete] = useState(
|
|
season.status === "active" || season.status === "completed"
|
|
);
|
|
|
|
// 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" | "rosters" | "summary">("participants");
|
|
const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "teams" | "controls">(
|
|
!userTeam && isCommissioner ? "board" : "available"
|
|
);
|
|
const [teamsSubTab, setTeamsSubTab] = useState<"rosters" | "summary">("rosters");
|
|
const [boardSubTab, setBoardSubTab] = useState<"board" | "pick-list">("board");
|
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
|
|
|
// Track autodraft status for all teams
|
|
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, AutodraftStatusEntry>>(() => {
|
|
const status: Record<string, AutodraftStatusEntry> = {};
|
|
autodraftSettings.forEach((setting) => {
|
|
status[setting.teamId] = {
|
|
isEnabled: setting.isEnabled,
|
|
mode: setting.mode,
|
|
queueOnly: setting.queueOnly,
|
|
};
|
|
});
|
|
return status;
|
|
});
|
|
|
|
// Track connected teams
|
|
const [connectedTeams, setConnectedTeams] = useState<Set<string>>(new Set());
|
|
|
|
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) => {
|
|
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);
|
|
// Replace pick dialog state
|
|
const [replacePickDialogOpen, setReplacePickDialogOpen] = useState(false);
|
|
const [replacePickSlot, setReplacePickSlot] = useState<{
|
|
pickNumber: number;
|
|
teamId: string;
|
|
oldParticipantId: string;
|
|
} | null>(null);
|
|
|
|
// Rollback confirm dialog state
|
|
const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false);
|
|
const [rollbackPickNumber, setRollbackPickNumber] = useState<number | null>(null);
|
|
const [isRollingBack, setIsRollingBack] = useState(false);
|
|
|
|
// Time bank adjustment dialog state
|
|
const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false);
|
|
const [timeBankTeamId, setTimeBankTeamId] = useState<string | null>(null);
|
|
const [commissionerAutodraftDialogOpen, setCommissionerAutodraftDialogOpen] = useState(false);
|
|
const [commissionerAutodraftTeamId, setCommissionerAutodraftTeamId] = useState<string | null>(null);
|
|
const [timeBankAmount, setTimeBankAmount] = useState("1");
|
|
const [timeBankUnit, setTimeBankUnit] = useState<"seconds" | "minutes" | "hours">("minutes");
|
|
const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add");
|
|
const [isAdjustingTimeBank, setIsAdjustingTimeBank] = useState(false);
|
|
|
|
// 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]);
|
|
|
|
// Listen for new picks from other users
|
|
useEffect(() => {
|
|
type PickMadePayload = { pick: (typeof draftPicks)[number] & { team?: { id?: string; name?: string }; participant?: { name?: string } }; nextPickNumber: number; isDraftComplete?: boolean };
|
|
const handlePickMade = (data: PickMadePayload) => {
|
|
if (isRevalidatingRef.current) {
|
|
// Buffer this pick; the revalidation-complete handler will merge it
|
|
// into the DB snapshot so it isn't silently dropped.
|
|
// Don't update currentPick either — the sync effect will set it from
|
|
// fresh season.currentPickNumber once loading → idle, keeping the
|
|
// pick counter and the picks list in sync with each other.
|
|
pendingPicksDuringRevalidationRef.current.push(data.pick);
|
|
} else {
|
|
setPicks((prev) => [...prev, data.pick]);
|
|
setCurrentPick(data.nextPickNumber);
|
|
}
|
|
|
|
// No meaningful "next turn" notification once the draft is over
|
|
if (data.isDraftComplete) return;
|
|
|
|
const notifTitle = `${season.league.name} Draft`;
|
|
const nextSlot = getTeamForPick(data.nextPickNumber, draftSlots);
|
|
const isNowMyTurn = !!(
|
|
userTeam && nextSlot && nextSlot.team.id === userTeam.id
|
|
);
|
|
|
|
if (isNowMyTurn) {
|
|
sendNotificationRef.current(notifTitle, `It's your turn to pick!`);
|
|
} else if (
|
|
notificationsModeRef.current === "all_picks" &&
|
|
data.pick?.team?.id !== userTeam?.id
|
|
) {
|
|
// Only notify about other teams' picks, not the user's own
|
|
const pickerName = data.pick?.team?.name || "A team";
|
|
const participantName = data.pick?.participant?.name || "a participant";
|
|
sendNotificationRef.current(
|
|
notifTitle,
|
|
`${pickerName} picked ${participantName}`
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleTimerUpdate = (data: { teamId: string; timeRemaining: number; currentPickNumber: number | null }) => {
|
|
// Bail out without creating a new object reference if nothing changed.
|
|
// Without this guard the spread `{ ...prev }` always returns a new object,
|
|
// causing a full re-render of the DraftRoom tree on every 1-second tick.
|
|
setTeamTimers((prev) => {
|
|
if (prev[data.teamId] === data.timeRemaining) return prev;
|
|
return { ...prev, [data.teamId]: data.timeRemaining };
|
|
});
|
|
// Also sync the current pick number from the server. This is critical
|
|
// for mobile reconnection: timer-update events arrive every second, so
|
|
// this ensures currentPick is correct within 1s of reconnecting — even
|
|
// if the HTTP revalidation hasn't completed yet.
|
|
// React's useState already bails out when the new value equals the old
|
|
// one (Object.is comparison on primitives), so no manual guard needed.
|
|
if (data.currentPickNumber !== null) {
|
|
setCurrentPick(data.currentPickNumber);
|
|
}
|
|
};
|
|
|
|
const handleDraftPaused = () => {
|
|
setIsPaused(true);
|
|
};
|
|
|
|
const handleDraftResumed = () => {
|
|
setIsPaused(false);
|
|
};
|
|
|
|
const handleDraftCompleted = () => {
|
|
setIsDraftComplete(true);
|
|
};
|
|
|
|
const handleAutodraftUpdated = (data: {
|
|
teamId: string;
|
|
isEnabled: boolean;
|
|
mode: "next_pick" | "while_on";
|
|
queueOnly: boolean;
|
|
source?: "commissioner" | "user";
|
|
}) => {
|
|
setAutodraftStatus((prev) => ({
|
|
...prev,
|
|
[data.teamId]: { isEnabled: data.isEnabled, mode: data.mode, queueOnly: data.queueOnly },
|
|
}));
|
|
|
|
// Update user's local state if it's their team
|
|
if (userTeam && data.teamId === userTeam.id) {
|
|
if (data.source === "commissioner") {
|
|
if (!data.isEnabled) {
|
|
toast.info("Commissioner turned off your autodraft");
|
|
} else {
|
|
toast.info(`Commissioner changed your autodraft to "${getAutodraftLabel(data.isEnabled, data.mode, data.queueOnly)}"`);
|
|
}
|
|
} else {
|
|
// Detect server-side auto-disable: was enabled with queueOnly, now disabled
|
|
const prev = userAutodraftRef.current;
|
|
if (!data.isEnabled && prev.isEnabled && prev.queueOnly && prev.mode === "while_on") {
|
|
toast.info("Autodraft disabled — your queue is empty");
|
|
}
|
|
}
|
|
|
|
setUserAutodraft({
|
|
isEnabled: data.isEnabled,
|
|
mode: data.mode,
|
|
queueOnly: data.queueOnly,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleTeamConnected = (data: { teamId: string }) => {
|
|
setConnectedTeams((prev) => new Set(prev).add(data.teamId));
|
|
};
|
|
|
|
const handleTeamDisconnected = (data: { teamId: string }) => {
|
|
setConnectedTeams((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(data.teamId);
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
const handleConnectedTeamsList = (data: { teamIds: string[] }) => {
|
|
setConnectedTeams(new Set(data.teamIds));
|
|
};
|
|
|
|
const handleParticipantRemovedFromQueues = (data: { participantId: string }) => {
|
|
setQueue((prev) =>
|
|
prev.filter((item) => item.participantId !== data.participantId)
|
|
);
|
|
};
|
|
|
|
const handleQueueEligibilityPruned = (data: { teamId: string; removedParticipantIds: string[] }) => {
|
|
if (data.teamId !== userTeam?.id) return;
|
|
const removed = new Set(data.removedParticipantIds);
|
|
setQueue((prev) => prev.filter((item) => !removed.has(item.participantId)));
|
|
};
|
|
|
|
// Fired when another window/session changes the user's queue (add/remove/reorder/clear).
|
|
// Only received by this team's own sockets (emitted to team-${teamId} private room).
|
|
// Ignored while this window has in-flight mutations to prevent the server echo
|
|
// from clobbering a more-recent optimistic update.
|
|
const handleQueueUpdated = (data: { queue: QueueItem[] }) => {
|
|
if (pendingQueueMutationsRef.current > 0) return;
|
|
setQueue(data.queue);
|
|
};
|
|
|
|
const handlePickReplaced = (data: { pickNumber: number; pick: (typeof draftPicks)[number] }) => {
|
|
setPicks((prev) =>
|
|
prev.map((p) => (p.pickNumber === data.pickNumber ? data.pick : p))
|
|
);
|
|
};
|
|
|
|
const handleDraftRolledBack = (data: { pickNumber: number }) => {
|
|
setPicks((prev) =>
|
|
prev.filter((p) => p.pickNumber < data.pickNumber)
|
|
);
|
|
setCurrentPick(data.pickNumber);
|
|
setIsDraftComplete(false);
|
|
setIsPaused(false);
|
|
};
|
|
|
|
// Full state sync sent by the server on join-draft. Provides an immediate,
|
|
// socket-based path to bring the client up to date — critical for mobile
|
|
// reconnection where the HTTP revalidation may be delayed or fail entirely.
|
|
const handleDraftStateSync = (data: { picks: (typeof draftPicks)[number][]; currentPickNumber: number; isPaused: boolean; status: string; timers?: Array<{ teamId: string; timeRemaining: number }>; queue?: QueueItem[] }) => {
|
|
// If a revalidation is in-flight, let it complete and handle the merge.
|
|
// The revalidation path already buffers picks and deduplicates — layering
|
|
// this on top would risk conflicts. But if NOT revalidating, apply the
|
|
// server snapshot directly.
|
|
if (!isRevalidatingRef.current) {
|
|
setPicks(data.picks);
|
|
setCurrentPick(data.currentPickNumber);
|
|
setIsPaused(data.isPaused);
|
|
setIsDraftComplete(
|
|
data.status === "active" || data.status === "completed"
|
|
);
|
|
if (data.timers) {
|
|
setTeamTimers((prev) => {
|
|
const updated = { ...prev };
|
|
data.timers?.forEach((t) => {
|
|
updated[t.teamId] = t.timeRemaining;
|
|
});
|
|
return updated;
|
|
});
|
|
}
|
|
if (data.queue) {
|
|
setQueue(data.queue);
|
|
}
|
|
}
|
|
};
|
|
|
|
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);
|
|
on("queue-eligibility-pruned", handleQueueEligibilityPruned);
|
|
on("queue-updated", handleQueueUpdated);
|
|
on("pick-replaced", handlePickReplaced);
|
|
on("draft-rolled-back", handleDraftRolledBack);
|
|
on("draft-state-sync", handleDraftStateSync);
|
|
|
|
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);
|
|
off("queue-eligibility-pruned", handleQueueEligibilityPruned);
|
|
off("queue-updated", handleQueueUpdated);
|
|
off("pick-replaced", handlePickReplaced);
|
|
off("draft-rolled-back", handleDraftRolledBack);
|
|
off("draft-state-sync", handleDraftStateSync);
|
|
};
|
|
// `on`/`off` are stable useCallback refs. `socketVersion` increments whenever
|
|
// useDraftSocket creates a new socket instance, ensuring this effect re-runs
|
|
// and re-registers all handlers on the fresh socket.
|
|
// `draftSlots`, `userTeam`, and `season.league.name` are intentionally omitted:
|
|
// they come from useLoaderData and are immutable for the lifetime of the page load.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [on, off, socketVersion]);
|
|
|
|
// 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]);
|
|
|
|
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]);
|
|
|
|
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]);
|
|
|
|
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]);
|
|
|
|
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);
|
|
}, []);
|
|
|
|
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);
|
|
}, []);
|
|
|
|
const handleSetAutodraftOpen = useCallback((teamId: string) => {
|
|
setCommissionerAutodraftTeamId(teamId);
|
|
setCommissionerAutodraftDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleCommissionerAutodraftUpdate = useCallback(() => {
|
|
setCommissionerAutodraftDialogOpen(false);
|
|
setCommissionerAutodraftTeamId(null);
|
|
}, []);
|
|
|
|
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);
|
|
}, []);
|
|
|
|
// 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]);
|
|
|
|
// 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]);
|
|
|
|
const miniDraftGrid = useMemo(() => ({
|
|
draftSlots,
|
|
draftGrid,
|
|
currentPick,
|
|
currentRound,
|
|
ownerMap,
|
|
teamTimers,
|
|
autodraftStatus,
|
|
seasonStatus: season.status,
|
|
draftPaused: isPaused,
|
|
onForceAutopick: isCommissioner ? handleForceAutopick : undefined,
|
|
onForceManualPickOpen: isCommissioner ? handleForceManualPickOpen : undefined,
|
|
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
|
|
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
|
|
}), [draftSlots, draftGrid, currentPick, currentRound, ownerMap, teamTimers, autodraftStatus, season.status, isPaused, isCommissioner, 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,
|
|
}), [filteredParticipants, participantRanks, miniDraftGrid, searchQuery, sportFilters, hideDrafted, hideIneligible, uniqueSports, draftedParticipantIds, queue, eligibility, canPick, userTeam, hideCompletedSports, userDraftedSportNames, handleHideCompletedSportsChange, handleMakePick, handleAddToQueue, handleRemoveFromQueue]);
|
|
|
|
const draftGridSectionProps = {
|
|
draftSlots,
|
|
draftGrid,
|
|
currentPick,
|
|
teamTimers,
|
|
autodraftStatus,
|
|
connectedTeams,
|
|
isCommissioner,
|
|
ownerMap,
|
|
seasonStatus: season.status,
|
|
draftPaused: isPaused,
|
|
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
|
|
onSetAutodraftOpen: isCommissioner ? handleSetAutodraftOpen : undefined,
|
|
onForceAutopick: handleForceAutopick,
|
|
onForceManualPickOpen: handleForceManualPickOpen,
|
|
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 });
|
|
},
|
|
[]
|
|
);
|
|
|
|
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 && season.status === "pre_draft" && (
|
|
<Button className="hidden md:flex" onClick={handleStartDraft}>Start Draft</Button>
|
|
)}
|
|
|
|
{isCommissioner && season.status === "draft" && !isDraftComplete && (
|
|
<div className="hidden md:flex">
|
|
{isPaused ? (
|
|
<Button onClick={handleResumeDraft} variant="default">
|
|
Resume Draft
|
|
</Button>
|
|
) : (
|
|
<Button onClick={handlePauseDraft} variant="outline">
|
|
Pause Draft
|
|
</Button>
|
|
)}
|
|
</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 */}
|
|
<Dialog
|
|
open={rollbackConfirmOpen}
|
|
onOpenChange={(open) => {
|
|
setRollbackConfirmOpen(open);
|
|
if (!open) setRollbackPickNumber(null);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Roll Back Draft</DialogTitle>
|
|
<DialogDescription>
|
|
{rollbackPickNumber !== null && (() => {
|
|
const count = picks.filter((p) => p.pickNumber >= rollbackPickNumber).length;
|
|
return `This will erase ${count} pick${count === 1 ? "" : "s"} (pick #${rollbackPickNumber} through the most recent) and return the draft to pick #${rollbackPickNumber}. This cannot be undone.`;
|
|
})()}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setRollbackConfirmOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleConfirmRollback} disabled={isRollingBack}>
|
|
{isRollingBack ? "Rolling Back..." : "Roll Back"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Time Bank Adjustment Dialog */}
|
|
<Dialog
|
|
open={timeBankDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setTimeBankDialogOpen(open);
|
|
if (!open) setTimeBankTeamId(null);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Adjust Time Bank</DialogTitle>
|
|
<DialogDescription>
|
|
{timeBankTeamId
|
|
? `Adjusting time for ${draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name}`
|
|
: "Adjust a team's time bank"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col gap-4 py-2">
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setTimeBankDirection("add")}
|
|
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
|
|
timeBankDirection === "add"
|
|
? "bg-emerald-500/20 border-emerald-500 text-emerald-400"
|
|
: "border-border text-muted-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
Add Time
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTimeBankDirection("remove")}
|
|
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
|
|
timeBankDirection === "remove"
|
|
? "bg-destructive/20 border-destructive text-destructive"
|
|
: "border-border text-muted-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
Remove Time
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="number"
|
|
min="0.001"
|
|
step="any"
|
|
value={timeBankAmount}
|
|
onChange={(e) => setTimeBankAmount(e.target.value)}
|
|
className="flex-1 px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm"
|
|
placeholder="Amount"
|
|
/>
|
|
<select
|
|
value={timeBankUnit}
|
|
onChange={(e) =>
|
|
setTimeBankUnit(e.target.value as "seconds" | "minutes" | "hours")
|
|
}
|
|
className="px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm"
|
|
>
|
|
<option value="seconds">Seconds</option>
|
|
<option value="minutes">Minutes</option>
|
|
<option value="hours">Hours</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setTimeBankDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleConfirmAdjustTimeBank}
|
|
disabled={isAdjustingTimeBank}
|
|
variant={timeBankDirection === "remove" ? "destructive" : "default"}
|
|
>
|
|
{isAdjustingTimeBank
|
|
? "Adjusting..."
|
|
: timeBankDirection === "add"
|
|
? "Add Time"
|
|
: "Remove Time"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Commissioner Autodraft Override Dialog */}
|
|
<Dialog
|
|
open={commissionerAutodraftDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setCommissionerAutodraftDialogOpen(open);
|
|
if (!open) setCommissionerAutodraftTeamId(null);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Set Autodraft</DialogTitle>
|
|
<DialogDescription>
|
|
{commissionerAutodraftTeamId
|
|
? `Change autodraft for ${draftSlots.find((s) => s.team.id === commissionerAutodraftTeamId)?.team.name ?? "this team"}`
|
|
: "Set a team's autodraft settings"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{commissionerAutodraftTeamId && (
|
|
<AutodraftSettings
|
|
seasonId={season.id}
|
|
teamId={commissionerAutodraftTeamId}
|
|
isEnabled={autodraftStatus[commissionerAutodraftTeamId]?.isEnabled ?? false}
|
|
mode={autodraftStatus[commissionerAutodraftTeamId]?.mode ?? "next_pick"}
|
|
queueOnly={autodraftStatus[commissionerAutodraftTeamId]?.queueOnly ?? false}
|
|
isMyTurn={false}
|
|
onUpdate={handleCommissionerAutodraftUpdate}
|
|
/>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|