* Redesign home page with new layout and component system - Two-column layout (My Leagues 2/3, Upcoming Events 1/3) with mobile stack - LeagueRow: square avatar, gradient draft highlight, rank/points display, progress bar - MyLeaguesCard, CreateLeagueCard with shared SectionCardHeader - UpcomingEventsCard: vertical timeline with grouped multi-league events - Shared gradient system: BracktGradients SVG defs, GradientIcon wrapper, brand.ts constants - Button default variant updated to green→cyan gradient - Navbar: plain nav links with gradient hover, support/admin icon buttons - Accessibility fixes: semantic h2 headings, aria-label on LeagueAvatar and nav elements - Storybook stories for all new components Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Responsive league row layout and mobile polish - League rows stack avatar+name on top, stats full-width below on mobile - Stats spread to right side on sm+ screens with border separator on mobile - Tighter padding on mobile (px-3/py-3), full padding on sm+ - Card headers and content use px-3 sm:px-6 to reduce mobile gutters - Two-column home layout deferred to lg breakpoint (tablet gets stacked) - Active leagues sorted by completion percentage descending - Default rank 1 / 0 points for active leagues with no scoring events yet - Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators - Remove dead StatDivider className prop Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Improve claude file. * Add StandingsPreview card component with podium row styling - New StandingsPreview component with gold/silver/bronze row tints for top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points) with rank and 7-day point change indicators - Fix GradientIcon in Storybook by adding BracktGradients decorator to preview.tsx (renamed from .ts to support JSX) - Fix degenerate SVG gradient on horizontal strokes by switching BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space coordinates (0→24) - Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only fix was sufficient once gradientUnits was corrected Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update components on league homepage. * Finish up league page styling. * Work on standings page. * Add story for RecentScoresCard * Update Point Progression Chart. * Sort point progression legend by ranking and add team links to standings rows * Fix standings discrepancy on change. * Create draft cell component. * Update draft board page * Draft room improvements. * Update some draft room styling. * Fix context menu missing. * Move tab navigation and autodraft to header row, narrow sidebar * Virtualize available participants list, memoize draft room props Adds @tanstack/react-virtual to replace separate mobile/desktop lists with a single unified virtual scroll loop. Also memoizes miniDraftGrid and availableParticipantsSectionProps, and switches pick lookup from Array.find to a Map for O(1) access. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update draft room UI. * More draft room fixes. * Draft room tweaks. * Fix Rosters page. * Queue Section fixes. * Mobile Draft fixes. * Fix draft board page. * Create bracket look. * Bracket work. * Finish bracket page. * Homepage initial styling * homepage copy * Add privacy policy. Fixes #88. * how to play copy * rules copy * Fix brackets on homepage. * Add footer to website. * Glow on dots. * Landing page copy. * Fix sidebar. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
180 lines
6.5 KiB
TypeScript
180 lines
6.5 KiB
TypeScript
import { ListOrdered } from "lucide-react";
|
||
import { Link } from "react-router";
|
||
import { Button } from "~/components/ui/button";
|
||
import { Card, CardContent, CardHeader } from "~/components/ui/card";
|
||
import { GradientIcon } from "~/components/ui/GradientIcon";
|
||
import { TeamAvatar } from "~/components/TeamAvatar";
|
||
|
||
// ─── Stat helpers (mirrors LeagueRow) ────────────────────────────────────────
|
||
|
||
function StatColumn({
|
||
label,
|
||
children,
|
||
}: {
|
||
label: string;
|
||
children: React.ReactNode;
|
||
}) {
|
||
return (
|
||
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||
{label}
|
||
</p>
|
||
<div className="flex items-baseline justify-end gap-1">{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatDivider() {
|
||
return <div className="h-8 w-px bg-border shrink-0 self-center" />;
|
||
}
|
||
|
||
function RankChangeIndicator({ change }: { change: number }) {
|
||
if (change === 0) return null;
|
||
if (change > 0) {
|
||
return (
|
||
<span className="text-xs font-semibold text-primary" aria-label={`up ${change}`}>
|
||
▲{change}
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span
|
||
className="text-xs font-semibold"
|
||
style={{ color: "var(--coral-accent, #ef4444)" }}
|
||
aria-label={`down ${Math.abs(change)}`}
|
||
>
|
||
▼{Math.abs(change)}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function PointChangeIndicator({ change }: { change: number }) {
|
||
if (change >= 0) {
|
||
return <span className="text-xs font-semibold text-primary">▲{Math.round(change)}</span>;
|
||
}
|
||
return (
|
||
<span className="text-xs font-semibold" style={{ color: "var(--coral-accent, #ef4444)" }}>
|
||
▼{Math.abs(Math.round(change))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ─── Row styles ───────────────────────────────────────────────────────────────
|
||
|
||
const ROW_RANK_CLASSES: Record<number, string> = {
|
||
1: "bg-yellow-500/10 hover:bg-yellow-500/15",
|
||
2: "bg-white/[0.14] hover:bg-white/[0.18]",
|
||
3: "bg-orange-600/[0.08] hover:bg-orange-600/[0.12]",
|
||
};
|
||
|
||
function rowClasses(currentRank: number | undefined, hasHref: boolean): string {
|
||
const podium = currentRank !== undefined ? ROW_RANK_CLASSES[currentRank] : undefined;
|
||
const base = podium ?? `bg-white/[0.04] ${hasHref ? "hover:bg-white/[0.07]" : ""}`;
|
||
return `flex flex-col sm:flex-row sm:items-center gap-0 rounded-lg px-3 py-3 sm:px-5 sm:py-4 transition-colors ${base}`;
|
||
}
|
||
|
||
// ─── Row content ──────────────────────────────────────────────────────────────
|
||
|
||
function RowContent({ entry }: { entry: StandingsPreviewEntry }) {
|
||
return (
|
||
<>
|
||
{/* Left: avatar + name */}
|
||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||
<TeamAvatar teamId={entry.teamId} teamName={entry.teamName} />
|
||
<div className="min-w-0">
|
||
<p className="font-medium text-sm leading-tight truncate">{entry.teamName}</p>
|
||
{entry.ownerName && (
|
||
<p className="text-xs text-muted-foreground truncate">{entry.ownerName}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: stats — second row on mobile */}
|
||
<div className="flex items-center gap-4 w-full border-t border-border/30 pt-2 mt-1 sm:w-auto sm:border-0 sm:pt-0 sm:mt-0 sm:shrink-0">
|
||
<StatColumn label="Ranking">
|
||
<span className="text-2xl font-bold leading-none">{entry.displayRank}</span>
|
||
{entry.rankChange !== undefined && entry.rankChange !== 0 && (
|
||
<RankChangeIndicator change={entry.rankChange} />
|
||
)}
|
||
</StatColumn>
|
||
<StatDivider />
|
||
<StatColumn label="Points">
|
||
<span className="text-2xl font-bold leading-none text-electric">
|
||
{Math.round(entry.points).toLocaleString("en-US")}
|
||
</span>
|
||
{entry.pointChange !== undefined && entry.pointChange !== 0 && (
|
||
<PointChangeIndicator change={entry.pointChange} />
|
||
)}
|
||
</StatColumn>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||
|
||
export interface StandingsPreviewEntry {
|
||
teamId: string;
|
||
teamName: string;
|
||
ownerName?: string | null;
|
||
/** Pre-computed display rank, e.g. 1, "T2", "T5". Use getDisplayRank(). */
|
||
displayRank: string | number;
|
||
/** Numeric rank used to select the gold/silver/bronze row tint (1–3 only). */
|
||
currentRank?: number;
|
||
points: number;
|
||
href?: string;
|
||
/** Positive = moved up, negative = moved down. From TeamStanding.rankChange. */
|
||
rankChange?: number;
|
||
/** 7-day point delta. From TeamStanding.sevenDayPointChange. */
|
||
pointChange?: number;
|
||
}
|
||
|
||
export interface StandingsPreviewProps {
|
||
entries: StandingsPreviewEntry[];
|
||
description?: string;
|
||
fullStandingsHref?: string;
|
||
}
|
||
|
||
export function StandingsPreview({ entries, description, fullStandingsHref }: StandingsPreviewProps) {
|
||
return (
|
||
<Card className="gap-2">
|
||
<CardHeader className="px-3 sm:px-6 pb-2">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2">
|
||
<GradientIcon icon={ListOrdered} className="h-5 w-5 shrink-0" />
|
||
<div>
|
||
<h2 className="text-xl font-bold leading-none tracking-tight">Standings</h2>
|
||
{description && (
|
||
<p className="text-sm text-muted-foreground mt-0.5">{description}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{fullStandingsHref && (
|
||
<Button variant="outline" size="sm" asChild>
|
||
<Link to={fullStandingsHref}>Full Standings</Link>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="px-3 sm:px-6">
|
||
<div className="space-y-3">
|
||
{entries.map((entry) =>
|
||
entry.href ? (
|
||
<Link
|
||
key={entry.teamId}
|
||
to={entry.href}
|
||
className={rowClasses(entry.currentRank, true)}
|
||
>
|
||
<RowContent entry={entry} />
|
||
</Link>
|
||
) : (
|
||
<div key={entry.teamId} className={rowClasses(entry.currentRank, false)}>
|
||
<RowContent entry={entry} />
|
||
</div>
|
||
)
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|