brackt/app/components/draft/AvailableParticipantsSection.tsx

608 lines
22 KiB
TypeScript
Raw Normal View History

import { memo, useCallback, 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 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;
};
}>;
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, 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 const AvailableParticipantsSection = memo(function AvailableParticipantsSection({
participants,
searchQuery,
sportFilters,
hideDrafted,
hideIneligible,
hideCompletedSports,
userDraftedSportNames,
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 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;
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>
<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>
{/* 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">
{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>
{/* Show/Hide Drafted Toggle */}
<label className="flex items-center gap-2 text-sm cursor-pointer whitespace-nowrap px-3 py-2 border rounded-md">
<Checkbox
checked={!hideDrafted}
onCheckedChange={(checked) => onHideDraftedChange(checked === false)}
/>
<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">
<Checkbox
checked={!hideIneligible}
onCheckedChange={(checked) => onHideIneligibleChange(checked === false)}
/>
<span>Show Ineligible</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
Redesign to dark-mode-only with navy palette and accent colors (#13) Removes light mode entirely in favour of a permanent dark theme with a navy-tinted background and three signature accents (electric blue, amber/gold, coral) exposed as CSS custom properties and Tailwind utilities (bg-electric, text-amber-accent, text-coral-accent). - Set class="dark" on <html> and apply Clerk dark base theme - Rewrite app.css: single :root palette (oklch navy values), custom --electric / --amber-accent / --coral-accent variables, remove duplicate .dark block and light-mode bg-white/bg-gray-950 rule - Install @clerk/themes for Clerk dark modal support - Replace hardcoded Tailwind colors across 30+ files: - Draft grid cells: blue-50/blue-950 → electric/15, green-50/950 → emerald/10 - Timer: green-600/yellow-600/red-600 → emerald-400/amber-accent/coral-accent - Status badges: blue-50/green-50/gray-50 → electric/emerald/muted variants - Success messages: green-500/15 text-green-700 dark:text-green-400 → emerald-500/15 text-emerald-400 - Info cards: blue-50 dark:bg-blue-950 → electric/10 - Warning cards: yellow-500 → amber-accent variants - Medal/placement badges: yellow-500/orange-600 → amber-accent/coral-accent - Movement indicators: green-600/red-600 → emerald-400/coral-accent - Connection dots: green-500/red-500 → emerald-500/coral-accent - Remove dark:hidden/dark:block logo toggle in welcome.tsx (always dark) - Update DraftGrid test assertions to match new class names Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 19:26:11 -08:00
? "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>
);
});