Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 36s

This commit is contained in:
TerryM
2026-06-01 15:11:53 +08:00
8 changed files with 144 additions and 18 deletions

View File

@@ -3,7 +3,7 @@ import { I18nProvider } from "./i18n";
import { MotionProvider } from "./motion"; import { MotionProvider } from "./motion";
import { ToastProvider } from "./components/Toast"; import { ToastProvider } from "./components/Toast";
import { PublicLayout } from "./layouts/PublicLayout"; import { PublicLayout } from "./layouts/PublicLayout";
import { Home } from "./pages/Home"; import { LocalizedHomePage } from "./pages/LocalizedHome";
import { Browse } from "./pages/Browse"; import { Browse } from "./pages/Browse";
import { CategoriesPage } from "./pages/Categories"; import { CategoriesPage } from "./pages/Categories";
import { CategoryPage } from "./pages/Category"; import { CategoryPage } from "./pages/Category";
@@ -18,6 +18,7 @@ import { AdminRouteTree } from "./adminRouteTree";
import { AdminRouterModeProvider } from "./adminRouterMode"; import { AdminRouterModeProvider } from "./adminRouterMode";
import { ImageLightboxProvider } from "./components/messageStream/overlays/ImageLightbox"; import { ImageLightboxProvider } from "./components/messageStream/overlays/ImageLightbox";
import { VideoPlayerProvider } from "./components/messageStream/overlays/VideoPlayer"; import { VideoPlayerProvider } from "./components/messageStream/overlays/VideoPlayer";
import { localizedHomeRoutes } from "./languageRoutes";
const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true"; const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true";
@@ -34,7 +35,19 @@ export default function App() {
<ScrollToTop /> <ScrollToTop />
<Routes> <Routes>
<Route element={<PublicLayout />}> <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="/browse" element={<Browse />} />
<Route <Route
path="/categories" path="/categories"

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { useI18n, type Lang } from "../i18n"; import { useI18n, type Lang } from "../i18n";
import { isHomePathname } from "../languageRoutes";
type PageMeta = { type PageMeta = {
title: string; title: string;
@@ -83,7 +84,7 @@ function routeMeta(
const q = params.get("q")?.trim(); const q = params.get("q")?.trim();
const sort = params.get("sort"); const sort = params.get("sort");
if (pathname === "/") { if (isHomePathname(pathname)) {
return { return {
title: t("heroTitle"), title: t("heroTitle"),
description: metaDescription(lang, "home"), description: metaDescription(lang, "home"),
@@ -146,7 +147,9 @@ export function DocumentMeta() {
useEffect(() => { useEffect(() => {
const brand = t("brand"); 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}`; const canonical = `${window.location.origin}${pathname}${search}`;
document.documentElement.lang = lang; document.documentElement.lang = lang;

View File

@@ -5,6 +5,7 @@ import React, {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { languageForHomePathname } from "./languageRoutes";
export type Lang = "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms"; 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 }) { export function I18nProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Lang>(() => { 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); const s = localStorage.getItem(LANG_KEY);
if (s === "zh" || s === "zh-TW") return "zh-CN"; if (s === "zh" || s === "zh-TW") return "zh-CN";
if ( if (
@@ -386,10 +391,10 @@ export function I18nProvider({ children }: { children: React.ReactNode }) {
return s; return s;
return "en"; return "en";
}); });
const setLang = (l: Lang) => { const setLang = useCallback((l: Lang) => {
localStorage.setItem(LANG_KEY, l); localStorage.setItem(LANG_KEY, l);
setLangState(l); setLangState(l);
}; }, []);
const t = useCallback( const t = useCallback(
(k: string) => dict[lang][k] || dict.en[k] || k, (k: string) => dict[lang][k] || dict.en[k] || k,
[lang], [lang],

View 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
View 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 ?? "/";
}

View File

@@ -11,6 +11,7 @@ import { DocumentMeta } from "../components/DocumentMeta";
import { SearchPanel } from "../components/SearchPanel"; import { SearchPanel } from "../components/SearchPanel";
import { useI18n, type Lang } from "../i18n"; import { useI18n, type Lang } from "../i18n";
import { LANG_OPTIONS } from "../i18nLanguages"; import { LANG_OPTIONS } from "../i18nLanguages";
import { homePathForLang, isHomePathname } from "../languageRoutes";
type PublicNavWhich = type PublicNavWhich =
| "home" | "home"
@@ -30,13 +31,13 @@ function navIsActive(
const sp = new URLSearchParams(search); const sp = new URLSearchParams(search);
switch (which) { switch (which) {
case "home": case "home":
return pathname === "/"; return isHomePathname(pathname);
case "browseAll": case "browseAll":
return pathname === "/browse" && !sp.has("sort"); return pathname === "/browse" && !sp.has("sort");
case "categories": case "categories":
return ( return (
pathname === "/categories" || pathname === "/categories" ||
(pathname === "/" && hash === "#categories") (isHomePathname(pathname) && hash === "#categories")
); );
case "browseLatest": case "browseLatest":
return pathname === "/browse" && sp.get("sort") === "latest"; return pathname === "/browse" && sp.get("sort") === "latest";
@@ -46,7 +47,8 @@ function navIsActive(
return pathname === "/browse" && sp.get("sort") === "popular"; return pathname === "/browse" && sp.get("sort") === "popular";
case "favorites": case "favorites":
return ( return (
pathname === "/favorites" || (pathname === "/" && hash === "#favorites") pathname === "/favorites" ||
(isHomePathname(pathname) && hash === "#favorites")
); );
default: default:
return false; return false;
@@ -296,7 +298,12 @@ export function PublicLayout() {
const na = (which: PublicNavWhich) => const na = (which: PublicNavWhich) =>
navIsActive(pathname, search, hash, which); 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"; const footerInContentFlow = pathname === "/browse";
// Current page name shown in the header brand slot (falls back to the brand). // Current page name shown in the header brand slot (falls back to the brand).
const pageTitle = usePageTitle(); 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"> <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. */} {/* Logo → home; page-name text → scroll to top of the current page. */}
<Link <Link
to="/" to={homePath}
aria-label={t("brand")} aria-label={t("brand")}
onClick={(e) => { onClick={(e) => {
if (isHome) { if (isHome) {
@@ -498,7 +505,7 @@ export function PublicLayout() {
</button> </button>
<MobileLanguageButton <MobileLanguageButton
lang={lang} lang={lang}
setLang={setLang} setLang={changeLang}
ariaLabel={t("langLabel")} ariaLabel={t("langLabel")}
onOpen={() => { onOpen={() => {
setOpen(false); 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"> <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. */} {/* Logo → home; page-name text → scroll to top of the current page. */}
<Link <Link
to="/" to={homePath}
aria-label={t("brand")} aria-label={t("brand")}
onClick={(e) => { onClick={(e) => {
if (isHome) { if (isHome) {
@@ -628,7 +635,7 @@ export function PublicLayout() {
</div> </div>
<LanguageDropdown <LanguageDropdown
lang={lang} lang={lang}
setLang={setLang} setLang={changeLang}
ariaLabel={t("langLabel")} ariaLabel={t("langLabel")}
className="hidden h-10 w-36 md:block lg:w-40" 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"> <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]"> <div className="grid h-[68px] grid-cols-4 gap-3 px-5 py-[10px] text-center text-[11px] leading-[17.6px]">
<BottomNavIcon <BottomNavIcon
to="/" to={homePath}
label={t("home")} label={t("home")}
icon="home" icon="home"
active={pathname === "/"} active={isHome}
/> />
<BottomNavIcon <BottomNavIcon
to="/browse" to="/browse"

View File

@@ -1,11 +1,12 @@
import { Heart } from "lucide-react"; import { Heart } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { homePathForLang } from "../../languageRoutes";
import { Reveal } from "../../motion"; import { Reveal } from "../../motion";
import { useSetPageTitle } from "../../components/PageTitleContext"; import { useSetPageTitle } from "../../components/PageTitleContext";
export default function Favorites() { export default function Favorites() {
const { t } = useI18n(); const { lang, t } = useI18n();
// Show "我的收藏" in the global header, consistent with the other pages. // Show "我的收藏" in the global header, consistent with the other pages.
useSetPageTitle(t("favorites")); useSetPageTitle(t("favorites"));
@@ -32,7 +33,7 @@ export default function Favorites() {
</p> </p>
<Link <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" 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")} {t("backToHome")}

View 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 />;
}