- Remove rounded clipping on navbar user avatar button (was showing dark circular background) - Fix "My Avatar" preview in team settings showing circular instead of square shape - Pass owner avatar data through to TeamAvatar in standings, draft room, and draft board so teams with avatarType="owner" correctly show the user's avatar instead of the generated flag - Consolidate user lookups in draft board loader to avoid duplicate DB queries - Fix DraftSlotWithTeam interface to use RawFlagConfig type and include logoUrl/flagConfig/avatarType fields Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
330 lines
13 KiB
TypeScript
330 lines
13 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,
|
|
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}
|
|
draftBoardHref={`/leagues/${league.id}/draft-board/${season.id}`}
|
|
draftRoomHref={`/leagues/${league.id}/draft/${season.id}`}
|
|
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,
|
|
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}
|
|
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>
|
|
);
|
|
}
|