From 4d959c704afccf928b9f00d5604aa6992d7e66ae Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Tue, 14 Apr 2026 20:26:02 -0700 Subject: [PATCH] Finish up league page styling. --- .../league/CommissionersPanel.stories.tsx | 172 ++++++++ app/components/league/CommissionersPanel.tsx | 121 ++++++ .../league/DraftInfoCard.stories.tsx | 145 +++++++ app/components/league/DraftInfoCard.tsx | 208 +++++++++ .../league/SportsSeasonsSummary.tsx | 2 +- app/components/league/TeamsPanel.stories.tsx | 94 ++++ app/components/league/TeamsPanel.tsx | 89 ++++ .../sport-season/UpcomingEventsCard.tsx | 41 +- app/routes/home.tsx | 1 + app/routes/leagues/$leagueId.tsx | 402 ++++-------------- package-lock.json | 8 +- package.json | 2 +- vite.config.ts | 4 + 13 files changed, 965 insertions(+), 324 deletions(-) create mode 100644 app/components/league/CommissionersPanel.stories.tsx create mode 100644 app/components/league/CommissionersPanel.tsx create mode 100644 app/components/league/DraftInfoCard.stories.tsx create mode 100644 app/components/league/DraftInfoCard.tsx create mode 100644 app/components/league/TeamsPanel.stories.tsx create mode 100644 app/components/league/TeamsPanel.tsx diff --git a/app/components/league/CommissionersPanel.stories.tsx b/app/components/league/CommissionersPanel.stories.tsx new file mode 100644 index 0000000..2d8f865 --- /dev/null +++ b/app/components/league/CommissionersPanel.stories.tsx @@ -0,0 +1,172 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { CommissionersPanel } from "./CommissionersPanel"; +import type { AuditLogEntry } from "~/models/audit-log"; + +const meta: Meta = { + title: "League/CommissionersPanel", + component: CommissionersPanel, + parameters: { + layout: "padded", + }, + args: { + auditLogUrl: "/leagues/league-abc-123/audit-log", + currentUserId: "user-alice", + }, +}; + +export default meta; +type Story = StoryObj; + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const commissioners = [ + { id: "c1", userId: "user-alice" }, + { id: "c2", userId: "user-bob" }, +]; + +const commissionerMap: Record = { + "user-alice": "Alice Johnson", + "user-bob": "Bob Smith", +}; + +const teams = [ + { id: "t1", ownerId: "user-alice" }, + { id: "t2", ownerId: "user-bob" }, + { id: "t3", ownerId: "user-carol" }, + { id: "t4", ownerId: null }, +]; + +const activityEntries: AuditLogEntry[] = [ + { + id: "log-1", + seasonId: "season-1", + leagueId: "league-abc-123", + actorClerkId: "user-alice", + actorDisplayName: "Alice Johnson", + action: "draft_order_randomized", + affectedTeamIds: null, + details: null, + createdAt: new Date("2026-04-13T10:30:00Z"), + }, + { + id: "log-2", + seasonId: "season-1", + leagueId: "league-abc-123", + actorClerkId: "user-alice", + actorDisplayName: "Alice Johnson", + action: "draft_settings_changed", + affectedTeamIds: null, + details: { changedFields: ["draftRounds", "draftTimerMode"] }, + createdAt: new Date("2026-04-12T18:15:00Z"), + }, + { + id: "log-3", + seasonId: "season-1", + leagueId: "league-abc-123", + actorClerkId: "user-bob", + actorDisplayName: "Bob Smith", + action: "league_settings_changed", + affectedTeamIds: null, + details: { changedFields: ["name"] }, + createdAt: new Date("2026-04-11T09:00:00Z"), + }, +]; + +// ─── Stories ────────────────────────────────────────────────────────────────── + +export const Default: Story = { + args: { + commissioners, + commissionerMap, + teams, + activityEntries, + }, +}; + +export const NoActivity: Story = { + name: "No Recent Activity", + args: { + commissioners, + commissionerMap, + teams, + activityEntries: [], + }, +}; + +export const SingleCommissioner: Story = { + name: "Single Commissioner (is self, has team)", + args: { + commissioners: [{ id: "c1", userId: "user-alice" }], + commissionerMap: { "user-alice": "Alice Johnson" }, + teams, + activityEntries, + }, +}; + +export const CommissionerWithoutTeam: Story = { + name: "Commissioner Without a Team", + args: { + commissioners: [ + { id: "c1", userId: "user-alice" }, + { id: "c2", userId: "user-nomatch" }, + ], + commissionerMap: { + "user-alice": "Alice Johnson", + "user-nomatch": "Charlie (no team)", + }, + teams, + activityEntries: [], + }, +}; + +export const LongNames: Story = { + name: "Long Commissioner Names (overflow test)", + args: { + commissioners: [ + { id: "c1", userId: "user-alice" }, + { id: "c2", userId: "user-long" }, + ], + commissionerMap: { + "user-alice": "Alice Johnson-Bartholomew-Richardson", + "user-long": "Bartholomew Christopherson-Nightingale III", + }, + teams, + activityEntries, + }, +}; + +export const ManyCommissioners: Story = { + name: "Many Commissioners", + args: { + commissioners: [ + { id: "c1", userId: "user-alice" }, + { id: "c2", userId: "user-bob" }, + { id: "c3", userId: "user-carol" }, + { id: "c4", userId: "user-dave" }, + ], + commissionerMap: { + "user-alice": "Alice Johnson", + "user-bob": "Bob Smith", + "user-carol": "Carol Williams", + "user-dave": "Dave Martinez", + }, + teams: [ + { id: "t1", ownerId: "user-alice" }, + { id: "t2", ownerId: "user-bob" }, + { id: "t3", ownerId: "user-carol" }, + // dave has no team + ], + activityEntries, + }, +}; + +export const NotSelf: Story = { + name: "Viewer is not a commissioner", + args: { + commissioners, + commissionerMap, + teams, + activityEntries, + currentUserId: "user-carol", + }, +}; diff --git a/app/components/league/CommissionersPanel.tsx b/app/components/league/CommissionersPanel.tsx new file mode 100644 index 0000000..a584fb2 --- /dev/null +++ b/app/components/league/CommissionersPanel.tsx @@ -0,0 +1,121 @@ +import { format } from "date-fns"; +import { Activity, ChevronRight, Crown } from "lucide-react"; +import { Link } from "react-router"; +import { GradientIcon } from "~/components/ui/GradientIcon"; +import { formatAuditDetail } from "~/lib/audit-log-display"; +import type { AuditLogEntry } from "~/models/audit-log"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface CommissionerEntry { + id: string; + userId: string; +} + +export interface CommissionersPanelProps { + commissioners: CommissionerEntry[]; + /** Maps userId → display name */ + commissionerMap: Record; + currentUserId: string | null; + /** Used to determine whether a commissioner also has a team in the league. */ + teams: Array<{ id: string; ownerId: string | null }>; + activityEntries: AuditLogEntry[]; + /** URL for the full audit log — shown only when there are activity entries. */ + auditLogUrl: string; +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function CommissionerRow({ + name, + isSelf, + hasTeam, +}: { + name: string; + isSelf: boolean; + hasTeam: boolean; +}) { + return ( +
+

