trptk/components/search/GlobalSearch.tsx
2026-02-24 17:14:07 +01:00

337 lines
10 KiB
TypeScript

"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 (
<button
type="button"
onClick={onClick}
className="flex w-full items-center gap-3 px-4 py-2 text-left transition-colors duration-200 ease-in-out hover:bg-lightline/50 dark:hover:bg-darkline/50"
>
<div className="relative size-10 shrink-0 overflow-hidden rounded-lg">
<Image
src={item.imageUrl || ARTIST_PLACEHOLDER_SRC}
alt=""
fill
sizes="40px"
className="object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-lighttext dark:text-darktext">{item.title}</p>
{item.subtitle && (
<p className="truncate text-xs text-lightsec dark:text-darksec">{item.subtitle}</p>
)}
</div>
</button>
);
}
/* ── 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<SearchResults>(emptyResults);
const [fetchedQuery, setFetchedQuery] = useState("");
const debouncedQuery = useDebounced(query, 300);
const router = useRouter();
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const close = useCallback(() => {
setOpen(false);
setQuery("");
setResults(emptyResults);
}, []);
useClickOutside([containerRef], close, open);
// Fetch results
const abortRef = useRef<AbortController | null>(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 (
<div
ref={containerRef}
className={`relative flex items-center gap-2 ${expanded ? "flex-1 md:flex-none" : ""}`}
>
{/* Search input */}
<AnimatePresence
onExitComplete={() => {
setExpanded(false);
onOpenChange?.(false);
}}
>
{open && (
<motion.div
key="search-input"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={fadeTransition}
className="relative flex flex-1 items-center"
>
<IoSearchOutline className="absolute left-3.5 text-lightsec dark:text-darksec" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => 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"
/>
</motion.div>
)}
</AnimatePresence>
{/* Button — always in place, icon swaps inside */}
<IconButton
onClick={() => {
if (open) {
close();
} else {
setOpen(true);
setExpanded(true);
onOpenChange?.(true);
}
}}
aria-label={open ? "Close search" : "Search"}
>
<AnimatePresence mode="wait" initial={false}>
{open ? (
<motion.span
key="close"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="flex"
>
<IoCloseOutline />
</motion.span>
) : (
<motion.span
key="search"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="flex"
>
<IoSearchOutline />
</motion.span>
)}
</AnimatePresence>
</IconButton>
{/* Results dropdown — matches full width of input + button */}
<AnimatePresence>
{showDropdown && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={fadeTransition}
className="absolute top-full right-0 left-0 z-50 mt-2 overflow-hidden rounded-xl bg-lightbg shadow-xl ring ring-lightline transition-[box-shadow,color,background-color] duration-300 ease-in-out hover:ring-lightline-hover dark:border-darkline dark:bg-darkbg dark:ring-darkline dark:hover:ring-darkline-hover"
>
{loading && !hasResults && (
<div className="p-4 text-sm text-lightsec dark:text-darksec">Searching...</div>
)}
{!loading && !hasResults && debouncedQuery.length >= 2 && (
<div className="p-4 text-sm text-lightsec dark:text-darksec">No results found.</div>
)}
{hasResults && (
<div className="max-h-120 overflow-y-auto pt-4 pb-2">
{groups.map((group, gi) => (
<div key={group.label}>
{gi > 0 && (
<div className="mx-4 mt-4 border-t border-lightline pt-4 dark:border-darkline" />
)}
<p className="px-4 text-sm text-lightsec dark:text-darksec">{group.label}</p>
{group.items.map((item) => (
<SearchResultCard
key={item.href}
item={item}
onClick={() => navigate(item.href)}
/>
))}
</div>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}