import { useEffect, useMemo, useRef, useState } from "react"; import { api } from "../api.js"; /** * UserAutocomplete — reusable user-search input with live suggestions. * * Loads the active-users roster once on mount and filters as the admin types. * On pick, fires `onApply(user_id_hash)` — the hash is guaranteed to exist * in the bandit/turn tables, so downstream filters can't miss the user just * because the admin typed a display name like "Alex Chen". * * Props: * value (string) — current selected user_id_hash / raw user_id * onApply (string)→ — called when admin picks a suggestion or hits Enter * placeholder (string) * allowEmpty (bool) — show a "(all users)" empty-state option * suggestions (array?) — pre-loaded user list; if omitted, fetches itself * className (string?) — additional class on the wrapper * * Matches: * - case-insensitive substring on display_name * - prefix match on user_id_hash * - exact match on raw user_id (if you happen to type one) */ export default function UserAutocomplete({ value = "", onApply, placeholder = "Search by name or hash", allowEmpty = false, suggestions: providedSuggestions = null, className = "", }) { const [draft, setDraft] = useState(value || ""); const [open, setOpen] = useState(false); const [highlight, setHi] = useState(0); const [loaded, setLoaded] = useState(providedSuggestions || []); const wrapRef = useRef(null); useEffect(() => { setDraft(value || ""); }, [value]); // Load active users once if caller didn't provide them. // Use a wide window so even dormant users show up in the suggestions. useEffect(() => { if (providedSuggestions !== null) { setLoaded(providedSuggestions); return; } let cancelled = false; (async () => { try { const r = await api.activeUsers(3650, 0, 500); // ~10 years window if (!cancelled) setLoaded(Array.isArray(r) ? r : []); } catch { /* dropdown just shows no suggestions */ } })(); return () => { cancelled = true; }; }, [providedSuggestions]); // Click-outside closes dropdown useEffect(() => { if (!open) return; function onDocClick(e) { if (!wrapRef.current?.contains(e.target)) setOpen(false); } document.addEventListener("mousedown", onDocClick); return () => document.removeEventListener("mousedown", onDocClick); }, [open]); const matches = useMemo(() => { const q = draft.trim().toLowerCase(); if (!q) return loaded.slice(0, 8); // empty query: show top 8 const out = []; const seen = new Set(); for (const u of loaded) { const name = (u.display_name || "").toLowerCase(); const hash = (u.user_id_hash || "").toLowerCase(); if (name.includes(q) || hash.includes(q)) { const key = u.user_id_hash || u.display_name; if (seen.has(key)) continue; seen.add(key); out.push(u); if (out.length >= 8) break; } } return out; }, [draft, loaded]); useEffect(() => { setHi(0); }, [matches.length]); function pick(user) { setDraft(user.user_id_hash); onApply?.(user.user_id_hash); setOpen(false); } function commit() { if (open && matches[highlight]) { pick(matches[highlight]); } else { // Free text — let the backend's _resolve_user_hash handle it onApply?.(draft.trim()); setOpen(false); } } function onKeyDown(e) { if (e.key === "Enter") { e.preventDefault(); commit(); } else if (e.key === "ArrowDown") { e.preventDefault(); setOpen(true); setHi((h) => (h + 1) % Math.max(matches.length, 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setHi((h) => (h - 1 + Math.max(matches.length, 1)) % Math.max(matches.length, 1)); } else if (e.key === "Escape") { setOpen(false); } } return ( { setDraft(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onKeyDown={onKeyDown} placeholder={placeholder} autoComplete="off" spellCheck={false} /> {draft && ( )} {open && ( )} ); } function shortHash(h) { if (!h) return ""; return h.length > 14 ? `${h.slice(0, 14)}…` : h; }