"use client"; import * as React from "react"; import { AnimatePresence, motion } from "framer-motion"; import { Search, X, Loader2 } from "lucide-react"; import { api, type Suggestions } from "@/lib/api"; import { cn } from "@/lib/utils"; type Item = { kind: "track" | "album" | "artist"; label: string; sub: string; cover: string | null; meta?: string; run: () => void }; export function SearchBar({ initial, busy, onSearch, onAlbum, onArtist, }: { /** Seeds the field from the URL, so a shared link shows what it searched. */ initial?: string; busy?: boolean; onSearch: (q: string) => void; onAlbum: (id: number) => void; onArtist: (id: number) => void; }) { const [q, setQ] = React.useState(initial ?? ""); const [items, setItems] = React.useState([]); const [open, setOpen] = React.useState(false); const [sel, setSel] = React.useState(-1); const inputRef = React.useRef(null); // A value seeded from the URL must not pop the suggestion list open over the // results that link was for. Only typing counts as asking for suggestions. const typed = React.useRef(false); const seeded = React.useRef(initial); // Follows the URL on Back/Forward, but never fights what is being typed: // only a genuinely different incoming query replaces the field. Declared // after the state it sets, or the effect closes over a binding that is not // initialised yet. React.useEffect(() => { if (initial === undefined || initial === seeded.current) return; seeded.current = initial; typed.current = false; setQ(initial); setOpen(false); }, [initial]); // Debounced so a fast typist makes one upstream call, not eight. The // AbortController matters more than the delay: without it a slow response // to "rad" can land after "radiohead" and repopulate the stale list. React.useEffect(() => { const v = q.trim(); if (v.length < 2) { setItems([]); setOpen(false); return; } const ac = new AbortController(); const t = setTimeout(async () => { try { const d: Suggestions = await api.suggest(v, ac.signal); const next: Item[] = [ ...d.tracks.map((t) => ({ kind: "track" as const, label: t.title, sub: t.artist, cover: t.cover, run: () => { const nq = `${t.artist} ${t.title}`; setQ(nq); setOpen(false); onSearch(nq); }, })), ...d.albums.map((a) => ({ kind: "album" as const, label: a.title, sub: a.artist, cover: a.cover, meta: a.nb_tracks ? `${a.nb_tracks} tracks` : "", run: () => { setOpen(false); onAlbum(a.album_id); }, })), ...d.artists.map((a) => ({ kind: "artist" as const, label: a.name, sub: a.nb_album ? `${a.nb_album} releases` : "Artist", cover: a.cover, run: () => { setOpen(false); onArtist(a.artist_id); }, })), ]; setItems(next); setSel(-1); setOpen(typed.current && next.length > 0); } catch { /* aborted or offline — leave the previous list alone */ } }, 180); return () => { clearTimeout(t); ac.abort(); }; }, [q, onSearch, onAlbum, onArtist]); // "/" focuses search, the way every music app does. React.useEffect(() => { const onKey = (e: KeyboardEvent) => { const tag = (document.activeElement as HTMLElement)?.tagName; if (e.key === "/" && !/^(INPUT|TEXTAREA)$/.test(tag)) { e.preventDefault(); inputRef.current?.focus(); } }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, []); const key = (e: React.KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); setSel((s) => (items.length ? (s + 1) % items.length : -1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setSel((s) => (items.length ? (s - 1 + items.length) % items.length : -1)); } else if (e.key === "Escape") { setOpen(false); } else if (e.key === "Enter") { e.preventDefault(); if (sel >= 0 && items[sel]) items[sel].run(); else { setOpen(false); onSearch(q.trim()); } } }; const groups: { title: string; kind: Item["kind"] }[] = [ { title: "Songs", kind: "track" }, { title: "Albums", kind: "album" }, { title: "Artists", kind: "artist" }, ]; return (
{ typed.current = true; setQ(e.target.value); }} onKeyDown={key} onFocus={() => items.length && setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 120)} placeholder="Search songs, albums, artists…" aria-label="Search" autoComplete="off" spellCheck={false} className="flex-1 min-w-0 bg-transparent border-0 outline-none text-base font-medium tracking-[-0.1px] text-ink placeholder:text-dim placeholder:font-normal" /> {busy && } {q && !busy && ( )}
{open && items.length > 0 && ( {groups.map((g) => { const rows = items.filter((i) => i.kind === g.kind); if (!rows.length) return null; return (
{g.title}
{rows.map((it) => { const idx = items.indexOf(it); return (
{ e.preventDefault(); it.run(); }} onMouseEnter={() => setSel(idx)} className={cn( "flex items-center gap-3 px-2.5 py-2 rounded-xl cursor-pointer transition-colors", idx === sel && "bg-accent-soft", )} > {/* eslint-disable-next-line @next/next/no-img-element */} (e.currentTarget.style.visibility = "hidden")} />
{it.label}
{it.sub}
{it.meta &&
{it.meta}
}
); })}
); })}
)}
); }