337 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|