"use client"; import { Loader2, Search } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import { getBulkOfferDestinations } from "@/actions/catalog-bulk"; import Link from "@/components/link"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { requestCatalogNavigation } from "../client/use-catalog-navigation"; import { useLatestRequest } from "../client/use-latest-request"; import { type CatalogSearchResult, type SearchCatalogKind, validSearchQuery, } from "../domain/search"; import { BulkOfferEditor } from "./bulk-offer-editor"; export function CatalogSearch({ catalogType = "normal", canEdit = false, onNavigate, autoFocus = false, }: { catalogType?: SearchCatalogKind; canEdit?: boolean; onNavigate?: (result: CatalogSearchResult) => void | Promise; autoFocus?: boolean; }) { const t = useTranslations("pages.admin.catalog.workspace"); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [status, setStatus] = useState<"idle" | "loading" | "ready" | "error">( "idle", ); const requests = useLatestRequest(); const inputRef = useRef(null); useEffect(() => { if (autoFocus) inputRef.current?.focus(); }, [autoFocus]); const bulk = useTranslations("pages.admin.catalog.bulk"); const router = useRouter(); const [selected, setSelected] = useState>(new Set()); const [pages, setPages] = useState<{ id: number; caption: string }[] | null>( null, ); const [destinationsLoading, setDestinationsLoading] = useState(false); const [selectionError, setSelectionError] = useState(null); const destinationRequest = useRef(0); const destinationBusy = useRef(false); const [refreshKey, setRefreshKey] = useState(0); // biome-ignore lint/correctness/useExhaustiveDependencies: Catalog switches invalidate the selection and destination requests. useEffect(() => { destinationRequest.current += 1; destinationBusy.current = false; setSelected(new Set()); setPages(null); setDestinationsLoading(false); setSelectionError(null); return () => { destinationRequest.current += 1; }; }, [catalogType]); async function loadDestinations() { if (destinationBusy.current) return; destinationBusy.current = true; const request = ++destinationRequest.current; setDestinationsLoading(true); setSelectionError(null); try { const result = await getBulkOfferDestinations(); if (request !== destinationRequest.current) return; if (!result.ok) { setSelectionError(result.error); return; } setPages(result.data.pages); } catch { if (request === destinationRequest.current) setSelectionError(bulk("requestFailed")); } finally { if (request === destinationRequest.current) { destinationBusy.current = false; setDestinationsLoading(false); } } } function mayEdit() { if (requestCatalogNavigation()) return true; setSelectionError(bulk("disabledReason")); return false; } function toggleOffer(id: number) { if (!mayEdit()) return; setSelected((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else if (next.size < 500) next.add(id); return next; }); if (!pages) void loadDestinations(); } // biome-ignore lint/correctness/useExhaustiveDependencies: Successful bulk edits must refresh unchanged search queries. useEffect(() => { const request = requests.start(); setResults([]); if (!validSearchQuery(query)) { setStatus("idle"); return () => requests.cancel(); } setStatus("loading"); const timer = setTimeout(async () => { try { const params = new URLSearchParams({ q: query.trim(), catalog: catalogType, }); const response = await fetch(`/api/admin/catalog/search?${params}`, { signal: request.signal, }); if (!response.ok) throw Error("Search failed"); const data = await response.json(); if (!request.isCurrent()) return; setResults(data.results); setStatus("ready"); } catch { if (request.isCurrent()) setStatus("error"); } }, 250); return () => { clearTimeout(timer); requests.cancel(); }; }, [query, catalogType, requests, refreshKey]); return (
setQuery(event.target.value)} maxLength={100} className="min-w-0" />
{canEdit && catalogType === "normal" && selected.size > 0 && (
{bulk("selectedCount", { count: selected.size })} {pages ? ( { setSelected(new Set()); setRefreshKey((value) => value + 1); router.refresh(); }} /> ) : ( )}
)} {selectionError && (

{selectionError}

)}
{status === "loading" && (

{t("searching")}

)} {status === "error" && (

{t("searchFailed")}

)} {status === "ready" && results.length === 0 && (

{t("searchNoResults")}

)}
{results.length > 0 && (
    {results.map((result) => (
  • {canEdit && catalogType === "normal" && result.kind === "offer" && ( = 500 && !selected.has(result.id)} onCheckedChange={() => toggleOffer(result.id)} /> )} { if ( onNavigate && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey ) { event.preventDefault(); void onNavigate(result); } }} className="block min-w-0 flex-1 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2" >
    {t(result.kind)} {result.caption} #{result.id}

    {result.path.join(" / ")}

    {result.furniture && (

    {result.furniture.classname} · #{result.furniture.id} · sprite {result.furniture.spriteId}

    )}
  • ))}
)}
); }