Implement Phase 1 of i18n spec: - Configure next-intl with 6 locales (pt, en, es, fr, de, zh-CN) and pathnames - Move routes to app/[locale] structure with localized layouts and metadata - Add middleware for root language negotiation (307), legacy redirects (308), and 404 for non-pt book - Implement language selector dropdown in header with cookie persistence - Add system typography rules for zh-CN and word-break/hyphenation for de/fr Co-Authored-By: Antigravity <noreply@google.com>
334 lines
12 KiB
React
334 lines
12 KiB
React
"use client";
|
|
|
|
import { useState, useMemo, useRef } from "react";
|
|
import Header from "@/components/Header";
|
|
import Footer from "@/components/Footer";
|
|
import PageHero from "@/components/PageHero";
|
|
import Link from "next/link";
|
|
import { categoriasGlossario } from "@/lib/glossarioData";
|
|
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 [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(),
|
|
}))
|
|
),
|
|
[]
|
|
);
|
|
|
|
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) => {
|
|
navigator.clipboard.writeText(`${window.location.origin}/glossario#${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: "Glossário" }]}
|
|
icon={BookOpen}
|
|
label={`${todosOsTermos.length} termos explicados`}
|
|
title="Glossário técnico do ESP32"
|
|
lede="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="Busca e filtros">
|
|
<div className="panel p-5 sm:p-7">
|
|
<div className="relative">
|
|
<label htmlFor="busca-glossario" className="sr-only">
|
|
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="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" />
|
|
Limpar
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
<span className="mr-1 text-sm text-muted">Mais buscados</span>
|
|
{BUSCAS_POPULARES.map((rotulo) => (
|
|
<button
|
|
key={rotulo}
|
|
type="button"
|
|
onClick={() => setBusca(rotulo)}
|
|
aria-pressed={busca.toLowerCase() === rotulo.toLowerCase()}
|
|
className="chip-btn"
|
|
>
|
|
{rotulo}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-7 border-t border-line pt-6">
|
|
<h2 className="text-sm font-semibold text-ink">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" />
|
|
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">Ordem alfabética</h2>
|
|
<div className="mt-3 flex flex-wrap gap-1.5" role="group" aria-label="Filtrar pela letra inicial">
|
|
<button type="button" onClick={() => setLetraFiltro(null)} aria-pressed={letraFiltro === null} className="chip-btn">
|
|
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-11 justify-center px-0 disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-surface"
|
|
>
|
|
{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 ? "termo" : "termos"}
|
|
{temFiltroAtivo && <span className="font-normal text-muted"> 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" />
|
|
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">Nenhum termo encontrado</h3>
|
|
<p className="mx-auto mt-2 max-w-md text-sm text-muted">
|
|
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" />
|
|
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={`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} />
|
|
</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" />
|
|
Na prática
|
|
</p>
|
|
<p className="mt-1">
|
|
<TextoComLinks texto={item.exemplo} />
|
|
</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">Do conceito ao chip certo.</h2>
|
|
<p className="lede mx-auto mt-4 max-w-2xl">
|
|
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="/seletor" className="btn btn-primary w-full sm:w-auto">
|
|
<Sliders className="h-4 w-4" aria-hidden="true" />
|
|
Escolher meu chip
|
|
</Link>
|
|
<Link href="/series" className="btn btn-ghost w-full sm:w-auto">
|
|
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 }) {
|
|
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={`/series/${parte}`}
|
|
className="font-semibold text-accent underline decoration-accent/30 underline-offset-2 hover:decoration-accent"
|
|
>
|
|
{parte}
|
|
</Link>
|
|
);
|
|
}
|
|
return parte;
|
|
})}
|
|
</>
|
|
);
|
|
} |