When a pick is made the picked player's row flashes teal (--electric)
then fades out over 750ms. Works correctly with the TanStack Virtual
list by keeping the row in filteredParticipants during the animation
via animatingOutParticipantIds, then removing it after 650ms.
Key design decisions:
- Animation state lives in AvailableParticipantsSection (internal
useState + timers); the draft room passes a pickAnimationSignal
{id, seq} rather than the Set, so memo only breaks on each pick,
not on the 650ms cleanup
- displayDrafted freezes row content (badges, buttons) during the
animation to prevent height shifts
- Per-ID independent timers via Map ref support rapid autodraft picks
running concurrently without cancellation
- Timers are cleared on unmount in both the draft room and component
- prefers-reduced-motion: skips the color flash, plain opacity fade only
- Removes "Draft Grid" heading from DraftGridSection (visual cleanup)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
786 lines
31 KiB
TypeScript
786 lines
31 KiB
TypeScript
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Checkbox } from "~/components/ui/checkbox";
|
|
import { MiniDraftGrid, type MiniDraftGridProps } from "~/components/draft/MiniDraftGrid";
|
|
import type { DraftIneligibilityReason } from "~/lib/draft-eligibility";
|
|
import {
|
|
Popover,
|
|
PopoverTrigger,
|
|
PopoverContent,
|
|
} from "~/components/ui/popover";
|
|
import {
|
|
Sheet,
|
|
SheetTrigger,
|
|
SheetContent,
|
|
SheetTitle,
|
|
SheetFooter,
|
|
SheetClose,
|
|
} from "~/components/ui/sheet";
|
|
import { ListPlus, ListX, ChevronDown, Eye, EyeOff, SlidersHorizontal } from "lucide-react";
|
|
|
|
type ListItem =
|
|
| { type: "participant"; index: number; key: string }
|
|
| { type: "divider"; round: number; key: string };
|
|
|
|
function getParticipantState(
|
|
participant: { id: string; sport: { id: string } },
|
|
draftedParticipantIds: Set<string>,
|
|
queueMap: Map<string, string>,
|
|
eligibility: {
|
|
eligibleSportIds: Set<string>;
|
|
ineligibleReasons: Record<string, DraftIneligibilityReason>;
|
|
} | null,
|
|
watchedParticipantIds: Set<string>
|
|
) {
|
|
const isDrafted = draftedParticipantIds.has(participant.id);
|
|
const isInQueue = queueMap.has(participant.id);
|
|
const isEligible = eligibility
|
|
? eligibility.eligibleSportIds.has(participant.sport.id)
|
|
: true;
|
|
const ineligibleReason = eligibility?.ineligibleReasons[participant.sport.id] ?? null;
|
|
const isWatched = watchedParticipantIds.has(participant.id);
|
|
return { isDrafted, isInQueue, isEligible, ineligibleReason, isWatched };
|
|
}
|
|
|
|
interface SportFilterContentProps {
|
|
sportsForDropdown: Array<{ name: string; isDrafted: boolean }>;
|
|
sportFilterSet: Set<string>;
|
|
hideCompletedSports: boolean;
|
|
hasTeam: boolean;
|
|
variant: "sheet" | "popover";
|
|
onToggleSport: (sport: string, checked: boolean | "indeterminate") => void;
|
|
onHideCompletedSportsChange: (hide: boolean) => void;
|
|
}
|
|
|
|
function SportFilterContent({
|
|
sportsForDropdown,
|
|
sportFilterSet,
|
|
hideCompletedSports,
|
|
hasTeam,
|
|
variant,
|
|
onToggleSport,
|
|
onHideCompletedSportsChange,
|
|
}: SportFilterContentProps) {
|
|
const isSheet = variant === "sheet";
|
|
const idPrefix = isSheet ? "sheet-sport-filter-" : "popover-sport-filter-";
|
|
const listClass = isSheet
|
|
? "overflow-y-auto flex-1 px-4"
|
|
: "max-h-64 overflow-y-auto p-2 space-y-0.5";
|
|
const itemBaseClass = isSheet
|
|
? "flex items-center gap-4 py-4 border-b last:border-b-0 cursor-pointer text-base"
|
|
: "flex items-center gap-2 px-2 py-2 rounded-sm hover:bg-accent cursor-pointer text-sm";
|
|
const toggleLabelClass = isSheet
|
|
? "flex items-center gap-4 py-2 cursor-pointer text-base"
|
|
: "flex items-center gap-2 px-4 py-3 cursor-pointer text-sm";
|
|
|
|
return (
|
|
<>
|
|
<div className={listClass}>
|
|
{sportsForDropdown
|
|
.filter(({ isDrafted }) => !isDrafted || !hideCompletedSports)
|
|
.map(({ name, isDrafted }) => {
|
|
const checkboxId = `${idPrefix}${name.replace(/\s+/g, "-").toLowerCase()}`;
|
|
return (
|
|
<label
|
|
key={name}
|
|
htmlFor={checkboxId}
|
|
className={`${itemBaseClass}${isDrafted ? " text-muted-foreground" : ""}`}
|
|
>
|
|
<Checkbox
|
|
id={checkboxId}
|
|
checked={sportFilterSet.has(name)}
|
|
onCheckedChange={(checked) => onToggleSport(name, checked)}
|
|
/>
|
|
{name}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
{hasTeam && (
|
|
isSheet ? (
|
|
<div className="px-4 border-t pt-3">
|
|
<label className={toggleLabelClass}>
|
|
<Checkbox
|
|
checked={!hideCompletedSports}
|
|
onCheckedChange={(checked) =>
|
|
onHideCompletedSportsChange(checked === false)
|
|
}
|
|
/>
|
|
<span>Show drafted sports</span>
|
|
</label>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<hr className="mx-2 border-border" />
|
|
<label className={toggleLabelClass}>
|
|
<Checkbox
|
|
checked={!hideCompletedSports}
|
|
onCheckedChange={(checked) =>
|
|
onHideCompletedSportsChange(checked === false)
|
|
}
|
|
/>
|
|
<span>Show drafted sports</span>
|
|
</label>
|
|
</>
|
|
)
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface AvailableParticipantsSectionProps {
|
|
participants: Array<{
|
|
id: string;
|
|
name: string;
|
|
sport: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
}>;
|
|
participantRanks: Map<string, { overallRank: number; sportRank: number }>;
|
|
miniDraftGrid?: MiniDraftGridProps;
|
|
searchQuery: string;
|
|
sportFilters: string[];
|
|
hideDrafted: boolean;
|
|
hideIneligible: boolean;
|
|
hideCompletedSports: boolean;
|
|
userDraftedSportNames: Set<string>;
|
|
uniqueSports: string[];
|
|
draftedParticipantIds: Set<string>;
|
|
queue: Array<{ id: string; participantId: string }>;
|
|
eligibility: {
|
|
eligibleSportIds: Set<string>;
|
|
ineligibleReasons: Record<string, DraftIneligibilityReason>;
|
|
} | null;
|
|
canPick: boolean;
|
|
hasTeam: boolean;
|
|
onSearchChange: (query: string) => void;
|
|
onSportFiltersChange: (sports: string[]) => void;
|
|
onHideDraftedChange: (hide: boolean) => void;
|
|
onHideIneligibleChange: (hide: boolean) => void;
|
|
onHideCompletedSportsChange: (hide: boolean) => void;
|
|
onMakePick: (participantId: string) => void;
|
|
onAddToQueue: (participantId: string) => void;
|
|
onRemoveFromQueue: (queueId: string) => void;
|
|
projectedPicks?: Array<{ round: number; picksFromNow: number }>;
|
|
watchedParticipantIds: Set<string>;
|
|
onToggleWatchlist: (participantId: string) => void;
|
|
showOnlyWatched: boolean;
|
|
onShowOnlyWatchedChange: (show: boolean) => void;
|
|
pickAnimationSignal: { id: string; seq: number } | null;
|
|
}
|
|
|
|
export const AvailableParticipantsSection = memo(function AvailableParticipantsSection({
|
|
participants,
|
|
participantRanks,
|
|
miniDraftGrid,
|
|
searchQuery,
|
|
sportFilters,
|
|
hideDrafted,
|
|
hideIneligible,
|
|
hideCompletedSports,
|
|
userDraftedSportNames,
|
|
uniqueSports,
|
|
draftedParticipantIds,
|
|
queue,
|
|
eligibility,
|
|
canPick,
|
|
hasTeam,
|
|
onSearchChange,
|
|
onSportFiltersChange,
|
|
onHideDraftedChange,
|
|
onHideIneligibleChange,
|
|
onHideCompletedSportsChange,
|
|
onMakePick,
|
|
onAddToQueue,
|
|
onRemoveFromQueue,
|
|
projectedPicks,
|
|
watchedParticipantIds,
|
|
onToggleWatchlist,
|
|
showOnlyWatched,
|
|
onShowOnlyWatchedChange,
|
|
pickAnimationSignal,
|
|
}: AvailableParticipantsSectionProps) {
|
|
const [animatingOutParticipantIds, setAnimatingOutParticipantIds] = useState<Set<string>>(new Set());
|
|
const animationTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
|
useEffect(() => {
|
|
if (!pickAnimationSignal) return;
|
|
const { id } = pickAnimationSignal;
|
|
const existing = animationTimersRef.current.get(id);
|
|
if (existing) clearTimeout(existing);
|
|
setAnimatingOutParticipantIds((prev) => new Set([...prev, id]));
|
|
const timer = setTimeout(() => {
|
|
setAnimatingOutParticipantIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(id);
|
|
return next;
|
|
});
|
|
animationTimersRef.current.delete(id);
|
|
}, 650);
|
|
animationTimersRef.current.set(id, timer);
|
|
}, [pickAnimationSignal]);
|
|
useEffect(() => () => { animationTimersRef.current.forEach(clearTimeout); }, []);
|
|
|
|
const queueMap = useMemo(
|
|
() => new Map(queue.map((item) => [item.participantId, item.id])),
|
|
[queue]
|
|
);
|
|
|
|
const sportFilterSet = useMemo(() => new Set(sportFilters), [sportFilters]);
|
|
|
|
const sportsForDropdown = useMemo(() => {
|
|
return uniqueSports.map((s) => ({
|
|
name: s,
|
|
isDrafted: hasTeam && userDraftedSportNames.has(s),
|
|
}));
|
|
}, [uniqueSports, userDraftedSportNames, hasTeam]);
|
|
|
|
const triggerText = useMemo(
|
|
() =>
|
|
sportFilters.length === 0
|
|
? "All Sports"
|
|
: sportFilters.length === 1
|
|
? sportFilters[0]
|
|
: `${sportFilters.length} Sports`,
|
|
[sportFilters]
|
|
);
|
|
|
|
const triggerAriaLabel = useMemo(
|
|
() =>
|
|
sportFilters.length === 0
|
|
? "Filter by sport: All Sports"
|
|
: sportFilters.length === 1
|
|
? `Filter by sport: ${sportFilters[0]}`
|
|
: `Filter by sport: ${sportFilters.length} sports selected`,
|
|
[sportFilters]
|
|
);
|
|
|
|
const handleToggleSport = useCallback(
|
|
(sport: string, checked: boolean | "indeterminate") => {
|
|
if (checked === true) {
|
|
onSportFiltersChange([...sportFilters, sport]);
|
|
} else {
|
|
onSportFiltersChange(sportFilters.filter((s) => s !== sport));
|
|
}
|
|
},
|
|
[sportFilters, onSportFiltersChange]
|
|
);
|
|
|
|
const handleReset = useCallback(() => {
|
|
onSportFiltersChange([]);
|
|
onHideCompletedSportsChange(false);
|
|
}, [onSportFiltersChange, onHideCompletedSportsChange]);
|
|
|
|
const emptyMessage = useMemo(() => {
|
|
if (participants.length > 0) return null;
|
|
const active: string[] = [];
|
|
if (hideDrafted) active.push("drafted players");
|
|
if (hideIneligible && eligibility) active.push("ineligible players");
|
|
if (hideCompletedSports) active.push("drafted sports");
|
|
if (sportFilters.length > 0) active.push("other sports");
|
|
if (active.length === 0) return "No participants found.";
|
|
return `No participants found. Try showing ${active.join(", ")}.`;
|
|
}, [participants.length, hideDrafted, hideIneligible, hideCompletedSports, eligibility, sportFilters]);
|
|
|
|
const hasActiveFilters = sportFilters.length > 0 || hideCompletedSports;
|
|
|
|
const anyFilterActive = searchQuery !== "" || sportFilters.length > 0 || hideDrafted || hideIneligible || hideCompletedSports;
|
|
|
|
const listItems = useMemo(() => {
|
|
const items: ListItem[] = [];
|
|
if (!projectedPicks || projectedPicks.length === 0 || anyFilterActive) {
|
|
for (let i = 0; i < participants.length; i++) {
|
|
items.push({ type: "participant", index: i, key: participants[i].id });
|
|
}
|
|
return items;
|
|
}
|
|
const dividersByAfterIndex = new Map<number, number>();
|
|
for (const pp of projectedPicks) {
|
|
if (pp.picksFromNow <= 0) continue;
|
|
const afterIndex = pp.picksFromNow - 1;
|
|
if (afterIndex >= 0 && afterIndex < participants.length - 1) {
|
|
dividersByAfterIndex.set(afterIndex, pp.round);
|
|
}
|
|
}
|
|
for (let i = 0; i < participants.length; i++) {
|
|
items.push({ type: "participant", index: i, key: participants[i].id });
|
|
const dividerRound = dividersByAfterIndex.get(i);
|
|
if (dividerRound !== undefined) {
|
|
items.push({ type: "divider", round: dividerRound, key: `divider-rd${dividerRound}` });
|
|
}
|
|
}
|
|
return items;
|
|
}, [participants, projectedPicks, anyFilterActive]);
|
|
|
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
const virtualizer = useVirtualizer({
|
|
count: listItems.length,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize: (index) => listItems[index]?.type === "divider" ? 36 : 72,
|
|
overscan: 5,
|
|
getItemKey: (index) => listItems[index].key,
|
|
});
|
|
|
|
const desktopGridClass = hasTeam
|
|
? "grid-cols-[60px_60px_1fr_140px]"
|
|
: "grid-cols-[60px_60px_1fr]";
|
|
|
|
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{miniDraftGrid && (
|
|
<div className="px-4 pt-4 pb-2 flex-shrink-0 border-b">
|
|
<MiniDraftGrid {...miniDraftGrid} />
|
|
</div>
|
|
)}
|
|
<div className="px-4 pt-4 pb-2 flex-shrink-0">
|
|
<div className="flex flex-col gap-2 md:flex-row md:flex-wrap md:items-center">
|
|
<div className="flex gap-2 md:contents">
|
|
<input
|
|
type="text"
|
|
placeholder="Search participants..."
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="flex-1 min-w-0 px-3 py-2 h-9 border rounded-md text-base md:text-sm bg-background text-foreground"
|
|
/>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="md:hidden shrink-0 h-9 gap-1.5"
|
|
onClick={() => setMobileFiltersOpen((prev) => !prev)}
|
|
aria-expanded={mobileFiltersOpen}
|
|
aria-label="Toggle filters"
|
|
>
|
|
<SlidersHorizontal className="h-4 w-4" />
|
|
Filters
|
|
</Button>
|
|
|
|
<div className="hidden md:block shrink-0">
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
aria-label={triggerAriaLabel}
|
|
className="w-[160px] justify-between text-sm font-normal"
|
|
>
|
|
<span className="truncate">{triggerText}</span>
|
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-72 p-0" align="start">
|
|
{hasActiveFilters && (
|
|
<div className="flex justify-end px-2 pt-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-auto px-2 py-1 text-xs"
|
|
onClick={handleReset}
|
|
>
|
|
Reset
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<SportFilterContent
|
|
sportsForDropdown={sportsForDropdown}
|
|
sportFilterSet={sportFilterSet}
|
|
hideCompletedSports={hideCompletedSports}
|
|
hasTeam={hasTeam}
|
|
variant="popover"
|
|
onToggleSport={handleToggleSport}
|
|
onHideCompletedSportsChange={onHideCompletedSportsChange}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`flex gap-2 flex-wrap md:contents ${mobileFiltersOpen ? "" : "hidden md:contents"}`}>
|
|
<div className="md:hidden">
|
|
<Sheet>
|
|
<SheetTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
aria-label={triggerAriaLabel}
|
|
className="justify-between text-base font-normal w-full"
|
|
>
|
|
<span className="truncate">{triggerText}</span>
|
|
<ChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</SheetTrigger>
|
|
<SheetContent side="bottom" className="max-h-[70vh] pt-12" aria-describedby={undefined}>
|
|
<SheetTitle className="sr-only">Filter by sport</SheetTitle>
|
|
<SportFilterContent
|
|
sportsForDropdown={sportsForDropdown}
|
|
sportFilterSet={sportFilterSet}
|
|
hideCompletedSports={hideCompletedSports}
|
|
hasTeam={hasTeam}
|
|
variant="sheet"
|
|
onToggleSport={handleToggleSport}
|
|
onHideCompletedSportsChange={onHideCompletedSportsChange}
|
|
/>
|
|
<SheetFooter className="px-4 pb-8 flex-row gap-2">
|
|
{hasActiveFilters && (
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={handleReset}
|
|
>
|
|
Reset
|
|
</Button>
|
|
)}
|
|
<SheetClose asChild>
|
|
<Button className="flex-1">Done</Button>
|
|
</SheetClose>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
|
|
<label className="flex flex-1 items-center gap-2 text-sm cursor-pointer px-3 py-2 border rounded-md md:shrink-0 md:flex-none">
|
|
<Checkbox
|
|
checked={!hideDrafted}
|
|
onCheckedChange={(checked) => onHideDraftedChange(checked === false)}
|
|
/>
|
|
<span>Show Drafted</span>
|
|
</label>
|
|
|
|
{hasTeam && eligibility && (
|
|
<label className="flex flex-1 items-center gap-2 text-sm cursor-pointer px-3 py-2 border rounded-md md:shrink-0 md:flex-none">
|
|
<Checkbox
|
|
checked={!hideIneligible}
|
|
onCheckedChange={(checked) => onHideIneligibleChange(checked === false)}
|
|
/>
|
|
<span>Show Ineligible</span>
|
|
</label>
|
|
)}
|
|
|
|
{hasTeam && (
|
|
<label className="flex flex-1 items-center gap-2 text-sm cursor-pointer px-3 py-2 border rounded-md md:shrink-0 md:flex-none">
|
|
<Checkbox
|
|
checked={showOnlyWatched}
|
|
onCheckedChange={(checked) => onShowOnlyWatchedChange(checked === true)}
|
|
disabled={watchedParticipantIds.size === 0}
|
|
/>
|
|
<span>Watched Only</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`hidden md:grid ${desktopGridClass} bg-muted border-b text-sm font-semibold flex-shrink-0 px-4`}>
|
|
<span className="text-center p-3 px-0">OVR</span>
|
|
<span className="text-center p-3 px-0">SPR</span>
|
|
<span className="text-left p-3">Participant</span>
|
|
</div>
|
|
|
|
<div ref={parentRef} className="flex-1 overflow-y-auto">
|
|
{participants.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
|
{emptyMessage}
|
|
</div>
|
|
) : (
|
|
<div
|
|
style={{
|
|
height: virtualizer.getTotalSize(),
|
|
position: "relative",
|
|
width: "100%",
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
|
const listItem = listItems[virtualItem.index];
|
|
|
|
if (listItem.type === "divider") {
|
|
return (
|
|
<div
|
|
key={virtualItem.key}
|
|
data-index={virtualItem.index}
|
|
ref={virtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: virtualItem.start,
|
|
left: 0,
|
|
right: 0,
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-2 px-4 py-2">
|
|
<div className="flex-1 border-t-2 border-dashed border-electric/40" />
|
|
<span className="text-sm font-semibold text-electric whitespace-nowrap">
|
|
Projected Round {listItem.round} Pick
|
|
</span>
|
|
<div className="flex-1 border-t-2 border-dashed border-electric/40" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const participant = participants[listItem.index];
|
|
const { isDrafted, isInQueue, isEligible, ineligibleReason, isWatched } =
|
|
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility, watchedParticipantIds);
|
|
const rank = participantRanks.get(participant.id);
|
|
const followsDivider = listItems[virtualItem.index - 1]?.type === "divider";
|
|
const isAnimatingOut = animatingOutParticipantIds.has(participant.id);
|
|
const animationClass = isAnimatingOut
|
|
? (hideDrafted ? "animate-pick-flash-out" : "animate-pick-flash")
|
|
: "";
|
|
// Freeze display state while animating so content doesn't shift height
|
|
const displayDrafted = isDrafted && !isAnimatingOut;
|
|
|
|
return (
|
|
<div
|
|
key={virtualItem.key}
|
|
data-index={virtualItem.index}
|
|
ref={virtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: virtualItem.start,
|
|
left: 0,
|
|
right: 0,
|
|
}}
|
|
>
|
|
<div className={animationClass}>
|
|
<div className="md:hidden px-4 py-1">
|
|
<div
|
|
className={`bg-card border rounded-lg p-3 flex items-start justify-between gap-3 transition-colors ${
|
|
displayDrafted
|
|
? "bg-muted/50 opacity-60"
|
|
: !isEligible
|
|
? "bg-destructive/10 opacity-75"
|
|
: isWatched
|
|
? "bg-emerald-500/10 border-l-2 border-l-emerald-500/30"
|
|
: ""
|
|
}`}
|
|
>
|
|
<div className="flex flex-col gap-1 min-w-0">
|
|
<span
|
|
className={`font-semibold text-sm truncate ${!isEligible && !displayDrafted ? "text-muted-foreground" : ""}`}
|
|
>
|
|
{participant.name}
|
|
</span>
|
|
<div className="flex flex-wrap gap-1 items-center">
|
|
<Badge variant="outline" className="text-xs">
|
|
{participant.sport.name}
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground tabular-nums">
|
|
OVR {rank?.overallRank} · SPR {rank?.sportRank}
|
|
</span>
|
|
{displayDrafted && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Drafted
|
|
</Badge>
|
|
)}
|
|
{!displayDrafted && !isEligible && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="text-xs"
|
|
title={ineligibleReason?.message}
|
|
>
|
|
Ineligible
|
|
</Badge>
|
|
)}
|
|
{!displayDrafted && !isEligible && ineligibleReason && (
|
|
<p className="text-xs text-destructive/90">
|
|
{ineligibleReason.message}
|
|
</p>
|
|
)}
|
|
{isInQueue && !displayDrafted && isEligible && (
|
|
<Badge variant="default" className="text-xs">
|
|
Queued
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{hasTeam && !displayDrafted && (
|
|
<div className="flex gap-2 flex-shrink-0">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="min-h-[44px] min-w-[44px]"
|
|
onClick={() => onToggleWatchlist(participant.id)}
|
|
title={isWatched ? "Remove from watchlist" : "Add to watchlist"}
|
|
>
|
|
{isWatched
|
|
? <EyeOff className="h-4 w-4 text-emerald-400" />
|
|
: <Eye className="h-4 w-4" />}
|
|
</Button>
|
|
{!isInQueue ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="min-h-[44px] min-w-[44px]"
|
|
onClick={() => onAddToQueue(participant.id)}
|
|
title={!isEligible ? ineligibleReason?.message : "Add to queue"}
|
|
disabled={!isEligible}
|
|
>
|
|
<ListPlus className="h-4 w-4" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="min-h-[44px] min-w-[44px]"
|
|
onClick={() => {
|
|
const queueId = queueMap.get(participant.id);
|
|
if (queueId) onRemoveFromQueue(queueId);
|
|
}}
|
|
title="Remove from queue"
|
|
>
|
|
<ListX className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="min-h-[44px]"
|
|
onClick={() => onMakePick(participant.id)}
|
|
disabled={!canPick || !isEligible}
|
|
title={
|
|
!canPick
|
|
? "Not your turn"
|
|
: !isEligible
|
|
? ineligibleReason?.message
|
|
: undefined
|
|
}
|
|
>
|
|
Draft
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>{/* md:hidden */}
|
|
|
|
<div
|
|
className={`hidden md:grid ${desktopGridClass} items-center px-4 transition-colors ${
|
|
followsDivider ? "" : "border-t"
|
|
} ${
|
|
displayDrafted
|
|
? "bg-muted/50 opacity-60"
|
|
: !isEligible
|
|
? "bg-destructive/10 opacity-75"
|
|
: isWatched
|
|
? "bg-emerald-500/10 border-l-2 border-l-emerald-500/30"
|
|
: "hover:bg-muted/50"
|
|
}`}
|
|
title={
|
|
!isEligible && !displayDrafted ? ineligibleReason?.message : undefined
|
|
}
|
|
>
|
|
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
|
|
{rank?.overallRank}
|
|
</span>
|
|
<span className="text-center text-muted-foreground tabular-nums p-3 px-0">
|
|
{rank?.sportRank}
|
|
</span>
|
|
<div className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`font-medium ${!isEligible && !displayDrafted ? "text-muted-foreground" : ""}`}
|
|
>
|
|
{participant.name}
|
|
</span>
|
|
<Badge variant="outline" className="text-xs">
|
|
{participant.sport.name}
|
|
</Badge>
|
|
{displayDrafted && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Drafted
|
|
</Badge>
|
|
)}
|
|
{!displayDrafted && !isEligible && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="text-xs"
|
|
title={ineligibleReason?.message}
|
|
>
|
|
Ineligible
|
|
</Badge>
|
|
)}
|
|
{isInQueue && !displayDrafted && isEligible && (
|
|
<Badge variant="default" className="text-xs">
|
|
Queued
|
|
</Badge>
|
|
)}
|
|
{!displayDrafted && !isEligible && ineligibleReason && (
|
|
<p className="mt-1 text-xs text-destructive/90">
|
|
{ineligibleReason.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{hasTeam && (
|
|
<div className="p-3">
|
|
<div className="flex gap-2 justify-end items-center">
|
|
{!displayDrafted && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onToggleWatchlist(participant.id)}
|
|
title={isWatched ? "Remove from watchlist" : "Add to watchlist"}
|
|
>
|
|
{isWatched
|
|
? <EyeOff className="h-4 w-4 text-emerald-400" />
|
|
: <Eye className="h-4 w-4" />}
|
|
</Button>
|
|
{!isInQueue ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onAddToQueue(participant.id)}
|
|
title={
|
|
!isEligible
|
|
? ineligibleReason?.message
|
|
: "Add to queue"
|
|
}
|
|
disabled={!isEligible}
|
|
>
|
|
<ListPlus className="h-4 w-4" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
const queueId = queueMap.get(participant.id);
|
|
if (queueId) onRemoveFromQueue(queueId);
|
|
}}
|
|
title="Remove from queue"
|
|
>
|
|
<ListX className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => onMakePick(participant.id)}
|
|
disabled={!canPick || !isEligible}
|
|
title={
|
|
!canPick
|
|
? "Not your turn"
|
|
: !isEligible
|
|
? ineligibleReason?.message
|
|
: undefined
|
|
}
|
|
>
|
|
Draft
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>{/* hidden md:grid */}
|
|
</div>{/* animationClass wrapper */}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
});
|