feat(i18n): routing and localization infrastructure (phase 1)
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>
This commit is contained in:
1 parent
e92aea0c84
commit
83c0ba3e74
38 files changed
+1699
-324
No files matched your search
@@ -0,0 +1,232 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ChevronDown, Globe } from "lucide-react";
|
||||
|
||||
export const LOCALES = [
|
||||
{ code: "pt", label: "Português", short: "PT", lang: "pt-BR" },
|
||||
{ code: "en", label: "English", short: "EN", lang: "en" },
|
||||
{ code: "es", label: "Español", short: "ES", lang: "es-419" },
|
||||
{ code: "fr", label: "Français", short: "FR", lang: "fr" },
|
||||
{ code: "de", label: "Deutsch", short: "DE", lang: "de" },
|
||||
{ code: "zh-CN", label: "简体中文", short: "中", lang: "zh-CN" },
|
||||
];
|
||||
|
||||
const CANONICAL_ROUTES = [
|
||||
{
|
||||
id: "series_item",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:series|serien)\/([^/]+)/,
|
||||
getSlug: (loc, key) => (loc === "de" ? `/serien/${key}` : `/series/${key}`),
|
||||
},
|
||||
{
|
||||
id: "frameworks_item",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/frameworks\/([^/]+)/,
|
||||
getSlug: (loc, key) => `/frameworks/${key}`,
|
||||
},
|
||||
{
|
||||
id: "series",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:series|serien)(?:\/|$)/,
|
||||
getSlug: (loc) => (loc === "de" ? "/serien" : "/series"),
|
||||
},
|
||||
{
|
||||
id: "catalogo",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:catalogo|catalog|catalogue|katalog)(?:\/|$)/,
|
||||
getSlug: (loc) => {
|
||||
if (loc === "de") return "/katalog";
|
||||
if (loc === "fr") return "/catalogue";
|
||||
if (loc === "en" || loc === "zh-CN") return "/catalog";
|
||||
return "/catalogo";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "comparacao",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:comparacao|compare|comparacion|comparaison|vergleich)(?:\/|$)/,
|
||||
getSlug: (loc) => {
|
||||
if (loc === "en" || loc === "zh-CN") return "/compare";
|
||||
if (loc === "es") return "/comparacion";
|
||||
if (loc === "fr") return "/comparaison";
|
||||
if (loc === "de") return "/vergleich";
|
||||
return "/comparacao";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "componentes",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:componentes|components|composants|komponenten)(?:\/|$)/,
|
||||
getSlug: (loc) => {
|
||||
if (loc === "en" || loc === "zh-CN") return "/components";
|
||||
if (loc === "fr") return "/composants";
|
||||
if (loc === "de") return "/komponenten";
|
||||
return "/componentes";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "diagnostico",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:diagnostico|diagnostics|diagnostic|diagnose)(?:\/|$)/,
|
||||
getSlug: (loc) => {
|
||||
if (loc === "en" || loc === "zh-CN") return "/diagnostics";
|
||||
if (loc === "fr") return "/diagnostic";
|
||||
if (loc === "de") return "/diagnose";
|
||||
return "/diagnostico";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "frameworks",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/frameworks(?:\/|$)/,
|
||||
getSlug: () => "/frameworks",
|
||||
},
|
||||
{
|
||||
id: "glossario",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:glossario|glossary|glosario|glossaire|glossar)(?:\/|$)/,
|
||||
getSlug: (loc) => {
|
||||
if (loc === "en" || loc === "zh-CN") return "/glossary";
|
||||
if (loc === "es") return "/glosario";
|
||||
if (loc === "fr") return "/glossaire";
|
||||
if (loc === "de") return "/glossar";
|
||||
return "/glossario";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "seletor",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:seletor|selector|selecteur|auswahl)(?:\/|$)/,
|
||||
getSlug: (loc) => {
|
||||
if (loc === "en" || loc === "es" || loc === "zh-CN") return "/selector";
|
||||
if (loc === "fr") return "/selecteur";
|
||||
if (loc === "de") return "/auswahl";
|
||||
return "/seletor";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "sobre",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:sobre|about|acerca|a-propos|ueber-uns)(?:\/|$)/,
|
||||
getSlug: (loc) => {
|
||||
if (loc === "en" || loc === "zh-CN") return "/about";
|
||||
if (loc === "es") return "/acerca";
|
||||
if (loc === "fr") return "/a-propos";
|
||||
if (loc === "de") return "/ueber-uns";
|
||||
return "/sobre";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "livro",
|
||||
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/livro(?:\/|$)/,
|
||||
getSlug: (loc) => (loc === "pt" ? "/livro" : ""),
|
||||
},
|
||||
];
|
||||
|
||||
export function getLocalizedHref(pathname, targetLocale) {
|
||||
if (!pathname || pathname === "/" || pathname === `/${targetLocale}` || pathname === `/${targetLocale}/`) {
|
||||
return `/${targetLocale}/`;
|
||||
}
|
||||
|
||||
for (const route of CANONICAL_ROUTES) {
|
||||
const m = pathname.match(route.match);
|
||||
if (m) {
|
||||
const slug = route.getSlug(targetLocale, m[1]);
|
||||
return slug ? `/${targetLocale}${slug}` : `/${targetLocale}/`;
|
||||
}
|
||||
}
|
||||
return `/${targetLocale}/`;
|
||||
}
|
||||
|
||||
export default function LanguageSelector() {
|
||||
const pathname = usePathname() || "";
|
||||
const [open, setOpen] = useState(false);
|
||||
const [hash, setHash] = useState("");
|
||||
const containerRef = useRef(null);
|
||||
|
||||
// Detecta locale da URL atual
|
||||
const currentLocaleCode =
|
||||
LOCALES.find((l) => pathname === `/${l.code}` || pathname.startsWith(`/${l.code}/`))?.code || "pt";
|
||||
|
||||
const currentLocale = LOCALES.find((l) => l.code === currentLocaleCode) || LOCALES[0];
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setHash(window.location.hash || "");
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
function onPointerDown(e) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function onKeyDown(e) {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSelect = (code) => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.cookie = `NEXT_LOCALE=${code}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex h-11 min-w-[44px] items-center gap-1.5 rounded-full border border-line bg-surface px-3 text-sm font-semibold text-ink transition-colors hover:bg-surface-2 focus-visible:outline-2 focus-visible:outline-accent"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={`Idioma atual: ${currentLocale.label}. Clique para trocar de idioma.`}
|
||||
title={`Idioma: ${currentLocale.label}`}
|
||||
>
|
||||
<Globe className="h-4 w-4 text-muted" aria-hidden="true" />
|
||||
<span className="font-semibold">{currentLocale.short}</span>
|
||||
<ChevronDown
|
||||
className={`h-3 w-3 text-muted transition-transform duration-200 ${open ? "rotate-180" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Idiomas disponíveis"
|
||||
className="absolute right-0 top-full mt-2 w-48 rounded-2xl border border-line bg-surface p-1.5 shadow-[0_24px_60px_-20px_rgba(15,23,42,0.35)] dark:shadow-[0_24px_60px_-20px_rgba(0,0,0,0.8)] z-50"
|
||||
>
|
||||
{LOCALES.map((loc) => {
|
||||
const isActive = loc.code === currentLocaleCode;
|
||||
const href = `${getLocalizedHref(pathname, loc.code)}${hash}`;
|
||||
|
||||
return (
|
||||
<a
|
||||
key={loc.code}
|
||||
href={href}
|
||||
lang={loc.lang}
|
||||
hrefLang={loc.lang}
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
onClick={() => handleSelect(loc.code)}
|
||||
className={`flex min-h-[44px] items-center justify-between rounded-xl px-3 py-2 text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-accent-soft text-accent font-semibold"
|
||||
: "text-ink-soft hover:bg-surface-2 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<span>{loc.label}</span>
|
||||
<span className={`text-xs ${isActive ? "text-accent" : "text-muted"}`}>
|
||||
{loc.short}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user