brackt/app/components/draft/DraftGridSection.tsx
Chris Parsons 9ed0282fd0
New design (#309)
* Redesign home page with new layout and component system

- Two-column layout (My Leagues 2/3, Upcoming Events 1/3) with mobile stack
- LeagueRow: square avatar, gradient draft highlight, rank/points display, progress bar
- MyLeaguesCard, CreateLeagueCard with shared SectionCardHeader
- UpcomingEventsCard: vertical timeline with grouped multi-league events
- Shared gradient system: BracktGradients SVG defs, GradientIcon wrapper, brand.ts constants
- Button default variant updated to green→cyan gradient
- Navbar: plain nav links with gradient hover, support/admin icon buttons
- Accessibility fixes: semantic h2 headings, aria-label on LeagueAvatar and nav elements
- Storybook stories for all new components

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Responsive league row layout and mobile polish

- League rows stack avatar+name on top, stats full-width below on mobile
- Stats spread to right side on sm+ screens with border separator on mobile
- Tighter padding on mobile (px-3/py-3), full padding on sm+
- Card headers and content use px-3 sm:px-6 to reduce mobile gutters
- Two-column home layout deferred to lg breakpoint (tablet gets stacked)
- Active leagues sorted by completion percentage descending
- Default rank 1 / 0 points for active leagues with no scoring events yet
- Fix ordinal bug for 11th/12th/13th; add aria-labels to rank change indicators
- Remove dead StatDivider className prop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Improve claude file.

* Add StandingsPreview card component with podium row styling

- New StandingsPreview component with gold/silver/bronze row tints for
  top 3, team avatar, and LeagueRow-style stat columns (Ranking + Points)
  with rank and 7-day point change indicators
- Fix GradientIcon in Storybook by adding BracktGradients decorator to
  preview.tsx (renamed from .ts to support JSX)
- Fix degenerate SVG gradient on horizontal strokes by switching
  BracktGradients to gradientUnits="userSpaceOnUse" with Lucide-space
  coordinates (0→24)
- Revert erroneous fill: url(#gradient) from GradientIcon; stroke-only
  fix was sufficient once gradientUnits was corrected

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update components on league homepage.

* Finish up league page styling.

* Work on standings page.

* Add story for RecentScoresCard

* Update Point Progression Chart.

* Sort point progression legend by ranking and add team links to standings rows

* Fix standings discrepancy on change.

* Create draft cell component.

* Update draft board page

* Draft room improvements.

* Update some draft room styling.

* Fix context menu missing.

* Move tab navigation and autodraft to header row, narrow sidebar

* Virtualize available participants list, memoize draft room props

Adds @tanstack/react-virtual to replace separate mobile/desktop lists
with a single unified virtual scroll loop. Also memoizes miniDraftGrid
and availableParticipantsSectionProps, and switches pick lookup from
Array.find to a Map for O(1) access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update draft room UI.

* More draft room fixes.

* Draft room tweaks.

* Fix Rosters page.

* Queue Section fixes.

* Mobile Draft fixes.

* Fix draft board page.

* Create bracket look.

* Bracket work.

* Finish bracket page.

* Homepage initial styling

* homepage copy

* Add privacy policy. Fixes #88.

* how to play copy

* rules copy

* Fix brackets on homepage.

* Add footer to website.

* Glow on dots.

* Landing page copy.

* Fix sidebar.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:14:55 -07:00

406 lines
16 KiB
TypeScript

import { memo, useMemo, useState } from "react";
import { TeamAvatar } from "~/components/TeamAvatar";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "~/components/ui/sheet";
import { Button } from "~/components/ui/button";
import { MoreVertical } from "lucide-react";
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
import { DraftPickCell } from "~/components/draft/DraftPickCell";
import type { SeasonStatus } from "~/models/season";
type MobileSheetData =
| { type: "team"; teamId: string }
| { type: "current-cell"; pickNumber: number; teamId: string }
| { type: "picked-cell"; pickNumber: number; teamId: string };
interface DraftGridSectionProps {
draftSlots: Array<{
id: string;
draftOrder: number;
team: {
id: string;
name: string;
logoUrl?: string | null;
};
}>;
draftGrid: Array<
Array<{
pickNumber: number;
round: number;
pickInRound: number;
teamId: string;
pick?: {
participant: {
name: string;
};
sport: {
name: string;
};
};
}>
>;
currentPick: number;
teamTimers: Record<string, number | undefined>;
autodraftStatus: Record<string, { isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }>;
connectedTeams: Set<string>;
isCommissioner: boolean;
seasonStatus?: SeasonStatus;
draftPaused?: boolean;
onAdjustTimeBankOpen?: (teamId: string) => void;
onSetAutodraftOpen?: (teamId: string) => void;
onForceAutopick: (pickNumber: number, teamId: string) => void;
onForceManualPickOpen: (pickNumber: number, teamId: string) => void;
onReplacePick?: (pickNumber: number, teamId: string) => void;
onRollbackToPick?: (pickNumber: number) => void;
ownerMap?: Record<string, string>;
}
export const DraftGridSection = memo(function DraftGridSection({
draftSlots,
draftGrid,
currentPick,
teamTimers,
autodraftStatus,
connectedTeams,
isCommissioner,
seasonStatus,
draftPaused,
onAdjustTimeBankOpen,
onSetAutodraftOpen,
onForceAutopick,
onForceManualPickOpen,
onReplacePick,
onRollbackToPick,
ownerMap = {},
}: DraftGridSectionProps) {
const [mobileSheet, setMobileSheet] = useState<MobileSheetData | null>(null);
const currentTeamId = useMemo(
() => draftGrid.flat().find((c) => c.pickNumber === currentPick)?.teamId ?? null,
[draftGrid, currentPick]
);
return (
<div className="h-full flex flex-col p-4">
<h2 className="text-xl font-semibold mb-4 flex-shrink-0">Draft Grid</h2>
<div className="flex-1 overflow-auto">
<div className="inline-block min-w-full min-h-full">
{/* Team Headers */}
<div className="flex gap-1.5 mb-1.5 sticky top-0 z-10 bg-background/95 backdrop-blur-sm py-1">
{draftSlots.map((slot) => {
const teamTime = teamTimers[slot.team.id];
const isAutodraft = autodraftStatus[slot.team.id]?.isEnabled || false;
const isConnected = connectedTeams.has(slot.team.id);
const headerInner = (
<div className="relative">
<div className="flex justify-center mb-1">
<TeamAvatar
teamId={slot.team.id}
teamName={ownerMap[slot.team.id] || slot.team.name}
logoUrl={slot.team.logoUrl}
size="md"
/>
</div>
<div
className={`font-semibold text-xs px-1 flex items-center justify-center ${
slot.team.id === currentTeamId
? "text-electric font-bold"
: !isConnected
? "italic text-muted-foreground"
: ""
}`}
>
{isAutodraft && (
<span className="mr-1 inline-flex items-center justify-center h-4 w-4 text-[10px] font-bold text-white bg-black shrink-0">A</span>
)}
<span className="truncate">{ownerMap[slot.team.id] || slot.team.name}</span>
</div>
<div
className={`text-xs font-mono px-1 ${getTimerColorClass(teamTime)}`}
>
{formatClockTime(teamTime)}
</div>
{isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen) && (
<button
className="absolute top-0 right-0 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
onClick={() => setMobileSheet({ type: "team", teamId: slot.team.id })}
>
<MoreVertical className="h-3.5 w-3.5" />
</button>
)}
</div>
);
if (isCommissioner && (onAdjustTimeBankOpen || onSetAutodraftOpen)) {
return (
<ContextMenu key={slot.id}>
<ContextMenuTrigger asChild>
<div className="flex-1 min-w-20 text-center cursor-context-menu">
{headerInner}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{onAdjustTimeBankOpen && (
<ContextMenuItem onClick={() => onAdjustTimeBankOpen(slot.team.id)}>
Adjust Time Bank...
</ContextMenuItem>
)}
{onSetAutodraftOpen && (
<ContextMenuItem onClick={() => onSetAutodraftOpen(slot.team.id)}>
Set Autodraft...
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
return (
<div key={slot.id} className="flex-1 min-w-20 text-center">
{headerInner}
</div>
);
})}
</div>
{/* Draft Grid Rows */}
<div className="space-y-1.5">
{draftGrid.map((roundPicks, roundIndex) => {
const round = roundIndex + 1;
const isEvenRound = round % 2 === 0;
const displayPicks = isEvenRound
? [...roundPicks].toReversed()
: roundPicks;
return (
<div key={round} className="flex gap-1.5 items-stretch">
{displayPicks.map((cell) => {
const isCurrent = cell.pickNumber === currentPick;
const isPicked = !!cell.pick;
const cellState = isPicked ? "picked" : isCurrent ? "current" : "upcoming";
const pickData = cell.pick
? { participant: { name: cell.pick.participant.name }, sport: { name: cell.pick.sport.name } }
: undefined;
const pendingCorona = cellState === "upcoming" ? { type: "pending" as const } : undefined;
const mobileButtons = isCommissioner && (
<>
{!isPicked && isCurrent && (
<button
className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
onClick={() => setMobileSheet({ type: "current-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
>
<MoreVertical className="h-3.5 w-3.5" />
</button>
)}
{isPicked && (onReplacePick || onRollbackToPick) && (
<button
className="absolute top-1 right-1 md:hidden p-1 min-w-[32px] min-h-[32px] flex items-center justify-center rounded hover:bg-muted"
onClick={() => setMobileSheet({ type: "picked-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
>
<MoreVertical className="h-3.5 w-3.5" />
</button>
)}
</>
);
if (isCommissioner && !isPicked && isCurrent) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<DraftPickCell
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
coronaState={pendingCorona}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
className="cursor-context-menu"
>
{mobileButtons}
</DraftPickCell>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() =>
onForceAutopick(cell.pickNumber, cell.teamId)
}
>
Force Auto Pick
</ContextMenuItem>
<ContextMenuItem
onClick={() =>
onForceManualPickOpen(
cell.pickNumber,
cell.teamId
)
}
>
Force Manual Pick
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) {
return (
<ContextMenu key={cell.pickNumber}>
<ContextMenuTrigger asChild>
<DraftPickCell
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
coronaState={pendingCorona}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
className="cursor-context-menu"
>
{mobileButtons}
</DraftPickCell>
</ContextMenuTrigger>
<ContextMenuContent>
{onReplacePick && (
<ContextMenuItem
onClick={() =>
onReplacePick(cell.pickNumber, cell.teamId)
}
>
Replace Pick
</ContextMenuItem>
)}
{onRollbackToPick && (
<ContextMenuItem
onClick={() => onRollbackToPick(cell.pickNumber)}
className="text-destructive focus:text-destructive"
>
Roll Back to This Pick
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
);
}
return (
<DraftPickCell
key={cell.pickNumber}
pickNumber={cell.pickNumber}
round={cell.round}
pickInRound={cell.pickInRound}
state={cellState}
pick={pickData}
coronaState={pendingCorona}
seasonStatus={seasonStatus}
draftPaused={draftPaused}
>
{mobileButtons}
</DraftPickCell>
);
})}
</div>
);
})}
</div>
</div>
</div>
{/* Mobile Commissioner Sheet */}
<Sheet open={!!mobileSheet} onOpenChange={(open) => !open && setMobileSheet(null)}>
<SheetContent side="bottom" className="pb-8">
<SheetHeader>
<SheetTitle>Commissioner Actions</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-3 p-4">
{mobileSheet?.type === "team" && (
<>
{onAdjustTimeBankOpen && (
<Button
onClick={() => {
onAdjustTimeBankOpen(mobileSheet.teamId);
setMobileSheet(null);
}}
>
Adjust Time Bank...
</Button>
)}
{onSetAutodraftOpen && (
<Button
variant="outline"
onClick={() => {
onSetAutodraftOpen(mobileSheet.teamId);
setMobileSheet(null);
}}
>
Set Autodraft...
</Button>
)}
</>
)}
{mobileSheet?.type === "current-cell" && (
<>
<Button
onClick={() => {
onForceAutopick(mobileSheet.pickNumber, mobileSheet.teamId);
setMobileSheet(null);
}}
>
Force Auto Pick
</Button>
<Button
variant="outline"
onClick={() => {
onForceManualPickOpen(mobileSheet.pickNumber, mobileSheet.teamId);
setMobileSheet(null);
}}
>
Force Manual Pick
</Button>
</>
)}
{mobileSheet?.type === "picked-cell" && (
<>
{onReplacePick && (
<Button
onClick={() => {
onReplacePick(mobileSheet.pickNumber, mobileSheet.teamId);
setMobileSheet(null);
}}
>
Replace Pick
</Button>
)}
{onRollbackToPick && (
<Button
variant="destructive"
onClick={() => {
onRollbackToPick(mobileSheet.pickNumber);
setMobileSheet(null);
}}
>
Roll Back to This Pick
</Button>
)}
</>
)}
</div>
</SheetContent>
</Sheet>
</div>
);
});