"use client"; import { SearchIcon } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; import type { AdminSearchResult } from "@/app/api/admin/search/route"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@/components/ui/command"; import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { useDebounce } from "@/hooks/use-debounce"; import { confirmUnsavedNavigation } from "@/hooks/use-unsaved-changes"; import { type AdminNavigationOptions, visibleAdminNavigation, } from "@/lib/admin-navigation"; import { AdminSearchRequest } from "@/lib/admin-search-request"; type SearchResult = AdminSearchResult; type SearchResponse = { ok: boolean; results: SearchResult[]; partial?: boolean; }; export function SearchDialog({ navigation, }: { navigation: AdminNavigationOptions; }) { const tNav = useTranslations("pages.admin.nav"); const getPages = (queryPlaceholder: string) => visibleAdminNavigation(navigation) .flatMap((group) => group.items.map((item) => ({ ...item, group: tNav(group.labelKey) })), ) .filter((item) => [tNav(item.labelKey), item.group, item.href] .join(" ") .toLocaleLowerCase() .includes(queryPlaceholder), ); const t = useTranslations("pages.admin.search"); const router = useRouter(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [failed, setFailed] = useState(false); const [partial, setPartial] = useState(false); const debouncedQuery = useDebounce(query, 250); const requestRef = useRef(new AdminSearchRequest()); const groupLabels: Record = { users: t("users"), articles: t("articles"), rooms: t("rooms"), guilds: t("guilds"), shop: t("shop"), rareValues: t("rareValues"), furniture: t("furniture"), catalog: t("catalog"), tickets: t("tickets"), }; const fetchResults = useCallback(async (q: string) => { requestRef.current.cancel(); setFailed(false); setPartial(false); setResults([]); if (!canSearchRecords(q)) { setResults([]); setLoading(false); return; } const controller = requestRef.current.start(); setLoading(true); try { const res = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, { signal: controller.signal, }); if (!res.ok) throw new Error("Search request failed"); const data: SearchResponse = await res.json(); if (!requestRef.current.accepts(controller)) return; if (data.ok) { setResults(data.results); setPartial(Boolean(data.partial)); } else setFailed(true); } catch (error) { if (error instanceof DOMException && error.name === "AbortError") return; if (requestRef.current.accepts(controller)) { setResults([]); setFailed(true); } } finally { if (requestRef.current.accepts(controller)) setLoading(false); } }, []); useEffect(() => { if (!open || query !== debouncedQuery) return; fetchResults(debouncedQuery); return () => requestRef.current.cancel(); }, [debouncedQuery, fetchResults, open, query]); useEffect(() => { return () => requestRef.current.cancel(); }, []); useEffect(() => { function handleKeyDown(e: KeyboardEvent) { if ((e.metaKey || e.ctrlKey) && e.key === "k") { e.preventDefault(); setOpen((prev) => !prev); } } document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, []); function handleQueryChange(value: string) { requestRef.current.cancel(); setQuery(value.slice(0, 64)); setResults([]); setFailed(false); setPartial(false); setLoading(canSearchRecords(value)); } function handleOpenChange(next: boolean) { if (!next) { requestRef.current.cancel(); setQuery(""); setResults([]); setFailed(false); setPartial(false); setLoading(false); } setOpen(next); } function handleSelect(url: string) { if (!confirmUnsavedNavigation()) return; handleOpenChange(false); setQuery(""); setResults([]); router.push(url); } const grouped = groupResults(query === debouncedQuery ? results : []); const pages = getPages(query.trim().toLocaleLowerCase()).slice(0, 8); return ( {t("placeholder")} Ctrl/⌘ K {t("placeholder")} {t("description")} {loading && canSearchRecords(query) ? (
…
) : null} {!loading && !failed && !partial && canSearchRecords(query) && ( {t("noResults")} )} {!canSearchRecords(query) && (

{t("hint")}

)} {(failed || partial) && (

{t(partial ? "partialError" : "loadError")}

)} {pages.length > 0 && ( {pages.map((item) => ( handleSelect(item.href)} > {tNav(item.labelKey)} {item.group} ))} )} {Object.entries(grouped).map(([group, items]) => ( {items.map((item) => ( handleSelect(item.url)} className="flex flex-col items-start gap-0.5 py-2" > {item.title} {item.subtitle ? ( {item.subtitle} ) : null} ))} ))}
); } function canSearchRecords(value: string) { const q = value.trim(); return q.length >= 2 || /^[1-9]$/.test(q); } function groupResults(results: SearchResult[]) { const grouped: Record = {}; for (const r of results) { if (!grouped[r.type]) grouped[r.type] = []; grouped[r.type].push(r); } return grouped; }