138 lines
4.8 KiB
TypeScript
138 lines
4.8 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* SearchAutocomplete — type-as-you-go dropdown on the /search input.
|
||
|
|
*
|
||
|
|
* Hits /api/search/autocomplete (Meilisearch) with debounced fetch and renders
|
||
|
|
* a two-section dropdown: matching documents (jump targets) and matching
|
||
|
|
* chunks (in-doc passages with excerpt). Sub-30ms target. Keyboard navigation
|
||
|
|
* via Up/Down + Enter. Esc closes.
|
||
|
|
*/
|
||
|
|
import { useEffect, useRef, useState } from "react";
|
||
|
|
import Link from "next/link";
|
||
|
|
|
||
|
|
interface DocSuggestion {
|
||
|
|
doc_id: string;
|
||
|
|
title: string;
|
||
|
|
collection?: string;
|
||
|
|
href: string;
|
||
|
|
}
|
||
|
|
interface ChunkSuggestion {
|
||
|
|
chunk_id: string;
|
||
|
|
doc_id: string;
|
||
|
|
page: number;
|
||
|
|
type: string;
|
||
|
|
excerpt: string;
|
||
|
|
ufo_anomaly: boolean;
|
||
|
|
href: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ApiResponse {
|
||
|
|
q: string;
|
||
|
|
duration_ms?: number;
|
||
|
|
documents: DocSuggestion[];
|
||
|
|
chunks: ChunkSuggestion[];
|
||
|
|
}
|
||
|
|
|
||
|
|
export function SearchAutocomplete({ query, onPick }: { query: string; onPick?: () => void }) {
|
||
|
|
const [data, setData] = useState<ApiResponse | null>(null);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [open, setOpen] = useState(false);
|
||
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
|
|
const abort = useRef<AbortController | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const q = query.trim();
|
||
|
|
if (q.length < 2) {
|
||
|
|
setData(null); setOpen(false); return;
|
||
|
|
}
|
||
|
|
if (timer.current) clearTimeout(timer.current);
|
||
|
|
timer.current = setTimeout(async () => {
|
||
|
|
abort.current?.abort();
|
||
|
|
abort.current = new AbortController();
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const r = await fetch(`/api/search/autocomplete?q=${encodeURIComponent(q)}`, {
|
||
|
|
signal: abort.current.signal,
|
||
|
|
});
|
||
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
|
|
const j = (await r.json()) as ApiResponse;
|
||
|
|
setData(j);
|
||
|
|
setOpen(j.documents.length + j.chunks.length > 0);
|
||
|
|
} catch (e) {
|
||
|
|
if ((e as Error).name === "AbortError") return;
|
||
|
|
setData(null); setOpen(false);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, 150);
|
||
|
|
return () => { if (timer.current) clearTimeout(timer.current); };
|
||
|
|
}, [query]);
|
||
|
|
|
||
|
|
if (!open || !data) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="absolute z-30 left-0 right-0 mt-1 max-h-[60vh] overflow-y-auto bg-[#0a121e] border border-[#00ff9c] rounded shadow-lg">
|
||
|
|
<div className="flex items-center justify-between px-3 py-1.5 text-[10px] font-mono uppercase tracking-widest text-[#5a6678] border-b border-[rgba(0,255,156,0.20)]">
|
||
|
|
<span>
|
||
|
|
⚡ autocomplete · {data.documents.length} docs · {data.chunks.length} trechos
|
||
|
|
</span>
|
||
|
|
<span>{loading ? "…" : `${data.duration_ms ?? "?"}ms`}</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{data.documents.length > 0 && (
|
||
|
|
<div>
|
||
|
|
<div className="px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-widest text-[#7fdbff]">
|
||
|
|
documentos
|
||
|
|
</div>
|
||
|
|
<ul>
|
||
|
|
{data.documents.map((d) => (
|
||
|
|
<li key={d.doc_id}>
|
||
|
|
<Link
|
||
|
|
href={d.href}
|
||
|
|
onClick={onPick}
|
||
|
|
className="block px-3 py-2 hover:bg-[rgba(0,255,156,0.06)] border-l-2 border-transparent hover:border-[#00ff9c]"
|
||
|
|
>
|
||
|
|
<div className="font-mono text-sm text-[#c8d4e6] truncate">{d.title}</div>
|
||
|
|
<div className="flex items-center gap-2 font-mono text-[10px] text-[#5a6678] mt-0.5">
|
||
|
|
<span>{d.doc_id}</span>
|
||
|
|
{d.collection && <span>· {d.collection}</span>}
|
||
|
|
</div>
|
||
|
|
</Link>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{data.chunks.length > 0 && (
|
||
|
|
<div>
|
||
|
|
<div className="px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-widest text-[#7fdbff]">
|
||
|
|
trechos
|
||
|
|
</div>
|
||
|
|
<ul>
|
||
|
|
{data.chunks.map((c) => (
|
||
|
|
<li key={`${c.doc_id}-${c.chunk_id}`}>
|
||
|
|
<Link
|
||
|
|
href={c.href}
|
||
|
|
onClick={onPick}
|
||
|
|
className="block px-3 py-2 hover:bg-[rgba(0,255,156,0.06)] border-l-2 border-transparent hover:border-[#00ff9c]"
|
||
|
|
>
|
||
|
|
<div className="flex items-center gap-2 font-mono text-[10px] text-[#5a6678] mb-0.5">
|
||
|
|
<span className="text-[#00ff9c]">{c.chunk_id}</span>
|
||
|
|
<span>p{c.page}</span>
|
||
|
|
<span>{c.type}</span>
|
||
|
|
{c.ufo_anomaly && <span className="text-[#00ff9c]">🛸</span>}
|
||
|
|
<span className="text-[#7fdbff] truncate">{c.doc_id}</span>
|
||
|
|
</div>
|
||
|
|
<div className="text-[13px] text-[#c8d4e6] line-clamp-2 leading-snug">{c.excerpt}</div>
|
||
|
|
</Link>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|