* Add pre-draft queue builder so users can rank players before draft order is set Creates a new /leagues/:leagueId/draft-queue/:seasonId page that lets team owners browse participants by VORP and build their autopick queue during the pre_draft phase. The queue uses the existing draftQueue table so it carries seamlessly into the live draft room. Adds a "Build Your Queue" button to DraftInfoCard that shows only when draft order has not been set yet (replaced by "Enter Draft Room" once it is). https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG * Improve pre-draft rankings UX: mobile tabs, add/remove toggle, rename On mobile, show tabs ("All Players" / "My Rankings") instead of a stacked layout that buries the queue below a long participant list. On the All Players list, the button now toggles between Add and Remove so users never need to switch tabs just to drop someone. Desktop keeps the side-by-side panel layout. Also renames the feature throughout from "queue builder" to "pre-draft rankings" and the DraftInfoCard button to "Set Pre-Draft Rankings". https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG * Fix all code review issues in pre-draft rankings feature - Replace useFetcher with direct fetch() for add/remove/reorder so rapid clicks no longer cancel in-flight requests - Add toast.error() on all failure paths (matching live draft room pattern) and revert optimistic state when operations fail - Add useRef to give handleReorder a stable reference without a localQueue closure dependency, preventing unnecessary QueueSection re-renders - Convert allPlayersPanel and rankingsPanel to useMemo - Remove leagueId from loader return; read from useParams() instead - Extract queueBuilderHref to a variable in the league home component - Add emptyMessage prop to QueueSection with a correct message for the pre-draft context ("Add players from All Players...") - Add draft-queue-access.test.ts covering all loader access control paths: 401/403 errors, status-based redirects, and redirect URL correctness https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG * Fix lint: use !== null check instead of != null oxlint enforces eqeqeq; replace != null with !== null && !== undefined. https://claude.ai/code/session_01Gu2DkTWL3nv74EMuGPpxhG --------- Co-authored-by: Claude <noreply@anthropic.com>
339 lines
14 KiB
TypeScript
339 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { Link, useSearchParams } from "react-router";
|
|
import { toast } from "sonner";
|
|
import type { Route } from "./+types/$leagueId";
|
|
|
|
import { Link2 } from "lucide-react";
|
|
import { Button } from "~/components/ui/button";
|
|
import { GradientIcon } from "~/components/ui/GradientIcon";
|
|
import { CommissionersPanel } from "~/components/league/CommissionersPanel";
|
|
import { TeamsPanel } from "~/components/league/TeamsPanel";
|
|
import { DraftInfoCard } from "~/components/league/DraftInfoCard";
|
|
import { SportsSeasonsSummary } from "~/components/league/SportsSeasonsSummary";
|
|
import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard";
|
|
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
|
import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview";
|
|
import { TimezonePromptBanner } from "~/components/league/TimezonePromptBanner";
|
|
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
return [{ title: `${data?.league?.name ?? "League"} - Brackt` }];
|
|
}
|
|
|
|
export { loader } from "./$leagueId.server";
|
|
|
|
export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
|
const {
|
|
league,
|
|
season,
|
|
teams,
|
|
commissioners,
|
|
currentUserId,
|
|
currentUserTimezone,
|
|
isUserCommissioner,
|
|
ownerMap,
|
|
ownerAvatarDataByUserId,
|
|
commissionerMap,
|
|
availableTeamCount,
|
|
sportsCount,
|
|
isDraftOrderSet,
|
|
draftSlots,
|
|
sportsSeasons,
|
|
standings,
|
|
origin,
|
|
upcomingCalendarEvents,
|
|
recentActivity,
|
|
showTimezoneBanner,
|
|
} = loaderData;
|
|
|
|
const myTeam = teams.find((t) => t.ownerId === currentUserId);
|
|
// 1-based pick position; undefined if myTeam isn't in the slots (order not yet set, or no team)
|
|
const userDraftPosition = myTeam
|
|
? draftSlots.findIndex((s) => s.team.id === myTeam.id) + 1 || undefined
|
|
: undefined;
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
// Guard against double-firing toasts (e.g. React Strict Mode double-invoke)
|
|
const processedParamsRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
const paramString = searchParams.toString();
|
|
if (!paramString || paramString === processedParamsRef.current) return;
|
|
|
|
if (searchParams.get("updated") === "true") {
|
|
processedParamsRef.current = paramString;
|
|
toast.success("Team updated successfully");
|
|
setSearchParams({});
|
|
} else if (searchParams.get("joined") === "true") {
|
|
processedParamsRef.current = paramString;
|
|
toast.success("Welcome to the league! You've been assigned a team.");
|
|
setSearchParams({});
|
|
} else if (searchParams.get("left") === "true") {
|
|
processedParamsRef.current = paramString;
|
|
toast.success("You have left the league. Your team is now available.");
|
|
setSearchParams({});
|
|
}
|
|
}, [searchParams, setSearchParams]);
|
|
|
|
const handleCopyInviteLink = async () => {
|
|
if (!season?.inviteCode) return;
|
|
|
|
const inviteUrl = `${origin}/i/${season.inviteCode}`;
|
|
try {
|
|
await navigator.clipboard.writeText(inviteUrl);
|
|
setCopied(true);
|
|
toast.success("Invite link copied to clipboard!");
|
|
setTimeout(() => setCopied(false), 2000);
|
|
} catch {
|
|
toast.error("Failed to copy invite link");
|
|
}
|
|
};
|
|
|
|
// Pre-compute standings map to avoid O(n²) lookups in render
|
|
const standingsMap = new Map(standings.map((s) => [s.teamId, s]));
|
|
|
|
const isTiedRank = buildTiedRankChecker(standings.map((s) => s.currentRank));
|
|
|
|
// Pre-compute sorted standings list
|
|
const sortedTeams = [...teams].toSorted((a, b) => {
|
|
const rankA = standingsMap.get(a.id)?.currentRank ?? Infinity;
|
|
const rankB = standingsMap.get(b.id)?.currentRank ?? Infinity;
|
|
return rankA - rankB;
|
|
});
|
|
|
|
const standingsEntries: StandingsPreviewEntry[] = sortedTeams.map((team) => {
|
|
const standing = standingsMap.get(team.id);
|
|
const ownerAvatarData = team.ownerId ? (ownerAvatarDataByUserId[team.ownerId] ?? null) : null;
|
|
return {
|
|
teamId: team.id,
|
|
teamName: team.name,
|
|
ownerName: team.ownerId ? ownerMap[team.ownerId] : null,
|
|
logoUrl: team.logoUrl,
|
|
flagConfig: team.flagConfig,
|
|
avatarType: team.avatarType,
|
|
ownerAvatarData,
|
|
displayRank: getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false),
|
|
currentRank: standing?.currentRank,
|
|
points: standing ? (standing.actualPoints ?? standing.totalPoints) : 0,
|
|
href: season ? `/leagues/${league.id}/standings/${season.id}/teams/${team.id}` : undefined,
|
|
rankChange: standing?.rankChange,
|
|
pointChange: standing?.sevenDayPointChange,
|
|
};
|
|
});
|
|
|
|
// Pre-compute sorted sports seasons: active → upcoming → completed,
|
|
// season_standings last within active, then alphabetical by sport name
|
|
const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
|
|
const statusOrder = { active: 0, upcoming: 1, completed: 2 } as const;
|
|
const statusDiff = statusOrder[a.status] - statusOrder[b.status];
|
|
if (statusDiff !== 0) return statusDiff;
|
|
if (a.status === "active") {
|
|
const aSeasonLong = a.scoringPattern === "season_standings" ? 1 : 0;
|
|
const bSeasonLong = b.scoringPattern === "season_standings" ? 1 : 0;
|
|
if (aSeasonLong !== bSeasonLong) return aSeasonLong - bSeasonLong;
|
|
}
|
|
return a.sport.name.localeCompare(b.sport.name);
|
|
});
|
|
|
|
const isDraftOrPreDraft = season?.status === "pre_draft" || season?.status === "draft";
|
|
const isActiveOrCompleted = season?.status === "active" || season?.status === "completed";
|
|
|
|
return (
|
|
<div className="container mx-auto py-8 px-4">
|
|
{showTimezoneBanner && <TimezonePromptBanner />}
|
|
<div className="mb-8">
|
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
|
|
<div>
|
|
<h1 className="text-4xl font-bold mb-2">{league.name}</h1>
|
|
{season && (
|
|
<p className="text-muted-foreground">
|
|
{season.year} Season •{" "}
|
|
<span className="capitalize">
|
|
{season.status.replace("_", " ")}
|
|
</span>
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2 shrink-0">
|
|
{myTeam && (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link to={`/teams/${myTeam.id}/settings`}>
|
|
Team Settings
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
{isUserCommissioner && (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<Link to={`/leagues/${league.id}/settings`}>
|
|
League Settings
|
|
</Link>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{season?.status === "draft" && (
|
|
<div className="mb-6 flex items-center justify-between gap-4 rounded-lg border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-yellow-700 dark:text-yellow-400">
|
|
<div className="flex items-center gap-2">
|
|
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-yellow-500 opacity-75" />
|
|
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-yellow-500" />
|
|
</span>
|
|
<span className="font-medium">Draft is live!</span>
|
|
<span className="hidden text-sm sm:inline">
|
|
Your league draft is currently in progress.
|
|
</span>
|
|
</div>
|
|
<Button asChild size="sm" variant="outline" className="shrink-0 border-yellow-500/60 text-yellow-700 hover:bg-yellow-500/20 dark:text-yellow-400">
|
|
<Link to={`/leagues/${league.id}/draft/${season.id}`}>
|
|
Enter Draft Room
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-6 md:grid-cols-3">
|
|
{/* Left Column - 2/3 width on desktop */}
|
|
<div className="md:col-span-2 space-y-6">
|
|
{/* Draft Info card - pre_draft/draft only */}
|
|
{season && isDraftOrPreDraft && (
|
|
<DraftInfoCard
|
|
draftRounds={season.draftRounds}
|
|
draftTimerMode={season.draftTimerMode}
|
|
draftInitialTime={season.draftInitialTime}
|
|
draftIncrementTime={season.draftIncrementTime}
|
|
sportsCount={sportsCount}
|
|
isDraftOrderSet={isDraftOrderSet}
|
|
isDraftOrPreDraft={isDraftOrPreDraft}
|
|
draftDateTime={season.draftDateTime}
|
|
draftTimezone={currentUserTimezone ?? season.overnightPauseTimezone}
|
|
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
|
|
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
|
|
queueBuilderHref={
|
|
myTeam && season.status === "pre_draft"
|
|
? `/leagues/${league.id}/draft-queue/${season.id}`
|
|
: undefined
|
|
}
|
|
userDraftPosition={userDraftPosition}
|
|
overnightPauseMode={season.overnightPauseMode}
|
|
overnightPauseStart={season.overnightPauseStart}
|
|
overnightPauseEnd={season.overnightPauseEnd}
|
|
overnightPauseTimezone={season.overnightPauseTimezone}
|
|
/>
|
|
)}
|
|
|
|
{/* Standings Panel - active/completed seasons */}
|
|
{season && isActiveOrCompleted && (
|
|
<StandingsPreview
|
|
entries={standingsEntries}
|
|
description={season.status === "completed" ? "Final standings" : undefined}
|
|
fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
|
|
/>
|
|
)}
|
|
|
|
{/* Sports Seasons Section */}
|
|
{season && sortedSportsSeasons.length > 0 && (
|
|
<SportsSeasonsSummary
|
|
leagueId={league.id}
|
|
sportsSeasons={sortedSportsSeasons.map((ss) => ({
|
|
id: ss.id,
|
|
sportName: ss.sport.name,
|
|
sportIconUrl: ss.sport.iconUrl,
|
|
seasonName: ss.name,
|
|
status: ss.status,
|
|
scoringPattern: ss.scoringPattern,
|
|
upcomingParticipantEvents: ss.upcomingParticipantEvents,
|
|
draftedParticipants: ss.draftedParticipantsWithPoints,
|
|
}))}
|
|
/>
|
|
)}
|
|
|
|
</div>
|
|
|
|
{/* Right Column - 1/3 width on desktop */}
|
|
<div className="space-y-14">
|
|
{/* Teams - shown above invite link when league is full */}
|
|
{season && isDraftOrPreDraft && availableTeamCount === 0 && (
|
|
<TeamsPanel teams={teams} currentUserId={currentUserId} ownerMap={ownerMap} draftSlots={draftSlots} />
|
|
)}
|
|
|
|
{/* Invite Link - bare, pre_draft commissioner only */}
|
|
{isUserCommissioner && season?.status === "pre_draft" && availableTeamCount > 0 && (
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<GradientIcon icon={Link2} className="h-5 w-5 shrink-0" />
|
|
<span className="text-xl font-bold leading-none tracking-tight">Invite Link</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
{availableTeamCount} spot{availableTeamCount !== 1 ? "s" : ""} remaining
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
readOnly
|
|
value={`${origin}/i/${season.inviteCode}`}
|
|
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted"
|
|
onClick={(e) => e.currentTarget.select()}
|
|
/>
|
|
<Button
|
|
onClick={handleCopyInviteLink}
|
|
variant={copied ? "default" : "outline"}
|
|
size="sm"
|
|
>
|
|
{copied ? "Copied!" : "Copy"}
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Anyone with this link can join your league
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Teams - shown below invite link when league is not full */}
|
|
{season && isDraftOrPreDraft && availableTeamCount > 0 && (
|
|
<TeamsPanel teams={teams} currentUserId={currentUserId} ownerMap={ownerMap} draftSlots={draftSlots} />
|
|
)}
|
|
|
|
{upcomingCalendarEvents.length > 0 && (
|
|
<UpcomingEventsCard
|
|
events={upcomingCalendarEvents}
|
|
title="Upcoming Events"
|
|
limit={6}
|
|
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
|
|
showLeagueAvatar={false}
|
|
/>
|
|
)}
|
|
|
|
{/* Draft historical view - active/completed only */}
|
|
{season && isActiveOrCompleted && (
|
|
<DraftInfoCard
|
|
draftRounds={season.draftRounds}
|
|
draftTimerMode={season.draftTimerMode}
|
|
draftInitialTime={season.draftInitialTime}
|
|
draftIncrementTime={season.draftIncrementTime}
|
|
sportsCount={sportsCount}
|
|
isDraftOrderSet={isDraftOrderSet}
|
|
isDraftOrPreDraft={false}
|
|
draftDateTime={season.draftDateTime}
|
|
draftTimezone={currentUserTimezone ?? season.overnightPauseTimezone}
|
|
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
|
|
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
|
|
/>
|
|
)}
|
|
|
|
<CommissionersPanel
|
|
commissioners={commissioners}
|
|
commissionerMap={commissionerMap}
|
|
currentUserId={currentUserId}
|
|
teams={teams}
|
|
activityEntries={recentActivity.entries}
|
|
auditLogUrl={`/leagues/${league.id}/audit-log`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|