Files
ESPDocs/app/[locale]/glossario/page.jsx
T
Lucas RayanandAntigravity c1bd2c7e11
CI / Lint e auditorias (push) Successful in 1m51s
CI / Trivy (dependências e segredos) (push) Successful in 15s
CI / Imagem Docker (linux/arm64) (push) Successful in 2m41s
feat(i18n): English localization (phase 3)
- Translate content to English across messages/en.json, data/en, lib/data/en, and public/exemplos/en
- Implement localized routing, slugs, and language negotiation with fallback to en
- Build dedicated search index for English with strict book exclusion
- Update components and pages to dynamically render localized strings and datasets
- Verify full structural parity and exact 1:1 match on Portuguese pages via comparator

Co-Authored-By: Antigravity <noreply@google.com>
2026-11-01 14:17:13 -03:00

360 lines
13 KiB
React

"use client";
import { useState, useMemo, useRef } from "react";
import { usePathname } from "next/navigation";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import PageHero from "@/components/PageHero";
import Link from "next/link";
import * as glossarioPt from "@/lib/glossarioData";
import * as glossarioEn from "@/lib/data/en/glossario";
import { getLocalizedHref } from "@/i18n/links";
import {
ArrowRight,
BookOpen,
Check,
Code2,
Cpu,
Database,
HelpCircle,
LayoutGrid,
Lightbulb,
Link2,
BatteryCharging,
RotateCcw,
Search,
ShieldCheck,
Sliders,
Sparkles,
Wifi,
X,
} from "lucide-react";
const BUSCAS_POPULARES = ["GPIO", "ULP", "PSRAM", "Strapping Pins", "Deep Sleep", "OTA", "Matter", "Secure Boot", "Wi-Fi 6", "MIPI"];
const ICONES = { Cpu, Database, Wifi, Sliders, Sparkles, BatteryCharging, Code2, ShieldCheck };
/** "Processamento, CPU & Silício" vira "Processamento": cabe no filtro sem truncar. */
const nomeCurto = (nome) => nome.split(/,| &/)[0].trim();
export default function GlossarioPage() {
const pathname = usePathname();
const isEn = pathname?.startsWith("/en");
const currentLocale = isEn ? "en" : "pt";
const { categoriasGlossario } = isEn ? glossarioEn : glossarioPt;
const [busca, setBusca] = useState("");
const [categoriaSelecionada, setCategoriaSelecionada] = useState("todas");
const [letraFiltro, setLetraFiltro] = useState(null);
const [copiadoSlug, setCopiadoSlug] = useState(null);
const searchInputRef = useRef(null);
const todosOsTermos = useMemo(
() =>
categoriasGlossario.flatMap((cat) =>
cat.termos.map((t) => ({
...t,
categoriaId: cat.id,
categoriaNome: cat.nome,
categoriaIcone: cat.icone,
slug: t.termo.toLowerCase().replace(/[\s()/²]+/g, "-"),
primeiraLetra: t.termo.charAt(0).toUpperCase(),
}))
),
[categoriasGlossario]
);
const alfabeto = useMemo(() => {
const disponiveis = new Set(todosOsTermos.map((t) => t.primeiraLetra));
return "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("").map((letra) => ({ letra, temTermos: disponiveis.has(letra) }));
}, [todosOsTermos]);
const termosFiltrados = useMemo(() => {
const termoBusca = busca.toLowerCase().trim();
return todosOsTermos.filter((item) => {
const matchCategoria = categoriaSelecionada === "todas" || item.categoriaId === categoriaSelecionada;
const matchLetra = !letraFiltro || item.primeiraLetra === letraFiltro;
const matchBusca =
!termoBusca ||
item.termo.toLowerCase().includes(termoBusca) ||
item.definicao.toLowerCase().includes(termoBusca) ||
item.exemplo.toLowerCase().includes(termoBusca) ||
item.categoriaNome.toLowerCase().includes(termoBusca);
return matchCategoria && matchLetra && matchBusca;
});
}, [todosOsTermos, categoriaSelecionada, letraFiltro, busca]);
const temFiltroAtivo = busca.trim() !== "" || categoriaSelecionada !== "todas" || letraFiltro !== null;
const limparTodosFiltros = () => {
setBusca("");
setCategoriaSelecionada("todas");
setLetraFiltro(null);
searchInputRef.current?.focus();
};
const copiarLinkTermo = (slug) => {
const basePath = getLocalizedHref("/glossario", currentLocale);
navigator.clipboard.writeText(`${window.location.origin}${basePath}#${slug}`);
setCopiadoSlug(slug);
setTimeout(() => setCopiadoSlug(null), 2000);
};
const total = termosFiltrados.length;
return (
<div className="site-canvas min-h-screen">
<Header />
<main id="conteudo">
<PageHero
trail={[{ label: isEn ? "Glossary" : "Glossário" }]}
icon={BookOpen}
label={isEn ? `${todosOsTermos.length} terms explained` : `${todosOsTermos.length} termos explicados`}
title={isEn ? "ESP32 Technical Glossary" : "Glossário técnico do ESP32"}
lede={
isEn
? "Direct definitions, hardware parameters, and practical examples for every ecosystem term, from ADC to Secure Boot."
: "Definições diretas, parâmetros de hardware e um exemplo prático para cada termo do ecossistema, de ADC a Secure Boot."
}
/>
<section className="shell pt-10 sm:pt-12" aria-label={isEn ? "Search and filters" : "Busca e filtros"}>
<div className="panel p-5 sm:p-7">
<div className="relative">
<label htmlFor="busca-glossario" className="sr-only">
{isEn ? "Search term, acronym, or concept" : "Buscar termo, sigla ou conceito"}
</label>
<Search className="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted" aria-hidden="true" />
<input
id="busca-glossario"
ref={searchInputRef}
type="search"
value={busca}
onChange={(e) => setBusca(e.target.value)}
placeholder={isEn ? "Search term, acronym, or concept" : "Buscar termo, sigla ou conceito"}
autoComplete="off"
className={`field pl-12 text-base ${busca ? "pr-28" : "pr-4"}`}
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
{busca && (
<button type="button" onClick={() => { setBusca(""); searchInputRef.current?.focus(); }} className="btn btn-ghost btn-sm">
<X className="h-3.5 w-3.5" aria-hidden="true" />
{isEn ? "Clear" : "Limpar"}
</button>
)}
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<span className="mr-1 text-sm text-muted">{isEn ? "Popular searches" : "Mais buscados"}</span>
{BUSCAS_POPULARES.map((rotulo) => (
<button
key={rotulo}
type="button"
onClick={() => { setBusca(rotulo); setCategoriaSelecionada("todas"); setLetraFiltro(null); }}
className="chip-btn"
>
{rotulo}
</button>
))}
</div>
<div className="mt-5 border-t border-line pt-5">
<h2 className="text-sm font-semibold text-ink">{isEn ? "Category" : "Categoria"}</h2>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => setCategoriaSelecionada("todas")}
aria-pressed={categoriaSelecionada === "todas"}
className="chip-btn"
>
<LayoutGrid className="h-4 w-4" aria-hidden="true" />
{isEn ? "All" : "Todas"}
<span className="tabular-nums opacity-80">{todosOsTermos.length}</span>
</button>
{categoriasGlossario.map((cat) => {
const Icone = ICONES[cat.icone] ?? Cpu;
return (
<button
key={cat.id}
type="button"
onClick={() => setCategoriaSelecionada(cat.id)}
aria-pressed={categoriaSelecionada === cat.id}
className="chip-btn"
>
<Icone className="h-4 w-4" aria-hidden="true" />
{nomeCurto(cat.nome)}
<span className="tabular-nums opacity-80">{cat.termos.length}</span>
</button>
);
})}
</div>
</div>
<div className="mt-6 border-t border-line pt-6">
<h2 className="text-sm font-semibold text-ink">{isEn ? "Alphabetical order" : "Ordem alfabética"}</h2>
<div className="mt-3 flex flex-wrap gap-1.5" role="group" aria-label={isEn ? "Filter by first letter" : "Filtrar pela letra inicial"}>
<button type="button" onClick={() => setLetraFiltro(null)} aria-pressed={letraFiltro === null} className="chip-btn">
{isEn ? "All" : "Todas"}
</button>
{alfabeto.map(({ letra, temTermos }) => (
<button
key={letra}
type="button"
disabled={!temTermos}
onClick={() => setLetraFiltro(letraFiltro === letra ? null : letra)}
aria-pressed={letraFiltro === letra}
className="chip-btn min-w-[2.25rem] justify-center text-xs"
>
{letra}
</button>
))}
</div>
</div>
</div>
</section>
<section className="shell section pt-8 sm:pt-10" aria-labelledby="resultado-titulo">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 id="resultado-titulo" className="text-base font-semibold text-ink" role="status" aria-live="polite">
{total} {total === 1 ? (isEn ? "term" : "termo") : (isEn ? "terms" : "termos")}
{temFiltroAtivo && <span className="font-normal text-muted">{isEn ? " with current filters" : " com os filtros atuais"}</span>}
</h2>
{temFiltroAtivo && (
<button type="button" onClick={limparTodosFiltros} className="inline-flex min-h-11 items-center gap-1.5 text-sm font-medium text-accent hover:text-accent-strong">
<RotateCcw className="h-4 w-4" aria-hidden="true" />
{isEn ? "Reset filters" : "Redefinir filtros"}
</button>
)}
</div>
{total === 0 ? (
<div className="card mt-6 px-6 py-14 text-center">
<HelpCircle className="mx-auto h-10 w-10 text-muted" aria-hidden="true" />
<h3 className="mt-4 font-display text-xl font-semibold text-ink">
{isEn ? "No terms found" : "Nenhum termo encontrado"}
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-muted">
{isEn
? "Nothing matches your current search and filters. Try a shorter acronym or reset to all categories."
: "Nada combina com a busca e os filtros atuais. Tente uma sigla mais curta ou volte para todas as categorias."}
</p>
<button type="button" onClick={limparTodosFiltros} className="btn btn-primary mt-6">
<RotateCcw className="h-4 w-4" aria-hidden="true" />
{isEn ? "Clear filters" : "Limpar filtros"}
</button>
</div>
) : (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{termosFiltrados.map((item) => {
const Icone = ICONES[item.categoriaIcone] ?? Cpu;
return (
<article key={item.slug} id={item.slug} className="card flex scroll-mt-28 flex-col p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<span className="chip">
<Icone className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
{nomeCurto(item.categoriaNome)}
</span>
<button
type="button"
onClick={() => copiarLinkTermo(item.slug)}
aria-label={isEn ? `Copy link for ${item.termo}` : `Copiar link para ${item.termo}`}
className="btn-icon inline-flex h-11 w-11 items-center justify-center rounded-full text-muted transition-colors hover:bg-surface-2 hover:text-accent"
>
{copiadoSlug === item.slug ? <Check className="h-4 w-4 text-ok" aria-hidden="true" /> : <Link2 className="h-4 w-4" aria-hidden="true" />}
</button>
</div>
<h3 className="mt-3 font-display text-xl font-semibold tracking-[-0.03em] text-ink">{item.termo}</h3>
<p className="mt-2 text-[0.9375rem] leading-relaxed text-muted">
<TextoComLinks texto={item.definicao} currentLocale={currentLocale} />
</p>
<div className="callout mt-5">
<p className="flex items-center gap-1.5 text-sm font-semibold text-ink">
<Lightbulb className="h-4 w-4 text-accent" aria-hidden="true" />
{isEn ? "In practice" : "Na prática"}
</p>
<p className="mt-1">
<TextoComLinks texto={item.exemplo} currentLocale={currentLocale} />
</p>
</div>
</article>
);
})}
</div>
)}
</section>
<section className="shell pb-20 md:pb-28">
<div className="panel p-8 text-center sm:p-12">
<h2 className="section-title mx-auto max-w-2xl">
{isEn ? "From concept to the right chip." : "Do conceito ao chip certo."}
</h2>
<p className="lede mx-auto mt-4 max-w-2xl">
{isEn
? "With the terms in hand, compare series side by side or answer the selector to find the model that fits your project."
: "Com os termos em mãos, compare as séries lado a lado ou responda ao seletor para chegar ao modelo que cabe no seu projeto."}
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
<Link href={getLocalizedHref("/seletor", currentLocale)} className="btn btn-primary w-full sm:w-auto">
<Sliders className="h-4 w-4" aria-hidden="true" />
{isEn ? "Choose my chip" : "Escolher meu chip"}
</Link>
<Link href={getLocalizedHref("/series", currentLocale)} className="btn btn-ghost w-full sm:w-auto">
{isEn ? "Explore series" : "Explorar as séries"}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
</div>
</section>
</main>
<Footer />
</div>
);
}
/**
* Função utilitária que detecta nomes de séries do ESP32 dentro do texto e os converte em links clicáveis.
*/
function TextoComLinks({ texto, currentLocale = "pt" }) {
if (!texto) return null;
const seriesKeys = [
"ESP32-S31",
"ESP32-C61",
"ESP32-S2",
"ESP32-S3",
"ESP32-C2",
"ESP32-C3",
"ESP32-C5",
"ESP32-C6",
"ESP32-P4",
"ESP32-H2",
"ESP32-H4",
"ESP32",
];
const regex = new RegExp(`\\b(${seriesKeys.join("|")})\\b`, "g");
const partes = texto.split(regex);
return (
<>
{partes.map((parte, i) => {
if (seriesKeys.includes(parte)) {
return (
<Link
key={i}
href={getLocalizedHref(`/series/${parte}`, currentLocale)}
className="font-semibold text-accent underline decoration-accent/30 underline-offset-2 hover:decoration-accent"
>
{parte}
</Link>
);
}
return parte;
})}
</>
);
}