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>
This commit is contained in:
parent
aacf0b29c8
commit
f9c03c8bf1
5 changed files with 523 additions and 51 deletions
|
|
@ -1,9 +1,18 @@
|
||||||
|
import React from 'react';
|
||||||
import { withRouter } from 'storybook-addon-remix-react-router';
|
import { withRouter } from 'storybook-addon-remix-react-router';
|
||||||
import type { Preview } from '@storybook/react-vite'
|
import type { Preview, Decorator } from '@storybook/react-vite'
|
||||||
import '../app/app.css'
|
import '../app/app.css'
|
||||||
|
import { BracktGradients } from '../app/components/ui/BracktGradients';
|
||||||
|
|
||||||
|
const withBracktGradients: Decorator = (Story) => (
|
||||||
|
<>
|
||||||
|
<BracktGradients />
|
||||||
|
<Story />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
decorators: [withRouter], // This wraps all stories in a Router context
|
decorators: [withRouter, withBracktGradients],
|
||||||
parameters: {
|
parameters: {
|
||||||
controls: {
|
controls: {
|
||||||
matchers: {
|
matchers: {
|
||||||
|
|
@ -21,4 +30,4 @@ const preview: Preview = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
265
app/components/league/StandingsPreview.stories.tsx
Normal file
265
app/components/league/StandingsPreview.stories.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { StandingsPreview } from "./StandingsPreview";
|
||||||
|
|
||||||
|
const meta: Meta<typeof StandingsPreview> = {
|
||||||
|
title: "League/StandingsPreview",
|
||||||
|
component: StandingsPreview,
|
||||||
|
parameters: {
|
||||||
|
layout: "padded",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof StandingsPreview>;
|
||||||
|
|
||||||
|
export const TopThreePodium: Story = {
|
||||||
|
args: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
teamId: "t1",
|
||||||
|
teamName: "Lightning Wolves",
|
||||||
|
ownerName: "alice",
|
||||||
|
displayRank: 1,
|
||||||
|
currentRank: 1,
|
||||||
|
points: 2810,
|
||||||
|
href: "/leagues/1/standings/1/teams/t1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t2",
|
||||||
|
teamName: "Shadow Hawks",
|
||||||
|
ownerName: "bob",
|
||||||
|
displayRank: 2,
|
||||||
|
currentRank: 2,
|
||||||
|
points: 2654.5,
|
||||||
|
href: "/leagues/1/standings/1/teams/t2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t3",
|
||||||
|
teamName: "Iron Eagles",
|
||||||
|
ownerName: "carol",
|
||||||
|
displayRank: 3,
|
||||||
|
currentRank: 3,
|
||||||
|
points: 2493,
|
||||||
|
href: "/leagues/1/standings/1/teams/t3",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FullLeague: Story = {
|
||||||
|
args: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
teamId: "t1",
|
||||||
|
teamName: "Lightning Wolves",
|
||||||
|
ownerName: "alice",
|
||||||
|
displayRank: 1,
|
||||||
|
currentRank: 1,
|
||||||
|
points: 2810,
|
||||||
|
rankChange: 2,
|
||||||
|
pointChange: 87.5,
|
||||||
|
href: "/leagues/1/standings/1/teams/t1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t2",
|
||||||
|
teamName: "Shadow Hawks",
|
||||||
|
ownerName: "bob",
|
||||||
|
displayRank: 2,
|
||||||
|
currentRank: 2,
|
||||||
|
points: 2654.5,
|
||||||
|
rankChange: 0,
|
||||||
|
pointChange: 42.0,
|
||||||
|
href: "/leagues/1/standings/1/teams/t2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t3",
|
||||||
|
teamName: "Iron Eagles",
|
||||||
|
ownerName: "carol",
|
||||||
|
displayRank: 3,
|
||||||
|
currentRank: 3,
|
||||||
|
points: 2493,
|
||||||
|
rankChange: -1,
|
||||||
|
pointChange: -12.3,
|
||||||
|
href: "/leagues/1/standings/1/teams/t3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t4",
|
||||||
|
teamName: "Cyber Foxes",
|
||||||
|
ownerName: "dave",
|
||||||
|
displayRank: 4,
|
||||||
|
currentRank: 4,
|
||||||
|
points: 2311.8,
|
||||||
|
rankChange: 1,
|
||||||
|
pointChange: 23.1,
|
||||||
|
href: "/leagues/1/standings/1/teams/t4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t5",
|
||||||
|
teamName: "Neon Tigers",
|
||||||
|
ownerName: "eve",
|
||||||
|
displayRank: 5,
|
||||||
|
currentRank: 5,
|
||||||
|
points: 2108,
|
||||||
|
rankChange: -2,
|
||||||
|
pointChange: -55.0,
|
||||||
|
href: "/leagues/1/standings/1/teams/t5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t6",
|
||||||
|
teamName: "Phantom Sharks",
|
||||||
|
ownerName: "frank",
|
||||||
|
displayRank: 6,
|
||||||
|
currentRank: 6,
|
||||||
|
points: 1987.3,
|
||||||
|
rankChange: 0,
|
||||||
|
pointChange: 0,
|
||||||
|
href: "/leagues/1/standings/1/teams/t6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t7",
|
||||||
|
teamName: "Crimson Tide FC",
|
||||||
|
ownerName: "grace",
|
||||||
|
displayRank: 7,
|
||||||
|
currentRank: 7,
|
||||||
|
points: 1834,
|
||||||
|
href: "/leagues/1/standings/1/teams/t7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t8",
|
||||||
|
teamName: "Arctic Wolves United",
|
||||||
|
ownerName: "hank",
|
||||||
|
displayRank: 8,
|
||||||
|
currentRank: 8,
|
||||||
|
points: 1692.6,
|
||||||
|
href: "/leagues/1/standings/1/teams/t8",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithTies: Story = {
|
||||||
|
args: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
teamId: "t1",
|
||||||
|
teamName: "Lightning Wolves",
|
||||||
|
ownerName: "alice",
|
||||||
|
displayRank: "T1",
|
||||||
|
currentRank: 1,
|
||||||
|
points: 2810,
|
||||||
|
href: "/leagues/1/standings/1/teams/t1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t2",
|
||||||
|
teamName: "Shadow Hawks",
|
||||||
|
ownerName: "bob",
|
||||||
|
displayRank: "T1",
|
||||||
|
currentRank: 1,
|
||||||
|
points: 2810,
|
||||||
|
href: "/leagues/1/standings/1/teams/t2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t3",
|
||||||
|
teamName: "Iron Eagles",
|
||||||
|
ownerName: "carol",
|
||||||
|
displayRank: "T3",
|
||||||
|
currentRank: 3,
|
||||||
|
points: 2493,
|
||||||
|
href: "/leagues/1/standings/1/teams/t3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t4",
|
||||||
|
teamName: "Cyber Foxes",
|
||||||
|
ownerName: "dave",
|
||||||
|
displayRank: "T3",
|
||||||
|
currentRank: 3,
|
||||||
|
points: 2493,
|
||||||
|
href: "/leagues/1/standings/1/teams/t4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllUnranked: Story = {
|
||||||
|
args: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
teamId: "t1",
|
||||||
|
teamName: "Lightning Wolves",
|
||||||
|
ownerName: "alice",
|
||||||
|
displayRank: "T1",
|
||||||
|
points: 0,
|
||||||
|
href: "/leagues/1/standings/1/teams/t1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t2",
|
||||||
|
teamName: "Shadow Hawks",
|
||||||
|
ownerName: "bob",
|
||||||
|
displayRank: "T1",
|
||||||
|
points: 0,
|
||||||
|
href: "/leagues/1/standings/1/teams/t2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t3",
|
||||||
|
teamName: "Iron Eagles",
|
||||||
|
ownerName: "carol",
|
||||||
|
displayRank: "T1",
|
||||||
|
points: 0,
|
||||||
|
href: "/leagues/1/standings/1/teams/t3",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoOwnerNames: Story = {
|
||||||
|
args: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
teamId: "t1",
|
||||||
|
teamName: "Lightning Wolves",
|
||||||
|
displayRank: 1,
|
||||||
|
currentRank: 1,
|
||||||
|
points: 2810,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t2",
|
||||||
|
teamName: "Shadow Hawks",
|
||||||
|
displayRank: 2,
|
||||||
|
currentRank: 2,
|
||||||
|
points: 2654.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t3",
|
||||||
|
teamName: "Iron Eagles",
|
||||||
|
displayRank: 3,
|
||||||
|
currentRank: 3,
|
||||||
|
points: 2493,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LongTeamNames: Story = {
|
||||||
|
args: {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
teamId: "t1",
|
||||||
|
teamName: "The Unstoppable Championship Winning Lightning Wolves",
|
||||||
|
ownerName: "alice_with_a_really_long_username",
|
||||||
|
displayRank: 1,
|
||||||
|
currentRank: 1,
|
||||||
|
points: 2810,
|
||||||
|
href: "/leagues/1/standings/1/teams/t1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
teamId: "t2",
|
||||||
|
teamName: "Shadow Hawks of the Northern Division",
|
||||||
|
ownerName: "bob",
|
||||||
|
displayRank: 2,
|
||||||
|
currentRank: 2,
|
||||||
|
points: 2654.5,
|
||||||
|
href: "/leagues/1/standings/1/teams/t2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
221
app/components/league/StandingsPreview.tsx
Normal file
221
app/components/league/StandingsPreview.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
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";
|
||||||
|
|
||||||
|
// ─── TeamAvatar ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const TEAM_AVATAR_COLORS = [
|
||||||
|
"#adf661",
|
||||||
|
"#2ce1c1",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#f59e0b",
|
||||||
|
"#ef4444",
|
||||||
|
"#3b82f6",
|
||||||
|
];
|
||||||
|
|
||||||
|
function hashTeamId(id: string): number {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < id.length; i++) {
|
||||||
|
hash = (hash * 31 + id.charCodeAt(i)) & 0xffff;
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TeamAvatar({ teamId, teamName }: { teamId: string; teamName: string }) {
|
||||||
|
const color = TEAM_AVATAR_COLORS[hashTeamId(teamId) % TEAM_AVATAR_COLORS.length];
|
||||||
|
const initials =
|
||||||
|
teamName
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((w) => w[0].toUpperCase())
|
||||||
|
.join("") || "?";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="img"
|
||||||
|
aria-label={teamName}
|
||||||
|
className="h-9 w-9 flex shrink-0 items-center justify-center font-bold text-sm"
|
||||||
|
style={{ backgroundColor: "#000", color }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 }) {
|
||||||
|
const sign = change >= 0 ? "+" : "";
|
||||||
|
if (change >= 0) {
|
||||||
|
return <span className="text-xs font-semibold text-primary">{sign}{change.toFixed(1)}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="text-xs font-semibold" style={{ color: "var(--coral-accent, #ef4444)" }}>
|
||||||
|
{sign}{change.toFixed(1)}
|
||||||
|
</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-2.5 sm:px-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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -8,8 +8,10 @@ export function BracktGradients() {
|
||||||
return (
|
return (
|
||||||
<svg width="0" height="0" aria-hidden className="absolute">
|
<svg width="0" height="0" aria-hidden className="absolute">
|
||||||
<defs>
|
<defs>
|
||||||
{/* Green (top) → Cyan (bottom) — the main Brackt brand gradient */}
|
{/* Green (top) → Cyan (bottom) — the main Brackt brand gradient.
|
||||||
<linearGradient id="brackt-primary-gradient" x1="0" y1="0" x2="0" y2="1">
|
userSpaceOnUse + 0→24 matches the Lucide icon viewBox so horizontal
|
||||||
|
strokes (zero bounding-box height) don't produce a degenerate gradient. */}
|
||||||
|
<linearGradient id="brackt-primary-gradient" x1="0" y1="0" x2="0" y2="24" gradientUnits="userSpaceOnUse">
|
||||||
<stop offset="0%" stopColor="#adf661" />
|
<stop offset="0%" stopColor="#adf661" />
|
||||||
<stop offset="100%" stopColor="#2ce1c1" />
|
<stop offset="100%" stopColor="#2ce1c1" />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ import {
|
||||||
} from "~/components/ui/card";
|
} from "~/components/ui/card";
|
||||||
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
|
import { SportSeasonCard } from "~/components/sports/SportSeasonCard";
|
||||||
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
|
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
|
||||||
import { TeamNameDisplay } from "~/components/ui/team-name-display";
|
|
||||||
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
import { buildTiedRankChecker, getDisplayRank } from "~/lib/standings-display";
|
||||||
|
import { StandingsPreview, type StandingsPreviewEntry } from "~/components/league/StandingsPreview";
|
||||||
import { formatAuditDetail } from "~/lib/audit-log-display";
|
import { formatAuditDetail } from "~/lib/audit-log-display";
|
||||||
|
|
||||||
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
|
|
@ -97,6 +97,21 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
return rankA - rankB;
|
return rankA - rankB;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const standingsEntries: StandingsPreviewEntry[] = sortedTeams.map((team) => {
|
||||||
|
const standing = standingsMap.get(team.id);
|
||||||
|
return {
|
||||||
|
teamId: team.id,
|
||||||
|
teamName: team.name,
|
||||||
|
ownerName: team.ownerId ? ownerMap[team.ownerId] : null,
|
||||||
|
displayRank: getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false),
|
||||||
|
currentRank: standing?.currentRank,
|
||||||
|
points: standing ? (standing.actualPoints ?? standing.totalPoints) : 0,
|
||||||
|
href: season ? `/leagues/${league.id}/standings/${season.id}/teams/${team.id}` : undefined,
|
||||||
|
rankChange: standing?.rankChange,
|
||||||
|
pointChange: standing?.sevenDayPointChange,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Pre-compute sorted sports seasons: active → upcoming → completed,
|
// Pre-compute sorted sports seasons: active → upcoming → completed,
|
||||||
// season_standings last within active, then alphabetical by sport name
|
// season_standings last within active, then alphabetical by sport name
|
||||||
const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
|
const sortedSportsSeasons = [...sportsSeasons].toSorted((a, b) => {
|
||||||
|
|
@ -171,51 +186,11 @@ export default function LeagueHome({ loaderData }: Route.ComponentProps) {
|
||||||
<div className="md:col-span-2 space-y-6">
|
<div className="md:col-span-2 space-y-6">
|
||||||
{/* Standings Panel - active/completed seasons */}
|
{/* Standings Panel - active/completed seasons */}
|
||||||
{season && isActiveOrCompleted && (
|
{season && isActiveOrCompleted && (
|
||||||
<Card>
|
<StandingsPreview
|
||||||
<CardHeader>
|
entries={standingsEntries}
|
||||||
<div className="flex items-center justify-between">
|
description={season.status === "completed" ? "Final standings" : undefined}
|
||||||
<div>
|
fullStandingsHref={`/leagues/${league.id}/standings/${season.id}`}
|
||||||
<CardTitle>Standings</CardTitle>
|
/>
|
||||||
<CardDescription>
|
|
||||||
{season.status === "completed" ? "Final standings" : "Current season standings"}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link to={`/leagues/${league.id}/standings/${season.id}`}>
|
|
||||||
Full Standings
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{sortedTeams.map((team) => {
|
|
||||||
const standing = standingsMap.get(team.id);
|
|
||||||
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={team.id}
|
|
||||||
className="flex items-center gap-3 py-2 border-b last:border-0"
|
|
||||||
>
|
|
||||||
<div className="w-8 text-center text-sm font-bold text-muted-foreground">
|
|
||||||
{getDisplayRank(standing, standings.length, standing ? isTiedRank(standing.currentRank) : false)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<TeamNameDisplay
|
|
||||||
teamName={team.name}
|
|
||||||
ownerName={ownerName}
|
|
||||||
href={`/leagues/${league.id}/standings/${season.id}/teams/${team.id}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium tabular-nums">
|
|
||||||
{standing ? (standing.actualPoints ?? standing.totalPoints) : 0} pts
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sports Seasons Section */}
|
{/* Sports Seasons Section */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue