"use client"; import { Input } from "@/components/ui/input"; import { Search, BookOpen, Library } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { useTranslate } from "@/hooks/useTranslate"; import { getImageUrl } from "@/lib/utils/image-url"; interface SearchSeriesResult { id: string; title: string; href: string; booksCount: number; } interface SearchBookResult { id: string; title: string; seriesTitle: string; href: string; } interface SearchResponse { series: SearchSeriesResult[]; books: SearchBookResult[]; } const MIN_QUERY_LENGTH = 2; export function GlobalSearch() { const { t } = useTranslate(); const router = useRouter(); const containerRef = useRef(null); const abortControllerRef = useRef(null); const [query, setQuery] = useState(""); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const [results, setResults] = useState({ series: [], books: [] }); const hasResults = results.series.length > 0 || results.books.length > 0; const firstResultHref = useMemo(() => { if (results.series.length > 0) { return results.series[0].href; } if (results.books.length > 0) { return results.books[0].href; } return null; }, [results.books, results.series]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (!containerRef.current?.contains(event.target as Node)) { setIsOpen(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, []); useEffect(() => { return () => { abortControllerRef.current?.abort(); }; }, []); useEffect(() => { const trimmedQuery = query.trim(); if (trimmedQuery.length < MIN_QUERY_LENGTH) { setResults({ series: [], books: [] }); setIsLoading(false); return; } const timeoutId = setTimeout(async () => { try { abortControllerRef.current?.abort(); const controller = new AbortController(); abortControllerRef.current = controller; setIsLoading(true); const response = await fetch(`/api/komga/search?q=${encodeURIComponent(trimmedQuery)}`, { method: "GET", signal: controller.signal, cache: "no-store", }); if (!response.ok) { throw new Error("Search request failed"); } const data = (await response.json()) as SearchResponse; setResults(data); setIsOpen(true); } catch (error) { if ((error as Error).name !== "AbortError") { setResults({ series: [], books: [] }); } } finally { setIsLoading(false); } }, 250); return () => { clearTimeout(timeoutId); }; }, [query]); const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (firstResultHref) { setIsOpen(false); router.push(firstResultHref); } }; return (
{ if (query.trim().length >= MIN_QUERY_LENGTH) { setIsOpen(true); } }} onChange={(event) => setQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Escape") { setIsOpen(false); } }} placeholder={t("header.search.placeholder")} aria-label={t("header.search.placeholder")} className="h-10 rounded-full border-border/60 bg-background/65 pl-10 pr-10 text-sm shadow-sm focus-visible:ring-primary/40" /> {isLoading && (
)} {isOpen && query.trim().length >= MIN_QUERY_LENGTH && (
{results.series.length > 0 && (
{t("header.search.series")}
{results.series.map((item) => ( setIsOpen(false)} className="flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-accent" aria-label={t("header.search.openSeries", { title: item.title })} > {item.title}

{item.title}

{t("series.books", { count: item.booksCount })}

))}
)} {results.books.length > 0 && (
{t("header.search.books")}
{results.books.map((item) => ( setIsOpen(false)} className="flex items-center gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-accent" aria-label={t("header.search.openBook", { title: item.title })} > {item.title}

{item.title}

{item.seriesTitle}

))}
)} {!isLoading && !hasResults && (

{t("header.search.empty")}

)}
)}
); }