Files
ESPDocs/app/[locale]/glossario/page.jsx
T
Lucas RayanandAntigravity 7fce46b9e1
CI / Lint e auditorias (push) Successful in 49s
CI / Trivy (dependências e segredos) (push) Successful in 11s
CI / Imagem Docker (linux/arm64) (push) Successful in 3m14s
feat(i18n): French and German localization (phase 5)
- Add full message catalogs and content for French (fr) and German (de)
- Implement ASCII canonical slugs for French and German routes
- Add language-specific search endpoints (/api/busca/fr, /api/busca/de)
- Configure strict 404 responses for /fr/livro and /de/livro
- Localize SEO, OpenGraph (fr_FR, de_DE), Twitter and JSON-LD schemas
- Add localized code example comments across 14 examples for fr and de
- Enable 'fr' and 'de' in ENABLED_LOCALES with root negotiation and redirect
- Extend test suite in check-i18n, smoke-imagem and verifica-vazamentos

Co-Authored-By: Antigravity <noreply@google.com>
2026-11-01 22:06:36 -03:00

449 lines
18 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 * as glossarioEs from "@/lib/data/es/glossario";
import * as glossarioFr from "@/lib/data/fr/glossario";
import * as glossarioDe from "@/lib/data/de/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 isEs = pathname?.startsWith("/es");
const isFr = pathname?.startsWith("/fr");
const isDe = pathname?.startsWith("/de");
const currentLocale = isDe ? "de" : isFr ? "fr" : isEs ? "es" : isEn ? "en" : "pt";
const { categoriasGlossario } = isDe ? glossarioDe : isFr ? glossarioFr : isEs ? glossarioEs : 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
isEn={isEn}
isEs={isEs}
isFr={isFr}
isDe={isDe}
trail={[{ label: isDe ? "Glossar" : isFr ? "Glossaire" : isEs ? "Glosario" : isEn ? "Glossary" : "Glossário" }]}
icon={BookOpen}
label={
isDe
? `${todosOsTermos.length} Begriffe erklärt`
: isFr
? `${todosOsTermos.length} termes expliqués`
: isEs
? `${todosOsTermos.length} términos explicados`
: isEn
? `${todosOsTermos.length} terms explained`
: `${todosOsTermos.length} termos explicados`
}
title={
isDe
? "Technisches Glossar für ESP32"
: isFr
? "Glossaire technique ESP32"
: isEs
? "Glosario técnico de ESP32"
: isEn
? "ESP32 Technical Glossary"
: "Glossário técnico do ESP32"
}
lede={
isDe
? "Präzise Definitionen, Hardware-Parameter und praktische Beispiele für jeden Begriff des Ökosystems – von ADC bis Secure Boot."
: isFr
? "Définitions précises, paramètres matériels et exemples pratiques pour chaque terme de l'écosystème, de l'ADC au Secure Boot."
: isEs
? "Definiciones directas, parámetros de hardware y un ejemplo práctico para cada término del ecosistema, desde ADC hasta Secure Boot."
: 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={isDe ? "Suche und Filter" : isFr ? "Recherche et filtres" : isEs ? "Búsqueda y filtros" : 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">
{isDe ? "Nach Begriff, Abkürzung oder Konzept suchen" : isFr ? "Rechercher un terme, un sigle ou un concept" : isEs ? "Buscar término, sigla o concepto" : 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={isDe ? "Nach Begriff, Abkürzung oder Konzept suchen" : isFr ? "Rechercher un terme, un sigle ou un concept" : isEs ? "Buscar término, sigla o concepto" : 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" />
{isDe ? "Löschen" : isFr ? "Effacer" : isEs ? "Limpiar" : 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">{isDe ? "Häufig gesucht" : isFr ? "Plus recherchés" : isEs ? "Más buscados" : 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">{isDe ? "Kategorie" : isFr ? "Catégorie" : isEs ? "Categoría" : 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" />
{isDe ? "Alle" : isFr ? "Toutes" : isEs ? "Todas" : 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">{isDe ? "Alphabetische Reihenfolge" : isFr ? "Ordre alphabétique" : isEs ? "Orden alfabético" : isEn ? "Alphabetical order" : "Ordem alfabética"}</h2>
<div className="mt-3 flex flex-wrap gap-1.5" role="group" aria-label={isDe ? "Nach Anfangsbuchstaben filtern" : isFr ? "Filtrer par lettre initiale" : isEs ? "Filtrar por letra inicial" : isEn ? "Filter by first letter" : "Filtrar pela letra inicial"}>
<button type="button" onClick={() => setLetraFiltro(null)} aria-pressed={letraFiltro === null} className="chip-btn">
{isDe ? "Alle" : isFr ? "Toutes" : isEs ? "Todas" : 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
? isDe
? "Begriff"
: isFr
? "terme"
: isEs
? "término"
: isEn
? "term"
: "termo"
: isDe
? "Begriffe"
: isFr
? "termes"
: isEs
? "términos"
: isEn
? "terms"
: "termos"}
{temFiltroAtivo && (
<span className="font-normal text-muted">
{isDe
? " mit den aktuellen Filtern"
: isFr
? " avec les filtres actuels"
: isEs
? " con los filtros actuales"
: 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" />
{isDe ? "Filter zurücksetzen" : isFr ? "Réinitialiser les filtres" : isEs ? "Restablecer filtros" : 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">
{isDe ? "Keine Begriffe gefunden" : isFr ? "Aucun terme trouvé" : isEs ? "No se encontraron términos" : isEn ? "No terms found" : "Nenhum termo encontrado"}
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-muted">
{isDe
? "Nichts entspricht Ihrer aktuellen Suche und den Filtern. Versuchen Sie eine kürzere Abkürzung oder wählen Sie alle Kategorien."
: isFr
? "Rien ne correspond à votre recherche et à vos filtres actuels. Essayez avec un sigle plus court ou revenez à toutes les catégories."
: isEs
? "Nada coincide con la búsqueda y los filtros actuales. Pruebe con una sigla más corta o vuelva a todas las categorías."
: 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" />
{isDe ? "Filter zurücksetzen" : isFr ? "Réinitialiser les filtres" : isEs ? "Limpiar filtros" : 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={
isDe
? `Link für ${item.termo} kopieren`
: isFr
? `Copier le lien pour ${item.termo}`
: isEs
? `Copiar enlace para ${item.termo}`
: 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" />
{isDe ? "In der Praxis" : isFr ? "En pratique" : isEs ? "En la práctica" : 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">
{isDe ? "Vom Konzept zum richtigen Chip." : isFr ? "Du concept à la bonne puce." : isEs ? "Del concepto al chip adecuado." : isEn ? "From concept to the right chip." : "Do conceito ao chip certo."}
</h2>
<p className="lede mx-auto mt-4 max-w-2xl">
{isDe
? "Mit den Begriffen zur Hand vergleichen Sie die Serien Seite an Seite oder nutzen Sie die Auswahlhilfe, um das Modell für Ihr Projekt zu finden."
: isFr
? "Avec les termes sous la main, comparez les séries côte à côte ou répondez au sélecteur pour trouver le modèle adapté à votre projet."
: isEs
? "Con los términos a mano, compare las series lado a lado o responda al selector para encontrar el modelo que se adapta a su proyecto."
: 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" />
{isDe ? "Meinen Chip wählen" : isFr ? "Choisir ma puce" : isEs ? "Elegir mi chip" : isEn ? "Choose my chip" : "Escolher meu chip"}
</Link>
<Link href={getLocalizedHref("/series", currentLocale)} className="btn btn-ghost w-full sm:w-auto">
{isDe ? "Serien erkunden" : isFr ? "Explorer les séries" : isEs ? "Explorar las series" : 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;
})}
</>
);
}