disclosure-bureau/web/components/command-palette.tsx

169 lines
5.5 KiB
TypeScript
Raw Permalink Normal View History

/**
* CommandPalette global Cmd+K (or Ctrl+K) overlay with hybrid_search.
*
* Renders directly in <RootLayout>. Type a query, debounced 250ms, hit /api/search/hybrid,
* arrow-key navigation, Enter opens the chunk anchor on /d/<doc>#cNNNN.
*/
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
interface Hit {
chunk_id: string;
doc_id: string;
page: number;
type: string;
bbox: { x: number; y: number; w: number; h: number } | null;
classification: string | null;
snippet: string;
score: number;
href: string;
}
export function CommandPalette() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [loading, setLoading] = useState(false);
const [hits, setHits] = useState<Hit[]>([]);
const [sel, setSel] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
// Global hotkey
useEffect(() => {
function onKey(e: KeyboardEvent) {
const cmd = e.metaKey || e.ctrlKey;
if (cmd && e.key.toLowerCase() === "k") {
e.preventDefault();
setOpen((o) => !o);
} else if (e.key === "Escape") {
setOpen(false);
}
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
// Focus input when opened
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 30);
else {
setQ("");
setHits([]);
setSel(0);
}
}, [open]);
// Debounced search
useEffect(() => {
if (!open || q.trim().length < 2) {
setHits([]);
return;
}
const ctl = new AbortController();
const t = setTimeout(async () => {
setLoading(true);
try {
const res = await fetch(
`/api/search/hybrid?q=${encodeURIComponent(q)}&lang=pt&top_k=10`,
{ signal: ctl.signal },
);
if (!res.ok) {
setHits([]);
return;
}
const data = (await res.json()) as { hits?: Hit[] };
setHits(data.hits ?? []);
setSel(0);
} catch {
// aborted or error — keep last hits
} finally {
setLoading(false);
}
}, 250);
return () => {
ctl.abort();
clearTimeout(t);
};
}, [q, open]);
const go = useCallback(
(h: Hit) => {
router.push(h.href);
setOpen(false);
},
[router],
);
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSel((s) => Math.min(hits.length - 1, s + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSel((s) => Math.max(0, s - 1));
} else if (e.key === "Enter") {
e.preventDefault();
if (hits[sel]) go(hits[sel]);
}
}
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start justify-center pt-24"
onClick={() => setOpen(false)}
>
<div
className="w-[min(640px,90vw)] bg-[#040810] border border-[#00ff9c] rounded-lg shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 px-4 py-3 border-b border-[rgba(0,255,156,0.20)]">
<span className="text-[#7fdbff] font-mono text-xs">K</span>
<input
ref={inputRef}
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={onKeyDown}
placeholder="busca semântica no corpus inteiro…"
className="flex-1 bg-transparent text-[#c8d4e6] outline-none font-mono text-sm placeholder:text-[#5a6678]"
/>
{loading && <span className="text-[#5a6678] font-mono text-xs animate-pulse"></span>}
</div>
<ul className="max-h-96 overflow-y-auto">
{hits.length === 0 && q.trim().length >= 2 && !loading && (
<li className="px-4 py-6 text-center text-[#5a6678] text-xs font-mono">sem resultados</li>
)}
{hits.map((h, i) => (
<li
key={`${h.doc_id}-${h.chunk_id}`}
onMouseEnter={() => setSel(i)}
onClick={() => go(h)}
className={`px-3 py-2 cursor-pointer border-l-2 ${
sel === i
? "border-[#00ff9c] bg-[rgba(0,255,156,0.06)]"
: "border-transparent hover:bg-[rgba(127,219,255,0.04)]"
}`}
>
<div className="flex items-center gap-2 text-[10px] font-mono mb-0.5">
<span className="text-[#00ff9c]">{h.chunk_id}</span>
<span className="text-[#5a6678]">p{h.page}</span>
<span className="text-[#5a6678]">{h.type}</span>
{h.classification && <span className="text-[#ff6b6b]">{h.classification}</span>}
<span className="text-[#8896aa] ml-auto">{h.score.toFixed(2)}</span>
</div>
<div className="text-[#c8d4e6] text-xs line-clamp-2">{h.snippet}</div>
<div className="text-[10px] font-mono text-[#5a6678] truncate mt-0.5">{h.doc_id}</div>
</li>
))}
</ul>
<div className="px-3 py-2 border-t border-[rgba(0,255,156,0.20)] text-[10px] font-mono text-[#5a6678] flex justify-between">
<span> navegar · abrir · esc fechar</span>
<span>{hits.length > 0 ? `${hits.length} hits` : "hybrid BM25+dense+rerank"}</span>
</div>
</div>
</div>
);
}