feat: add localized home routes
This commit is contained in:
17
src/App.tsx
17
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() {
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={<LocalizedHomePage targetLang="en" />}
|
||||
/>
|
||||
{localizedHomeRoutes.map((route) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={
|
||||
<LocalizedHomePage targetLang={route.lang} />
|
||||
}
|
||||
/>
|
||||
))}
|
||||
<Route path="/browse" element={<Browse />} />
|
||||
<Route
|
||||
path="/categories"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useI18n, type Lang } from "../i18n";
|
||||
import { isHomePathname } from "../languageRoutes";
|
||||
|
||||
type PageMeta = {
|
||||
title: string;
|
||||
@@ -83,7 +84,7 @@ function routeMeta(
|
||||
const q = params.get("q")?.trim();
|
||||
const sort = params.get("sort");
|
||||
|
||||
if (pathname === "/") {
|
||||
if (isHomePathname(pathname)) {
|
||||
return {
|
||||
title: t("heroTitle"),
|
||||
description: metaDescription(lang, "home"),
|
||||
@@ -146,7 +147,9 @@ export function DocumentMeta() {
|
||||
|
||||
useEffect(() => {
|
||||
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;
|
||||
|
||||
@@ -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<Lang>(() => {
|
||||
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],
|
||||
|
||||
49
src/languageRoutes.test.ts
Normal file
49
src/languageRoutes.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
35
src/languageRoutes.ts
Normal file
35
src/languageRoutes.ts
Normal file
@@ -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 ?? "/";
|
||||
}
|
||||
@@ -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() {
|
||||
<div className="flex h-8 min-w-0 shrink items-center gap-2 text-[20px] font-black leading-5 tracking-tight text-ark-gold">
|
||||
{/* Logo → home; page-name text → scroll to top of the current page. */}
|
||||
<Link
|
||||
to="/"
|
||||
to={homePath}
|
||||
aria-label={t("brand")}
|
||||
onClick={(e) => {
|
||||
if (isHome) {
|
||||
@@ -498,7 +505,7 @@ export function PublicLayout() {
|
||||
</button>
|
||||
<MobileLanguageButton
|
||||
lang={lang}
|
||||
setLang={setLang}
|
||||
setLang={changeLang}
|
||||
ariaLabel={t("langLabel")}
|
||||
onOpen={() => {
|
||||
setOpen(false);
|
||||
@@ -541,7 +548,7 @@ export function PublicLayout() {
|
||||
<div className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold">
|
||||
{/* Logo → home; page-name text → scroll to top of the current page. */}
|
||||
<Link
|
||||
to="/"
|
||||
to={homePath}
|
||||
aria-label={t("brand")}
|
||||
onClick={(e) => {
|
||||
if (isHome) {
|
||||
@@ -628,7 +635,7 @@ export function PublicLayout() {
|
||||
</div>
|
||||
<LanguageDropdown
|
||||
lang={lang}
|
||||
setLang={setLang}
|
||||
setLang={changeLang}
|
||||
ariaLabel={t("langLabel")}
|
||||
className="hidden h-10 w-36 md:block lg:w-40"
|
||||
/>
|
||||
@@ -754,10 +761,10 @@ export function PublicLayout() {
|
||||
<nav className="fixed inset-x-0 bottom-0 z-40 select-none bg-[#0C0D0F]/95 pb-[max(env(safe-area-inset-bottom),0px)] backdrop-blur md:hidden">
|
||||
<div className="grid h-[68px] grid-cols-4 gap-3 px-5 py-[10px] text-center text-[11px] leading-[17.6px]">
|
||||
<BottomNavIcon
|
||||
to="/"
|
||||
to={homePath}
|
||||
label={t("home")}
|
||||
icon="home"
|
||||
active={pathname === "/"}
|
||||
active={isHome}
|
||||
/>
|
||||
<BottomNavIcon
|
||||
to="/browse"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Heart } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { homePathForLang } from "../../languageRoutes";
|
||||
import { Reveal } from "../../motion";
|
||||
import { useSetPageTitle } from "../../components/PageTitleContext";
|
||||
|
||||
export default function Favorites() {
|
||||
const { t } = useI18n();
|
||||
const { lang, t } = useI18n();
|
||||
// Show "我的收藏" in the global header, consistent with the other pages.
|
||||
useSetPageTitle(t("favorites"));
|
||||
|
||||
@@ -32,7 +33,7 @@ export default function Favorites() {
|
||||
</p>
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
to={homePathForLang(lang)}
|
||||
className="mt-4 inline-flex h-11 items-center justify-center rounded-full border border-ark-gold/60 bg-ark-gold/10 px-6 text-sm font-medium text-ark-gold transition hover:bg-ark-gold/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
>
|
||||
{t("backToHome")}
|
||||
|
||||
13
src/pages/LocalizedHome/index.tsx
Normal file
13
src/pages/LocalizedHome/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { type Lang, useI18n } from "../../i18n";
|
||||
import { Home } from "../Home";
|
||||
|
||||
export function LocalizedHomePage({ targetLang }: { targetLang: Lang }) {
|
||||
const { setLang } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
setLang(targetLang);
|
||||
}, [setLang, targetLang]);
|
||||
|
||||
return <Home />;
|
||||
}
|
||||
Reference in New Issue
Block a user