* Migrate authentication from Clerk to BetterAuth (#322) Replaces @clerk/react-router with self-hosted better-auth to eliminate the external Clerk dependency and keep all user/session data in our own PostgreSQL database. **What changed** - New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler - New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param - New: UserMenu component replacing Clerk's UserButton - Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable - Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9) - All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin - root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query) - useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check - models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern) - Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix - scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts) - scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export - BETTERAUTH_MIGRATION.md: dev and production runbooks - All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server - Test fixtures: added emailVerified field **Follow-up (post-stable)** - Rename actor_clerk_id column → actor_user_id in commissioner_audit_log - Drop clerk_id column from users once migration confirmed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1. The adapter works correctly at runtime with our versions — the peer dep is only for stricter type checking. This unblocks npm ci in CI without a risky drizzle major-version upgrade. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1458 lines
51 KiB
TypeScript
1458 lines
51 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,
|
|
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,
|
|
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: 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 });
|
|
},
|
|
[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>
|
|
);
|
|
}
|