+ {name} + {isSelf && ( + (You) + )} +

+ {!hasTeam && ( +

No team

+ )} +
+ ); +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export function CommissionersPanel({ + commissioners, + commissionerMap, + currentUserId, + teams, + activityEntries, + auditLogUrl, +}: CommissionersPanelProps) { + return ( +
+ {/* Commissioners */} +
+
+ + Commissioners +
+
+ {commissioners.map((commissioner) => ( + t.ownerId === commissioner.userId)} + /> + ))} +
+
+ + {/* Commish Activity */} + {activityEntries.length > 0 && ( +
+
+
+ + Commish Activity +
+ + View All + + +
+
    + {activityEntries.map((entry) => ( +
  • + + {format(new Date(entry.createdAt), "MMM d, HH:mm")} + + + + {entry.actorDisplayName ?? entry.actorClerkId} + + {" — "} + + {formatAuditDetail(entry)} + + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/app/components/league/DraftInfoCard.stories.tsx b/app/components/league/DraftInfoCard.stories.tsx new file mode 100644 index 0000000..151231c --- /dev/null +++ b/app/components/league/DraftInfoCard.stories.tsx @@ -0,0 +1,145 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DraftInfoCard } from "./DraftInfoCard"; + +const meta: Meta = { + title: "League/DraftInfoCard", + component: DraftInfoCard, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +const base = { + draftRounds: 8, + draftTimerMode: "chess_clock" as const, + draftInitialTime: 120, // 2:00 bank + draftIncrementTime: 15, // +0:15 per pick + sportsCount: 3, + isDraftOrderSet: false, + isDraftOrPreDraft: true, + draftBoardHref: "/leagues/1/draft-board/1", + draftRoomHref: "/leagues/1/draft/1", +}; + +export const PreDraftInProgress: Story = { + args: { + ...base, + draftDateTime: new Date("2025-06-14T19:00:00"), + }, +}; + +export const PreDraftOrderSet: Story = { + args: { + ...base, + isDraftOrderSet: true, + draftDateTime: new Date("2025-06-14T19:00:00"), + }, +}; + +export const PreDraftNoDate: Story = { + args: { + ...base, + }, +}; + +export const StandardTimer: Story = { + args: { + ...base, + draftTimerMode: "standard", + draftInitialTime: 90, + draftIncrementTime: 90, + isDraftOrderSet: true, + draftDateTime: new Date("2025-06-14T19:00:00"), + }, +}; + +export const StandardTimerLong: Story = { + name: "Standard Timer (slow league — 1 day/pick)", + args: { + ...base, + draftTimerMode: "standard", + draftInitialTime: 86400, + draftIncrementTime: 86400, + isDraftOrderSet: true, + draftDateTime: new Date("2025-06-14T19:00:00"), + }, +}; + +export const ChessClockSlow: Story = { + name: "Chess Clock (slow — 8h bank, 1h increment)", + args: { + ...base, + draftInitialTime: 28800, + draftIncrementTime: 3600, + isDraftOrderSet: true, + draftDateTime: new Date("2025-06-14T19:00:00"), + }, +}; + +export const WithDraftPosition: Story = { + name: "4 panels — draft position set (pick 3)", + args: { + ...base, + isDraftOrderSet: true, + draftDateTime: new Date("2025-06-14T19:00:00"), + userDraftPosition: 3, + }, +}; + +export const NonCommissionerView: Story = { + name: "Non-commissioner (no Set Order CTA)", + args: { + ...base, + draftDateTime: new Date("2025-06-14T19:00:00"), + }, +}; + +export const ActiveSeason: Story = { + name: "Active season — bare historical view", + args: { + draftRounds: 8, + draftTimerMode: "chess_clock", + draftInitialTime: 120, + draftIncrementTime: 15, + sportsCount: 3, + isDraftOrderSet: true, + isDraftOrPreDraft: false, + draftDateTime: new Date("2025-03-01T14:00:00"), + draftBoardHref: "/leagues/1/draft-board/1", + draftRoomHref: "/leagues/1/draft/1", + }, +}; + +export const ActiveSeasonStandard: Story = { + name: "Active season — bare, standard timer", + args: { + draftRounds: 10, + draftTimerMode: "standard", + draftInitialTime: 90, + draftIncrementTime: 90, + sportsCount: 6, + isDraftOrderSet: true, + isDraftOrPreDraft: false, + draftDateTime: new Date("2025-03-01T14:00:00"), + draftBoardHref: "/leagues/1/draft-board/1", + draftRoomHref: "/leagues/1/draft/1", + }, +}; + +export const ActiveSeasonNoDate: Story = { + name: "Active season — bare, no draft date", + args: { + draftRounds: 8, + draftTimerMode: "chess_clock", + draftInitialTime: 120, + draftIncrementTime: 15, + sportsCount: 3, + isDraftOrderSet: true, + isDraftOrPreDraft: false, + draftBoardHref: "/leagues/1/draft-board/1", + draftRoomHref: "/leagues/1/draft/1", + }, +}; diff --git a/app/components/league/DraftInfoCard.tsx b/app/components/league/DraftInfoCard.tsx new file mode 100644 index 0000000..cbbffdb --- /dev/null +++ b/app/components/league/DraftInfoCard.tsx @@ -0,0 +1,208 @@ +import { format } from "date-fns"; +import { ChevronRight, ChessPawn, Hourglass, LayoutGrid } from "lucide-react"; +import { Link } from "react-router"; +import { formatClockTime } from "~/lib/draft-timer"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader } from "~/components/ui/card"; +import { GradientIcon } from "~/components/ui/GradientIcon"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface DraftInfoCardProps { + draftRounds: number; + draftTimerMode: "standard" | "chess_clock"; + /** Per-pick time for standard; initial bank for chess clock (seconds) */ + draftInitialTime: number; + /** Bonus seconds added after each pick (chess clock only; ignored in standard) */ + draftIncrementTime: number; + sportsCount: number; + isDraftOrderSet: boolean; + /** true when season status is pre_draft or draft */ + isDraftOrPreDraft: boolean; + draftDateTime?: string | Date | null; + draftBoardHref: string; + draftRoomHref: string; + /** 1-based draft position for the current user; omit or undefined to hide the panel */ + userDraftPosition?: number; +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function InfoPanel({ label, value, sub }: { label: string; value: React.ReactNode; sub?: React.ReactNode }) { + return ( +
+ + {label} + + {value} + {sub && {sub}} +
+ ); +} + +// ─── Card variant (pre_draft / draft) ───────────────────────────────────────── + +function DraftInfoCardVariant({ + draftRounds, + draftTimerMode, + draftInitialTime, + draftIncrementTime, + sportsCount, + isDraftOrderSet, + draftDateTime, + draftRoomHref, + userDraftPosition, +}: DraftInfoCardProps) { + const flexSpots = Math.max(0, draftRounds - sportsCount); + + const timerLabel = draftTimerMode === "chess_clock" ? "Chess Clock" : "Standard Timer"; + const timerValue = formatClockTime(draftInitialTime); + const timerSub = draftTimerMode === "chess_clock" + ? `+${formatClockTime(draftIncrementTime)} per pick` + : "per pick"; + + const roundsSub = [ + `${sportsCount} sport${sportsCount !== 1 ? "s" : ""}`, + flexSpots > 0 ? `${flexSpots} flex` : null, + ].filter(Boolean).join(" + "); + + return ( + + +
+
+ +

Draft Info

+
+ {isDraftOrderSet && ( + + )} +
+
+ + +
+ {/* Draft Date */} + + + {/* Rounds */} + + + {/* User's Draft Position (only when set) */} + {userDraftPosition !== null && userDraftPosition !== undefined && ( + + )} + + {/* Timer */} + + {draftTimerMode === "chess_clock" + ? + : } + {timerValue} + + } + sub={timerSub} + /> +
+
+
+ ); +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Converts a seconds value to a short human-readable string, e.g. "1 minute", "10 seconds", "2 hours". */ +function formatHumanTime(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + const parts: string[] = []; + if (h > 0) parts.push(`${h} hour${h !== 1 ? "s" : ""}`); + if (m > 0) parts.push(`${m} minute${m !== 1 ? "s" : ""}`); + if (s > 0) parts.push(`${s} second${s !== 1 ? "s" : ""}`); + return parts.join(" ") || "0 seconds"; +} + +// ─── Bare variant (active / completed) ──────────────────────────────────────── + +function DraftInfoBareVariant({ + draftRounds, + draftTimerMode, + draftInitialTime, + draftIncrementTime, + sportsCount, + draftDateTime, + draftBoardHref, +}: DraftInfoCardProps) { + const flexSpots = Math.max(0, draftRounds - sportsCount); + + return ( +
+
+
+ + Draft +
+ + View Draft Board + + +
+ +
+ {/* Mode icon */} +
+ {draftTimerMode === "chess_clock" + ? + : } +
+ +
+ {draftDateTime && ( +
+ + {format(new Date(draftDateTime), "MMM d, yyyy")} + + | + + {format(new Date(draftDateTime), "p")} + +
+ )} + +
+ {draftRounds} rounds ({sportsCount} sport{sportsCount !== 1 ? "s" : ""} + {flexSpots > 0 && ` + ${flexSpots} flex`}) +
+ +
+ {draftTimerMode === "standard" + ? `Standard — ${formatHumanTime(draftInitialTime)}/pick` + : `Chess Clock — ${formatHumanTime(draftInitialTime)} + ${formatHumanTime(draftIncrementTime)} increment`} +
+
+
+
+ ); +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export function DraftInfoCard(props: DraftInfoCardProps) { + if (props.isDraftOrPreDraft) { + return ; + } + return ; +} diff --git a/app/components/league/SportsSeasonsSummary.tsx b/app/components/league/SportsSeasonsSummary.tsx index 543f122..d1ab5e6 100644 --- a/app/components/league/SportsSeasonsSummary.tsx +++ b/app/components/league/SportsSeasonsSummary.tsx @@ -144,7 +144,7 @@ function ParticipantChip({ }) { const pointsLabel = formatPoints(participant, scoringPattern); return ( - + {participant.name} {pointsLabel && ( · {pointsLabel} diff --git a/app/components/league/TeamsPanel.stories.tsx b/app/components/league/TeamsPanel.stories.tsx new file mode 100644 index 0000000..d30863b --- /dev/null +++ b/app/components/league/TeamsPanel.stories.tsx @@ -0,0 +1,94 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { TeamsPanel } from "./TeamsPanel"; + +const meta: Meta = { + title: "League/TeamsPanel", + component: TeamsPanel, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +const teams = [ + { id: "t1", name: "Lightning Wolves", ownerId: "u1" }, + { id: "t2", name: "Shadow Hawks", ownerId: "u2" }, + { id: "t3", name: "Iron Eagles", ownerId: "u3" }, + { id: "t4", name: "Cyber Foxes", ownerId: null }, + { id: "t5", name: "Neon Tigers", ownerId: null }, + { id: "t6", name: "Phantom Sharks", ownerId: null }, + { id: "t7", name: "Crimson Tide FC", ownerId: null }, + { id: "t8", name: "Arctic Wolves", ownerId: null }, +]; + +const ownerMap = { + u1: "alice", + u2: "bob", + u3: "carol", +}; + +const draftSlots = teams.map((t) => ({ team: { id: t.id } })); + +// No draft order: only show claimed teams + progress bar +export const NoOrderPartialFill: Story = { + name: "No order — partial fill", + args: { + teams, + currentUserId: "u1", + ownerMap, + draftSlots: [], + }, +}; + +export const NoOrderFull: Story = { + name: "No order — league full", + args: { + teams: teams.map((t, i) => ({ ...t, ownerId: `u${i + 1}` })), + currentUserId: "u1", + ownerMap: Object.fromEntries(teams.map((_, i) => [`u${i + 1}`, `user${i + 1}`])), + draftSlots: [], + }, +}; + +// Draft order set: show all teams sorted, with position numbers +export const OrderSetPartialFill: Story = { + name: "Draft order set — partial fill (progress bar shown)", + args: { + teams, + currentUserId: "u1", + ownerMap, + draftSlots, + }, +}; + +export const OrderSetFull: Story = { + name: "Draft order set — league full (no progress bar)", + args: { + teams: teams.map((t, i) => ({ ...t, ownerId: `u${i + 1}` })), + currentUserId: "u1", + ownerMap: Object.fromEntries(teams.map((_, i) => [`u${i + 1}`, `user${i + 1}`])), + draftSlots, + }, +}; + +export const YourTeamHighlighted: Story = { + name: "Your team highlighted (pick 4)", + args: { + teams, + currentUserId: "u3", + ownerMap, + draftSlots, + }, +}; + +export const NoOwnerNames: Story = { + name: "No owner names resolved", + args: { + teams, + currentUserId: null, + ownerMap: {}, + draftSlots, + }, +}; diff --git a/app/components/league/TeamsPanel.tsx b/app/components/league/TeamsPanel.tsx new file mode 100644 index 0000000..4ca927f --- /dev/null +++ b/app/components/league/TeamsPanel.tsx @@ -0,0 +1,89 @@ +import { Users } from "lucide-react"; +import { GradientIcon } from "~/components/ui/GradientIcon"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface TeamsPanelProps { + teams: Array<{ id: string; name: string; ownerId: string | null }>; + currentUserId: string | null; + /** Maps userId → display name */ + ownerMap: Record; + /** When non-empty, sorts all teams in draft order and shows position numbers */ + draftSlots: Array<{ team: { id: string } }>; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +export function TeamsPanel({ teams, currentUserId, ownerMap, draftSlots }: TeamsPanelProps) { + const hasDraftOrder = draftSlots.length > 0; + const claimedCount = teams.filter((t) => t.ownerId !== null).length; + const isFull = claimedCount === teams.length; + const fillPercent = teams.length > 0 ? Math.round((claimedCount / teams.length) * 100) : 0; + + const displayTeams = hasDraftOrder + ? (() => { + const orderMap = new Map(draftSlots.map((s, i) => [s.team.id, i])); + return teams.toSorted( + (a, b) => (orderMap.get(a.id) ?? Infinity) - (orderMap.get(b.id) ?? Infinity) + ); + })() + : teams.filter((t) => t.ownerId !== null); + + const showProgressBar = !(hasDraftOrder && isFull); + + return ( +
+
+ + + {hasDraftOrder ? "Draft Order" : "Teams"} + +
+ + {showProgressBar && ( +
+
+ + Filled + + + {claimedCount} + / {teams.length} + +
+
+
+
+
+ )} + +
+ {displayTeams.map((team, index) => { + const isOwned = team.ownerId === currentUserId; + const ownerName = team.ownerId ? ownerMap[team.ownerId] : null; + return ( +
+ {hasDraftOrder && ( + + {index + 1} + + )} +

{team.name}

+

+ {team.ownerId + ? isOwned + ? You + : {ownerName || "Unknown"} + : + } +

+
+ ); + })} +
+
+ ); +} diff --git a/app/components/sport-season/UpcomingEventsCard.tsx b/app/components/sport-season/UpcomingEventsCard.tsx index d6d3e2e..e965944 100644 --- a/app/components/sport-season/UpcomingEventsCard.tsx +++ b/app/components/sport-season/UpcomingEventsCard.tsx @@ -15,6 +15,8 @@ interface Props { limit?: number; viewAllUrl?: string; emptyMessage?: string; + /** Whether to show the league avatar next to participants. Default true. */ + showLeagueAvatar?: boolean; } export interface LeagueParticipants { @@ -92,17 +94,20 @@ function TimelineDot({ isFirst }: { isFirst: boolean }) { function ParticipantsByLeague({ leagues, isAllCompete, + showLeagueAvatar = true, }: { leagues: LeagueParticipants[]; isAllCompete: boolean; + showLeagueAvatar?: boolean; }) { return (
- {leagues.map((league) => ( -
- - {isAllCompete && league.participants.length > 1 ? ( - {league.participants.length} of your picks + {leagues.map((league) => { + const content = + isAllCompete && league.participants.length > 1 ? ( + + {league.participants.length} of your picks + ) : ( {league.participants.slice(0, 3).map((p) => ( @@ -116,9 +121,24 @@ function ParticipantsByLeague({ )} - )} -
- ))} + ); + + if (!showLeagueAvatar) { + return
{content}
; + } + + return ( +
+ + {content} +
+ ); + })}
); } @@ -127,10 +147,12 @@ export function EventRow({ event, isFirst, isLast, + showLeagueAvatar = true, }: { event: GroupedEvent; isFirst: boolean; isLast: boolean; + showLeagueAvatar?: boolean; }) { const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null; const dateStr = gameDate ? format(gameDate, "MMM d") : formatEventDate(event.eventDate); @@ -182,6 +204,7 @@ export function EventRow({
@@ -204,6 +227,7 @@ export function UpcomingEventsCard({ limit, viewAllUrl, emptyMessage, + showLeagueAvatar = true, }: Props) { const localFilteredEvents = useMemo(() => { const todayStr = new Intl.DateTimeFormat("en-CA").format(new Date()); @@ -248,6 +272,7 @@ export function UpcomingEventsCard({ event={event} isFirst={index === 0} isLast={index === displayedEvents.length - 1} + showLeagueAvatar={showLeagueAvatar} /> ))} {viewAllUrl && hiddenCount > 0 && ( diff --git a/app/routes/home.tsx b/app/routes/home.tsx index 761a505..202b7f7 100644 --- a/app/routes/home.tsx +++ b/app/routes/home.tsx @@ -228,6 +228,7 @@ function isValidStatus(s: string): s is ValidStatus { events={upcomingCalendarEvents} limit={8} viewAllUrl="/upcoming-events" + showLeagueAvatar={leagueRows.length > 1} /> diff --git a/app/routes/leagues/$leagueId.tsx b/app/routes/leagues/$leagueId.tsx index 0d62ca0..d139d7a 100644 --- a/app/routes/leagues/$leagueId.tsx +++ b/app/routes/leagues/$leagueId.tsx @@ -1,22 +1,19 @@ import { useEffect, useRef, useState } from "react"; import { Link, useSearchParams } from "react-router"; import { toast } from "sonner"; -import { format } from "date-fns"; import type { Route } from "./+types/$leagueId"; +import { Link2 } from "lucide-react"; import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; +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 { formatAuditDetail } from "~/lib/audit-log-display"; + export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `${data?.league?.name ?? "League"} - Brackt` }]; @@ -36,7 +33,6 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { commissionerMap, availableTeamCount, sportsCount, - teamsWithOwners, isDraftOrderSet, draftSlots, sportsSeasons, @@ -47,6 +43,10 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { } = 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); @@ -132,18 +132,28 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) { return (
-
-

{league.name}

-
+
+
+

{league.name}

+ {season && ( +

+ {season.year} Season •{" "} + + {season.status.replace("_", " ")} + +

+ )} +
+
{myTeam && ( - )} {isUserCommissioner && ( -
- {season && ( -

- {season.year} Season •{" "} - - {season.status.replace("_", " ")} - -

- )}
{season?.status === "draft" && ( @@ -184,12 +186,29 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
{/* Left Column - 2/3 width on desktop */}
+ {/* Draft Info card - pre_draft/draft only */} + {season && isDraftOrPreDraft && ( + + )} + {/* Standings Panel - active/completed seasons */} {season && isActiveOrCompleted && ( )} @@ -209,17 +228,26 @@ fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`} /> )} - {isUserCommissioner && season && season.status === "pre_draft" && availableTeamCount > 0 && ( - - - Invite Link - - Share this link to invite people to join your league ( - {availableTeamCount} spot{availableTeamCount !== 1 ? "s" : ""}{" "} - available) - - - +
+ + {/* Right Column - 1/3 width on desktop */} +
+ {/* Teams - shown above invite link when league is full */} + {season && isDraftOrPreDraft && availableTeamCount === 0 && ( + + )} + + {/* Invite Link - bare, pre_draft commissioner only */} + {isUserCommissioner && season?.status === "pre_draft" && availableTeamCount > 0 && ( +
+
+ + Invite Link +
+
+

+ {availableTeamCount} spot{availableTeamCount !== 1 ? "s" : ""} remaining +

Anyone with this link can join your league

- - +
+
)} - {/* Draft Order card - shown when order is set, pre_draft/draft */} - {season && isDraftOrPreDraft && draftSlots.length > 0 && ( - - -
-
- Draft Order - - {season.status === "pre_draft" - ? "Upcoming draft order" - : "Current draft order"} - -
- -
-
- -
- {draftSlots.map((slot, index) => { - const ownerName = slot.team.ownerId - ? ownerMap[slot.team.ownerId] - : null; - return ( -
-
- {index + 1} -
-
-

- {slot.team.name} -

- {ownerName && ( -

- {ownerName} -

- )} -
-
- ); - })} -
-
-
+ {/* Teams - shown below invite link when league is not full */} + {season && isDraftOrPreDraft && availableTeamCount > 0 && ( + )} - {/* Teams card - pre_draft/draft only; replaced by standings panel post-draft */} - {season && isDraftOrPreDraft && ( - - - Teams - - {teams.length} team{teams.length !== 1 ? "s" : ""} in this league - - - -
- {teams.map((team) => { - const isOwned = team.ownerId === currentUserId; - const ownerName = team.ownerId - ? ownerMap[team.ownerId] - : null; - return ( - - - {team.name} - - {team.ownerId ? ( - isOwned ? ( - - Your Team - - ) : ( - {ownerName || "Unknown Owner"} - ) - ) : ( - - Available - - )} - - - - ); - })} -
-
-
- )} -
- - {/* Right Column - 1/3 width on desktop */} -
{upcomingCalendarEvents.length > 0 && ( )} - - - League Info - - -
-

Created

-

- {new Date(league.createdAt).toLocaleDateString()} -

-
- {season && ( - <> - {isDraftOrPreDraft && ( -
-

- Teams Filled -

-

- {teamsWithOwners} of {teams.length} -

-
- )} - {isDraftOrPreDraft && ( -
-

- Draft Order Set -

-
-

- {isDraftOrderSet ? "Yes" : "No"} -

- {!isDraftOrderSet && isUserCommissioner && ( - - )} -
-
- )} -
-

- Draft Rounds -

-

{season.draftRounds}

-
-
-

- Draft Timer Mode -

-

- {season.draftTimerMode === "standard" ? "Standard Timer" : "Chess Clock"} -

-
-
-

- Sports Selected -

-

{sportsCount}

-
-
-

Draft Board

- - View Draft Board - -
- {isActiveOrCompleted && ( -
-

Standings

- - View Standings - -
- )} -
-

Flex Spots

-

- {Math.max(0, season.draftRounds - sportsCount)} -

-

- Picks not tied to a specific sport -

-
- {season.draftDateTime && ( -
-

- Draft Date -

-

- {format(new Date(season.draftDateTime), "PPP 'at' p")} -

-
- )} - - )} -
-
- - - Commissioners - - {commissioners.length} commissioner - {commissioners.length !== 1 ? "s" : ""} - - - -
- {commissioners.map((commissioner) => { - const commissionerName = commissionerMap[commissioner.userId]; - const hasTeam = teams.some( - (t) => t.ownerId === commissioner.userId - ); - return ( -
-

- {commissionerName || "Unknown User"} - {commissioner.userId === currentUserId && ( - - (You) - - )} -

- {!hasTeam && ( -

- No team -

- )} -
- ); - })} -
-
-
- - {recentActivity.entries.length > 0 && ( - - -
- Recent Activity - Recent commissioner actions -
- -
- -
    - {recentActivity.entries.map((entry) => ( -
  • - - {format(new Date(entry.createdAt), "MMM d, HH:mm")} - - - {entry.actorDisplayName ?? entry.actorClerkId} - {" — "} - {formatAuditDetail(entry)} - -
  • - ))} -
-
-
+ {/* Draft historical view - active/completed only */} + {season && isActiveOrCompleted && ( + )} + +
diff --git a/package-lock.json b/package-lock.json index 201e399..23d4290 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "drizzle-orm": "~0.36.3", "express": "^5.1.0", "isbot": "^5.1.27", - "lucide-react": "^0.545.0", + "lucide-react": "^1.8.0", "morgan": "^1.10.0", "next-themes": "^0.4.6", "nprogress": "^0.2.0", @@ -12517,9 +12517,9 @@ } }, "node_modules/lucide-react": { - "version": "0.545.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.545.0.tgz", - "integrity": "sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", + "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index 118e9f6..7bbd154 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "drizzle-orm": "~0.36.3", "express": "^5.1.0", "isbot": "^5.1.27", - "lucide-react": "^0.545.0", + "lucide-react": "^1.8.0", "morgan": "^1.10.0", "next-themes": "^0.4.6", "nprogress": "^0.2.0", diff --git a/vite.config.ts b/vite.config.ts index 6d3faef..5740605 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -37,6 +37,10 @@ export default defineConfig((env) => ({ allowedHosts: true, }, + ssr: { + noExternal: ["lucide-react"], + }, + optimizeDeps: { exclude: ["@sentry/react-router"] }