Finish up league page styling.

This commit is contained in:
Chris Parsons 2026-04-14 20:26:02 -07:00
parent e93a7fcae2
commit 4d959c704a
13 changed files with 965 additions and 324 deletions

View file

@ -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<typeof CommissionersPanel> = {
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<typeof CommissionersPanel>;
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const commissioners = [
{ id: "c1", userId: "user-alice" },
{ id: "c2", userId: "user-bob" },
];
const commissionerMap: Record<string, string> = {
"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",
},
};

View file

@ -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<string, string | null>;
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 (
<div className="flex items-center gap-2 min-w-0 py-1.5 border-b last:border-0">
<p className="font-medium truncate min-w-0 flex-1">
{name}
{isSelf && (
<span className="ml-2 text-xs text-primary">(You)</span>
)}
</p>
{!hasTeam && (
<p className="text-xs text-muted-foreground shrink-0">No team</p>
)}
</div>
);
}
// ─── Public API ───────────────────────────────────────────────────────────────
export function CommissionersPanel({
commissioners,
commissionerMap,
currentUserId,
teams,
activityEntries,
auditLogUrl,
}: CommissionersPanelProps) {
return (
<div className="space-y-14">
{/* Commissioners */}
<div>
<div className="flex items-center gap-2 mb-4">
<GradientIcon icon={Crown} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Commissioners</span>
</div>
<div>
{commissioners.map((commissioner) => (
<CommissionerRow
key={commissioner.id}
name={commissionerMap[commissioner.userId] || "Unknown User"}
isSelf={commissioner.userId === currentUserId}
hasTeam={teams.some((t) => t.ownerId === commissioner.userId)}
/>
))}
</div>
</div>
{/* Commish Activity */}
{activityEntries.length > 0 && (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<GradientIcon icon={Activity} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Commish Activity</span>
</div>
<Link
to={auditLogUrl}
className="flex items-center gap-0.5 text-xs font-semibold uppercase tracking-wide text-electric hover:underline"
>
View All
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</div>
<ul className="space-y-3">
{activityEntries.map((entry) => (
<li key={entry.id} className="flex items-start gap-3 text-sm">
<span className="text-muted-foreground whitespace-nowrap shrink-0">
{format(new Date(entry.createdAt), "MMM d, HH:mm")}
</span>
<span className="min-w-0">
<span className="font-medium">
{entry.actorDisplayName ?? entry.actorClerkId}
</span>
{" — "}
<span className="text-muted-foreground">
{formatAuditDetail(entry)}
</span>
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,145 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { DraftInfoCard } from "./DraftInfoCard";
const meta: Meta<typeof DraftInfoCard> = {
title: "League/DraftInfoCard",
component: DraftInfoCard,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof DraftInfoCard>;
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",
},
};

View file

@ -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 (
<div className="flex flex-col rounded-lg bg-white/[0.04] px-3 py-3 gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground leading-none">
{label}
</span>
<span className="text-xl font-bold leading-none">{value}</span>
{sub && <span className="text-xs text-muted-foreground leading-none">{sub}</span>}
</div>
);
}
// ─── 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 (
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GradientIcon icon={LayoutGrid} className="h-5 w-5 shrink-0" />
<h2 className="text-xl font-bold leading-none tracking-tight">Draft Info</h2>
</div>
{isDraftOrderSet && (
<Button asChild>
<Link to={draftRoomHref}>Enter Draft Room</Link>
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className={`grid gap-2 ${userDraftPosition !== null && userDraftPosition !== undefined ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-2 sm:grid-cols-3"}`}>
{/* Draft Date */}
<InfoPanel
label="Draft Date"
value={draftDateTime ? format(new Date(draftDateTime), "MMM d, yyyy") : "TBD"}
sub={draftDateTime ? format(new Date(draftDateTime), "p") : undefined}
/>
{/* Rounds */}
<InfoPanel label="Rounds" value={draftRounds} sub={roundsSub} />
{/* User's Draft Position (only when set) */}
{userDraftPosition !== null && userDraftPosition !== undefined && (
<InfoPanel label="Your Pick" value={`#${userDraftPosition}`} sub="round 1 position" />
)}
{/* Timer */}
<InfoPanel
label={timerLabel}
value={
<span className="flex items-center gap-1.5">
{draftTimerMode === "chess_clock"
? <ChessPawn className="h-4 w-4 shrink-0 text-muted-foreground" />
: <Hourglass className="h-4 w-4 shrink-0 text-muted-foreground" />}
{timerValue}
</span>
}
sub={timerSub}
/>
</div>
</CardContent>
</Card>
);
}
// ─── 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 (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<GradientIcon icon={LayoutGrid} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">Draft</span>
</div>
<Link
to={draftBoardHref}
className="flex items-center gap-0.5 text-xs font-semibold uppercase tracking-wide text-electric hover:underline"
>
View Draft Board
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="flex gap-3">
{/* Mode icon */}
<div className="flex size-16 shrink-0 items-center justify-center bg-black">
{draftTimerMode === "chess_clock"
? <ChessPawn className="h-6 w-6 text-white" />
: <Hourglass className="h-6 w-6 text-white" />}
</div>
<div className="min-w-0">
{draftDateTime && (
<div className="flex items-center gap-2 mb-0.5" suppressHydrationWarning>
<span className="text-xs font-semibold uppercase tracking-wide text-electric">
{format(new Date(draftDateTime), "MMM d, yyyy")}
</span>
<span className="text-border">|</span>
<span className="text-xs text-muted-foreground">
{format(new Date(draftDateTime), "p")}
</span>
</div>
)}
<div className="text-sm font-bold uppercase tracking-wide leading-tight">
{draftRounds} rounds ({sportsCount} sport{sportsCount !== 1 ? "s" : ""}
{flexSpots > 0 && ` + ${flexSpots} flex`})
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{draftTimerMode === "standard"
? `Standard — ${formatHumanTime(draftInitialTime)}/pick`
: `Chess Clock — ${formatHumanTime(draftInitialTime)} + ${formatHumanTime(draftIncrementTime)} increment`}
</div>
</div>
</div>
</div>
);
}
// ─── Public API ───────────────────────────────────────────────────────────────
export function DraftInfoCard(props: DraftInfoCardProps) {
if (props.isDraftOrPreDraft) {
return <DraftInfoCardVariant {...props} />;
}
return <DraftInfoBareVariant {...props} />;
}

View file

@ -144,7 +144,7 @@ function ParticipantChip({
}) {
const pointsLabel = formatPoints(participant, scoringPattern);
return (
<Badge variant="secondary" className="text-xs h-5 px-1.5 shrink-0 gap-1">
<Badge variant="secondary" className="text-xs h-5 px-1.5 shrink-0 gap-1 bg-white/[0.1]">
<span>{participant.name}</span>
{pointsLabel && (
<span className="text-muted-foreground font-normal">· {pointsLabel}</span>

View file

@ -0,0 +1,94 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { TeamsPanel } from "./TeamsPanel";
const meta: Meta<typeof TeamsPanel> = {
title: "League/TeamsPanel",
component: TeamsPanel,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof TeamsPanel>;
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,
},
};

View file

@ -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<string, string | null>;
/** 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 (
<div>
<div className="flex items-center gap-2 mb-3">
<GradientIcon icon={Users} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">
{hasDraftOrder ? "Draft Order" : "Teams"}
</span>
</div>
{showProgressBar && (
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Filled
</span>
<span className="text-sm font-bold">
{claimedCount}
<span className="text-muted-foreground font-normal"> / {teams.length}</span>
</span>
</div>
<div className="h-2 w-full rounded-full bg-white/[0.08] overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-[#adf661] to-[#2ce1c1] transition-all"
style={{ width: `${fillPercent}%` }}
/>
</div>
</div>
)}
<div>
{displayTeams.map((team, index) => {
const isOwned = team.ownerId === currentUserId;
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
return (
<div key={team.id} className="flex items-center gap-3 py-1.5 border-b last:border-0">
{hasDraftOrder && (
<span className="text-xs font-bold text-muted-foreground w-4 shrink-0 text-right">
{index + 1}
</span>
)}
<p className="font-medium truncate min-w-0 flex-1 text-sm">{team.name}</p>
<p className="text-xs shrink-0">
{team.ownerId
? isOwned
? <span className="text-primary font-medium">You</span>
: <span className="text-muted-foreground">{ownerName || "Unknown"}</span>
: <span className="text-muted-foreground/50"></span>
}
</p>
</div>
);
})}
</div>
</div>
);
}

View file

@ -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 (
<div className="space-y-1">
{leagues.map((league) => (
<div key={league.leagueId} className="flex items-center gap-1.5">
<LeagueAvatar leagueId={league.leagueId} leagueName={league.leagueName} className="h-5 w-5" textClassName="text-[8px]" />
{isAllCompete && league.participants.length > 1 ? (
<span className="text-xs text-muted-foreground">{league.participants.length} of your picks</span>
{leagues.map((league) => {
const content =
isAllCompete && league.participants.length > 1 ? (
<span className="text-xs text-muted-foreground">
{league.participants.length} of your picks
</span>
) : (
<span className="flex flex-wrap gap-1">
{league.participants.slice(0, 3).map((p) => (
@ -116,9 +121,24 @@ function ParticipantsByLeague({
</Badge>
)}
</span>
)}
);
if (!showLeagueAvatar) {
return <div key={league.leagueId}>{content}</div>;
}
return (
<div key={league.leagueId} className="flex items-center gap-1.5">
<LeagueAvatar
leagueId={league.leagueId}
leagueName={league.leagueName}
className="h-5 w-5"
textClassName="text-[8px]"
/>
{content}
</div>
))}
);
})}
</div>
);
}
@ -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({
<ParticipantsByLeague
leagues={event.leagues}
isAllCompete={event.isAllCompete}
showLeagueAvatar={showLeagueAvatar}
/>
</div>
</div>
@ -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 && (

View file

@ -228,6 +228,7 @@ function isValidStatus(s: string): s is ValidStatus {
events={upcomingCalendarEvents}
limit={8}
viewAllUrl="/upcoming-events"
showLeagueAvatar={leagueRows.length > 1}
/>
</div>

View file

@ -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,25 +132,9 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
return (
<div className="container mx-auto py-8 px-4">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-4xl font-bold">{league.name}</h1>
<div className="flex gap-2">
{myTeam && (
<Button variant="outline" asChild>
<Link to={`/teams/${myTeam.id}/settings`}>
Team Settings
</Link>
</Button>
)}
{isUserCommissioner && (
<Button variant="outline" asChild>
<Link to={`/leagues/${league.id}/settings`}>
League Settings
</Link>
</Button>
)}
</div>
</div>
<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 {" "}
@ -160,6 +144,24 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
</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">
@ -184,12 +186,29 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
<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}
/>
)}
{/* Standings Panel - active/completed seasons */}
{season && isActiveOrCompleted && (
<StandingsPreview
entries={standingsEntries}
description={season.status === "completed" ? "Final standings" : undefined}
fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
/>
)}
@ -209,17 +228,26 @@ fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
/>
)}
{isUserCommissioner && season && season.status === "pre_draft" && availableTeamCount > 0 && (
<Card>
<CardHeader>
<CardTitle>Invite Link</CardTitle>
<CardDescription>
Share this link to invite people to join your league (
{availableTeamCount} spot{availableTeamCount !== 1 ? "s" : ""}{" "}
available)
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
</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"
@ -239,295 +267,49 @@ fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
<p className="text-xs text-muted-foreground">
Anyone with this link can join your league
</p>
</CardContent>
</Card>
</div>
</div>
)}
{/* Draft Order card - shown when order is set, pre_draft/draft */}
{season && isDraftOrPreDraft && draftSlots.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Draft Order</CardTitle>
<CardDescription>
{season.status === "pre_draft"
? "Upcoming draft order"
: "Current draft order"}
</CardDescription>
</div>
<Button asChild size="sm">
<Link to={`/leagues/${league.id}/draft/${season.id}`}>
Enter Draft Room
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{draftSlots.map((slot, index) => {
const ownerName = slot.team.ownerId
? ownerMap[slot.team.ownerId]
: null;
return (
<div
key={slot.id}
className="flex items-center gap-3 py-2 border-b last:border-0"
>
<div className="flex items-center justify-center w-7 h-7 rounded-full bg-primary text-primary-foreground font-bold text-xs">
{index + 1}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">
{slot.team.name}
</p>
{ownerName && (
<p className="text-xs text-muted-foreground truncate">
{ownerName}
</p>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Teams - shown below invite link when league is not full */}
{season && isDraftOrPreDraft && availableTeamCount > 0 && (
<TeamsPanel teams={teams} currentUserId={currentUserId} ownerMap={ownerMap} draftSlots={draftSlots} />
)}
{/* Teams card - pre_draft/draft only; replaced by standings panel post-draft */}
{season && isDraftOrPreDraft && (
<Card>
<CardHeader>
<CardTitle>Teams</CardTitle>
<CardDescription>
{teams.length} team{teams.length !== 1 ? "s" : ""} in this league
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{teams.map((team) => {
const isOwned = team.ownerId === currentUserId;
const ownerName = team.ownerId
? ownerMap[team.ownerId]
: null;
return (
<Card
key={team.id}
className={isOwned ? "border-primary" : ""}
>
<CardHeader>
<CardTitle className="text-lg">{team.name}</CardTitle>
<CardDescription>
{team.ownerId ? (
isOwned ? (
<span className="text-primary font-medium">
Your Team
</span>
) : (
<span>{ownerName || "Unknown Owner"}</span>
)
) : (
<span className="text-muted-foreground">
Available
</span>
)}
</CardDescription>
</CardHeader>
</Card>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
{/* Right Column - 1/3 width on desktop */}
<div className="space-y-6">
{upcomingCalendarEvents.length > 0 && (
<UpcomingEventsCard
events={upcomingCalendarEvents}
title="Upcoming Events"
limit={6}
viewAllUrl={`/leagues/${league.id}/upcoming-events`}
showLeagueAvatar={false}
/>
)}
<Card>
<CardHeader>
<CardTitle>League Info</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div>
<p className="text-sm text-muted-foreground">Created</p>
<p className="font-medium">
{new Date(league.createdAt).toLocaleDateString()}
</p>
</div>
{season && (
<>
{isDraftOrPreDraft && (
<div>
<p className="text-sm text-muted-foreground">
Teams Filled
</p>
<p className="font-medium">
{teamsWithOwners} of {teams.length}
</p>
</div>
)}
{isDraftOrPreDraft && (
<div>
<p className="text-sm text-muted-foreground">
Draft Order Set
</p>
<div className="flex items-center gap-2">
<p className="font-medium">
{isDraftOrderSet ? "Yes" : "No"}
</p>
{!isDraftOrderSet && isUserCommissioner && (
<Button size="sm" variant="outline" asChild>
<Link to={`/leagues/${league.id}/settings#draft-order`}>
Set Order
</Link>
</Button>
)}
</div>
</div>
)}
<div>
<p className="text-sm text-muted-foreground">
Draft Rounds
</p>
<p className="font-medium">{season.draftRounds}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
Draft Timer Mode
</p>
<p className="font-medium">
{season.draftTimerMode === "standard" ? "Standard Timer" : "Chess Clock"}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">
Sports Selected
</p>
<p className="font-medium">{sportsCount}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Draft Board</p>
<Link
to={`/leagues/${league.id}/draft-board/${season.id}`}
className="text-electric hover:underline font-medium"
>
View Draft Board
</Link>
</div>
{isActiveOrCompleted && (
<div>
<p className="text-sm text-muted-foreground">Standings</p>
<Link
to={`/leagues/${league.id}/standings/${season.id}`}
className="text-electric hover:underline font-medium"
>
View Standings
</Link>
</div>
)}
<div>
<p className="text-sm text-muted-foreground">Flex Spots</p>
<p className="font-medium text-primary">
{Math.max(0, season.draftRounds - sportsCount)}
</p>
<p className="text-xs text-muted-foreground">
Picks not tied to a specific sport
</p>
</div>
{season.draftDateTime && (
<div>
<p className="text-sm text-muted-foreground">
Draft Date
</p>
<p className="font-medium">
{format(new Date(season.draftDateTime), "PPP 'at' p")}
</p>
</div>
)}
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Commissioners</CardTitle>
<CardDescription>
{commissioners.length} commissioner
{commissioners.length !== 1 ? "s" : ""}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{commissioners.map((commissioner) => {
const commissionerName = commissionerMap[commissioner.userId];
const hasTeam = teams.some(
(t) => t.ownerId === commissioner.userId
);
return (
<div
key={commissioner.id}
className="py-2 border-b last:border-0"
>
<p className="font-medium">
{commissionerName || "Unknown User"}
{commissioner.userId === currentUserId && (
<span className="ml-2 text-xs text-primary">
(You)
</span>
{/* 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}`}
/>
)}
</p>
{!hasTeam && (
<p className="text-xs text-muted-foreground">
No team
</p>
)}
</div>
);
})}
</div>
</CardContent>
</Card>
{recentActivity.entries.length > 0 && (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>Recent commissioner actions</CardDescription>
</div>
<Button variant="outline" size="sm" asChild>
<Link to={`/leagues/${league.id}/audit-log`}>View all</Link>
</Button>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{recentActivity.entries.map((entry) => (
<li key={entry.id} className="flex items-start gap-3 text-sm">
<span className="text-muted-foreground whitespace-nowrap shrink-0">
{format(new Date(entry.createdAt), "MMM d, HH:mm")}
</span>
<span>
<span className="font-medium">{entry.actorDisplayName ?? entry.actorClerkId}</span>
{" — "}
<span className="text-muted-foreground">{formatAuditDetail(entry)}</span>
</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
<CommissionersPanel
commissioners={commissioners}
commissionerMap={commissionerMap}
currentUserId={currentUserId}
teams={teams}
activityEntries={recentActivity.entries}
auditLogUrl={`/leagues/${league.id}/audit-log`}
/>
</div>
</div>
</div>

8
package-lock.json generated
View file

@ -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"

View file

@ -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",

View file

@ -37,6 +37,10 @@ export default defineConfig((env) => ({
allowedHosts: true,
},
ssr: {
noExternal: ["lucide-react"],
},
optimizeDeps: {
exclude: ["@sentry/react-router"]
}