Finish up league page styling.
This commit is contained in:
parent
e93a7fcae2
commit
4d959c704a
13 changed files with 965 additions and 324 deletions
172
app/components/league/CommissionersPanel.stories.tsx
Normal file
172
app/components/league/CommissionersPanel.stories.tsx
Normal 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",
|
||||
},
|
||||
};
|
||||
121
app/components/league/CommissionersPanel.tsx
Normal file
121
app/components/league/CommissionersPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
app/components/league/DraftInfoCard.stories.tsx
Normal file
145
app/components/league/DraftInfoCard.stories.tsx
Normal 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",
|
||||
},
|
||||
};
|
||||
208
app/components/league/DraftInfoCard.tsx
Normal file
208
app/components/league/DraftInfoCard.tsx
Normal 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} />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
94
app/components/league/TeamsPanel.stories.tsx
Normal file
94
app/components/league/TeamsPanel.stories.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
89
app/components/league/TeamsPanel.tsx
Normal file
89
app/components/league/TeamsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -228,6 +228,7 @@ function isValidStatus(s: string): s is ValidStatus {
|
|||
events={upcomingCalendarEvents}
|
||||
limit={8}
|
||||
viewAllUrl="/upcoming-events"
|
||||
showLeagueAvatar={leagueRows.length > 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
8
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ export default defineConfig((env) => ({
|
|||
allowedHosts: true,
|
||||
},
|
||||
|
||||
ssr: {
|
||||
noExternal: ["lucide-react"],
|
||||
},
|
||||
|
||||
optimizeDeps: {
|
||||
exclude: ["@sentry/react-router"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue