Release / Plano (reaproveitar a imagem?) (push) Successful in 12s
CI / Lint e auditorias (push) Successful in 1m52s
CI / Trivy (dependências e segredos) (push) Successful in 14s
CI / Imagem Docker (linux/arm64) (push) Failing after 1m22s
Release / ci (push) Failing after 3m31s
Release / Publica a imagem (linux/arm64) (push) Skipped
Release / Cria a release no Gitea (push) Skipped
Release / Deploy no Coolify (push) Skipped
O botão do AliExpress passa a ficar no rodapé do card, alinhado entre os cards. O link usa a coluna Link_<idioma> da planilha (en, es, fr, de, zh-CN) e cai no Link (Brasil) quando ela está vazia. Colunas de link não viram filtro, e a API lê A1:AZ1000. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
507 lines
18 KiB
React
507 lines
18 KiB
React
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { usePathname } from "next/navigation";
|
|
import Header from "@/components/Header";
|
|
import Footer from "@/components/Footer";
|
|
import PageHero from "@/components/PageHero";
|
|
import BoardCard from "@/components/BoardCard";
|
|
import { isLinkColumn } from "@/lib/boardLinks";
|
|
import { AlertTriangle, RefreshCw, Search, ShoppingCart, SlidersHorizontal } from "lucide-react";
|
|
|
|
const HEADER_MAP_EN = {
|
|
"Nome": "Name",
|
|
"Descrição": "Description",
|
|
"Série": "Series",
|
|
"Loja Oficial": "Official Store",
|
|
"Fabricante": "Manufacturer",
|
|
"Processador": "Processor",
|
|
"Memória Flash": "Flash Memory",
|
|
"PSRAM": "PSRAM",
|
|
"Conector USB": "USB Connector",
|
|
"Interface USB": "USB Interface",
|
|
"Antena": "Antenna",
|
|
"Bateria": "Battery",
|
|
"Display": "Display",
|
|
"Sensores": "Sensors",
|
|
"Botões": "Buttons",
|
|
"LEDs": "LEDs",
|
|
};
|
|
|
|
const VALUE_MAP_EN = {
|
|
"Sim": "Yes",
|
|
"Não": "No",
|
|
"Opcional": "Optional",
|
|
};
|
|
|
|
const HEADER_MAP_ES = {
|
|
"Nome": "Nombre",
|
|
"Descrição": "Descripción",
|
|
"Série": "Serie",
|
|
"Loja Oficial": "Tienda oficial",
|
|
"Fabricante": "Fabricante",
|
|
"Processador": "Procesador",
|
|
"Memória Flash": "Memoria flash",
|
|
"PSRAM": "PSRAM",
|
|
"Conector USB": "Conector USB",
|
|
"Interface USB": "Interfaz USB",
|
|
"Antena": "Antena",
|
|
"Bateria": "Batería",
|
|
"Display": "Pantalla",
|
|
"Sensores": "Sensores",
|
|
"Botões": "Botones",
|
|
"LEDs": "LEDs",
|
|
};
|
|
|
|
const VALUE_MAP_ES = {
|
|
"Sim": "Sí",
|
|
"Não": "No",
|
|
"Opcional": "Opcional",
|
|
};
|
|
|
|
const HEADER_MAP_FR = {
|
|
"Nome": "Nom",
|
|
"Descrição": "Description",
|
|
"Série": "Série",
|
|
"Loja Oficial": "Boutique officielle",
|
|
"Fabricante": "Fabricant",
|
|
"Processador": "Processeur",
|
|
"Memória Flash": "Mémoire Flash",
|
|
"PSRAM": "PSRAM",
|
|
"Conector USB": "Connecteur USB",
|
|
"Interface USB": "Interface USB",
|
|
"Antena": "Antenne",
|
|
"Bateria": "Batterie",
|
|
"Display": "Écran",
|
|
"Sensores": "Capteurs",
|
|
"Botões": "Boutons",
|
|
"LEDs": "LEDs",
|
|
};
|
|
|
|
const VALUE_MAP_FR = {
|
|
"Sim": "Oui",
|
|
"Não": "Non",
|
|
"Opcional": "Optionnel",
|
|
};
|
|
|
|
const HEADER_MAP_DE = {
|
|
"Nome": "Name",
|
|
"Descrição": "Beschreibung",
|
|
"Série": "Serie",
|
|
"Loja Oficial": "Offizieller Store",
|
|
"Fabricante": "Hersteller",
|
|
"Processador": "Prozessor",
|
|
"Memória Flash": "Flash-Speicher",
|
|
"PSRAM": "PSRAM",
|
|
"Conector USB": "USB-Anschluss",
|
|
"Interface USB": "USB-Schnittstelle",
|
|
"Antena": "Antenne",
|
|
"Bateria": "Batterie",
|
|
"Display": "Display",
|
|
"Sensores": "Sensoren",
|
|
"Botões": "Tasten",
|
|
"LEDs": "LEDs",
|
|
};
|
|
const HEADER_MAP_ZH = {
|
|
"Nome": "名称",
|
|
"Descrição": "描述",
|
|
"Série": "系列",
|
|
"Loja Oficial": "官方商店",
|
|
"Fabricante": "制造商",
|
|
"Processador": "处理器",
|
|
"Memória Flash": "闪存",
|
|
"PSRAM": "PSRAM",
|
|
"Conector USB": "USB 接口",
|
|
"Interface USB": "USB 接口类型",
|
|
"Antena": "天线",
|
|
"Bateria": "电池",
|
|
"Display": "显示屏",
|
|
"Sensores": "传感器",
|
|
"Botões": "按键",
|
|
"LEDs": "LEDs",
|
|
};
|
|
|
|
const VALUE_MAP_DE = {
|
|
"Sim": "Ja",
|
|
"Não": "Nein",
|
|
"Opcional": "Optional",
|
|
};
|
|
const VALUE_MAP_ZH = {
|
|
"Sim": "是",
|
|
"Não": "否",
|
|
"Opcional": "可选",
|
|
};
|
|
|
|
export default function PlacasPage() {
|
|
const pathname = usePathname() || "";
|
|
const isEn = pathname === "/en" || pathname.startsWith("/en/");
|
|
const isEs = pathname === "/es" || pathname.startsWith("/es/");
|
|
const isFr = pathname === "/fr" || pathname.startsWith("/fr/");
|
|
const isDe = pathname === "/de" || pathname.startsWith("/de/");
|
|
const isZh = pathname === "/zh-CN" || pathname.startsWith("/zh-CN/");
|
|
|
|
const [boards, setBoards] = useState([]);
|
|
const [filteredBoards, setFilteredBoards] = useState([]);
|
|
const [headers, setHeaders] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [activeFilters, setActiveFilters] = useState({});
|
|
const [filterOptions, setFilterOptions] = useState({});
|
|
const [filtrosAbertos, setFiltrosAbertos] = useState(false);
|
|
|
|
const excludedColumns = ["Nome", "Descrição", "Link", "Imagem", "Loja Oficial"];
|
|
|
|
useEffect(() => {
|
|
fetchBoards();
|
|
setFiltrosAbertos(window.matchMedia("(min-width: 1024px)").matches);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
applyFilters();
|
|
}, [boards, searchTerm, activeFilters]);
|
|
|
|
const fetchBoards = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch("/api/placas");
|
|
|
|
if (!response.ok) {
|
|
throw new Error(isEn ? "Failed to load board data" : isEs ? "Error al cargar los datos de las placas" : isFr ? "Erreur lors du chargement des données des cartes" : isZh ? "加载开发板数据出错" : isDe ? "Fehler beim Laden der Board-Daten" : "Erro ao carregar dados das placas");
|
|
}
|
|
|
|
const result = await response.json();
|
|
setBoards(result.data);
|
|
setHeaders(result.headers);
|
|
setFilteredBoards(result.data);
|
|
|
|
generateFilterOptions(result.data, result.headers);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const generateFilterOptions = (data, headers) => {
|
|
const options = {};
|
|
const filterableColumns = headers.filter((h) => !excludedColumns.includes(h) && !isLinkColumn(h));
|
|
|
|
filterableColumns.forEach((column) => {
|
|
const uniqueValues = new Set();
|
|
data.forEach((item) => {
|
|
const value = item[column];
|
|
if (value && value.toString().trim()) {
|
|
uniqueValues.add(value.toString().trim());
|
|
}
|
|
});
|
|
|
|
const values = Array.from(uniqueValues).sort();
|
|
const binaryValues = ["Sim", "Não", "Opcional"];
|
|
const isBinaryFilter = values.every((v) => binaryValues.includes(v));
|
|
|
|
options[column] = {
|
|
values: values,
|
|
isBinary: isBinaryFilter,
|
|
};
|
|
});
|
|
|
|
setFilterOptions(options);
|
|
};
|
|
|
|
const applyFilters = () => {
|
|
let filtered = [...boards];
|
|
|
|
if (searchTerm) {
|
|
filtered = filtered.filter((board) => {
|
|
const searchLower = searchTerm.toLowerCase();
|
|
return (
|
|
board.Nome?.toLowerCase().includes(searchLower) ||
|
|
board.Descrição?.toLowerCase().includes(searchLower)
|
|
);
|
|
});
|
|
}
|
|
|
|
Object.keys(activeFilters).forEach((column) => {
|
|
if (activeFilters[column] && activeFilters[column].length > 0) {
|
|
filtered = filtered.filter((board) => {
|
|
const boardValue = board[column]?.toString().trim();
|
|
if (activeFilters[column].includes("binary-enabled")) {
|
|
return boardValue === "Sim" || boardValue === "Opcional";
|
|
}
|
|
return activeFilters[column].includes(boardValue);
|
|
});
|
|
}
|
|
});
|
|
|
|
setFilteredBoards(filtered);
|
|
};
|
|
|
|
const handleFilterToggle = (column, value) => {
|
|
setActiveFilters((prev) => {
|
|
const newFilters = { ...prev };
|
|
|
|
if (!newFilters[column]) {
|
|
newFilters[column] = [value];
|
|
} else {
|
|
const index = newFilters[column].indexOf(value);
|
|
if (index > -1) {
|
|
newFilters[column] = newFilters[column].filter((v) => v !== value);
|
|
if (newFilters[column].length === 0) {
|
|
delete newFilters[column];
|
|
}
|
|
} else {
|
|
newFilters[column] = [...newFilters[column], value];
|
|
}
|
|
}
|
|
|
|
return newFilters;
|
|
});
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setActiveFilters({});
|
|
setSearchTerm("");
|
|
};
|
|
|
|
const activeFilterCount = Object.keys(activeFilters).length + (searchTerm ? 1 : 0);
|
|
|
|
const filterableColumns = headers.filter((h) => !excludedColumns.includes(h) && !isLinkColumn(h));
|
|
|
|
const hero = (
|
|
<PageHero
|
|
isEn={isEn}
|
|
isEs={isEs}
|
|
isFr={isFr}
|
|
isDe={isDe} isZh={isZh}
|
|
trail={[{ label: isEn ? "Board Catalog" : isEs ? "Catálogo de placas" : isFr ? "Catalogue de cartes" : isZh ? "开发板目录" : isDe ? "Board-Katalog" : "Catálogo de placas" }]}
|
|
icon={ShoppingCart}
|
|
label={isEn ? "Real DevKits and modules" : isEs ? "DevKits y módulos reales" : isFr ? "DevKits et modules réels" : isZh ? "真实的 DevKit 与模组" : isDe ? "Echte DevKits und Module" : "DevKits e módulos reais"}
|
|
title={isEn ? "ESP32 Board Catalog" : isEs ? "Catálogo de placas ESP32" : isFr ? "Catalogue de cartes ESP32" : isZh ? "ESP32 开发板目录" : isDe ? "ESP32-Board-Katalog" : "Catálogo de placas ESP32"}
|
|
lede={
|
|
isEn
|
|
? "Compare official and third-party boards for your project: USB interface, on-board sensors and direct store links."
|
|
: isEs
|
|
? "Compare placas oficiales y alternativas para su proyecto: interfaz USB, sensores integrados y enlace directo a la tienda."
|
|
: isFr
|
|
? "Comparez les cartes officielles et alternatives pour votre projet : interface USB, capteurs intégrés et lien direct vers la boutique."
|
|
: isZh ? "对比官方与替代开发板,为你的项目选型:USB 接口、板载传感器,以及直达商店的链接。" : isDe
|
|
? "Vergleichen Sie offizielle und Drittanbieter-Boards für Ihr Projekt: USB-Schnittstelle, integrierte Sensoren und direkter Store-Link."
|
|
: "Compare placas oficiais e alternativas para o seu projeto: interface USB, sensores integrados e link direto para a loja."
|
|
}
|
|
/>
|
|
);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="site-canvas min-h-screen">
|
|
<Header />
|
|
<main id="conteudo">
|
|
{hero}
|
|
<section className="shell section pt-10" aria-busy="true" aria-label={isEn ? "Loading boards" : isEs ? "Cargando placas" : isFr ? "Chargement des cartes" : isZh ? "正在加载开发板" : isDe ? "Boards werden geladen" : "Carregando placas"}>
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="card h-72 animate-pulse bg-surface-2" />
|
|
))}
|
|
</div>
|
|
<p role="status" className="mt-6 text-center text-sm text-muted">
|
|
{isEn ? "Querying boards database…" : isEs ? "Consultando la base de datos de placas…" : isFr ? "Consultation de la base de cartes…" : isZh ? "正在查询开发板数据库…" : isDe ? "Board-Datenbank wird abgefragt…" : "Consultando o banco de placas…"}
|
|
</p>
|
|
</section>
|
|
</main>
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="site-canvas min-h-screen">
|
|
<Header />
|
|
<main id="conteudo">
|
|
{hero}
|
|
<section className="shell section pt-10">
|
|
<div role="alert" className="callout callout-danger flex items-start gap-3">
|
|
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-danger" aria-hidden="true" />
|
|
<div>
|
|
<p className="font-semibold text-ink">
|
|
{isEn ? "Could not load boards" : isEs ? "No se pudieron cargar las placas" : isFr ? "Impossible de charger les cartes" : isZh ? "无法加载开发板" : isDe ? "Boards konnten nicht geladen werden" : "Não foi possível carregar as placas"}
|
|
</p>
|
|
<p className="mt-1">
|
|
{error}. {isEn ? "Check your connection and try again." : isEs ? "Compruebe la conexión y vuelva a intentarlo." : isFr ? "Vérifiez votre connexion et réessayez." : isZh ? "请检查网络连接后重试。" : isDe ? "Prüfen Sie Ihre Verbindung und versuchen Sie es erneut." : "Verifique a conexão e tente de novo."}
|
|
</p>
|
|
<button type="button" onClick={fetchBoards} className="btn btn-primary btn-sm mt-4">
|
|
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
|
{isEn ? "Try again" : isEs ? "Reintentar" : isFr ? "Réessayer" : isZh ? "重试" : isDe ? "Erneut versuchen" : "Tentar novamente"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const filtros = (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<label htmlFor="busca-placa" className="text-sm font-semibold text-ink">
|
|
{isEn ? "Search board" : isEs ? "Buscar placa" : isFr ? "Rechercher une carte" : isZh ? "搜索开发板" : isDe ? "Board suchen" : "Buscar placa"}
|
|
</label>
|
|
<div className="relative mt-2">
|
|
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted" aria-hidden="true" />
|
|
<input
|
|
id="busca-placa"
|
|
type="search"
|
|
placeholder={isEn ? "Name or description" : isEs ? "Nombre o descripción" : isFr ? "Nom ou description" : isZh ? "名称或描述" : isDe ? "Name oder Beschreibung" : "Nome ou descrição"}
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="field pl-10 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{filterableColumns.map((column) => {
|
|
const filterData = filterOptions[column];
|
|
if (!filterData) return null;
|
|
const colLabel = isEn ? (HEADER_MAP_EN[column] || column) : isEs ? (HEADER_MAP_ES[column] || column) : isFr ? (HEADER_MAP_FR[column] || column) : isZh ? (HEADER_MAP_ZH[column] || column) : isDe ? (HEADER_MAP_DE[column] || column) : column;
|
|
|
|
if (filterData.isBinary) {
|
|
return (
|
|
<label key={column} className="flex min-h-11 cursor-pointer items-center justify-between gap-3 border-t border-line pt-4 text-sm font-medium text-ink-soft">
|
|
{colLabel}
|
|
<input
|
|
type="checkbox"
|
|
checked={activeFilters[column]?.includes("binary-enabled") || false}
|
|
onChange={() => handleFilterToggle(column, "binary-enabled")}
|
|
className="h-5 w-5 shrink-0 cursor-pointer accent-[var(--accent)]"
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<fieldset key={column} className="border-t border-line pt-4">
|
|
<legend className="flex items-center gap-2 pr-2 text-sm font-semibold text-ink">
|
|
{colLabel}
|
|
{activeFilters[column]?.length > 0 && <span className="chip chip-accent px-2 py-0">{activeFilters[column].length}</span>}
|
|
</legend>
|
|
<div className="mt-2 max-h-44 space-y-0.5 overflow-y-auto pr-1">
|
|
{filterData.values.map((option) => {
|
|
const optLabel = isEn ? (VALUE_MAP_EN[option] || option) : isEs ? (VALUE_MAP_ES[option] || option) : isFr ? (VALUE_MAP_FR[option] || option) : isZh ? (VALUE_MAP_ZH[option] || option) : isDe ? (VALUE_MAP_DE[option] || option) : option;
|
|
return (
|
|
<label key={option} className="flex min-h-10 cursor-pointer items-center gap-3 rounded-lg px-2 text-sm text-muted transition-colors hover:bg-surface-2 hover:text-ink">
|
|
<input
|
|
type="checkbox"
|
|
checked={activeFilters[column]?.includes(option) || false}
|
|
onChange={() => handleFilterToggle(column, option)}
|
|
className="h-4 w-4 shrink-0 cursor-pointer accent-[var(--accent)]"
|
|
/>
|
|
{optLabel}
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="site-canvas min-h-screen">
|
|
<Header />
|
|
|
|
<main id="conteudo">
|
|
{hero}
|
|
|
|
<section className="shell section pt-10 sm:pt-12" aria-label={isEn ? "Boards" : isEs ? "Placas" : isFr ? "Cartes" : isZh ? "开发板" : isDe ? "Boards" : "Placas"}>
|
|
<div className="grid gap-8 lg:grid-cols-[17rem_1fr] lg:gap-10">
|
|
<aside className="lg:sticky lg:top-28 lg:self-start">
|
|
<details className="panel" open={filtrosAbertos} onToggle={(e) => setFiltrosAbertos(e.currentTarget.open)}>
|
|
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 px-5 py-3 lg:cursor-default">
|
|
<span className="flex items-center gap-2 text-sm font-semibold text-ink">
|
|
<SlidersHorizontal className="h-4 w-4 text-accent" aria-hidden="true" />
|
|
{isEn ? "Filters" : isEs ? "Filtros" : isFr ? "Filtres" : isZh ? "筛选" : isDe ? "Filter" : "Filtros"}
|
|
{activeFilterCount > 0 && <span className="chip chip-accent px-2 py-0">{activeFilterCount}</span>}
|
|
</span>
|
|
{activeFilterCount > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
clearFilters();
|
|
}}
|
|
className="text-sm font-medium text-accent hover:text-accent-strong"
|
|
>
|
|
{isEn ? "Clear" : isEs ? "Limpiar" : isFr ? "Effacer" : isZh ? "清除" : isDe ? "Zurücksetzen" : "Limpar"}
|
|
</button>
|
|
)}
|
|
</summary>
|
|
<div className="border-t border-line p-5 lg:max-h-[calc(100vh-11rem)] lg:overflow-y-auto">{filtros}</div>
|
|
</details>
|
|
</aside>
|
|
|
|
<div>
|
|
<p role="status" aria-live="polite" className="text-sm text-muted">
|
|
<strong className="font-semibold text-ink">{filteredBoards.length}</strong>{" "}
|
|
{isEn
|
|
? filteredBoards.length === 1
|
|
? "board found"
|
|
: "boards found"
|
|
: isEs
|
|
? filteredBoards.length === 1
|
|
? "placa encontrada"
|
|
: "placas encontradas"
|
|
: isFr
|
|
? filteredBoards.length === 1
|
|
? "carte trouvée"
|
|
: "cartes trouvées"
|
|
: isZh ? "款开发板" : isDe
|
|
? filteredBoards.length === 1
|
|
? "Board gefunden"
|
|
: "Boards gefunden"
|
|
: filteredBoards.length === 1
|
|
? "placa encontrada"
|
|
: "placas encontradas"}
|
|
</p>
|
|
|
|
{filteredBoards.length === 0 ? (
|
|
<div className="card mt-4 px-6 py-14 text-center">
|
|
<Search className="mx-auto h-10 w-10 text-muted" aria-hidden="true" />
|
|
<h2 className="mt-4 font-display text-xl font-semibold text-ink">
|
|
{isEn ? "No boards match these filters" : isEs ? "Ninguna placa coincide con estos filtros" : isFr ? "Aucune carte ne correspond à ces filtres" : isZh ? "没有符合这些筛选条件的开发板" : isDe ? "Keine Boards entsprechen diesen Filtern" : "Nenhuma placa com esses filtros"}
|
|
</h2>
|
|
<p className="mx-auto mt-2 max-w-md text-sm text-muted">
|
|
{isEn
|
|
? "Remove some filters or search by another name."
|
|
: isEs
|
|
? "Elimine algunos filtros o busque por otro nombre."
|
|
: isFr
|
|
? "Supprimez certains filtres ou recherchez un autre nom."
|
|
: isZh ? "请去掉一些筛选条件,或换个名称搜索。" : isDe
|
|
? "Entfernen Sie einige Filter oder suchen Sie nach einem anderen Namen."
|
|
: "Remova alguns filtros ou busque por outro nome."}
|
|
</p>
|
|
<button type="button" onClick={clearFilters} className="btn btn-primary mt-6">
|
|
{isEn ? "Clear filters" : isEs ? "Limpiar filtros" : isFr ? "Effacer les filtres" : isZh ? "清除筛选" : isDe ? "Filter zurücksetzen" : "Limpar filtros"}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
{filteredBoards.map((board, index) => (
|
|
<BoardCard key={index} board={board} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<Footer />
|
|
</div>
|
|
);
|
|
}
|