Create draft cell component.
This commit is contained in:
parent
266502eeec
commit
f17d2a3250
5 changed files with 279 additions and 81 deletions
|
|
@ -1,10 +1,10 @@
|
||||||
import { Card } from "~/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "~/components/ui/context-menu";
|
} from "~/components/ui/context-menu";
|
||||||
|
import { DraftPickCell } from "~/components/draft/DraftPickCell";
|
||||||
|
|
||||||
interface DraftCell {
|
interface DraftCell {
|
||||||
pickNumber: number;
|
pickNumber: number;
|
||||||
|
|
@ -52,7 +52,7 @@ export function DraftGrid({
|
||||||
const totalTeams = draftSlots.length;
|
const totalTeams = draftSlots.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<div className="p-4">
|
||||||
{title && <h2 className="text-xl font-semibold mb-4">{title}</h2>}
|
{title && <h2 className="text-xl font-semibold mb-4">{title}</h2>}
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
<div className="min-w-max">
|
<div className="min-w-max">
|
||||||
|
|
@ -108,43 +108,30 @@ export function DraftGrid({
|
||||||
const actualIndex = isEvenRound
|
const actualIndex = isEvenRound
|
||||||
? roundPicks.length - 1 - index
|
? roundPicks.length - 1 - index
|
||||||
: index;
|
: index;
|
||||||
const slot = draftSlots[actualIndex];
|
|
||||||
const pickNumber = roundIndex * totalTeams + actualIndex + 1;
|
const pickNumber = roundIndex * totalTeams + actualIndex + 1;
|
||||||
const isCurrent = pickNumber === currentPick;
|
const isCurrent = pickNumber === currentPick;
|
||||||
const isPicked = !!cell;
|
const isPicked = !!cell;
|
||||||
|
|
||||||
const cellContent = (
|
const cellContent = (
|
||||||
<div
|
<DraftPickCell
|
||||||
className={`flex-1 min-w-0 h-20 border-2 rounded-lg p-2 transition-all ${
|
key={pickNumber}
|
||||||
isPicked
|
pickNumber={pickNumber}
|
||||||
? "bg-emerald-500/10 border-emerald-500/30"
|
round={round}
|
||||||
: isCurrent
|
pickInRound={actualIndex + 1}
|
||||||
? "border-electric bg-electric/15 shadow-lg shadow-electric/10"
|
state={
|
||||||
: "border-border bg-card"
|
isPicked ? "picked" : isCurrent ? "current" : "upcoming"
|
||||||
}`}
|
}
|
||||||
title={`Overall Pick #${pickNumber}`}
|
pick={
|
||||||
>
|
cell
|
||||||
<div className="text-xs font-mono text-muted-foreground mb-1">
|
? {
|
||||||
{round}.{String(actualIndex + 1).padStart(2, "0")}
|
participant: { name: cell.participant.name },
|
||||||
</div>
|
sport: { name: cell.sport.name },
|
||||||
{isPicked ? (
|
}
|
||||||
<div className="text-xs">
|
: undefined
|
||||||
<div className="font-semibold truncate">
|
}
|
||||||
{cell.participant.name}
|
/>
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground truncate">
|
|
||||||
{cell.sport.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : isCurrent ? (
|
|
||||||
<div className="text-xs font-semibold text-electric">
|
|
||||||
On Clock
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wrap with context menu if commissioner and current unpicked cell
|
|
||||||
if (
|
if (
|
||||||
isCommissioner &&
|
isCommissioner &&
|
||||||
!isPicked &&
|
!isPicked &&
|
||||||
|
|
@ -152,6 +139,7 @@ export function DraftGrid({
|
||||||
onForceAutopick &&
|
onForceAutopick &&
|
||||||
onForceManualPick
|
onForceManualPick
|
||||||
) {
|
) {
|
||||||
|
const slot = draftSlots[actualIndex];
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={pickNumber}>
|
<ContextMenu key={pickNumber}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
|
|
@ -185,6 +173,6 @@ export function DraftGrid({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ describe('DraftGrid Component', () => {
|
||||||
|
|
||||||
const currentPickCell = screen.getByText('On Clock').parentElement;
|
const currentPickCell = screen.getByText('On Clock').parentElement;
|
||||||
expect(currentPickCell).toHaveClass('border-electric');
|
expect(currentPickCell).toHaveClass('border-electric');
|
||||||
expect(currentPickCell).toHaveClass('bg-electric/15');
|
expect(currentPickCell).toHaveClass('bg-electric/20');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { MoreVertical } from "lucide-react";
|
import { MoreVertical } from "lucide-react";
|
||||||
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
||||||
|
import { DraftPickCell } from "~/components/draft/DraftPickCell";
|
||||||
|
|
||||||
type MobileSheetData =
|
type MobileSheetData =
|
||||||
| { type: "team"; teamId: string }
|
| { type: "team"; teamId: string }
|
||||||
|
|
@ -180,37 +181,14 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
{displayPicks.map((cell) => {
|
{displayPicks.map((cell) => {
|
||||||
const isCurrent = cell.pickNumber === currentPick;
|
const isCurrent = cell.pickNumber === currentPick;
|
||||||
const isPicked = !!cell.pick;
|
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 cellClassName = `relative flex-1 min-w-32 h-20 border-2 rounded-lg p-2 transition-all ${
|
const mobileButtons = isCommissioner && (
|
||||||
isCurrent
|
|
||||||
? "border-electric bg-electric/20 shadow-lg shadow-electric/25 ring-1 ring-electric/40"
|
|
||||||
: isPicked
|
|
||||||
? "bg-emerald-500/10 border-emerald-500/30"
|
|
||||||
: "border-border bg-card"
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const cellInner = (
|
|
||||||
<>
|
<>
|
||||||
<div className="text-xs font-mono text-muted-foreground mb-1">
|
{!isPicked && isCurrent && (
|
||||||
{cell.round}.
|
|
||||||
{String(cell.pickInRound).padStart(2, "0")}
|
|
||||||
</div>
|
|
||||||
{isPicked ? (
|
|
||||||
<div className="text-xs">
|
|
||||||
<div className="font-semibold truncate">
|
|
||||||
{cell.pick?.participant.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground truncate">
|
|
||||||
{cell.pick?.sport.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : isCurrent ? (
|
|
||||||
<div className="text-sm font-bold text-electric animate-pulse">
|
|
||||||
On Clock
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{/* Mobile commissioner button */}
|
|
||||||
{isCommissioner && !isPicked && isCurrent && (
|
|
||||||
<button
|
<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"
|
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 })}
|
onClick={() => setMobileSheet({ type: "current-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
|
||||||
|
|
@ -218,7 +196,7 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
<MoreVertical className="h-3.5 w-3.5" />
|
<MoreVertical className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isCommissioner && isPicked && (onReplacePick || onRollbackToPick) && (
|
{isPicked && (onReplacePick || onRollbackToPick) && (
|
||||||
<button
|
<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"
|
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 })}
|
onClick={() => setMobileSheet({ type: "picked-cell", pickNumber: cell.pickNumber, teamId: cell.teamId })}
|
||||||
|
|
@ -229,17 +207,19 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Commissioner context menu on the current unpicked cell
|
|
||||||
if (isCommissioner && !isPicked && isCurrent) {
|
if (isCommissioner && !isPicked && isCurrent) {
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={cell.pickNumber}>
|
<ContextMenu key={cell.pickNumber}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<DraftPickCell
|
||||||
className={cellClassName}
|
pickNumber={cell.pickNumber}
|
||||||
title={`Overall Pick #${cell.pickNumber}`}
|
round={cell.round}
|
||||||
|
pickInRound={cell.pickInRound}
|
||||||
|
state={cellState}
|
||||||
|
pick={pickData}
|
||||||
>
|
>
|
||||||
{cellInner}
|
{mobileButtons}
|
||||||
</div>
|
</DraftPickCell>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
|
|
@ -264,17 +244,19 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Commissioner context menu on already-picked cells
|
|
||||||
if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) {
|
if (isCommissioner && isPicked && (onReplacePick || onRollbackToPick)) {
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={cell.pickNumber}>
|
<ContextMenu key={cell.pickNumber}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<DraftPickCell
|
||||||
className={cellClassName}
|
pickNumber={cell.pickNumber}
|
||||||
title={`Overall Pick #${cell.pickNumber}`}
|
round={cell.round}
|
||||||
|
pickInRound={cell.pickInRound}
|
||||||
|
state={cellState}
|
||||||
|
pick={pickData}
|
||||||
>
|
>
|
||||||
{cellInner}
|
{mobileButtons}
|
||||||
</div>
|
</DraftPickCell>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
{onReplacePick && (
|
{onReplacePick && (
|
||||||
|
|
@ -300,13 +282,16 @@ export const DraftGridSection = memo(function DraftGridSection({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<DraftPickCell
|
||||||
key={cell.pickNumber}
|
key={cell.pickNumber}
|
||||||
className={cellClassName}
|
pickNumber={cell.pickNumber}
|
||||||
title={`Overall Pick #${cell.pickNumber}`}
|
round={cell.round}
|
||||||
|
pickInRound={cell.pickInRound}
|
||||||
|
state={cellState}
|
||||||
|
pick={pickData}
|
||||||
>
|
>
|
||||||
{cellInner}
|
{mobileButtons}
|
||||||
</div>
|
</DraftPickCell>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
136
app/components/draft/DraftPickCell.stories.tsx
Normal file
136
app/components/draft/DraftPickCell.stories.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { DraftPickCell } from "./DraftPickCell";
|
||||||
|
|
||||||
|
const meta: Meta<typeof DraftPickCell> = {
|
||||||
|
title: "Draft/DraftPickCell",
|
||||||
|
component: DraftPickCell,
|
||||||
|
parameters: {
|
||||||
|
layout: "padded",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof DraftPickCell>;
|
||||||
|
|
||||||
|
const basePickArgs = {
|
||||||
|
pickNumber: 1,
|
||||||
|
round: 1,
|
||||||
|
pickInRound: 1,
|
||||||
|
state: "picked" as const,
|
||||||
|
pick: {
|
||||||
|
participant: { name: "LeBron James" },
|
||||||
|
sport: { name: "Basketball" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Upcoming: Story = {
|
||||||
|
args: {
|
||||||
|
pickNumber: 5,
|
||||||
|
round: 1,
|
||||||
|
pickInRound: 5,
|
||||||
|
state: "upcoming",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Current: Story = {
|
||||||
|
args: {
|
||||||
|
pickNumber: 5,
|
||||||
|
round: 1,
|
||||||
|
pickInRound: 5,
|
||||||
|
state: "current",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Picked: Story = {
|
||||||
|
args: basePickArgs,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PickedElectricCorona: Story = {
|
||||||
|
args: {
|
||||||
|
...basePickArgs,
|
||||||
|
corona: "electric",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PickedEmeraldCorona: Story = {
|
||||||
|
args: {
|
||||||
|
...basePickArgs,
|
||||||
|
corona: "emerald",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PickedAmberCorona: Story = {
|
||||||
|
args: {
|
||||||
|
...basePickArgs,
|
||||||
|
corona: "amber",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PickedCoralCorona: Story = {
|
||||||
|
args: {
|
||||||
|
...basePickArgs,
|
||||||
|
corona: "coral",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PickedLongNames: Story = {
|
||||||
|
name: "Picked — Long Names (truncation)",
|
||||||
|
args: {
|
||||||
|
pickNumber: 12,
|
||||||
|
round: 2,
|
||||||
|
pickInRound: 6,
|
||||||
|
state: "picked",
|
||||||
|
pick: {
|
||||||
|
participant: { name: "Alexander Ovechkin-Washington" },
|
||||||
|
sport: { name: "Ice Hockey — NHL 2025/26" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllStatesSideBySide: Story = {
|
||||||
|
name: "All States — Side by Side",
|
||||||
|
render: () => (
|
||||||
|
<div className="flex gap-2 items-stretch">
|
||||||
|
<DraftPickCell
|
||||||
|
pickNumber={1}
|
||||||
|
round={1}
|
||||||
|
pickInRound={1}
|
||||||
|
state="picked"
|
||||||
|
pick={basePickArgs.pick}
|
||||||
|
/>
|
||||||
|
<DraftPickCell
|
||||||
|
pickNumber={2}
|
||||||
|
round={1}
|
||||||
|
pickInRound={2}
|
||||||
|
state="current"
|
||||||
|
/>
|
||||||
|
<DraftPickCell
|
||||||
|
pickNumber={3}
|
||||||
|
round={1}
|
||||||
|
pickInRound={3}
|
||||||
|
state="upcoming"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CoronaVariantsRow: Story = {
|
||||||
|
name: "Corona Variants — Side by Side",
|
||||||
|
render: () => (
|
||||||
|
<div className="flex gap-2 items-stretch">
|
||||||
|
{(
|
||||||
|
["gradient", "electric", "emerald", "amber", "coral"] as const
|
||||||
|
).map((variant, i) => (
|
||||||
|
<DraftPickCell
|
||||||
|
key={variant}
|
||||||
|
pickNumber={i + 1}
|
||||||
|
round={1}
|
||||||
|
pickInRound={i + 1}
|
||||||
|
state="picked"
|
||||||
|
pick={basePickArgs.pick}
|
||||||
|
corona={variant}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
89
app/components/draft/DraftPickCell.tsx
Normal file
89
app/components/draft/DraftPickCell.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
type CoronaVariant =
|
||||||
|
| "gradient"
|
||||||
|
| "electric"
|
||||||
|
| "emerald"
|
||||||
|
| "amber"
|
||||||
|
| "coral";
|
||||||
|
|
||||||
|
interface DraftPickCellProps {
|
||||||
|
pickNumber: number;
|
||||||
|
round: number;
|
||||||
|
pickInRound: number;
|
||||||
|
state: "picked" | "current" | "upcoming";
|
||||||
|
pick?: {
|
||||||
|
participant: { name: string };
|
||||||
|
sport: { name: string };
|
||||||
|
};
|
||||||
|
corona?: CoronaVariant;
|
||||||
|
className?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coronaClasses: Record<CoronaVariant, string> = {
|
||||||
|
gradient: "[background:linear-gradient(to_bottom,#adf661,#2ce1c1)]",
|
||||||
|
electric: "bg-electric",
|
||||||
|
emerald: "bg-emerald-500",
|
||||||
|
amber: "bg-amber-accent",
|
||||||
|
coral: "bg-coral-accent",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DraftPickCell({
|
||||||
|
pickNumber,
|
||||||
|
round,
|
||||||
|
pickInRound,
|
||||||
|
state,
|
||||||
|
pick,
|
||||||
|
corona = "gradient",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: DraftPickCellProps) {
|
||||||
|
const showCorona = state === "picked";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex-1 min-w-32 overflow-hidden",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{showCorona && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-0.5 bottom-0.5 left-0 right-0 rounded-lg",
|
||||||
|
coronaClasses[corona],
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative z-10 h-20 border-2 rounded-lg p-2 transition-all",
|
||||||
|
showCorona && "mr-1",
|
||||||
|
state === "current"
|
||||||
|
? "border-electric bg-electric/20 shadow-lg shadow-electric/30 ring-2 ring-electric/50 animate-pulse"
|
||||||
|
: state === "picked"
|
||||||
|
? "bg-muted border-transparent"
|
||||||
|
: "border-border bg-card",
|
||||||
|
)}
|
||||||
|
title={`Overall Pick #${pickNumber}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-mono text-muted-foreground mb-1">
|
||||||
|
{round}.{String(pickInRound).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
{state === "picked" && pick ? (
|
||||||
|
<div className="text-xs">
|
||||||
|
<div className="font-semibold truncate">{pick.participant.name}</div>
|
||||||
|
<div className="text-muted-foreground truncate">
|
||||||
|
{pick.sport.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : state === "current" ? (
|
||||||
|
<div className="text-sm font-bold text-electric">On Clock</div>
|
||||||
|
) : null}
|
||||||
|
{children && <div className="absolute inset-0 pointer-events-none [&_*]:pointer-events-auto">{children}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue