Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
384 lines
13 KiB
React
384 lines
13 KiB
React
"use client";
|
|
|
|
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useRouter } from "next/navigation";
|
|
import {
|
|
Search,
|
|
X,
|
|
CornerDownLeft,
|
|
ArrowUp,
|
|
ArrowDown,
|
|
FileText,
|
|
Cpu,
|
|
Terminal,
|
|
BookOpen,
|
|
AlertTriangle,
|
|
Box,
|
|
Sparkles,
|
|
Plug,
|
|
} from "lucide-react";
|
|
import { preparar, buscar, EXEMPLOS_DE_BUSCA } from "@/lib/searchEngine";
|
|
import { normalizar } from "@/lib/searchTopics";
|
|
|
|
const TIPOS = {
|
|
resposta: { label: "Resposta", Icon: Sparkles, cor: "bg-accent text-accent-contrast" },
|
|
pino: { label: "Pinos", Icon: Plug, cor: "bg-accent text-accent-contrast" },
|
|
pagina: { label: "Página", Icon: FileText, cor: "bg-surface-2 text-ink-soft" },
|
|
serie: { label: "Série", Icon: Cpu, cor: "bg-accent-soft text-accent" },
|
|
framework: { label: "Framework", Icon: Terminal, cor: "bg-teal-soft text-teal" },
|
|
glossario: { label: "Glossário", Icon: BookOpen, cor: "bg-copper-soft text-copper" },
|
|
erro: { label: "Erro", Icon: AlertTriangle, cor: "bg-gold-soft text-gold" },
|
|
componente: { label: "Componente", Icon: Box, cor: "bg-surface-2 text-ink-soft" },
|
|
};
|
|
|
|
const INTELIGENTES = new Set(["resposta", "pino"]);
|
|
|
|
function Destaque({ texto, termos }) {
|
|
if (!termos.length) return texto;
|
|
// Posições do texto normalizado só valem para o original se o comprimento não mudou.
|
|
const norm = normalizar(texto);
|
|
if (norm.length !== texto.length) return texto;
|
|
const marcas = new Array(texto.length).fill(false);
|
|
for (const t of termos) {
|
|
let i = norm.indexOf(t);
|
|
while (i !== -1) {
|
|
for (let k = i; k < i + t.length; k++) marcas[k] = true;
|
|
i = norm.indexOf(t, i + t.length);
|
|
}
|
|
}
|
|
const partes = [];
|
|
let atual = "";
|
|
let marcado = false;
|
|
for (let i = 0; i < texto.length; i++) {
|
|
if (marcas[i] !== marcado && atual) {
|
|
partes.push({ t: atual, m: marcado });
|
|
atual = "";
|
|
}
|
|
marcado = marcas[i];
|
|
atual += texto[i];
|
|
}
|
|
if (atual) partes.push({ t: atual, m: marcado });
|
|
return partes.map((p, i) =>
|
|
p.m ? (
|
|
<mark key={i} className="rounded-sm bg-accent-soft px-0.5 text-accent">
|
|
{p.t}
|
|
</mark>
|
|
) : (
|
|
<span key={i}>{p.t}</span>
|
|
)
|
|
);
|
|
}
|
|
|
|
function Pilula({ children, onClick, forte = false }) {
|
|
const base = "rounded-full px-2.5 py-1 text-xs font-semibold";
|
|
if (!onClick) return <span className={`${base} ${forte ? "bg-accent text-accent-contrast" : "bg-accent-soft text-accent"}`}>{children}</span>;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
className={`${base} cursor-pointer border border-line bg-surface text-ink-soft transition-colors hover:border-accent hover:text-accent`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function SearchDialog() {
|
|
const [aberto, setAberto] = useState(false);
|
|
const [montado, setMontado] = useState(false);
|
|
const [consulta, setConsulta] = useState("");
|
|
const [indice, setIndice] = useState(null);
|
|
const [erro, setErro] = useState(false);
|
|
const [ativo, setAtivo] = useState(0);
|
|
const inputRef = useRef(null);
|
|
const listaRef = useRef(null);
|
|
const router = useRouter();
|
|
|
|
useEffect(() => setMontado(true), []);
|
|
|
|
const fechar = useCallback(() => {
|
|
setAberto(false);
|
|
setConsulta("");
|
|
setAtivo(0);
|
|
}, []);
|
|
|
|
// Atalhos globais: Ctrl/⌘+K abre; "/" abre fora de campos de texto.
|
|
useEffect(() => {
|
|
function onKey(e) {
|
|
const emCampo = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName) || e.target?.isContentEditable;
|
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
|
e.preventDefault();
|
|
setAberto((v) => !v);
|
|
} else if (e.key === "/" && !emCampo && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
e.preventDefault();
|
|
setAberto(true);
|
|
} else if (e.key === "Escape") {
|
|
fechar();
|
|
}
|
|
}
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, [fechar]);
|
|
|
|
const carregarIndice = useCallback(() => {
|
|
setErro(false);
|
|
let cancelado = false;
|
|
fetch("/api/busca?v=2", { cache: "no-cache" })
|
|
.then((r) => {
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
return r.json();
|
|
})
|
|
.then((dados) => {
|
|
if (!cancelado) {
|
|
setIndice(preparar(dados));
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
console.error("Erro ao carregar índice de busca:", err);
|
|
if (!cancelado) setErro(true);
|
|
});
|
|
return () => {
|
|
cancelado = true;
|
|
};
|
|
}, []);
|
|
|
|
// Carrega o índice (textos + especificações + pinos) na primeira abertura.
|
|
useEffect(() => {
|
|
if (!aberto || indice) return;
|
|
return carregarIndice();
|
|
}, [aberto, indice, carregarIndice]);
|
|
|
|
useEffect(() => {
|
|
if (aberto) {
|
|
document.body.style.overflow = "hidden";
|
|
inputRef.current?.focus();
|
|
}
|
|
return () => {
|
|
document.body.style.overflow = "";
|
|
};
|
|
}, [aberto]);
|
|
|
|
const termos = useMemo(() => normalizar(consulta).split(/\s+/).filter((t) => t.length > 1), [consulta]);
|
|
|
|
const busca = useMemo(() => {
|
|
if (!indice) return { resultados: [], interpretacao: { chips: [], topicos: [] }, sugestoes: [], totalRespostas: 0 };
|
|
if (!consulta.trim()) {
|
|
return { resultados: indice.itens.filter((i) => i.tipo === "pagina"), interpretacao: { chips: [], topicos: [] }, sugestoes: [], totalRespostas: 0 };
|
|
}
|
|
return buscar(indice, consulta);
|
|
}, [indice, consulta]);
|
|
|
|
const { resultados, interpretacao, sugestoes } = busca;
|
|
const vazia = !consulta.trim();
|
|
|
|
useEffect(() => setAtivo(0), [consulta]);
|
|
|
|
useEffect(() => {
|
|
listaRef.current?.querySelector(`[data-idx="${ativo}"]`)?.scrollIntoView({ block: "nearest" });
|
|
}, [ativo]);
|
|
|
|
function ir(item) {
|
|
if (!item) return;
|
|
fechar();
|
|
router.push(item.href);
|
|
// Se já estamos na página de destino, só a âncora muda: avisa quem escuta (abas da série).
|
|
if (item.href.includes("#")) setTimeout(() => window.dispatchEvent(new HashChangeEvent("hashchange")), 80);
|
|
}
|
|
|
|
function anexar(termo) {
|
|
setConsulta((c) => `${c.trim()} ${termo}`.trim());
|
|
inputRef.current?.focus();
|
|
}
|
|
|
|
function onInputKey(e) {
|
|
if (e.key === "ArrowDown") {
|
|
e.preventDefault();
|
|
setAtivo((i) => Math.min(i + 1, resultados.length - 1));
|
|
} else if (e.key === "ArrowUp") {
|
|
e.preventDefault();
|
|
setAtivo((i) => Math.max(i - 1, 0));
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
ir(resultados[ativo]);
|
|
}
|
|
}
|
|
|
|
const entendeu = interpretacao.chips.length > 0 || interpretacao.topicos.length > 0;
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
id="botao-busca"
|
|
onClick={() => setAberto(true)}
|
|
className="flex h-11 min-w-11 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-full border border-line px-3 text-ink transition-colors hover:bg-surface-2 xl:px-4"
|
|
aria-label="Buscar no ESPDocs (Ctrl+K)"
|
|
>
|
|
<Search className="h-4 w-4" aria-hidden="true" />
|
|
<span className="hidden text-sm font-medium xl:inline">Buscar</span>
|
|
<kbd className="hidden rounded-md border border-line bg-surface-2 px-1.5 py-0.5 font-mono text-xs font-semibold text-muted xl:inline">Ctrl K</kbd>
|
|
</button>
|
|
|
|
{montado &&
|
|
aberto &&
|
|
createPortal(
|
|
<div className="fixed inset-0 z-[120] flex items-start justify-center px-4 pt-[8vh]" role="dialog" aria-modal="true" aria-label="Buscar no ESPDocs">
|
|
<div className="dialog-scrim absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={fechar} />
|
|
<div className="dialog-panel relative flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-line bg-canvas shadow-2xl">
|
|
<div className="flex items-center gap-3 border-b border-line px-5">
|
|
<Search className="h-5 w-5 shrink-0 text-accent" aria-hidden="true" />
|
|
<input
|
|
ref={inputRef}
|
|
id="campo-busca"
|
|
type="text"
|
|
value={consulta}
|
|
onChange={(e) => setConsulta(e.target.value)}
|
|
onKeyDown={onInputKey}
|
|
placeholder="Pergunte ao ESPDocs: “flash s3”, “pinos usb c6”, “gpio0 esp32”…"
|
|
className="flex-1 bg-transparent py-5 text-base text-ink outline-none placeholder:text-muted"
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
aria-label="Termo de busca"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={fechar}
|
|
className="grid h-9 w-9 cursor-pointer place-items-center rounded-full text-muted transition-colors hover:bg-surface-2 hover:text-ink"
|
|
aria-label="Fechar busca"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{entendeu && (
|
|
<div className="flex flex-wrap items-center gap-2 border-b border-line bg-surface px-5 py-2.5">
|
|
<span className="text-xs font-semibold text-muted">Entendi:</span>
|
|
{interpretacao.chips.map((c) => (
|
|
<Pilula key={c.chave} forte>{c.nome}</Pilula>
|
|
))}
|
|
{interpretacao.topicos.map((t) => (
|
|
<Pilula key={t.id}>{t.nome}</Pilula>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{sugestoes.length > 0 && (
|
|
<div className="flex flex-wrap items-center gap-2 border-b border-line px-5 py-2.5">
|
|
<span className="text-xs font-semibold text-muted">{interpretacao.chips.length ? "Perguntar sobre:" : "Para qual chip?"}</span>
|
|
{sugestoes.map((s) => (
|
|
<Pilula key={s.rotulo} onClick={() => anexar(s.anexar)}>
|
|
{s.rotulo}
|
|
</Pilula>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div ref={listaRef} className="flex-1 overflow-y-auto p-2" role="listbox">
|
|
{erro && (
|
|
<div className="p-6 text-center">
|
|
<p className="text-sm font-semibold text-danger">Não foi possível carregar o índice de busca.</p>
|
|
<button
|
|
type="button"
|
|
onClick={carregarIndice}
|
|
className="btn btn-primary btn-sm mt-3"
|
|
>
|
|
Tentar novamente
|
|
</button>
|
|
</div>
|
|
)}
|
|
{!erro && !indice && <p className="p-6 text-center text-sm text-muted">Carregando…</p>}
|
|
|
|
{indice && vazia && (
|
|
<div className="px-3 pb-3 pt-1">
|
|
<p className="text-xs font-semibold text-muted">Experimente perguntar</p>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{EXEMPLOS_DE_BUSCA.map((ex) => (
|
|
<Pilula key={ex} onClick={() => setConsulta(ex)}>
|
|
{ex}
|
|
</Pilula>
|
|
))}
|
|
</div>
|
|
<p className="mt-4 text-xs font-semibold text-muted">Ir para</p>
|
|
</div>
|
|
)}
|
|
|
|
{indice && !vazia && resultados.length === 0 && (
|
|
<div className="p-8 text-center">
|
|
<p className="font-display text-lg font-semibold text-ink">Nenhum resultado para “{consulta}”</p>
|
|
<p className="mt-1 text-sm text-muted">Tente “brownout”, “flash s3”, “pinos usb c6” ou “LVGL”.</p>
|
|
</div>
|
|
)}
|
|
|
|
{resultados.map((item, idx) => {
|
|
const { Icon, label, cor } = TIPOS[item.tipo] ?? TIPOS.pagina;
|
|
const sel = idx === ativo;
|
|
const inteligente = INTELIGENTES.has(item.tipo);
|
|
return (
|
|
<button
|
|
key={`${item.id}-${idx}`}
|
|
type="button"
|
|
role="option"
|
|
aria-selected={sel}
|
|
data-idx={idx}
|
|
onMouseMove={() => setAtivo(idx)}
|
|
onClick={() => ir(item)}
|
|
className={`flex w-full cursor-pointer items-start gap-3 rounded-2xl p-3 text-left transition-colors ${
|
|
sel ? "bg-surface-2" : ""
|
|
} ${inteligente ? "my-1 border border-line" : ""}`}
|
|
>
|
|
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-xl ${cor}`}>
|
|
<Icon className="h-4 w-4" aria-hidden="true" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="truncate text-sm font-semibold text-ink">
|
|
{inteligente ? item.titulo : <Destaque texto={item.titulo} termos={termos} />}
|
|
</span>
|
|
<span className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold ${inteligente ? "bg-accent-soft text-accent" : cor}`}>{label}</span>
|
|
</div>
|
|
<p className="mt-0.5 line-clamp-2 text-xs leading-relaxed text-muted">
|
|
{inteligente ? item.descricao : <Destaque texto={item.descricao} termos={termos} />}
|
|
</p>
|
|
|
|
{item.linhas && (
|
|
<dl className="mt-2 grid gap-x-4 gap-y-1 text-xs sm:grid-cols-[auto_1fr]">
|
|
{item.linhas.map((l) => (
|
|
<div key={l.k} className="contents">
|
|
<dt className="font-semibold text-ink-soft">{l.k}</dt>
|
|
<dd className="text-ink">{l.v}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
)}
|
|
|
|
{item.chips && (
|
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
{item.chips.map((c) => (
|
|
<span key={c} className="rounded-md border border-line bg-surface px-1.5 py-0.5 font-mono text-xs text-ink-soft">
|
|
{c}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{sel && <CornerDownLeft className="mt-2 h-4 w-4 shrink-0 text-accent" aria-hidden="true" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="hidden items-center gap-4 border-t border-line bg-surface px-4 py-2.5 text-xs text-muted md:flex">
|
|
<span className="flex items-center gap-1"><ArrowUp className="h-3 w-3" /><ArrowDown className="h-3 w-3" /> navegar</span>
|
|
<span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" /> abrir</span>
|
|
<span><kbd className="font-mono font-semibold">Esc</kbd> fechar</span>
|
|
<span className="ml-auto">{vazia ? "Dica: pressione / em qualquer página" : `${resultados.length} resultado(s)`}</span>
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
);
|
|
}
|