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>
This commit is contained in:
Chris Parsons 2026-04-02 10:31:10 -07:00
parent 13bc1e3b5f
commit e0cd829b9f
29 changed files with 1499 additions and 230 deletions

View file

@ -4,7 +4,7 @@
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
--font-sans: "Barlow", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@ -51,42 +51,43 @@
html {
color-scheme: dark;
font-size: 18px;
}
:root {
--radius: 0.625rem;
--background: oklch(0.13 0.015 255);
--foreground: oklch(0.95 0.01 255);
--card: oklch(0.18 0.02 255);
--card-foreground: oklch(0.95 0.01 255);
--popover: oklch(0.18 0.02 255);
--popover-foreground: oklch(0.95 0.01 255);
--primary: oklch(0.623 0.214 259.815);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.22 0.025 255);
--secondary-foreground: oklch(0.95 0.01 255);
--muted: oklch(0.22 0.025 255);
--muted-foreground: oklch(0.65 0.02 255);
--accent: oklch(0.25 0.03 255);
--accent-foreground: oklch(0.95 0.01 255);
--background: #14171e;
--foreground: #ffffff;
--card: #1c1f26;
--card-foreground: #ffffff;
--popover: #1c1f26;
--popover-foreground: #ffffff;
--primary: #adf661;
--primary-foreground: #14171e;
--secondary: #1c1f26;
--secondary-foreground: #ffffff;
--muted: #1c1f26;
--muted-foreground: rgb(255 255 255 / 55%);
--accent: #2ce1c1;
--accent-foreground: #14171e;
--destructive: oklch(0.65 0.22 25);
--border: oklch(1 0 0 / 8%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.623 0.214 259.815 / 50%);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--border: rgb(255 255 255 / 10%);
--input: rgb(255 255 255 / 12%);
--ring: #adf661;
--chart-1: #adf661;
--chart-2: #2ce1c1;
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.18 0.02 255);
--sidebar-foreground: oklch(0.95 0.01 255);
--sidebar-primary: oklch(0.623 0.214 259.815);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.25 0.03 255);
--sidebar-accent-foreground: oklch(0.95 0.01 255);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.623 0.214 259.815 / 50%);
--electric: oklch(0.623 0.214 259.815);
--sidebar: #1c1f26;
--sidebar-foreground: #ffffff;
--sidebar-primary: #adf661;
--sidebar-primary-foreground: #14171e;
--sidebar-accent: #2ce1c1;
--sidebar-accent-foreground: #14171e;
--sidebar-border: rgb(255 255 255 / 10%);
--sidebar-ring: #adf661;
--electric: #2ce1c1;
--amber-accent: oklch(0.79 0.17 70);
--coral-accent: oklch(0.68 0.19 35);
}
@ -98,6 +99,10 @@ html {
body {
@apply bg-background text-foreground;
}
.bg-card {
background: #1c1f26;
border-color: #2a2e38 !important;
}
}
/* NProgress theme override */

View file

@ -0,0 +1,15 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CreateLeagueCard } from "./CreateLeagueCard";
const meta: Meta<typeof CreateLeagueCard> = {
title: "League/CreateLeagueCard",
component: CreateLeagueCard,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof CreateLeagueCard>;
export const Default: Story = {};

View file

@ -0,0 +1,21 @@
import { Swords } from "lucide-react";
import { Link } from "react-router";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { SectionCardHeader } from "~/components/ui/SectionCardHeader";
export function CreateLeagueCard() {
return (
<Card className="gap-2">
<SectionCardHeader icon={Swords} title="Start a New League" />
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Invite friends, pick your sports, and start drafting.
</p>
<Button asChild className="w-full">
<Link to="/leagues/new">Create a League</Link>
</Button>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,52 @@
const AVATAR_COLORS = [
"#adf661",
"#2ce1c1",
"#8b5cf6",
"#f59e0b",
"#ef4444",
"#3b82f6",
];
function hashLeagueId(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) & 0xffff;
}
return hash;
}
interface LeagueAvatarProps {
leagueId: string;
leagueName: string;
/** Tailwind size classes, e.g. "h-10 w-10" (default) or "h-5 w-5" */
className?: string;
/** Font size class, e.g. "text-sm" (default) or "text-[10px]" */
textClassName?: string;
}
export function LeagueAvatar({
leagueId,
leagueName,
className = "h-10 w-10",
textClassName = "text-sm",
}: LeagueAvatarProps) {
const color = AVATAR_COLORS[hashLeagueId(leagueId) % AVATAR_COLORS.length];
const initials =
leagueName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0].toUpperCase())
.join("") || "?";
return (
<div
role="img"
aria-label={leagueName}
className={`flex shrink-0 items-center justify-center rounded-none font-bold ${className} ${textClassName}`}
style={{ backgroundColor: "#000", color }}
>
{initials}
</div>
);
}

View file

