diff --git a/src/App.tsx b/src/App.tsx index 076f0ca..2dd6d7a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { I18nProvider } from "./i18n"; import { MotionProvider } from "./motion"; import { ToastProvider } from "./components/Toast"; import { PublicLayout } from "./layouts/PublicLayout"; -import { Home } from "./pages/Home"; +import { LocalizedHomePage } from "./pages/LocalizedHome"; import { Browse } from "./pages/Browse"; import { CategoriesPage } from "./pages/Categories"; import { CategoryPage } from "./pages/Category"; @@ -18,6 +18,7 @@ import { AdminRouteTree } from "./adminRouteTree"; import { AdminRouterModeProvider } from "./adminRouterMode"; import { ImageLightboxProvider } from "./components/messageStream/overlays/ImageLightbox"; import { VideoPlayerProvider } from "./components/messageStream/overlays/VideoPlayer"; +import { localizedHomeRoutes } from "./languageRoutes"; const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true"; @@ -34,7 +35,19 @@ export default function App() { }> - } /> + } + /> + {localizedHomeRoutes.map((route) => ( + + } + /> + ))} } /> { const brand = t("brand"); - const title = pathname === "/" ? meta.title : `${meta.title} | ${brand}`; + const title = isHomePathname(pathname) + ? meta.title + : `${meta.title} | ${brand}`; const canonical = `${window.location.origin}${pathname}${search}`; document.documentElement.lang = lang; diff --git a/src/i18n.tsx b/src/i18n.tsx index 9f5994b..3c5952a 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -5,6 +5,7 @@ import React, { useMemo, useState, } from "react"; +import { languageForHomePathname } from "./languageRoutes"; export type Lang = "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms"; @@ -372,6 +373,10 @@ const LANG_KEY = "ark_lang"; export function I18nProvider({ children }: { children: React.ReactNode }) { const [lang, setLangState] = useState(() => { + const routeLang = languageForHomePathname(window.location.pathname); + if (routeLang) return routeLang; + if (window.location.pathname === "/") return "en"; + const s = localStorage.getItem(LANG_KEY); if (s === "zh" || s === "zh-TW") return "zh-CN"; if ( @@ -386,10 +391,10 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { return s; return "en"; }); - const setLang = (l: Lang) => { + const setLang = useCallback((l: Lang) => { localStorage.setItem(LANG_KEY, l); setLangState(l); - }; + }, []); const t = useCallback( (k: string) => dict[lang][k] || dict.en[k] || k, [lang], diff --git a/src/languageRoutes.test.ts b/src/languageRoutes.test.ts new file mode 100644 index 0000000..36bb616 --- /dev/null +++ b/src/languageRoutes.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + homePathForLang, + isHomePathname, + languageForHomePathname, + localizedHomeRoutes, +} from "./languageRoutes"; + +const expectedRoutes = [ + ["/chinese", "zh-CN"], + ["/japanese", "ja"], + ["/korean", "ko"], + ["/vietnamese", "vi"], + ["/indonesian", "id"], + ["/malay", "ms"], +] as const; + +describe("language home routes", () => { + it("uses CTO-style full language path names except default English root", () => { + expect(localizedHomeRoutes.map(({ path, lang }) => [path, lang])).toEqual( + expectedRoutes, + ); + }); + + it("maps localized home paths to language codes", () => { + for (const [path, lang] of expectedRoutes) { + expect(languageForHomePathname(path)).toBe(lang); + expect(languageForHomePathname(`${path}/`)).toBe(lang); + } + expect(languageForHomePathname("/english")).toBeNull(); + expect(languageForHomePathname("/unknown")).toBeNull(); + }); + + it("treats root and localized language paths as home pages", () => { + expect(isHomePathname("/")).toBe(true); + expect(isHomePathname("/chinese")).toBe(true); + expect(isHomePathname("/browse")).toBe(false); + }); + + it("links each active language back to its localized home", () => { + expect(homePathForLang("en")).toBe("/"); + expect(homePathForLang("zh-CN")).toBe("/chinese"); + expect(homePathForLang("ja")).toBe("/japanese"); + expect(homePathForLang("ko")).toBe("/korean"); + expect(homePathForLang("vi")).toBe("/vietnamese"); + expect(homePathForLang("id")).toBe("/indonesian"); + expect(homePathForLang("ms")).toBe("/malay"); + }); +}); diff --git a/src/languageRoutes.ts b/src/languageRoutes.ts new file mode 100644 index 0000000..b990a7b --- /dev/null +++ b/src/languageRoutes.ts @@ -0,0 +1,35 @@ +import type { Lang } from "./i18n"; + +export const localizedHomeRoutes: ReadonlyArray<{ lang: Lang; path: string }> = + [ + { lang: "zh-CN", path: "/chinese" }, + { lang: "ja", path: "/japanese" }, + { lang: "ko", path: "/korean" }, + { lang: "vi", path: "/vietnamese" }, + { lang: "id", path: "/indonesian" }, + { lang: "ms", path: "/malay" }, + ]; + +function normalizePathname(pathname: string): string { + const normalized = pathname.replace(/\/+$/, ""); + return normalized || "/"; +} + +export function languageForHomePathname(pathname: string): Lang | null { + const normalized = normalizePathname(pathname); + return ( + localizedHomeRoutes.find((route) => route.path === normalized)?.lang ?? null + ); +} + +export function isHomePathname(pathname: string): boolean { + return ( + normalizePathname(pathname) === "/" || + languageForHomePathname(pathname) !== null + ); +} + +export function homePathForLang(lang: Lang): string { + if (lang === "en") return "/"; + return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? "/"; +} diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 68916df..a609d44 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -11,6 +11,7 @@ import { DocumentMeta } from "../components/DocumentMeta"; import { SearchPanel } from "../components/SearchPanel"; import { useI18n, type Lang } from "../i18n"; import { LANG_OPTIONS } from "../i18nLanguages"; +import { homePathForLang, isHomePathname } from "../languageRoutes"; type PublicNavWhich = | "home" @@ -30,13 +31,13 @@ function navIsActive( const sp = new URLSearchParams(search); switch (which) { case "home": - return pathname === "/"; + return isHomePathname(pathname); case "browseAll": return pathname === "/browse" && !sp.has("sort"); case "categories": return ( pathname === "/categories" || - (pathname === "/" && hash === "#categories") + (isHomePathname(pathname) && hash === "#categories") ); case "browseLatest": return pathname === "/browse" && sp.get("sort") === "latest"; @@ -46,7 +47,8 @@ function navIsActive( return pathname === "/browse" && sp.get("sort") === "popular"; case "favorites": return ( - pathname === "/favorites" || (pathname === "/" && hash === "#favorites") + pathname === "/favorites" || + (isHomePathname(pathname) && hash === "#favorites") ); default: return false; @@ -296,7 +298,12 @@ export function PublicLayout() { const na = (which: PublicNavWhich) => navIsActive(pathname, search, hash, which); - const isHome = pathname === "/"; + const isHome = isHomePathname(pathname); + const homePath = homePathForLang(lang); + const changeLang = (nextLang: Lang) => { + setLang(nextLang); + if (isHome) nav(homePathForLang(nextLang), { replace: true }); + }; const footerInContentFlow = pathname === "/browse"; // Current page name shown in the header brand slot (falls back to the brand). const pageTitle = usePageTitle(); @@ -453,7 +460,7 @@ export function PublicLayout() {
{/* Logo → home; page-name text → scroll to top of the current page. */} { if (isHome) { @@ -498,7 +505,7 @@ export function PublicLayout() { { setOpen(false); @@ -541,7 +548,7 @@ export function PublicLayout() {
{/* Logo → home; page-name text → scroll to top of the current page. */} { if (isHome) { @@ -628,7 +635,7 @@ export function PublicLayout() {
@@ -754,10 +761,10 @@ export function PublicLayout() {