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>
This commit is contained in:
parent
ff208460ac
commit
7ebc590764
5 changed files with 61 additions and 36 deletions
|
|
@ -8,7 +8,7 @@ export function CreateLeagueCard() {
|
||||||
return (
|
return (
|
||||||
<Card className="gap-2">
|
<Card className="gap-2">
|
||||||
<SectionCardHeader icon={Swords} title="Start a New League" />
|
<SectionCardHeader icon={Swords} title="Start a New League" />
|
||||||
<CardContent>
|
<CardContent className="px-3 sm:px-6">
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Invite friends, pick your sports, and start drafting.
|
Invite friends, pick your sports, and start drafting.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,11 @@ export interface LeagueRowProps {
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function ordinal(n: number): string {
|
function ordinal(n: number): string {
|
||||||
const s = ["th", "st", "nd", "rd"];
|
|
||||||
const v = n % 100;
|
const v = n % 100;
|
||||||
return n + (s[(v - 20) % 10] ?? s[v] ?? s[0]);
|
// 11–13 are always "th" (e.g. 11th, 12th, 13th)
|
||||||
|
if (v >= 11 && v <= 13) return `${n}th`;
|
||||||
|
const s = ["th", "st", "nd", "rd"];
|
||||||
|
return `${n}${s[n % 10] ?? "th"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -37,7 +39,7 @@ function StatColumn({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="text-right shrink-0">
|
<div className="text-right shrink-0 flex-1 sm:flex-none">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -53,10 +55,10 @@ function StatDivider() {
|
||||||
// ─── Season progress bar ──────────────────────────────────────────────────────
|
// ─── Season progress bar ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SeasonProgress({ pct, status }: { pct: number; status: "active" | "completed" }) {
|
function SeasonProgress({ pct, status }: { pct: number; status: "active" | "completed" }) {
|
||||||
const label = status === "completed" ? "100%" : `${pct}%`;
|
const label = status === "completed" ? "100% Complete" : `${pct}% Complete`;
|
||||||
const fill = status === "completed" ? 100 : pct;
|
const fill = status === "completed" ? 100 : pct;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 mt-1.5 w-24">
|
<div className="flex items-center gap-1.5 mt-1.5 w-40">
|
||||||
<div className="flex-1 h-1 rounded-full bg-white/10 overflow-hidden">
|
<div className="flex-1 h-1 rounded-full bg-white/10 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-electric transition-all"
|
className="h-full rounded-full bg-electric transition-all"
|
||||||
|
|
@ -74,10 +76,10 @@ function RankChange({ current, previous }: { current: number; previous: number |
|
||||||
if (previous === undefined || previous === current) return null;
|
if (previous === undefined || previous === current) return null;
|
||||||
const delta = previous - current;
|
const delta = previous - current;
|
||||||
if (delta > 0) {
|
if (delta > 0) {
|
||||||
return <span className="text-xs font-semibold text-primary">▲{delta}</span>;
|
return <span className="text-xs font-semibold text-primary" aria-label={`ranked up ${delta}`}>▲{delta}</span>;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="text-xs font-semibold" style={{ color: "var(--coral-accent, #ef4444)" }}>
|
<span className="text-xs font-semibold" style={{ color: "var(--coral-accent, #ef4444)" }} aria-label={`ranked down ${Math.abs(delta)}`}>
|
||||||
▼{Math.abs(delta)}
|
▼{Math.abs(delta)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
@ -86,6 +88,7 @@ function RankChange({ current, previous }: { current: number; previous: number |
|
||||||
// ─── Row variants ─────────────────────────────────────────────────────────────
|
// ─── Row variants ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRowProps) {
|
function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRowProps) {
|
||||||
|
const draftUrl = `/leagues/${leagueId}/draft/${seasonId}`;
|
||||||
const picksLabel =
|
const picksLabel =
|
||||||
picksUntilMyTurn === 0
|
picksUntilMyTurn === 0
|
||||||
? "You're on the clock!"
|
? "You're on the clock!"
|
||||||
|
|
@ -94,7 +97,7 @@ function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRo
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-primary/40 bg-primary/10 px-5 py-4">
|
<div className="flex items-start gap-3 rounded-lg border border-primary/40 bg-primary/10 px-3 py-3 sm:px-5 sm:py-4">
|
||||||
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="font-semibold leading-tight truncate">{leagueName}</p>
|
<p className="font-semibold leading-tight truncate">{leagueName}</p>
|
||||||
|
|
@ -109,10 +112,15 @@ function DraftRow({ leagueId, leagueName, seasonId, picksUntilMyTurn }: LeagueRo
|
||||||
<span className="text-xs text-muted-foreground">{picksLabel}</span>
|
<span className="text-xs text-muted-foreground">{picksLabel}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{seasonId && (
|
||||||
|
<Button asChild size="sm" className="mt-3 sm:hidden">
|
||||||
|
<Link to={draftUrl}>Enter Draft</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{seasonId && (
|
{seasonId && (
|
||||||
<Button asChild size="sm" className="shrink-0">
|
<Button asChild size="sm" className="shrink-0 hidden sm:inline-flex">
|
||||||
<Link to={`/leagues/${leagueId}/draft/${seasonId}`}>Enter Draft</Link>
|
<Link to={draftUrl}>Enter Draft</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -132,15 +140,19 @@ function ActiveRow({
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/leagues/${leagueId}`}
|
to={`/leagues/${leagueId}`}
|
||||||
className="flex items-center gap-3 rounded-lg bg-card px-5 py-4 hover:bg-white/[0.06] transition-colors"
|
className="flex flex-col sm:flex-row sm:items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
|
||||||
>
|
>
|
||||||
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
{/* Avatar + name */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<p className="font-semibold leading-tight truncate">{leagueName}</p>
|
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
||||||
<SeasonProgress pct={completionPercentage} status="active" />
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold leading-tight truncate">{leagueName}</p>
|
||||||
|
<SeasonProgress pct={completionPercentage} status="active" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Stats — second row on mobile, right side on desktop */}
|
||||||
{showStats && (
|
{showStats && (
|
||||||
<div className="flex items-center gap-4 shrink-0">
|
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
||||||
{currentRank !== undefined && (
|
{currentRank !== undefined && (
|
||||||
<StatColumn label="Ranking">
|
<StatColumn label="Ranking">
|
||||||
<span className="text-2xl font-bold leading-none">#{currentRank}</span>
|
<span className="text-2xl font-bold leading-none">#{currentRank}</span>
|
||||||
|
|
@ -179,16 +191,20 @@ function PreDraftRow({
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/leagues/${leagueId}`}
|
to={`/leagues/${leagueId}`}
|
||||||
className="flex items-center gap-3 rounded-lg bg-card px-5 py-4 hover:bg-white/[0.06] transition-colors"
|
className="flex flex-col sm:flex-row sm:items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
|
||||||
>
|
>
|
||||||
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
{/* Avatar + name */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<p className="font-semibold leading-tight truncate">{leagueName}</p>
|
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">Pre-Draft</p>
|
<div className="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>
|
</div>
|
||||||
<div className="flex items-center gap-4 shrink-0">
|
{/* Stats — second row on mobile, right side on desktop */}
|
||||||
|
<div className="flex items-center gap-4 border-t border-border/50 pt-2 sm:border-0 sm:pt-0 sm:shrink-0">
|
||||||
<StatColumn label="Draft">
|
<StatColumn label="Draft">
|
||||||
<span className="text-lg font-bold leading-none">{draftTimeValue}</span>
|
<span className="text-2xl font-bold leading-none">{draftTimeValue}</span>
|
||||||
</StatColumn>
|
</StatColumn>
|
||||||
{draftPosition !== undefined && (
|
{draftPosition !== undefined && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -213,7 +229,7 @@ function CompletedRow({
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
to={`/leagues/${leagueId}`}
|
to={`/leagues/${leagueId}`}
|
||||||
className="flex items-center gap-3 rounded-lg bg-card px-5 py-4 hover:bg-white/[0.06] transition-colors"
|
className="flex items-center gap-3 rounded-lg bg-card px-3 py-3 sm:px-5 sm:py-4 hover:bg-white/[0.06] transition-colors"
|
||||||
>
|
>
|
||||||
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
<LeagueAvatar leagueId={leagueId} leagueName={leagueName} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,14 @@ interface MyLeaguesCardProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MyLeaguesCard({ leagues, viewAllUrl }: MyLeaguesCardProps) {
|
export function MyLeaguesCard({ leagues, viewAllUrl }: MyLeaguesCardProps) {
|
||||||
const sorted = leagues.toSorted(
|
const sorted = leagues.toSorted((a, b) => {
|
||||||
(a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status]
|
const statusDiff = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
|
||||||
);
|
if (statusDiff !== 0) return statusDiff;
|
||||||
|
if (a.status === "active") {
|
||||||
|
return (b.completionPercentage ?? 0) - (a.completionPercentage ?? 0);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
const right = viewAllUrl ? (
|
const right = viewAllUrl ? (
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -33,7 +38,7 @@ export function MyLeaguesCard({ leagues, viewAllUrl }: MyLeaguesCardProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="bg-[#181b22] gap-2">
|
<Card className="bg-[#181b22] gap-2">
|
||||||
<SectionCardHeader icon={Shield} title="My Leagues" right={right} />
|
<SectionCardHeader icon={Shield} title="My Leagues" right={right} />
|
||||||
<CardContent>
|
<CardContent className="px-3 sm:px-6">
|
||||||
{sorted.length === 0 ? (
|
{sorted.length === 0 ? (
|
||||||
<div className="py-4 text-center">
|
<div className="py-4 text-center">
|
||||||
<p className="text-sm text-muted-foreground mb-3">
|
<p className="text-sm text-muted-foreground mb-3">
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ interface SectionCardHeaderProps {
|
||||||
*/
|
*/
|
||||||
export function SectionCardHeader({ icon, title, right }: SectionCardHeaderProps) {
|
export function SectionCardHeader({ icon, title, right }: SectionCardHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="px-3 sm:px-6 pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GradientIcon icon={icon} className="h-5 w-5 shrink-0" />
|
<GradientIcon icon={icon} className="h-5 w-5 shrink-0" />
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,10 @@ export async function loader(args: Route.LoaderArgs) {
|
||||||
currentRank = standing.currentRank ?? undefined;
|
currentRank = standing.currentRank ?? undefined;
|
||||||
totalPoints = standing.totalPoints ?? undefined;
|
totalPoints = standing.totalPoints ?? undefined;
|
||||||
previousRank = standing.previousRank ?? undefined;
|
previousRank = standing.previousRank ?? undefined;
|
||||||
|
} else if (myTeam && season?.status === "active") {
|
||||||
|
// No standing row yet (no scoring events) — default to rank 1, 0 points
|
||||||
|
currentRank = 1;
|
||||||
|
totalPoints = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const perSeasonEvents = await Promise.all(
|
const perSeasonEvents = await Promise.all(
|
||||||
|
|
@ -209,17 +213,17 @@ function isValidStatus(s: string): s is ValidStatus {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8 px-4">
|
<div className="container mx-auto py-8 px-4">
|
||||||
{/*
|
{/*
|
||||||
Desktop: 3-col grid, left column (My Leagues + Create) spans 2, right (Events) spans 1.
|
lg+: 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.
|
mobile/tablet: single column stacked in order: My Leagues, Upcoming Events, Create League.
|
||||||
*/}
|
*/}
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* My Leagues — order 1 on mobile, col-span-2 on desktop */}
|
{/* My Leagues — order 1 on mobile, col-span-2 on desktop */}
|
||||||
<div className="order-1 md:col-span-2">
|
<div className="order-1 lg:col-span-2">
|
||||||
<MyLeaguesCard leagues={leagueRows} />
|
<MyLeaguesCard leagues={leagueRows} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Events — order 2 on mobile, right column on desktop spanning both rows */}
|
{/* 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">
|
<div className="order-2 lg:col-span-1 lg:row-span-2">
|
||||||
<UpcomingEventsCard
|
<UpcomingEventsCard
|
||||||
events={upcomingCalendarEvents}
|
events={upcomingCalendarEvents}
|
||||||
limit={8}
|
limit={8}
|
||||||
|
|
@ -228,7 +232,7 @@ function isValidStatus(s: string): s is ValidStatus {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create a League — order 3 on mobile, below My Leagues on desktop */}
|
{/* Create a League — order 3 on mobile, below My Leagues on desktop */}
|
||||||
<div className="order-3 md:col-span-2">
|
<div className="order-3 lg:col-span-2">
|
||||||
<CreateLeagueCard />
|
<CreateLeagueCard />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue