Sessions 5-7a: 955 tests, deployment ready
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export type PlayerResult = {
|
||||
id: string;
|
||||
full_name: string;
|
||||
team?: string;
|
||||
position?: string;
|
||||
headshot_url?: string;
|
||||
};
|
||||
|
||||
export type Sport = 'NBA' | 'MLB' | 'WNBA';
|
||||
|
||||
type Props = {
|
||||
sport: Sport;
|
||||
gameId?: string;
|
||||
placeholder?: string;
|
||||
initialValue?: string;
|
||||
onSelect: (player: PlayerResult) => void;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
const SPORT_TINT: Record<Sport, string> = {
|
||||
NBA: 'var(--nba)',
|
||||
MLB: 'var(--mlb)',
|
||||
WNBA: 'var(--wnba)',
|
||||
};
|
||||
|
||||
export default function PlayerSearch({
|
||||
sport,
|
||||
gameId,
|
||||
placeholder = 'Search players…',
|
||||
initialValue = '',
|
||||
onSelect,
|
||||
autoFocus = false,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState(initialValue);
|
||||
const [results, setResults] = useState<PlayerResult[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputId = useId();
|
||||
const listboxId = `${inputId}-listbox`;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Debounced fetch
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
if (q.length < 2) {
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ sport, q });
|
||||
if (gameId) params.set('game_id', gameId);
|
||||
const res = await fetch(`/api/players/search?${params.toString()}`, { signal: ctrl.signal });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!ctrl.signal.aborted) {
|
||||
setResults(Array.isArray(data?.players) ? data.players.slice(0, 5) : []);
|
||||
setHighlight(0);
|
||||
}
|
||||
} catch {
|
||||
if (!ctrl.signal.aborted) setResults([]);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
}
|
||||
}, 220);
|
||||
return () => {
|
||||
clearTimeout(t);
|
||||
ctrl.abort();
|
||||
};
|
||||
}, [query, sport, gameId]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onClick);
|
||||
return () => document.removeEventListener('mousedown', onClick);
|
||||
}, []);
|
||||
|
||||
const noResults = useMemo(
|
||||
() => !loading && results !== null && results.length === 0 && query.trim().length >= 2,
|
||||
[loading, results, query],
|
||||
);
|
||||
|
||||
const choose = (p: PlayerResult) => {
|
||||
onSelect(p);
|
||||
setQuery(p.full_name);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!results || results.length === 0) return;
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
setHighlight((h) => Math.min(results.length - 1, h + 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => Math.max(0, h - 1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const sel = results[highlight];
|
||||
if (sel) choose(sel);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ position: 'relative' }}>
|
||||
<input
|
||||
id={inputId}
|
||||
role="combobox"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open && (loading || !!results?.length || noResults)}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={results && results[highlight] ? `${inputId}-opt-${highlight}` : undefined}
|
||||
autoComplete="off"
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder}
|
||||
className="input-field"
|
||||
value={query}
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
onKeyDown={onKeyDown}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
{open && (loading || results !== null) && (
|
||||
<ul
|
||||
id={listboxId}
|
||||
role="listbox"
|
||||
className="surface-elevated"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 'calc(100% + 6px)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 30,
|
||||
margin: 0,
|
||||
padding: 4,
|
||||
listStyle: 'none',
|
||||
maxHeight: 280,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<li style={{ padding: 12 }}>
|
||||
<span className="lbl" style={{ color: 'var(--text-1)' }}>SEARCHING…</span>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{!loading && noResults && (
|
||||
<li style={{ padding: 12 }}>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-0)', margin: 0 }}>
|
||||
No players found for "{query}".
|
||||
</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-1)', margin: '4px 0 0' }}>Check spelling.</p>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{!loading && results && results.map((p, i) => {
|
||||
const active = i === highlight;
|
||||
return (
|
||||
<li
|
||||
key={p.id}
|
||||
id={`${inputId}-opt-${i}`}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onMouseDown={(e) => { e.preventDefault(); choose(p); }}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
background: active ? 'var(--bg-2)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 24, height: 24, borderRadius: 999,
|
||||
background: 'var(--bg-3)',
|
||||
border: `1px solid ${SPORT_TINT[sport]}`,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 10,
|
||||
fontFamily: 'var(--font-mono, "IBM Plex Mono")',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-1)',
|
||||
}}
|
||||
>
|
||||
{p.full_name.split(' ').map((n) => n[0]).slice(0, 2).join('')}
|
||||
</span>
|
||||
<span style={{ flex: 1, color: 'var(--text-0)', fontSize: 14, fontWeight: 600 }}>
|
||||
{p.full_name}
|
||||
</span>
|
||||
{p.team ? (
|
||||
<span className="mono" style={{ fontSize: 11, color: 'var(--text-1)' }}>{p.team}</span>
|
||||
) : null}
|
||||
<span
|
||||
className="pill"
|
||||
style={{
|
||||
color: SPORT_TINT[sport],
|
||||
background: 'transparent',
|
||||
border: `1px solid ${SPORT_TINT[sport]}`,
|
||||
}}
|
||||
>
|
||||
{sport}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user