Replaces the single-sport native <select> with a popover (desktop) and bottom sheet (mobile) checkbox multi-select. No sports selected shows all participants; selecting one or more narrows the list. Trigger label adapts to reflect current filter state and a Reset button appears when filters are active. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
533 lines
21 KiB
TypeScript
533 lines
21 KiB
TypeScript
import { useMemo } from "react";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Checkbox } from "~/components/ui/checkbox";
|
|
import {
|
|
Popover,
|
|
PopoverTrigger,
|
|
PopoverContent,
|
|
} from "~/components/ui/popover";
|
|
import {
|
|
Sheet,
|
|
SheetTrigger,
|
|
SheetContent,
|
|
SheetTitle,
|
|
SheetFooter,
|
|
SheetClose,
|
|
} from "~/components/ui/sheet";
|
|
import { ListPlus, ListX, ChevronDown } from "lucide-react";
|
|
|
|
function getParticipantState(
|
|
participant: { id: string; sport: { id: string } },
|
|
draftedParticipantIds: Set<string>,
|
|
queueMap: Map<string, string>,
|
|
eligibility: {
|
|
eligibleSportIds: Set<string>;
|
|
ineligibleReasons: Record<string, string>;
|
|
} | null
|
|
) {
|
|
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];
|
|
return { isDrafted, isInQueue, isEligible, ineligibleReason };
|
|
}
|
|
|
|
interface AvailableParticipantsSectionProps {
|
|
participants: Array<{
|
|
id: string;
|
|
name: string;
|
|
sport: {
|
|
id: string;
|
|
name: string;
|
|
};
|
|
}>;
|
|
searchQuery: string;
|
|
sportFilters: string[];
|
|
hideDrafted: boolean;
|
|
hideIneligible: boolean;
|
|
hideCompletedSports: boolean;
|
|
uniqueSports: string[];
|
|
draftedParticipantIds: Set<string>;
|
|
queue: Array<{ id: string; participantId: string }>;
|
|
eligibility: {
|
|
eligibleSportIds: Set<string>;
|
|
ineligibleReasons: Record<string, string>;
|
|
} | 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;
|
|
}
|
|
|
|
export function AvailableParticipantsSection({
|
|
participants,
|
|
searchQuery,
|
|
sportFilters,
|
|
hideDrafted,
|
|
hideIneligible,
|
|
hideCompletedSports,
|
|
uniqueSports,
|
|
draftedParticipantIds,
|
|
queue,
|
|
eligibility,
|
|
canPick,
|
|
hasTeam,
|
|
onSearchChange,
|
|
onSportFiltersChange,
|
|
onHideDraftedChange,
|
|
onHideIneligibleChange,
|
|
onHideCompletedSportsChange,
|
|
onMakePick,
|
|
onAddToQueue,
|
|
onRemoveFromQueue,
|
|
}: AvailableParticipantsSectionProps) {
|
|
const queueMap = useMemo(
|
|
() => new Map(queue.map((item) => [item.participantId, item.id])),
|
|
[queue]
|
|
);
|
|
|
|
const sportFilterSet = useMemo(() => new Set(sportFilters), [sportFilters]);
|
|
|
|
const triggerText =
|
|
sportFilters.length === 0
|
|
? "All Sports"
|
|
: sportFilters.length === 1
|
|
? sportFilters[0]
|
|
: `${sportFilters.length} sports`;
|
|
|
|
const triggerAriaLabel =
|
|
sportFilters.length === 0
|
|
? "Filter by sport: All Sports"
|
|
: sportFilters.length === 1
|
|
? `Filter by sport: ${sportFilters[0]}`
|
|
: `Filter by sport: ${sportFilters.length} sports selected`;
|
|
|
|
function handleToggleSport(sport: string, checked: boolean | "indeterminate") {
|
|
if (checked === true) {
|
|
onSportFiltersChange([...sportFilters, sport]);
|
|
} else {
|
|
onSportFiltersChange(sportFilters.filter((s) => s !== sport));
|
|
}
|
|
}
|
|
|
|
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]);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
<div className="px-4 pt-4 pb-2 flex-shrink-0">
|
|
<div className="space-y-2">
|
|
{/* Search Input */}
|
|
<input
|
|
type="text"
|
|
placeholder="Search participants..."
|
|
value={searchQuery}
|
|
onChange={(e) => onSearchChange(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-md text-base md:text-sm bg-background text-foreground"
|
|
/>
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
{/* Sport Filter Multi-Select */}
|
|
<div className="flex-1 min-w-[120px]">
|
|
{/* Mobile: bottom sheet */}
|
|
<div className="md:hidden">
|
|
<Sheet>
|
|
<SheetTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
aria-label={triggerAriaLabel}
|
|
className="w-full justify-between text-base font-normal"
|
|
>
|
|
<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>
|
|
<div className="overflow-y-auto flex-1 px-4">
|
|
{uniqueSports.map((sport) => {
|
|
const checkboxId = `sheet-sport-filter-${sport.replace(/\s+/g, "-").toLowerCase()}`;
|
|
return (
|
|
<label
|
|
key={sport}
|
|
htmlFor={checkboxId}
|
|
className="flex items-center gap-4 py-4 border-b last:border-b-0 cursor-pointer text-base"
|
|
>
|
|
<Checkbox
|
|
id={checkboxId}
|
|
checked={sportFilterSet.has(sport)}
|
|
onCheckedChange={(checked) => handleToggleSport(sport, checked)}
|
|
/>
|
|
{sport}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
<SheetFooter className="px-4 pb-8 flex-row gap-2">
|
|
{sportFilters.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
className="flex-1"
|
|
onClick={() => onSportFiltersChange([])}
|
|
>
|
|
Reset
|
|
</Button>
|
|
)}
|
|
<SheetClose asChild>
|
|
<Button className="flex-1">Done</Button>
|
|
</SheetClose>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</div>
|
|
|
|
{/* Desktop: popover */}
|
|
<div className="hidden md:block">
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
aria-label={triggerAriaLabel}
|
|
className="w-full 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">
|
|
{sportFilters.length > 0 && (
|
|
<div className="flex justify-end px-2 pt-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-auto px-2 py-1 text-xs"
|
|
onClick={() => onSportFiltersChange([])}
|
|
>
|
|
Reset
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<div className="max-h-64 overflow-y-auto p-2 space-y-0.5">
|
|
{uniqueSports.map((sport) => {
|
|
const checkboxId = `popover-sport-filter-${sport.replace(/\s+/g, "-").toLowerCase()}`;
|
|
return (
|
|
<label
|
|
key={sport}
|
|
htmlFor={checkboxId}
|
|
className="flex items-center gap-2 px-2 py-2 rounded-sm hover:bg-accent cursor-pointer text-sm"
|
|
>
|
|
<Checkbox
|
|
id={checkboxId}
|
|
checked={sportFilterSet.has(sport)}
|
|
onCheckedChange={(checked) => handleToggleSport(sport, checked)}
|
|
/>
|
|
{sport}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Show/Hide Drafted Toggle */}
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
|
|
<input
|
|
type="checkbox"
|
|
checked={!hideDrafted}
|
|
onChange={(e) => onHideDraftedChange(!e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<span>Show Drafted</span>
|
|
</label>
|
|
|
|
{/* Show/Hide Ineligible Toggle - only show if user has a team */}
|
|
{hasTeam && eligibility && (
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
|
|
<input
|
|
type="checkbox"
|
|
checked={!hideIneligible}
|
|
onChange={(e) => onHideIneligibleChange(!e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<span>Show Ineligible</span>
|
|
</label>
|
|
)}
|
|
|
|
{/* Show drafted sports - only show if user has a team */}
|
|
{hasTeam && (
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
|
|
<input
|
|
type="checkbox"
|
|
checked={!hideCompletedSports}
|
|
onChange={(e) => onHideCompletedSportsChange(!e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<span>Show drafted sports</span>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile Card List - visible on mobile only */}
|
|
<div className="flex md:hidden flex-1 overflow-y-auto flex-col gap-2 px-4 py-2">
|
|
{participants.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground text-sm">
|
|
{emptyMessage}
|
|
</div>
|
|
) : (
|
|
participants.map((participant) => {
|
|
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
|
|
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
|
|
|
|
return (
|
|
<div
|
|
key={participant.id}
|
|
className={`bg-card border rounded-lg p-3 flex items-start justify-between gap-3 transition-colors ${
|
|
isDrafted
|
|
? "bg-muted/50 opacity-60"
|
|
: !isEligible
|
|
? "bg-destructive/10 opacity-75"
|
|
: ""
|
|
}`}
|
|
>
|
|
<div className="flex flex-col gap-1 min-w-0">
|
|
<span
|
|
className={`font-semibold text-sm truncate ${!isEligible && !isDrafted ? "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>
|
|
{isDrafted && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Drafted
|
|
</Badge>
|
|
)}
|
|
{!isDrafted && !isEligible && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="text-xs"
|
|
title={ineligibleReason}
|
|
>
|
|
Ineligible
|
|
</Badge>
|
|
)}
|
|
{isInQueue && !isDrafted && isEligible && (
|
|
<Badge variant="default" className="text-xs">
|
|
Queued
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{hasTeam && !isDrafted && (
|
|
<div className="flex gap-2 flex-shrink-0">
|
|
{!isInQueue ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="min-h-[44px] min-w-[44px]"
|
|
onClick={() => onAddToQueue(participant.id)}
|
|
title={!isEligible ? ineligibleReason : "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
|
|
: undefined
|
|
}
|
|
>
|
|
Draft
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Desktop Table - hidden on mobile */}
|
|
<div className="hidden md:flex flex-1 overflow-hidden">
|
|
<div className="h-full w-full overflow-y-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted sticky top-0">
|
|
<tr>
|
|
<th className="text-left p-3 font-semibold">Participant</th>
|
|
<th className="text-left p-3 font-semibold">Sport</th>
|
|
{hasTeam && (
|
|
<th className="text-right p-3 font-semibold">Queue</th>
|
|
)}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{participants.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={hasTeam ? 3 : 2}
|
|
className="text-center py-8 text-muted-foreground"
|
|
>
|
|
{emptyMessage}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
participants.map((participant) => {
|
|
const { isDrafted, isInQueue, isEligible, ineligibleReason } =
|
|
getParticipantState(participant, draftedParticipantIds, queueMap, eligibility);
|
|
|
|
return (
|
|
<tr
|
|
key={participant.id}
|
|
className={`border-t transition-colors ${
|
|
isDrafted
|
|
? "bg-muted/50 opacity-60"
|
|
: !isEligible
|
|
? "bg-destructive/10 opacity-75"
|
|
: "hover:bg-muted/50"
|
|
}`}
|
|
title={
|
|
!isEligible && !isDrafted ? ineligibleReason : undefined
|
|
}
|
|
>
|
|
<td className="p-3">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`font-medium ${!isEligible && !isDrafted ? "text-muted-foreground" : ""}`}
|
|
>
|
|
{participant.name}
|
|
</span>
|
|
{isDrafted && (
|
|
<Badge variant="secondary" className="text-xs">
|
|
Drafted
|
|
</Badge>
|
|
)}
|
|
{!isDrafted && !isEligible && (
|
|
<Badge
|
|
variant="destructive"
|
|
className="text-xs"
|
|
title={ineligibleReason}
|
|
>
|
|
Ineligible
|
|
</Badge>
|
|
)}
|
|
{isInQueue && !isDrafted && isEligible && (
|
|
<Badge variant="default" className="text-xs">
|
|
Queued
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="p-3 text-muted-foreground">
|
|
{participant.sport.name}
|
|
</td>
|
|
{hasTeam && (
|
|
<td className="p-3 text-right">
|
|
<div className="flex gap-2 justify-end items-center">
|
|
{!isDrafted && (
|
|
<>
|
|
{/* Queue icon button */}
|
|
{!isInQueue ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => onAddToQueue(participant.id)}
|
|
title={
|
|
!isEligible
|
|
? ineligibleReason
|
|
: "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>
|
|
)}
|
|
{/* Draft button - always visible, disabled when not your turn */}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={() => onMakePick(participant.id)}
|
|
disabled={!canPick || !isEligible}
|
|
title={
|
|
!canPick
|
|
? "Not your turn"
|
|
: !isEligible
|
|
? ineligibleReason
|
|
: undefined
|
|
}
|
|
>
|
|
Draft
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|