fix(i18n): overhaul i18n with [locale] routing, SSR translations, and hreflang SEO
Rewrites the i18n system introduced in PR #94 to use Next.js App Router [locale] dynamic segments with SSR-rendered translations and proper middleware locale routing. - Add middleware locale detection: / rewrites to /zh-CN/ internally, /en and /ja pass through, /zh-CN/... redirects to bare path - Move all 7 pages under app/[locale]/ with SSR translation injection - Fix server→client serialization: pre-evaluate function-valued translations (makeSerializable) to eliminate hydration flash - Fix language switch key flash: use hard navigation with localStorage- only persistence, avoiding React state update before page reload - Add <link rel="alternate" hreflang> tags for multilingual SEO - Fix Supabase setAll overwriting locale rewrite response - Trim locales from 22 to 3 (zh-CN/en/ja), delete 19 incomplete files - LLM-translate 240 firstact game preset JSONs (en + ja, landscape + portrait) and story titles via gemini-3.5-flash - Delete 11 one-off migration scripts and outdated i18n docs - Add useLocalePath hook and navigation utilities Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,10 +4,12 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { track } from "@/lib/analytics";
|
||||
import { useI18n } from "@/lib/i18n/client";
|
||||
import { useLocalePath } from "@/lib/i18n/hooks";
|
||||
|
||||
export function CustomForm() {
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const lp = useLocalePath();
|
||||
const [worldSetting, setWorldSetting] = useState("");
|
||||
const [styleGuide, setStyleGuide] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -26,7 +28,7 @@ export function CustomForm() {
|
||||
JSON.stringify({ worldSetting, styleGuide }),
|
||||
);
|
||||
track("game_start", { source: "custom" });
|
||||
router.push("/play?custom=1");
|
||||
router.push(lp("/play?custom=1"));
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useI18n } from "@/lib/i18n/client";
|
||||
import { LOCALES, LOCALE_NAMES, type Locale } from "@/lib/i18n/config";
|
||||
import { LOCALES, LOCALE_NAMES, type Locale, setLocale as saveLocalePreference } from "@/lib/i18n/config";
|
||||
import { localePath, stripLocalePrefix } from "@/lib/i18n/navigation";
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
className?: string;
|
||||
@@ -11,45 +13,29 @@ interface LanguageSwitcherProps {
|
||||
variant?: "compact" | "full";
|
||||
}
|
||||
|
||||
// Locales with actual filled-in translations. The catalog ships stub files
|
||||
// for the other 18 locales (so the loader doesn't 404), but only these
|
||||
// three have been reviewed. Hide the rest until they're translated.
|
||||
const TRANSLATED_LOCALES: Locale[] = ["zh-CN", "en", "ja"];
|
||||
|
||||
// Short labels for the compact header button — keeps the row tidy next to
|
||||
// the gear/github/x icons where every other item is 1-2 glyphs.
|
||||
const SHORT_LOCALE_NAMES: Record<Locale, string> = {
|
||||
"zh-CN": "中文",
|
||||
"zh-TW": "繁中",
|
||||
"zh-HK": "繁中",
|
||||
en: "EN",
|
||||
ja: "日本語",
|
||||
ko: "한국어",
|
||||
es: "ES",
|
||||
fr: "FR",
|
||||
de: "DE",
|
||||
"pt-BR": "PT",
|
||||
pt: "PT",
|
||||
ru: "RU",
|
||||
it: "IT",
|
||||
vi: "VI",
|
||||
th: "TH",
|
||||
id: "ID",
|
||||
tr: "TR",
|
||||
pl: "PL",
|
||||
nl: "NL",
|
||||
uk: "UK",
|
||||
hi: "हिन्दी",
|
||||
cs: "CZ",
|
||||
};
|
||||
|
||||
export function LanguageSwitcher({ className = "", variant = "full" }: LanguageSwitcherProps) {
|
||||
const { locale, setLocale, t } = useI18n();
|
||||
const { locale, t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const currentLocaleName = LOCALE_NAMES[locale] || locale;
|
||||
const currentShortName = SHORT_LOCALE_NAMES[locale] || locale;
|
||||
const availableLocales = LOCALES.filter((l) => TRANSLATED_LOCALES.includes(l));
|
||||
|
||||
function switchTo(newLocale: Locale) {
|
||||
const basePath = stripLocalePrefix(pathname);
|
||||
const newPath = localePath(basePath, newLocale);
|
||||
// Only persist to localStorage — do NOT update React state (setLocale)
|
||||
// because that triggers a re-render with isLoading=true before the
|
||||
// browser navigates away, flashing translation keys for one frame.
|
||||
saveLocalePreference(newLocale);
|
||||
window.location.href = newPath;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
@@ -85,14 +71,11 @@ export function LanguageSwitcher({ className = "", variant = "full" }: LanguageS
|
||||
/>
|
||||
<div className="absolute right-0 top-full mt-1 w-44 overflow-hidden rounded-sm border border-clay-900/15 bg-cream-50 shadow-xl shadow-clay-900/10 z-20">
|
||||
<div className="py-1">
|
||||
{availableLocales.map((loc) => (
|
||||
{LOCALES.map((loc) => (
|
||||
<button
|
||||
key={loc}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLocale(loc);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onClick={() => switchTo(loc)}
|
||||
className={`flex w-full items-center justify-between px-3 py-1.5 text-sm font-serif transition-colors hover:bg-cream-100 ${
|
||||
locale === loc ? "text-ember-500" : "text-clay-700"
|
||||
}`}
|
||||
@@ -108,4 +91,3 @@ export function LanguageSwitcher({ className = "", variant = "full" }: LanguageS
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Preset } from "@/lib/presets";
|
||||
import { useLocalePath } from "@/lib/i18n/hooks";
|
||||
|
||||
export function PresetCard({
|
||||
preset,
|
||||
@@ -11,9 +12,10 @@ export function PresetCard({
|
||||
ordinal: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const lp = useLocalePath();
|
||||
return (
|
||||
<button
|
||||
onClick={() => router.push(`/play?preset=${preset.id}`)}
|
||||
onClick={() => router.push(lp(`/play?preset=${preset.id}`))}
|
||||
className="group block w-full py-10 md:py-12 border-t border-clay-900/10 hover:border-clay-900/35 transition-[border-color,padding] duration-500 text-left"
|
||||
>
|
||||
<div className="flex items-baseline gap-6 md:gap-10">
|
||||
|
||||
Reference in New Issue
Block a user