@ -0,0 +1,99 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LeagueRow } from "./LeagueRow";
const meta: Meta<typeof LeagueRow> = {
title: "League/LeagueRow",
component: LeagueRow,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof LeagueRow>;
const baseLeague = {
leagueId: "league-abc-123",
leagueName: "Alpha Dynasty",
numSports: 2,
};
export const DraftInProgress: Story = {
args: {
...baseLeague,
status: "draft",
seasonId: "season-xyz-456",
},
};
export const ActiveRankUp: Story = {
args: {
...baseLeague,
leagueName: "Hardcourt Elites",
leagueId: "league-hce-999",
numSports: 1,
status: "active",
currentRank: 4,
previousRank: 6,
totalPoints: 1422.8,
},
};
export const ActiveRankDown: Story = {
args: {
...baseLeague,
leagueName: "Monday Night Madness",
leagueId: "league-mnm-777",
numSports: 3,
status: "active",
currentRank: 7,
previousRank: 5,
totalPoints: 987.5,
},
};
export const ActiveNoChange: Story = {
args: {
...baseLeague,
leagueName: "The Bracket Boys",
leagueId: "league-tbb-555",
numSports: 2,
status: "active",
currentRank: 1,
previousRank: 1,
totalPoints: 2810.0,
},
};
export const ActiveNoStanding: Story = {
args: {
...baseLeague,
leagueName: "Fresh Start",
leagueId: "league-fs-222",
numSports: 2,
status: "active",
},
};
export const PreDraft: Story = {
args: {
...baseLeague,
leagueName: "Summer Smash League",
leagueId: "league-ssl-333",
numSports: 4,
status: "pre_draft",
seasonId: "season-ssl-999",
},
};
export const Completed: Story = {
args: {
...baseLeague,
leagueName: "2023 World Tour",
leagueId: "league-wt-111",
numSports: 2,
status: "completed",
currentRank: 3,
totalPoints: 3150.2,
},
};

View file

@ -0,0 +1,234 @@
import { formatDistanceToNow } from "date-fns";
import { Link } from "react-router";
import { Button } from "~/components/ui/button";
import { LeagueAvatar } from "./LeagueAvatar";
export interface LeagueRowProps {
leagueId: string;
leagueName: string;
numSports: number;
status: "draft" | "active" | "pre_draft" | "completed";
seasonId?: string;
currentRank?: number;
totalPoints?: number;
previousRank?: number;
completionPercentage?: number;
draftDateTime?: string | null;
picksUntilMyTurn?: number;
draftPosition?: number;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function ordinal(n: number): string {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]);
}
// ─── Shared stat column ───────────────────────────────────────────────────────
function StatColumn({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="text-right shrink-0">
<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" />;
}
// ─── Season progress bar ──────────────────────────────────────────────────────
function SeasonProgress({ pct, status }: { pct: number; status: "active" | "completed" }) {
const label = status === "completed" ? "100%" : `${pct}%`;
const fill = status === "completed" ? 100 : pct;
return (
<div className="flex items-center gap-1.5 mt-1.5 w-24">
<div className="flex-1 h-1 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full rounded-full bg-electric transition-all"
style={{ width: `${fill}%` }}
/>
</div>
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
</div>
);
}
// ─── Rank change indicator ────────────────────────────────────────────────────
function RankChange({ current, previous }: { current: number; previous: number | undefined }) {
if (previous === undefined || previous === current) return null;
const delta = previous - current;
if (delta > 0) {
return <span className="text-xs font-semibold text-primary">{delta}</span>;
}
return (
<span className="text-xs font-semibold" style={{ color: "var(--coral-accent, #ef4444)" }}>
{Math.abs(delta)}
</span>
);
}
// ─── Row variants ─────────────────────────────────────────────────────────────
function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRowProps) {
const picksLabel =
picksUntilMyTurn === 0
? "You're on the clock!"
: picksUntilMyTurn !== undefined
? `Up in ${picksUntilMyTurn} pick${picksUntilMyTurn === 1 ? "" : "s"}`
: null;
return (
<div className="flex items-center gap-3 rounded-lg border border-primary/40 bg-primary/10 px-5 py-4">
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<div className="flex-1 min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<div className="flex items-center gap-1.5">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-electric" />
<span className="text-xs font-semibold tracking-wide text-electric uppercase">
Draft in Progress
</span>
</div>
{picksLabel && (
<span className="text-xs text-muted-foreground">{picksLabel}</span>
)}
</div>
</div>
{seasonId && (
<Button asChild size="sm" className="shrink-0">
<Link to={`/leagues/${leagueId}/draft/${seasonId}`}>Enter Draft</Link>
</Button>
)}
</div>
);
}
function ActiveRow({
leagueId,
leagueName,
currentRank,
totalPoints,
previousRank,
completionPercentage = 0,
}: LeagueRowProps) {
const showStats = currentRank !== undefined || totalPoints !== undefined;
return (
<Link
to={`/leagues/${leagueId}`}
className="flex items-center gap-3 rounded-lg bg-card px-5 py-4 hover:bg-white/[0.06] transition-colors"
>
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<div className="flex-1 min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<SeasonProgress pct={completionPercentage} status="active" />
</div>
{showStats && (
<div className="flex items-center gap-4 shrink-0">
{currentRank !== undefined && (
<StatColumn label="Ranking">
<span className="text-2xl font-bold leading-none">#{currentRank}</span>
<RankChange current={currentRank} previous={previousRank} />
</StatColumn>
)}
{currentRank !== undefined && totalPoints !== undefined && <StatDivider />}
{totalPoints !== undefined && (
<StatColumn label="Points">
<span className="text-2xl font-bold leading-none text-electric">
{Math.round(totalPoints).toLocaleString("en-US")}
</span>
</StatColumn>
)}
</div>
)}
</Link>
);
}
function PreDraftRow({
leagueId,
leagueName,
draftDateTime,
draftPosition,
}: LeagueRowProps) {
let draftTimeValue = "Not scheduled";
if (draftDateTime) {
const draftDate = new Date(draftDateTime);
draftTimeValue =
draftDate > new Date()
? formatDistanceToNow(draftDate, { addSuffix: true })
: "Starting soon";
}
return (
<Link
to={`/leagues/${leagueId}`}
className="flex items-center gap-3 rounded-lg bg-card px-5 py-4 hover:bg-white/[0.06] transition-colors"
>
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<div className="flex-1 min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<p className="text-xs text-muted-foreground mt-0.5">Pre-Draft</p>
</div>
<div className="flex items-center gap-4 shrink-0">
<StatColumn label="Draft">
<span className="text-lg font-bold leading-none">{draftTimeValue}</span>
</StatColumn>
{draftPosition !== undefined && (
<>
<StatDivider />
<StatColumn label="Position">
<span className="text-2xl font-bold leading-none">
{ordinal(draftPosition)}
</span>
</StatColumn>
</>
)}
</div>
</Link>
);
}
function CompletedRow({
leagueId,
leagueName,
completionPercentage = 0,
}: LeagueRowProps) {
return (
<Link
to={`/leagues/${leagueId}`}
className="flex items-center gap-3 rounded-lg bg-card px-5 py-4 hover:bg-white/[0.06] transition-colors"
>
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
<div className="flex-1 min-w-0">
<p className="font-semibold leading-tight truncate">{leagueName}</p>
<SeasonProgress pct={completionPercentage} status="completed" />
</div>
</Link>
);
}
// ─── Public export ────────────────────────────────────────────────────────────
export function LeagueRow(props: LeagueRowProps) {
if (props.status === "draft") return <DraftRow {...props} />;
if (props.status === "active") return <ActiveRow {...props} />;
if (props.status === "pre_draft") return <PreDraftRow {...props} />;
return <CompletedRow {...props} />;
}

View file

@ -0,0 +1,96 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MyLeaguesCard } from "./MyLeaguesCard";
const meta: Meta<typeof MyLeaguesCard> = {
title: "League/MyLeaguesCard",
component: MyLeaguesCard,
parameters: {
layout: "padded",
},
};
export default meta;
type Story = StoryObj<typeof MyLeaguesCard>;
export const MixedStatuses: Story = {
args: {
leagues: [
{
leagueId: "league-abc-123",
leagueName: "Alpha Dynasty",
numSports: 2,
status: "active",
currentRank: 4,
previousRank: 6,
totalPoints: 1422.8,
},
{
leagueId: "league-hce-999",
leagueName: "Hardcourt Elites",
numSports: 1,
status: "draft",
seasonId: "season-xyz-456",
},
{
leagueId: "league-ssl-333",
leagueName: "Summer Smash",
numSports: 3,
status: "pre_draft",
},
{
leagueId: "league-wt-111",
leagueName: "2023 World Tour",
numSports: 2,
status: "completed",
currentRank: 2,
totalPoints: 3150.2,
},
],
},
};
export const DraftingOnly: Story = {
args: {
leagues: [
{
leagueId: "league-abc-123",
leagueName: "World Aquatics Open",
numSports: 2,
status: "draft",
seasonId: "season-wao-789",
},
],
},
};
export const ActiveOnly: Story = {
args: {
leagues: [
{
leagueId: "league-abc-123",
leagueName: "Alpha Dynasty",
numSports: 2,
status: "active",
currentRank: 4,
previousRank: 6,
totalPoints: 1422.8,
},
{
leagueId: "league-hce-999",
leagueName: "Hardcourt Elites",
numSports: 1,
status: "active",
currentRank: 12,
previousRank: 11,
totalPoints: 2810.0,
},
],
viewAllUrl: "/leagues",
},
};
export const Empty: Story = {
args: {
leagues: [],
},
};

View file

