"use client"; import { useState, useRef, useCallback, useEffect } from "react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { motion, AnimatePresence } from "framer-motion"; import { IoSearchOutline, IoCloseOutline } from "react-icons/io5"; import { IconButton } from "@/components/IconButton"; import { useDebounced } from "@/hooks/useDebounced"; import { useClickOutside } from "@/hooks/useClickOutside"; import { ARTIST_PLACEHOLDER_SRC } from "@/lib/constants"; /* ── Types ── */ type SearchResults = { releases: Array<{ name: string; albumArtist?: string; slug: string; imageUrl?: string }>; artists: Array<{ name: string; role?: string; slug: string; imageUrl?: string }>; composers: Array<{ name: string; years?: string; slug: string; imageUrl?: string }>; works: Array<{ title: string; composerName?: string; arrangerName?: string; slug: string; imageUrl?: string; }>; blog: Array<{ title: string; category?: string; slug: string; imageUrl?: string }>; }; const emptyResults: SearchResults = { releases: [], artists: [], composers: [], works: [], blog: [], }; type ResultItem = { title: string; subtitle?: string; href: string; imageUrl?: string }; type ResultGroup = { label: string; items: ResultItem[] }; /* ── Animation ── */ const fadeTransition = { type: "tween" as const, ease: "easeInOut" as const, duration: 0.3 }; /* ── Grouping ── */ function groupResults(data: SearchResults): ResultGroup[] { const groups: ResultGroup[] = []; if (data.releases.length > 0) { groups.push({ label: "Releases", items: data.releases.map((r) => ({ title: r.name, subtitle: r.albumArtist, href: `/release/${r.slug}`, imageUrl: r.imageUrl, })), }); } if (data.artists.length > 0) { groups.push({ label: "Artists", items: data.artists.map((a) => ({ title: a.name, subtitle: a.role, href: `/artist/${a.slug}`, imageUrl: a.imageUrl, })), }); } if (data.composers.length > 0) { groups.push({ label: "Composers", items: data.composers.map((c) => ({ title: c.name, subtitle: c.years, href: `/composer/${c.slug}`, imageUrl: c.imageUrl, })), }); } if (data.works.length > 0) { groups.push({ label: "Works", items: data.works.map((w) => ({ title: w.title, subtitle: w.arrangerName ? `${w.composerName} (arr. ${w.arrangerName})` : w.composerName, href: `/work/${w.slug}`, imageUrl: w.imageUrl, })), }); } if (data.blog.length > 0) { groups.push({ label: "Blog", items: data.blog.map((b) => ({ title: b.title, subtitle: b.category, href: `/blog/${b.slug}`, imageUrl: b.imageUrl, })), }); } return groups; } /* ── Result card ── */ function SearchResultCard({ item, onClick }: { item: ResultItem; onClick: () => void }) { return ( {item.title} {item.subtitle && ( {item.subtitle} )} ); } /* ── Main component ── */ export function GlobalSearch({ onOpenChange }: { onOpenChange?: (open: boolean) => void } = {}) { const [open, setOpen] = useState(false); const [expanded, setExpanded] = useState(false); const [query, setQuery] = useState(""); const [results, setResults] = useState(emptyResults); const [fetchedQuery, setFetchedQuery] = useState(""); const debouncedQuery = useDebounced(query, 300); const router = useRouter(); const containerRef = useRef(null); const inputRef = useRef(null); const close = useCallback(() => { setOpen(false); setQuery(""); setResults(emptyResults); }, []); useClickOutside([containerRef], close, open); // Fetch results const abortRef = useRef(null); useEffect(() => { if (!debouncedQuery || debouncedQuery.length < 2) return; abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, { signal: controller.signal, }) .then((res) => res.json()) .then((data: SearchResults) => { setResults(data); setFetchedQuery(debouncedQuery); }) .catch(() => { if (!controller.signal.aborted) { setResults(emptyResults); setFetchedQuery(debouncedQuery); } }); return () => controller.abort(); }, [debouncedQuery]); // Derived state const activeResults = debouncedQuery && debouncedQuery.length >= 2 ? results : emptyResults; const loading = debouncedQuery.length >= 2 && fetchedQuery !== debouncedQuery; // Escape key useEffect(() => { if (!open) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [open, close]); // Focus input when opening useEffect(() => { if (open) { requestAnimationFrame(() => inputRef.current?.focus()); } }, [open]); const navigate = (href: string) => { close(); router.push(href); }; const groups = groupResults(activeResults); const hasResults = groups.length > 0; const showDropdown = open && debouncedQuery.length >= 2; return ( {/* Search input */} { setExpanded(false); onOpenChange?.(false); }} > {open && ( setQuery(e.target.value)} placeholder="Search..." className="relative z-10 w-full rounded-xl border border-lightline bg-lightbg px-4 py-3.5 text-sm text-lighttext shadow-lg transition-colors duration-200 outline-none placeholder:text-lightsec focus:border-lightline-hover md:min-w-80 dark:border-darkline dark:bg-darkbg dark:text-darktext dark:placeholder:text-darksec dark:focus:border-darkline-hover" /> )} {/* Button — always in place, icon swaps inside */} { if (open) { close(); } else { setOpen(true); setExpanded(true); onOpenChange?.(true); } }} aria-label={open ? "Close search" : "Search"} > {open ? ( ) : ( )} {/* Results dropdown — matches full width of input + button */} {showDropdown && ( {loading && !hasResults && ( Searching... )} {!loading && !hasResults && debouncedQuery.length >= 2 && ( No results found. )} {hasResults && ( {groups.map((group, gi) => ( {gi > 0 && ( )} {group.label} {group.items.map((item) => ( navigate(item.href)} /> ))} ))} )} )} ); }
{item.title}
{item.subtitle}
{group.label}