Merge terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 36s
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 36s
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 { 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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
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 { 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"
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
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