@ -0,0 +1,59 @@
import { Shield } from "lucide-react";
import { Link } from "react-router";
import { Card, CardContent } from "~/components/ui/card";
import { SectionCardHeader } from "~/components/ui/SectionCardHeader";
import { LeagueRow, type LeagueRowProps } from "./LeagueRow";
const STATUS_ORDER: Record<LeagueRowProps["status"], number> = {
draft: 0,
active: 1,
pre_draft: 2,
completed: 3,
};
interface MyLeaguesCardProps {
leagues: LeagueRowProps[];
viewAllUrl?: string;
}
export function MyLeaguesCard({ leagues, viewAllUrl }: MyLeaguesCardProps) {
const sorted = leagues.toSorted(
(a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]
);
const right = viewAllUrl ? (
<Link
to={viewAllUrl}
className="text-xs font-semibold uppercase tracking-wide text-electric hover:underline"
>
View All Leagues
</Link>
) : undefined;
return (
<Card className="bg-[#181b22] gap-2">
<SectionCardHeader icon={Shield} title="My Leagues" right={right} />
<CardContent>
{sorted.length === 0 ? (
<div className="py-4 text-center">
<p className="text-sm text-muted-foreground mb-3">
You aren&apos;t a member of any leagues yet.
</p>
<Link
to="/leagues/new"
className="text-sm font-medium text-primary hover:underline"
>
Create your first league
</Link>
</div>
) : (
<div className="space-y-3">
{sorted.map((league) => (
<LeagueRow key={league.leagueId} {...league} />
))}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -1,12 +1,5 @@
import { useState } from "react";
import { Link } from "react-router";
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from "~/components/ui/navigation-menu";
import {
Sheet,
SheetContent,
@ -14,8 +7,9 @@ import {
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet";
import { Button } from "~/components/ui/button";
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/react-router";
import { MenuIcon } from "lucide-react";
import { HelpCircle, MenuIcon, Settings } from "lucide-react";
interface NavbarProps {
isAdmin: boolean;
@ -27,51 +21,38 @@ export function Navbar({ isAdmin }: NavbarProps) {
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="flex h-16 items-center justify-between px-4 md:px-6 lg:px-8">
{/* Logo */}
<Link to="/" className="flex items-center">
<img src="/logo.svg" alt="Brackt" className="h-6" />
</Link>
{/* Left: Logo + Nav links */}
<div className="flex items-center gap-2">
<Link to="/" className="flex items-center mr-2">
<img src="/logo.svg" alt="Brackt" className="h-14" />
</Link>
<nav aria-label="Main navigation" className="hidden md:flex items-center gap-1">
<Link to="/how-to-play" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:bg-[linear-gradient(to_bottom,#adf661,#2ce1c1)] px-3 py-2">How To Play</Link>
<Link to="/rules" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground transition-colors hover:bg-transparent hover:text-transparent hover:bg-clip-text hover:bg-[linear-gradient(to_bottom,#adf661,#2ce1c1)] px-3 py-2">Rules</Link>
</nav>
</div>
{/* Desktop Navigation */}
<NavigationMenu className="hidden md:flex">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/">Home</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/how-to-play">How To Play</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/rules">Rules</Link>
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/support">Support</Link>
</NavigationMenuLink>
</NavigationMenuItem>
{isAdmin && (
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/admin">Admin</Link>
</NavigationMenuLink>
</NavigationMenuItem>
)}
</NavigationMenuList>
</NavigationMenu>
{/* Desktop Auth */}
<div className="hidden md:flex items-center gap-4">
{/* Desktop Right: Support + Admin icons + Auth */}
<div className="hidden md:flex items-center gap-2">
<Link
to="/support"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-white/5 transition-colors"
aria-label="Support"
>
<HelpCircle className="h-5 w-5" />
</Link>
{isAdmin && (
<Link
to="/admin"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-white/5 transition-colors"
aria-label="Admin"
>
<Settings className="h-5 w-5" />
</Link>
)}
<SignedOut>
<SignInButton mode="modal">
<button className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2">
Sign In
</button>
<Button>Sign In</Button>
</SignInButton>
</SignedOut>
<SignedIn>
@ -79,13 +60,27 @@ export function Navbar({ isAdmin }: NavbarProps) {
</SignedIn>
</div>
{/* Mobile Menu */}
<div className="flex md:hidden items-center gap-4">
{/* Mobile: icons + Auth + Hamburger */}
<div className="flex md:hidden items-center gap-1">
<Link
to="/support"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Support"
>
<HelpCircle className="h-5 w-5" />
</Link>
{isAdmin && (
<Link
to="/admin"
className="inline-flex items-center justify-center h-9 w-9 rounded-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Admin"
>
<Settings className="h-5 w-5" />
</Link>
)}
<SignedOut>
<SignInButton mode="modal">
<button className="inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2">
Sign In
</button>
<Button>Sign In</Button>
</SignInButton>
</SignedOut>
<SignedIn>
@ -105,14 +100,7 @@ export function Navbar({ isAdmin }: NavbarProps) {
<SheetHeader>
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-4 mt-8">
<Link
to="/"
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Home
</Link>
<nav aria-label="Mobile navigation" className="flex flex-col gap-4 mt-8">
<Link
to="/how-to-play"
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
@ -127,22 +115,6 @@ export function Navbar({ isAdmin }: NavbarProps) {
>
Rules
</Link>
<Link
to="/support"
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Support
</Link>
{isAdmin && (
<Link
to="/admin"
className="block px-4 py-2 text-lg font-medium hover:bg-accent rounded-md transition-colors"
onClick={() => setOpen(false)}
>
Admin
</Link>
)}
</nav>
</SheetContent>
</Sheet>

View file

@ -0,0 +1,136 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { EventRow } from "./UpcomingEventsCard";
import type { GroupedEvent } from "./UpcomingEventsCard";
// ─── Shared fixtures ──────────────────────────────────────────────────────────
const tomorrowISO = "2026-04-15T17:00:00.000Z";
const nextWeekStr = "2026-04-22";
const singleLeague = {
leagueId: "league-abc-123",
leagueName: "Alpha Dynasty",
};
const otherLeague = {
leagueId: "league-xyz-999",
leagueName: "Hardcourt Elites",
};
// ─── EventRow ─────────────────────────────────────────────────────────────────
const eventRowMeta: Meta<typeof EventRow> = {
title: "Sport Season/EventRow",
component: EventRow,
parameters: { layout: "padded" },
};
export default eventRowMeta;
type EventRowStory = StoryObj<typeof EventRow>;
const baseEvent: GroupedEvent = {
id: "event-1",
name: "NBA Playoffs Game 3",
matchLabel: "Lakers vs Celtics",
eventDate: null,
earliestGameTime: tomorrowISO,
eventType: "playoff_game",
sportName: "NBA",
sportSeasonName: "2025 NBA Season",
sportsSeasonPageUrl: "/leagues/league-abc-123/sports-seasons/ss-nba-1",
isAllCompete: false,
totalParticipants: 2,
leagues: [
{
...singleLeague,
participants: [
{ id: "p1", name: "LeBron James" },
{ id: "p2", name: "Jayson Tatum" },
],
},
],
};
export const FirstEvent: EventRowStory = {
args: { event: baseEvent, isFirst: true, isLast: false },
};
export const MiddleEvent: EventRowStory = {
args: {
event: {
...baseEvent,
id: "event-2",
name: "F1 Monaco Grand Prix",
matchLabel: null,
earliestGameTime: null,
eventDate: nextWeekStr,
eventType: "race",
sportName: "F1",
isAllCompete: true,
totalParticipants: 4,
leagues: [
{
...singleLeague,
participants: [
{ id: "p3", name: "Max Verstappen" },
{ id: "p4", name: "Charles Leclerc" },
{ id: "p5", name: "Fernando Alonso" },
{ id: "p6", name: "Lewis Hamilton" },
],
},
],
},
isFirst: false,
isLast: false,
},
};
export const LastEvent: EventRowStory = {
args: {
event: { ...baseEvent, id: "event-last", name: "Snooker World Final", matchLabel: "Round of 16" },
isFirst: false,
isLast: true,
},
};
export const MultiLeague: EventRowStory = {
args: {
event: {
...baseEvent,
id: "event-multi",
name: "Champions League Quarter-Final",
matchLabel: "Man City vs Real Madrid",
sportName: "UCL",
leagues: [
{
...singleLeague,
participants: [{ id: "p10", name: "Erling Haaland" }],
},
{
...otherLeague,
participants: [{ id: "p11", name: "Vinicius Jr" }],
},
],
},
isFirst: true,
isLast: true,
},
};
export const NoLink: EventRowStory = {
args: {
event: { ...baseEvent, sportsSeasonPageUrl: undefined },
isFirst: true,
isLast: true,
},
};
export const TBDDate: EventRowStory = {
args: {
event: { ...baseEvent, earliestGameTime: null, eventDate: null },
isFirst: false,
isLast: true,
},
};

View file

@ -0,0 +1,264 @@
import { format } from "date-fns";
import { ChevronRight, Clock } from "lucide-react";
import { useMemo } from "react";
import { Link } from "react-router";
import { Badge } from "~/components/ui/badge";
import { LeagueAvatar } from "~/components/league/LeagueAvatar";
import { GradientIcon } from "~/components/ui/GradientIcon";
import { formatEventDate } from "~/lib/date-utils";
import { BRACKT_GRADIENT } from "~/lib/brand";
import type { CalendarPanelEvent } from "./UpcomingCalendarPanel";
interface Props {
events: CalendarPanelEvent[];
limit?: number;
viewAllUrl?: string;
emptyMessage?: string;
}
export interface LeagueParticipants {
leagueId: string;
leagueName: string;
participants: Array<{ id: string; name: string }>;
}
export interface GroupedEvent {
id: string;
name: string;
matchLabel?: string | null;
eventDate: string | null;
earliestGameTime: string | null;
eventType: string;
sportName: string;
sportSeasonName: string;
sportsSeasonPageUrl?: string;
leagues: LeagueParticipants[];
totalParticipants: number;
isAllCompete: boolean;
}
function groupEvents(events: CalendarPanelEvent[]): GroupedEvent[] {
const map = new Map<string, GroupedEvent>();
for (const event of events) {
const existing = map.get(event.id);
const isAllCompete = event.eventType !== "playoff_game";
const leagueEntry: LeagueParticipants = {
leagueId: event.leagueId ?? "unknown",
leagueName: event.leagueName ?? "League",
participants: event.relevantParticipants,
};
if (existing) {
existing.leagues.push(leagueEntry);
existing.totalParticipants += event.relevantParticipants.length;
} else {
map.set(event.id, {
id: event.id,
name: event.name,
matchLabel: event.matchLabel,
eventDate: event.eventDate,
earliestGameTime: event.earliestGameTime,
eventType: event.eventType,
sportName: event.sportName,
sportSeasonName: event.sportSeasonName,
sportsSeasonPageUrl: event.sportsSeasonPageUrl,
leagues: [leagueEntry],
totalParticipants: event.relevantParticipants.length,
isAllCompete,
});
}
}
return Array.from(map.values());
}
function TimelineDot({ isFirst }: { isFirst: boolean }) {
if (isFirst) {
return (
<div
className="relative z-10 mt-1 h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: BRACKT_GRADIENT }}
/>
);
}
return (
<div className="relative z-10 mt-1 h-2.5 w-2.5 shrink-0 rounded-full border-2 border-border bg-background" />
);
}
function ParticipantsByLeague({
leagues,
isAllCompete,
}: {
leagues: LeagueParticipants[];
isAllCompete: 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>
) : (
<span className="flex flex-wrap gap-1">
{league.participants.slice(0, 3).map((p) => (
<Badge key={p.id} variant="secondary" className="text-xs font-normal">
{p.name}
</Badge>
))}
{league.participants.length > 3 && (
<Badge variant="outline" className="text-xs text-muted-foreground">
+{league.participants.length - 3} more
</Badge>
)}
</span>
)}
</div>
))}
</div>
);
}
export function EventRow({
event,
isFirst,
isLast,
}: {
event: GroupedEvent;
isFirst: boolean;
isLast: boolean;
}) {
const gameDate = event.earliestGameTime ? new Date(event.earliestGameTime) : null;
const dateStr = gameDate ? format(gameDate, "MMM d") : formatEventDate(event.eventDate);
const timeStr = gameDate
? gameDate.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })
: null;
const displayName = event.matchLabel ? `${event.name}${event.matchLabel}` : event.name;
const content = (
<div className="flex gap-3 min-w-0">
{/* Timeline spine */}
<div className="flex flex-col items-center">
<TimelineDot isFirst={isFirst} />
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
</div>
{/* Content */}
<div className={`flex flex-1 min-w-0 items-center gap-3 ${isLast ? "" : "pb-10"}`}>
{/* Left: date + name */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5" suppressHydrationWarning>
<span
className={`text-xs font-semibold uppercase tracking-wide ${isFirst ? "text-electric" : "text-muted-foreground"}`}
>
{dateStr ?? "TBD"}
</span>
{timeStr && (
<>
<span className="text-border">|</span>
<span className="text-xs text-muted-foreground">{timeStr}</span>
</>
)}
</div>
<div
className={`text-sm font-bold uppercase tracking-wide leading-tight ${isFirst ? "text-foreground" : "text-muted-foreground"}`}
>
{displayName}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{event.sportName}
</div>
</div>
{/* Divider */}
<div className="w-px self-stretch bg-border shrink-0" />
{/* Right: participants per league */}
<div className="shrink-0">
<ParticipantsByLeague
leagues={event.leagues}
isAllCompete={event.isAllCompete}
/>
</div>
</div>
</div>
);
if (event.sportsSeasonPageUrl) {
return (
<Link to={event.sportsSeasonPageUrl} className="block hover:opacity-80 transition-opacity">
{content}
</Link>
);
}
return <div>{content}</div>;
}
export function UpcomingEventsCard({
events,
limit,
viewAllUrl,
emptyMessage,
}: Props) {
const localFilteredEvents = useMemo(() => {
const todayStr = new Intl.DateTimeFormat("en-CA").format(new Date());
return events.filter(
(e) => (e.earliestGameTime ?? e.eventDate ?? "9999-12-31") >= todayStr
);
}, [events]);
const grouped = groupEvents(localFilteredEvents);
const displayedEvents = limit !== undefined ? grouped.slice(0, limit) : grouped;
const hiddenCount = limit !== undefined ? Math.max(0, grouped.length - limit) : 0;
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<GradientIcon icon={Clock} className="h-5 w-5 shrink-0" />
<span className="text-xl font-bold leading-none tracking-tight">My Upcoming Events</span>
</div>
{viewAllUrl && (
<Link
to={viewAllUrl}
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>
{/* Timeline */}
{grouped.length === 0 ? (
<p className="text-sm text-muted-foreground py-2">
{emptyMessage ?? "No upcoming events in the next 30 days."}
</p>
) : (
<div>
{displayedEvents.map((event, index) => (
<EventRow
key={event.id}
event={event}
isFirst={index === 0}
isLast={index === displayedEvents.length - 1}
/>
))}
{viewAllUrl && hiddenCount > 0 && (
<Link
to={viewAllUrl}
className="flex items-center gap-1 text-sm text-electric hover:underline mt-1"
>
View all {grouped.length} upcoming events
<ChevronRight className="h-3.5 w-3.5" />
</Link>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,121 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { UpcomingEventsCard } from "./UpcomingEventsCard";
import type { CalendarPanelEvent } from "./UpcomingCalendarPanel";
const meta: Meta<typeof UpcomingEventsCard> = {
title: "Sport Season/UpcomingEventsCard",
component: UpcomingEventsCard,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof UpcomingEventsCard>;
const tomorrowISO = "2026-04-15T17:00:00.000Z";
const nextWeekStr = "2026-04-22";
const inTwoWeeksStr = "2026-04-29";
const sampleEvents: CalendarPanelEvent[] = [
{
id: "event-1",
name: "NBA Playoffs Game 3",
matchLabel: "Lakers vs Celtics",
eventDate: null,
earliestGameTime: tomorrowISO,
eventType: "playoff_game",
sportsSeasonId: "ss-nba-1",
relevantParticipants: [
{ id: "p1", name: "LeBron James" },
{ id: "p2", name: "Jayson Tatum" },
],
sportName: "NBA",
sportSeasonName: "2025 NBA Season",
leagueName: "Alpha Dynasty",
leagueId: "league-abc-123",
sportsSeasonPageUrl: "/leagues/league-abc-123/sports-seasons/ss-nba-1",
},
{
id: "event-2",
name: "F1 Monaco Grand Prix",
matchLabel: null,
eventDate: nextWeekStr,
earliestGameTime: null,
eventType: "race",
sportsSeasonId: "ss-f1-1",
relevantParticipants: [
{ id: "p3", name: "Max Verstappen" },
{ id: "p4", name: "Charles Leclerc" },
{ id: "p5", name: "Fernando Alonso" },
],
sportName: "F1",
sportSeasonName: "2025 F1 Season",
leagueName: "Alpha Dynasty",
leagueId: "league-abc-123",
sportsSeasonPageUrl: "/leagues/league-abc-123/sports-seasons/ss-f1-1",
},
{
id: "event-3",
name: "Champions League Quarter-Final",
matchLabel: "Man City vs Real Madrid",
eventDate: inTwoWeeksStr,
earliestGameTime: null,
eventType: "playoff_game",
sportsSeasonId: "ss-ucl-1",
relevantParticipants: [
{ id: "p6", name: "Erling Haaland" },
],
sportName: "UCL",
sportSeasonName: "2024-25 UCL",
leagueName: "Hardcourt Elites",
leagueId: "league-xyz-999",
sportsSeasonPageUrl: "/leagues/league-xyz-999/sports-seasons/ss-ucl-1",
},
];
// Same event appearing in two leagues
const multiLeagueEvents: CalendarPanelEvent[] = [
...sampleEvents,
{
...sampleEvents[0],
leagueName: "Hardcourt Elites",
leagueId: "league-xyz-999",
relevantParticipants: [{ id: "p10", name: "Anthony Davis" }],
},
];
export const WithEvents: Story = {
args: {
events: sampleEvents,
limit: 8,
viewAllUrl: "/upcoming-events",
},
};
export const MultiLeagueGrouped: Story = {
args: {
events: multiLeagueEvents,
limit: 8,
viewAllUrl: "/upcoming-events",
},
};
export const WithLimit: Story = {
args: {
events: sampleEvents,
limit: 1,
viewAllUrl: "/upcoming-events",
},
};
export const Empty: Story = {
args: {
events: [],
viewAllUrl: "/upcoming-events",
},
};
export const NoViewAll: Story = {
args: {
events: sampleEvents,
},
};

View file

@ -0,0 +1,19 @@
/**
* Renders hidden SVG gradient definitions that can be referenced by ID
* anywhere in the document via stroke="url(#brackt-primary-gradient)" etc.
*
* Mount this once near the root of the app.
*/
export function BracktGradients() {
return (
<svg width="0" height="0" aria-hidden className="absolute">
<defs>
{/* Green (top) → Cyan (bottom) — the main Brackt brand gradient */}
<linearGradient id="brackt-primary-gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#adf661" />
<stop offset="100%" stopColor="#2ce1c1" />
</linearGradient>
</defs>
</svg>
);
}

View file

@ -0,0 +1,30 @@
import type { ComponentType } from "react";
import type { LucideProps } from "lucide-react";
interface GradientIconProps extends Omit<LucideProps, "color"> {
icon: ComponentType<LucideProps>;
/** Defaults to the primary brand gradient (green → cyan). */
gradientId?: string;
}
/**
* Renders any Lucide icon with its stroke set to a named SVG gradient.
* Requires <BracktGradients /> to be mounted somewhere in the document.
*
* Usage:
* <GradientIcon icon={Shield} className="h-5 w-5" />
* <GradientIcon icon={Trophy} gradientId="brackt-primary-gradient" className="h-6 w-6" />
*/
export function GradientIcon({
icon: Icon,
gradientId = "brackt-primary-gradient",
style,
...props
}: GradientIconProps) {
return (
<Icon
{...props}
style={{ stroke: `url(#${gradientId})`, ...style }}
/>
);
}

View file

@ -0,0 +1,28 @@
import type { ComponentType, ReactNode } from "react";
import type { LucideProps } from "lucide-react";
import { CardHeader } from "~/components/ui/card";
import { GradientIcon } from "~/components/ui/GradientIcon";
interface SectionCardHeaderProps {
icon: ComponentType<LucideProps>;
title: string;
right?: ReactNode;
}
/**
* Consistent header for top-level section cards (My Leagues, Create a League, etc.).
* Renders a gradient icon + xl bold title, with an optional right-side slot.
*/
export function SectionCardHeader({ icon, title, right }: SectionCardHeaderProps) {
return (
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GradientIcon icon={icon} className="h-5 w-5 shrink-0" />
<h2 className="text-xl font-bold leading-none tracking-tight">{title}</h2>
</div>
{right}
</div>
</CardHeader>
);
}

View file

@ -5,11 +5,12 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "app/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-xs font-semibold uppercase tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default:
"bg-[linear-gradient(to_bottom,#adf661,#2ce1c1)] text-primary-foreground hover:opacity-90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:

View file

@ -1,20 +1,38 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "app/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
const cardVariants = cva("", {
variants: {
variant: {
default:
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
tile:
"flex rounded-lg pr-1 [background:linear-gradient(to_bottom,#adf661,#2ce1c1)]",
},
},
defaultVariants: {
variant: "default",
},
})
function Card({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof cardVariants>) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
className={cn(cardVariants({ variant }), className)}
{...props}
/>
)
}
export { cardVariants }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div

3
app/lib/brand.ts Normal file
View file

@ -0,0 +1,3 @@
export const BRACKT_GRADIENT = "linear-gradient(to bottom, #adf661, #2ce1c1)";
export const BRACKT_GRADIENT_START = "#adf661";
export const BRACKT_GRADIENT_END = "#2ce1c1";

View file

@ -16,6 +16,7 @@ import { NavigationProgress } from "~/components/NavigationProgress";
import { ClerkProvider } from "@clerk/react-router";
import { dark } from "@clerk/themes";
import { Toaster } from "~/components/ui/sonner";
import { BracktGradients } from "~/components/ui/BracktGradients";
import { isUserAdminByClerkId } from "~/models/user";
import { AlertCircle, FileQuestion, Lock, ShieldOff, ServerCrash } from "lucide-react";
@ -35,11 +36,11 @@ export async function loader(args: Route.LoaderArgs) {
}
export const links: Route.LinksFunction = () => [
{ rel: "icon", href: "/favicon.ico", sizes: "48x48" },
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
{ rel: "icon", href: "/favicon-96x96.png", type: "image/png", sizes: "96x96" },
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
{ rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico?v=20260402", sizes: "48x48" },
{ rel: "icon", href: "/favicon.svg?v=20260402", type: "image/svg+xml" },
{ rel: "icon", href: "/favicon-96x96.png?v=20260402", type: "image/png", sizes: "96x96" },
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png?v=20260402" },
{ rel: "manifest", href: "/site.webmanifest?v=20260402" },
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
@ -48,7 +49,7 @@ export const links: Route.LinksFunction = () => [
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
href: "https://fonts.googleapis.com/css2?family=Barlow:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,300;1,400;1,500;1,600;1,700;1,800&display=swap",
},
];
@ -62,6 +63,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
<Links />
</head>
<body>
<BracktGradients />
{children}
<ScrollRestoration />
<Scripts />

View file

@ -1,5 +1,5 @@
import { useEffect } from "react";
import { Link, useSearchParams } from "react-router";
import { useSearchParams } from "react-router";
import { toast } from "sonner";
import { getAuth } from "@clerk/react-router/server";
import { addDays, subDays } from "date-fns";
@ -10,17 +10,16 @@ import { findSeasonById, findCurrentSeasonWithSports } from "~/models/season";
import { findTeamByOwnerAndSeason } from "~/models/team";
import { getDraftedParticipantsBySportsSeason } from "~/models/draft-pick";
import { getUpcomingEventsForDraftedParticipants } from "~/models/scoring-event";
import { getTeamStanding } from "~/models/standings";
import { getSeasonCompletionPercentage } from "~/lib/season-helpers.server";
import { findDraftSlotsBySeasonId } from "~/models/draft-slot";
import { getTeamForPick } from "~/lib/draft-order";
import type { CalendarPanelEvent } from "~/components/sport-season/UpcomingCalendarPanel";
import { UpcomingCalendarPanel } from "~/components/sport-season/UpcomingCalendarPanel";
import { UpcomingEventsCard } from "~/components/sport-season/UpcomingEventsCard";
import { MyLeaguesCard } from "~/components/league/MyLeaguesCard";
import { CreateLeagueCard } from "~/components/league/CreateLeagueCard";
import type { LeagueRowProps } from "~/components/league/LeagueRow";
import { toEventSortKey } from "~/lib/date-utils";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
export function meta() {
return [
@ -39,14 +38,12 @@ export async function loader(args: Route.LoaderArgs) {
return { leagues: [], isLoggedIn: false, upcomingCalendarEvents: [] };
}
// Fetch leagues where user has a team in the current season
const leagues = await findLeaguesWithActiveSeasonsByUserId(userId);
const today = new Date();
const dateFromStr = subDays(today, 1).toISOString().split("T")[0];
const dateToStr = addDays(today, 30).toISOString().split("T")[0];
// Fetch season details and calendar events in parallel per league
const leaguesWithData = await Promise.all(
leagues.map(async (league) => {
const [season, seasonWithSports] = await Promise.all([
@ -54,16 +51,55 @@ export async function loader(args: Route.LoaderArgs) {
findCurrentSeasonWithSports(league.id),
]);
// Build calendar events for this league
const numSports = seasonWithSports?.seasonSports?.length ?? 0;
const calendarEvents: CalendarPanelEvent[] = [];
let currentRank: number | undefined;
let totalPoints: number | undefined;
let previousRank: number | undefined;
let completionPercentage = 0;
let picksUntilMyTurn: number | undefined;
let draftPosition: number | undefined;
if (league.currentSeasonId && seasonWithSports?.seasonSports) {
const myTeam = await findTeamByOwnerAndSeason(userId, league.currentSeasonId);
if (myTeam) {
const participantsBySportsSeason = await getDraftedParticipantsBySportsSeason(
myTeam.id,
league.currentSeasonId
);
// Fetch standing, completion %, and calendar events in parallel
const [standing, pct, participantsBySportsSeason] = await Promise.all([
getTeamStanding(myTeam.id, league.currentSeasonId),
getSeasonCompletionPercentage(league.currentSeasonId),
getDraftedParticipantsBySportsSeason(myTeam.id, league.currentSeasonId),
]);
completionPercentage = pct;
// Draft slot info — needed for pre-draft position and in-progress picks-until-turn
if (season?.status === "draft" || season?.status === "pre_draft") {
const draftSlots = await findDraftSlotsBySeasonId(league.currentSeasonId);
const mySlot = draftSlots.find((s) => s.teamId === myTeam.id);
if (mySlot) {
draftPosition = mySlot.draftOrder;
}
if (season.status === "draft" && season.currentPickNumber) {
const currentPick = season.currentPickNumber;
let offset = 0;
// Snake draft: one full cycle is draftSlots.length * 2 picks
while (offset < draftSlots.length * 2) {
const slot = getTeamForPick(currentPick + offset, draftSlots);
if (slot?.teamId === myTeam.id) {
picksUntilMyTurn = offset;
break;
}
offset++;
}
}
}
if (standing) {
currentRank = standing.currentRank ?? undefined;
totalPoints = standing.totalPoints ?? undefined;
previousRank = standing.previousRank ?? undefined;
}
const perSeasonEvents = await Promise.all(
seasonWithSports.seasonSports.map(async ({ sportsSeason: ss }) => {
@ -96,6 +132,14 @@ export async function loader(args: Route.LoaderArgs) {
return {
...league,
currentSeason: season,
numSports,
currentRank,
totalPoints,
previousRank,
completionPercentage,
picksUntilMyTurn,
draftPosition,
draftDateTime: season?.draftDateTime?.toISOString() ?? null,
calendarEvents,
};
})
@ -138,80 +182,56 @@ export default function Home({ loaderData }: Route.ComponentProps) {
);
}
const VALID_STATUSES = ["draft", "active", "pre_draft", "completed"] as const;
type ValidStatus = (typeof VALID_STATUSES)[number];
function isValidStatus(s: string): s is ValidStatus {
return VALID_STATUSES.includes(s as ValidStatus);
}
const leagueRows: LeagueRowProps[] = leagues.map((league) => {
const rawStatus = league.currentSeason?.status ?? "pre_draft";
return {
leagueId: league.id,
leagueName: league.name,
numSports: league.numSports,
status: isValidStatus(rawStatus) ? rawStatus : "pre_draft",
seasonId: league.currentSeasonId ?? undefined,
currentRank: league.currentRank,
totalPoints: league.totalPoints,
previousRank: league.previousRank,
completionPercentage: league.completionPercentage,
picksUntilMyTurn: league.picksUntilMyTurn,
draftPosition: league.draftPosition,
draftDateTime: league.draftDateTime,
};
});
return (
<div className="container mx-auto py-8 px-4">
<div className="flex items-center justify-between mb-8">
<h1 className="text-4xl font-bold">My Leagues</h1>
<Button asChild>
<Link to="/leagues/new">Create New League</Link>
</Button>
{/*
Desktop: 3-col grid, left column (My Leagues + Create) spans 2, right (Events) spans 1.
Mobile: single column stacked in order: My Leagues, Upcoming Events, Create League.
*/}
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
{/* My Leagues — order 1 on mobile, col-span-2 on desktop */}
<div className="order-1 md:col-span-2">
<MyLeaguesCard leagues={leagueRows} />
</div>
{/* Upcoming Events — order 2 on mobile, right column on desktop spanning both rows */}
<div className="order-2 md:col-span-1 md:row-span-2">
<UpcomingEventsCard
events={upcomingCalendarEvents}
limit={8}
viewAllUrl="/upcoming-events"
/>
</div>
{/* Create a League — order 3 on mobile, below My Leagues on desktop */}
<div className="order-3 md:col-span-2">
<CreateLeagueCard />
</div>
</div>
{upcomingCalendarEvents.length > 0 && (
<div className="mb-8">
<UpcomingCalendarPanel
events={upcomingCalendarEvents}
showLeague={true}
limit={6}
viewAllUrl="/upcoming-events"
/>
</div>
)}
{leagues.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>No Leagues</CardTitle>
<CardDescription>
You aren't a member of any leagues yet
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link to="/leagues/new">Create a New League</Link>
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{leagues.map((league) => (
<Link key={league.id} to={`/leagues/${league.id}`}>
<Card className="hover:border-primary transition-colors cursor-pointer h-full">
<CardHeader>
<CardTitle>{league.name}</CardTitle>
<CardDescription>
Created {new Date(league.createdAt).toLocaleDateString()}
</CardDescription>
</CardHeader>
<CardContent>
{league.currentSeason ? (
<div className="space-y-2">
<div>
<p className="text-sm text-muted-foreground">
Current Season
</p>
<p className="font-medium">
{league.currentSeason.year}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Status</p>
<p className="font-medium capitalize">
{league.currentSeason.status.replace("_", " ")}
</p>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
No active season
</p>
)}
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -1 +1,43 @@
<svg xmlns="http://www.w3.org/2000/svg" width="234" height="194" viewBox="0 0 234 194" fill="none"><path d="M174.6 140.8H197C201.4 140.8 204.533 139.933 206.4 138.2C208.4 136.333 209.4 133.333 209.4 129.2V117.6C209.4 113.467 208.4 110.533 206.4 108.8C204.4 107.067 201.133 106.2 196.6 106.2H174.6C173.133 106.2 172.4 106.933 172.4 108.4V138.6C172.4 140.067 173.133 140.8 174.6 140.8ZM174.6 85.6H195C199.267 85.6 202.4 84.7334 204.4 83C206.4 81.1334 207.4 78.1334 207.4 74V66.2C207.4 62.0667 206.4 59.1334 204.4 57.4C202.533 55.5334 199.467 54.6 195.2 54.6H174.6C173.133 54.6 172.4 55.3334 172.4 56.8V83.4C172.4 84.8667 173.133 85.6 174.6 85.6ZM147 157.8V37.6C147 34.8 148.4 33.4 151.2 33.4H199.2C209.733 33.4 217.867 36.2 223.6 41.8C229.333 47.4 232.2 55.4 232.2 65.8V74C232.2 85.3334 226.933 92.6667 216.4 96C221.733 96.8 226 99 229.2 102.6C232.4 106.067 234 111.067 234 117.6V129.6C234 140 231.133 148 225.4 153.6C219.667 159.2 211.6 162 201.2 162H151.2C148.4 162 147 160.6 147 157.8Z" fill="#2B7FFF"></path><path d="M0 0H85C93.2843 0 100 6.71573 100 15V24H0V0Z" fill="#2B7FFF"></path><path d="M76 0H85C93.2843 0 100 6.71573 100 15V97H91C82.7157 97 76 90.2843 76 82V0Z" fill="#2B7FFF"></path><path d="M0 194H50H85C93.2843 194 100 187.284 100 179V170H0V194Z" fill="#F0C731"></path><path d="M76 194H85C93.2843 194 100 187.284 100 179V97H91C82.7157 97 76 103.716 76 112V194Z" fill="#F0C731"></path></svg>
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><metadata><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:Description><dc:creator>RealFaviconGenerator</dc:creator><dc:source>https://realfavicongenerator.net</dc:source></rdf:Description></rdf:RDF></metadata><style>
#light-icon {
display: inline;
}
#dark-icon {
display: none;
}
@media (prefers-color-scheme: dark) {
#light-icon {
display: none;
}
#dark-icon {
display: inline;
}
}
</style><g id="light-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><g clip-path="url(#SvgjsClipPath1062)"><rect width="1000" height="1000" fill="#14171e"></rect><g transform="matrix(1.9030853771677332,0,0,1.9030853771677332,189.78756809477363,100)"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_2" data-name="Layer 2" viewBox="0 0 326.01 420.37" width="326.01" height="420.37"><defs>
<style>
.cls-1 {
fill: url(#linear-gradient);
stroke-width: 0px;
}
</style>
<linearGradient id="linear-gradient" x1="163" y1="0" x2="163" y2="420.37" gradientUnits="userSpaceOnUse">
<stop offset=".1" stop-color="#adf661"></stop>
<stop offset=".91" stop-color="#2ce1c1"></stop>
</linearGradient>
<clipPath id="SvgjsClipPath1062"><rect width="1000" height="1000" x="0" y="0" rx="0" ry="0"></rect></clipPath></defs><g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M276.96,204.66c53.62,32.3,55.39,89.93,42.18,146.12-11.49,48.88-44.57,68.07-108.03,69.04-69.48,1.07-138.99.24-210.16.24v-150.46h91.63v-119.54H0V0C10.93,0,22.08,0,33.24,0c62.95.08,125.91-.13,188.86.33,53.15.39,90.76,37.31,93.03,90.36,2.83,66.07,1.4,70.4-38.16,113.96ZM38.76,379.74c66.89,0,132.74,3.65,197.93-1.36,39.4-3.03,54.11-41.77,45.33-100.97-5.14-34.62-27.75-50.5-62.13-50.53-17.83-.02-35.66,0-54.1,0v-38.54c18.77,0,36.78.73,54.71-.16,32.13-1.58,49.91-19.74,52.97-51.08,5.71-58.53-6.66-92.05-40.81-94.07-63.94-3.78-128.27-1.09-193.46-1.09v70.81h91.87v193.85H38.76v73.14Z"></path>
</g></svg></g></g></svg></g><g id="dark-icon"><svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="1000"><g><g transform="matrix(2.3788567214596665,0,0,2.3788567214596665,112.23446011846704,0)"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_2" data-name="Layer 2" viewBox="0 0 326.01 420.37" width="326.01" height="420.37"><defs>
<style>
.cls-1 {
fill: url(#linear-gradient);
stroke-width: 0px;
}
</style>
<linearGradient id="linear-gradient" x1="163" y1="0" x2="163" y2="420.37" gradientUnits="userSpaceOnUse">
<stop offset=".1" stop-color="#adf661"></stop>
<stop offset=".91" stop-color="#2ce1c1"></stop>
</linearGradient>
</defs><g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M276.96,204.66c53.62,32.3,55.39,89.93,42.18,146.12-11.49,48.88-44.57,68.07-108.03,69.04-69.48,1.07-138.99.24-210.16.24v-150.46h91.63v-119.54H0V0C10.93,0,22.08,0,33.24,0c62.95.08,125.91-.13,188.86.33,53.15.39,90.76,37.31,93.03,90.36,2.83,66.07,1.4,70.4-38.16,113.96ZM38.76,379.74c66.89,0,132.74,3.65,197.93-1.36,39.4-3.03,54.11-41.77,45.33-100.97-5.14-34.62-27.75-50.5-62.13-50.53-17.83-.02-35.66,0-54.1,0v-38.54c18.77,0,36.78.73,54.71-.16,32.13-1.58,49.91-19.74,52.97-51.08,5.71-58.53-6.66-92.05-40.81-94.07-63.94-3.78-128.27-1.09-193.46-1.09v70.81h91.87v193.85H38.76v73.14Z"></path>
</g></svg></g></g></svg></g></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -1,14 +1,28 @@
<svg width="931" height="194" viewBox="0 0 931 194" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M897.4 162H880.6C877.667 162 876.2 160.585 876.2 157.754V56.4635C876.2 54.9808 875.467 54.2395 874 54.2395H851.2C848.4 54.2395 847 52.8243 847 49.9938V36.2457C847 33.4152 848.4 32 851.2 32H926.8C929.6 32 931 33.4152 931 36.2457V49.9938C931 52.8243 929.6 54.2395 926.8 54.2395H904C902.4 54.2395 901.6 54.9808 901.6 56.4635V157.754C901.6 160.585 900.2 162 897.4 162Z" fill="white"/>
<path d="M737.284 162H720.217C717.406 162 716 160.585 716 157.754V36.2457C716 33.4152 717.406 32 720.217 32H737.284C740.095 32 741.501 33.4152 741.501 36.2457V93.8663H741.902C742.973 90.6314 745.048 86.4531 748.127 81.3313L778.648 36.2457C780.388 33.4152 782.998 32 786.479 32H806.156C807.629 32 808.499 32.4717 808.767 33.4152C809.168 34.2239 808.834 35.3022 807.763 36.6501L769.612 89.8227L812.18 156.946C813.117 158.563 813.251 159.843 812.582 160.787C812.046 161.596 810.841 162 808.967 162H794.309C789.624 162 786.478 160.585 784.872 157.754L754.151 108.221L741.501 123.182V157.754C741.501 160.585 740.095 162 737.284 162Z" fill="white"/>
<path d="M631.301 162H609.699C599.262 162 591.203 159.17 585.522 153.509C579.841 147.848 577 139.693 577 129.045V64.9549C577 54.3069 579.841 46.1524 585.522 40.4914C591.203 34.8305 599.262 32 609.699 32H631.301C641.606 32 649.599 34.8979 655.28 40.6936C661.093 46.3546 664 54.4417 664 64.9549V74.4572C664 77.4225 662.547 78.9051 659.64 78.9051H642.993C640.219 78.9051 638.831 77.4225 638.831 74.4572V66.168C638.831 61.8548 637.907 58.8222 636.057 57.07C634.339 55.183 631.367 54.2395 627.139 54.2395H613.663C609.567 54.2395 606.595 55.183 604.745 57.07C603.027 58.8222 602.169 61.8548 602.169 66.168V127.832C602.169 132.145 603.027 135.245 604.745 137.132C606.595 138.884 609.567 139.76 613.663 139.76H627.139C631.367 139.76 634.339 138.884 636.057 137.132C637.907 135.245 638.831 132.145 638.831 127.832V119.543C638.831 116.578 640.219 115.095 642.993 115.095H659.64C662.547 115.095 664 116.578 664 119.543V129.045C664 139.558 661.093 147.713 655.28 153.509C649.599 159.17 641.606 162 631.301 162Z" fill="white"/>
<path d="M450.814 162H431.689C429.299 162 428.502 160.585 429.299 157.754L460.775 36.2457C461.705 33.4152 463.697 32 466.752 32H493.248C496.568 32 498.561 33.4152 499.225 36.2457L530.701 157.754C531.498 160.585 530.701 162 528.311 162H509.186C507.725 162 506.729 161.73 506.197 161.191C505.666 160.517 505.268 159.372 505.002 157.754L499.424 135.515H460.576L454.998 157.754C454.732 159.372 454.268 160.517 453.604 161.191C453.072 161.73 452.143 162 450.814 162ZM479.701 56.4635L465.357 114.893H494.842L480.498 56.4635H479.701Z" fill="white"/>
<path d="M319.185 162H302.197C299.399 162 298 160.585 298 157.754V36.2457C298 33.4152 299.399 32 302.197 32H349.763C360.156 32 368.284 34.8979 374.146 40.6936C380.009 46.4894 382.94 54.6438 382.94 65.1571V80.9269C382.94 96.1576 377.011 106.266 365.153 111.253V112.062L386.537 157.148C387.737 160.383 386.604 162 383.14 162H366.951C364.553 162 362.821 161.73 361.755 161.191C360.689 160.652 359.823 159.506 359.157 157.754L339.171 114.084H325.78C324.182 114.084 323.382 114.825 323.382 116.308V157.754C323.382 160.585 321.983 162 319.185 162ZM325.581 93.4619H345.167C349.43 93.4619 352.561 92.5858 354.56 90.8336C356.559 88.9466 357.558 85.9813 357.558 81.9378V66.168C357.558 61.9896 356.559 58.957 354.56 57.07C352.561 55.183 349.43 54.2395 345.167 54.2395H325.581C324.115 54.2395 323.382 54.9808 323.382 56.4635V91.2379C323.382 92.7206 324.115 93.4619 325.581 93.4619Z" fill="white"/>
<g transform="translate(0 98) scale(1 1.04) translate(0 -98)">
<path d="M184.6 142.24H207C211.4 142.24 214.533 141.35 216.4 139.571C218.4 137.655 219.4 134.575 219.4 130.333V118.426C219.4 114.184 218.4 111.173 216.4 109.393C214.4 107.614 211.133 106.725 206.6 106.725H184.6C183.133 106.725 182.4 107.477 182.4 108.983V139.981C182.4 141.487 183.133 142.24 184.6 142.24ZM184.6 85.5801H205C209.267 85.5801 212.4 84.6905 214.4 82.9114C216.4 80.9953 217.4 77.916 217.4 73.6734V65.6672C217.4 61.4246 216.4 58.4137 214.4 56.6345C212.533 54.7185 209.467 53.7605 205.2 53.7605H184.6C183.133 53.7605 182.4 54.5132 182.4 56.0187V83.3219C182.4 84.8274 183.133 85.5801 184.6 85.5801ZM157 159.689V36.311C157 33.437 158.4 32 161.2 32H209.2C219.733 32 227.867 34.874 233.6 40.6221C239.333 46.3701 242.2 54.5816 242.2 65.2566V73.6734C242.2 85.3064 236.933 92.8336 226.4 96.2551C231.733 97.0762 236 99.3344 239.2 103.03C242.4 106.588 244 111.72 244 118.426V130.743C244 141.418 241.133 149.63 235.4 155.378C229.667 161.126 221.6 164 211.2 164H161.2C158.4 164 157 162.563 157 159.689Z" fill="#2B7FFF"/>
</g>
<path d="M0 0H85C93.2843 0 100 6.71573 100 15V24H0V0Z" fill="#2B7FFF"/>
<path d="M76 0H85C93.2843 0 100 6.71573 100 15V97H91C82.7157 97 76 90.2843 76 82V0Z" fill="#2B7FFF"/>
<path d="M0 194H50H85C93.2843 194 100 187.284 100 179V170H0V194Z" fill="#F0C731"/>
<path d="M76 194H85C93.2843 194 100 187.284 100 179V97H91C82.7157 97 76 103.716 76 112V194Z" fill="#F0C731"/>
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1366 768">
<defs>
<style>
.cls-1 {
fill: url(#linear-gradient);
}
.cls-1, .cls-2 {
stroke-width: 0px;
}
.cls-2 {
fill: #fff;
}
</style>
<linearGradient id="linear-gradient" x1="163" y1="159.57" x2="163" y2="579.94" gradientUnits="userSpaceOnUse">
<stop offset=".1" stop-color="#adf661"/>
<stop offset=".91" stop-color="#2ce1c1"/>
</linearGradient>
</defs>
<path class="cls-1" d="M276.96,364.23c53.62,32.3,55.39,89.93,42.18,146.12-11.49,48.88-44.57,68.07-108.03,69.04-69.48,1.07-138.99.24-210.16.24v-150.46h91.63v-119.54H0v-150.06c10.93,0,22.08-.01,33.24,0,62.95.08,125.91-.13,188.86.33,53.15.39,90.76,37.31,93.03,90.36,2.83,66.07,1.4,70.4-38.16,113.96ZM38.76,539.3c66.89,0,132.74,3.65,197.93-1.36,39.4-3.03,54.11-41.77,45.33-100.97-5.14-34.62-27.75-50.5-62.13-50.53-17.83-.02-35.66,0-54.1,0v-38.54c18.77,0,36.78.73,54.71-.16,32.13-1.58,49.91-19.74,52.97-51.08,5.71-58.53-6.66-92.05-40.81-94.07-63.94-3.78-128.27-1.09-193.46-1.09v70.81h91.87v193.85H38.76v73.14Z"/>
<path class="cls-2" d="M509.65,277.27c2.8,1.6,3.8,4.2,3,7.81l-7.21,31.23c-.4,3.6-2.81,4.8-7.21,3.6-4.8-2.4-10.81-3.6-18.02-3.6-3.21,0-5.61.21-7.21.6-14.41.81-26.23,8.31-35.43,22.52-9.21,14.22-13.81,31.53-13.81,51.95v180.16c0,4.01-2.01,6.01-6.01,6.01h-30.63c-4.01,0-6.01-2-6.01-6.01v-291.86c0-4,2-6.01,6.01-6.01h30.63c4,0,6.01,2.01,6.01,6.01v36.03c0,1.2.3,1.8.9,1.8s1.29-.39,2.1-1.2c11.61-30.42,30.63-45.64,57.05-45.64,10,0,18.62,2.21,25.82,6.61Z"/>
<path class="cls-2" d="M710.23,296.18c16.81,18.22,25.22,42.54,25.22,72.96v202.38c0,4.01-2.01,6.01-6.01,6.01h-30.63c-4.01,0-6.01-2-6.01-6.01v-18.02c0-.8-.3-1.29-.9-1.5-.6-.2-1.3.1-2.1.9-14.82,19.62-37.04,29.43-66.66,29.43-22.02,0-41.14-6.31-57.35-18.92-16.21-12.61-24.32-34.92-24.32-66.96,0-34.02,9.81-58.95,29.43-74.77,19.61-15.81,45.43-23.72,77.47-23.72h42.04c1.6,0,2.4-.8,2.4-2.4v-23.42c0-20.01-4.61-36.03-13.81-48.04-9.21-12.01-21.62-18.02-37.23-18.02-12.01,0-22.32,4.01-30.93,12.01-8.61,8.01-13.72,18.22-15.31,30.63,0,4.01-2.01,6.01-6.01,6.01l-33.63-.6c-2.01,0-3.51-.6-4.5-1.8-1-1.2-1.3-2.6-.9-4.2,2-23.62,11.61-42.73,28.83-57.35,17.21-14.61,38.43-21.92,63.66-21.92,28.02,0,50.44,9.11,67.26,27.32ZM674.8,529.19c12.01-10.6,18.02-25.32,18.02-44.14v-50.44c0-1.6-.81-2.4-2.4-2.4h-42.64c-19.22,0-34.44,5.1-45.64,15.31-11.21,10.21-16.81,25.33-16.81,45.34,0,17.62,4.3,30.73,12.91,39.33,8.6,8.61,19.91,12.91,33.93,12.91,16.41,0,30.63-5.3,42.64-15.91Z"/>
<path class="cls-2" d="M809.01,557.12c-17.02-16.81-25.52-39.03-25.52-66.66v-129.72c0-27.22,8.6-49.34,25.82-66.36,17.21-17.01,39.83-25.52,67.86-25.52,18.81,0,35.43,3.81,49.84,11.41,14.41,7.61,25.52,17.72,33.33,30.33s11.71,26.53,11.71,41.74v6.01c0,4.01-2.01,6.01-6.01,6.01l-30.63,1.2c-4.01,0-6.01-2-6.01-6.01v-4.2c0-13.21-4.91-24.72-14.71-34.53-9.81-9.81-22.32-14.71-37.53-14.71s-27.53,5.31-36.93,15.91c-9.41,10.61-14.11,24.53-14.11,41.74v123.71c0,17.22,4.7,31.13,14.11,41.74,9.4,10.61,21.71,15.91,36.93,15.91s27.72-4.9,37.53-14.71c9.81-9.81,14.71-21.32,14.71-34.53v-4.2c0-4,2-6.01,6.01-6.01l30.63.6c4,0,6.01,2.01,6.01,6.01v6.61c0,24.02-8.71,43.94-26.12,59.75-17.42,15.82-40.34,23.72-68.76,23.72s-51.15-8.41-68.16-25.22Z"/>
<path class="cls-2" d="M1019.49,571.53V163.17c0-4,2-6.01,6.01-6.01h30.63c4,0,6.01,2.01,6.01,6.01v241.41c0,.81.39,1.41,1.2,1.8.8.4,1.4.21,1.8-.6l90.08-128.51c1.6-2.4,4-3.6,7.21-3.6h31.23c2.4,0,4,.7,4.8,2.1.8,1.41.39,3.11-1.2,5.1l-59.45,85.28c-1.2,1.2-1.41,2.4-.6,3.6l70.86,201.18.6,2.4c0,2.81-1.8,4.2-5.4,4.2h-31.83c-3.6,0-6.01-1.6-7.21-4.8l-53.45-166.95c-.4-2.4-1.6-2.6-3.6-.6l-43.84,62.46c-.81.81-1.2,1.8-1.2,3v100.89c0,4.01-2.01,6.01-6.01,6.01h-30.63c-4.01,0-6.01-2-6.01-6.01Z"/>
<path class="cls-2" d="M1359.99,305.49h-45.64c-1.6,0-2.4.81-2.4,2.4v183.76c0,18.42,3.2,31.13,9.61,38.13,6.4,7.01,16.21,10.31,29.43,9.91h6.01c4,0,6.01,2.01,6.01,6.01v25.82c0,4.01-2.01,6.01-6.01,6.01h-15.01c-23.63,0-41.54-5.1-53.75-15.31-12.22-10.21-18.32-29.52-18.32-57.95v-196.38c0-1.6-.81-2.4-2.4-2.4h-23.42c-4.01,0-6.01-2-6.01-6.01v-19.82c0-4,2-6.01,6.01-6.01h23.42c1.6,0,2.4-.8,2.4-2.4v-67.26c0-4,2-6.01,6.01-6.01h30.03c4,0,6.01,2.01,6.01,6.01v67.26c0,1.6.8,2.4,2.4,2.4h45.64c4,0,6.01,2.01,6.01,6.01v19.82c0,4.01-2.01,6.01-6.01,6.01Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

18
public/logomark.svg Normal file
View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 326.01 420.37">
<defs>
<style>
.cls-1 {
fill: url(#linear-gradient);
stroke-width: 0px;
}
</style>
<linearGradient id="linear-gradient" x1="163" y1="0" x2="163" y2="420.37" gradientUnits="userSpaceOnUse">
<stop offset=".1" stop-color="#adf661"/>
<stop offset=".91" stop-color="#2ce1c1"/>
</linearGradient>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M276.96,204.66c53.62,32.3,55.39,89.93,42.18,146.12-11.49,48.88-44.57,68.07-108.03,69.04-69.48,1.07-138.99.24-210.16.24v-150.46h91.63v-119.54H0V0C10.93,0,22.08,0,33.24,0c62.95.08,125.91-.13,188.86.33,53.15.39,90.76,37.31,93.03,90.36,2.83,66.07,1.4,70.4-38.16,113.96ZM38.76,379.74c66.89,0,132.74,3.65,197.93-1.36,39.4-3.03,54.11-41.77,45.33-100.97-5.14-34.62-27.75-50.5-62.13-50.53-17.83-.02-35.66,0-54.1,0v-38.54c18.77,0,36.78.73,54.71-.16,32.13-1.58,49.91-19.74,52.97-51.08,5.71-58.53-6.66-92.05-40.81-94.07-63.94-3.78-128.27-1.09-193.46-1.09v70.81h91.87v193.85H38.76v73.14Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -1,6 +1,6 @@
{
"name": "MyWebSite",
"short_name": "MySite",
"name": "Brackt",
"short_name": "Brackt",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
@ -15,7 +15,7 @@
"purpose": "maskable"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"theme_color": "#2ce1c1",
"background_color": "#14171e",
"display": "standalone"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB