feat: support mobile video previews

This commit is contained in:
TerryM
2026-06-01 16:35:40 +08:00
parent c53032155b
commit a968f47640
16 changed files with 275 additions and 47 deletions

View File

@@ -35,19 +35,11 @@ export default function App() {
<ScrollToTop />
<Routes>
<Route element={<PublicLayout />}>
{/* English (root, no prefix) */}
<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"
@@ -67,6 +59,37 @@ export default function App() {
element={<PostRedirect />}
/>
<Route path="/favorites" element={<Favorites />} />
{/* Each non-English language gets its own nested tree. */}
{localizedHomeRoutes.map((route) => (
<Route key={route.path} path={route.path}>
<Route
index
element={
<LocalizedHomePage targetLang={route.lang} />
}
/>
<Route path="browse" element={<Browse />} />
<Route
path="categories"
element={<CategoriesPage />}
/>
<Route
path="official-recommendations"
element={<OfficialRecommendationsPage />}
/>
<Route
path="category/:slug"
element={<CategoryPage />}
/>
<Route path="search" element={<SearchPage />} />
<Route
path="resource/:id"
element={<PostRedirect />}
/>
<Route path="favorites" element={<Favorites />} />
</Route>
))}
</Route>
{adminEnabled ? (

View File

@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
import type { Resource } from "../api";
import { CategoryIcon } from "./CategoryIcon";
import { useI18n } from "../i18n";
import { useLocalizedPath } from "../useLocalizedPath";
import { resourceTypeLabel } from "../resourceTypeLabels";
import { formatDateYmd } from "../utils/format";
@@ -16,10 +17,11 @@ export function LatestUpdateRow({
iconKey: string;
}) {
const { t } = useI18n();
const lp = useLocalizedPath();
const dateStr = formatDateYmd(r.updatedAt);
return (
<Link to={`/resource/${r.id}`} className={LATEST_CARD_CLASS}>
<Link to={lp(`/resource/${r.id}`)} className={LATEST_CARD_CLASS}>
<div className="flex shrink-0 items-center justify-center pt-0.5">
<CategoryIcon
iconKey={iconKey}

View File

@@ -15,6 +15,7 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { assetUrl, type Category } from "../api";
import { useI18n } from "../i18n";
import { useLocalizedPath } from "../useLocalizedPath";
import { resourceTypeLabel } from "../resourceTypeLabels";
import { cleanCategoryDisplayName } from "../utils/categoryDisplay";
import { formatDateYmd } from "../utils/format";
@@ -91,6 +92,7 @@ function PopularRankRow({
}) {
const { t, lang } = useI18n();
const navigate = useNavigate();
const lp = useLocalizedPath();
const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false);
const [coverFailed, setCoverFailed] = useState(false);
@@ -120,7 +122,9 @@ function PopularRankRow({
<button
type="button"
onClick={() =>
navigate(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`)
navigate(
lp(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`),
)
}
aria-label={r.title}
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"

View File

@@ -4,6 +4,7 @@ import { Link } from "react-router-dom";
import type { Resource } from "../api";
import { assetUrl } from "../api";
import { useI18n } from "../i18n";
import { useLocalizedPath } from "../useLocalizedPath";
import { useMemo, useState } from "react";
import { formatDateYmd } from "../utils/format";
import { DownloadCloudIcon } from "./icons/DownloadCloudIcon";
@@ -49,6 +50,7 @@ export function RecommendedCard({
layout?: "carousel" | "grid";
}) {
const { t } = useI18n();
const lp = useLocalizedPath();
const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false);
const figmaCover =
@@ -105,7 +107,7 @@ export function RecommendedCard({
}`}
>
<Link
to={`/resource/${r.id}`}
to={lp(`/resource/${r.id}`)}
aria-label={displayTitle}
className="absolute inset-0 z-10 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
/>

View File

@@ -10,6 +10,7 @@ import {
import { Link } from "react-router-dom";
import { getJSON, itemsOrEmpty, readJSONCache } from "../api";
import { langQuery, type Lang } from "../i18n";
import { useLocalizedPath } from "../useLocalizedPath";
import type { Post, PostListResponse } from "../types/post";
import { MessageBubble } from "./messageStream/MessageBubble";
import { postDisplayText, postTitleText } from "./messageStream/utils/postText";
@@ -126,6 +127,7 @@ export function SearchPanel({
onResultClick,
}: SearchPanelProps) {
const inputRef = useRef<HTMLInputElement>(null);
const lp = useLocalizedPath();
const [tags, setTags] = useState<TagItem[]>([]);
const [selectedTag, setSelectedTag] = useState("");
const [tagPosts, setTagPosts] = useState<Post[]>([]);
@@ -334,7 +336,7 @@ export function SearchPanel({
return (
<Link
key={post.id}
to={`/browse?post=${encodeURIComponent(post.id)}`}
to={lp(`/browse?post=${encodeURIComponent(post.id)}`)}
onClick={onResultClick}
className="block rounded-2xl border border-white/10 bg-[#191921] px-4 py-3 transition hover:border-ark-gold/60 hover:bg-[#22232D] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
>

View File

@@ -9,6 +9,7 @@ import {
} from "react";
import type { Attachment } from "../../types/post";
import { AttachmentDownloadPill } from "./AttachmentDownloadPill";
import { useVideoPreviewSource } from "./hooks/useVideoPreviewSource";
import { useVideoPlayer } from "./overlays/VideoPlayer";
function pad2(n: number): string {
@@ -127,6 +128,7 @@ export function MessageInlineVideo({
const [snapProgress, setSnapProgress] = useState(false);
const t = TOKENS[size];
const videoSrc = useVideoPreviewSource(attachment);
useEffect(() => {
const v = videoRef.current;
@@ -270,7 +272,7 @@ export function MessageInlineVideo({
<>
<video
ref={videoRef}
src={attachment.url}
src={videoSrc}
poster={attachment.posterUrl}
playsInline
autoPlay={autoPlay}

View File

@@ -6,12 +6,20 @@ import { useI18n } from "../../../i18n";
import type { Attachment, Post } from "../../../types/post";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { MessageInlineVideo } from "../MessageInlineVideo";
import {
useShouldUseMobilePreview,
useVideoPreviewSource,
} from "../hooks/useVideoPreviewSource";
import { useVideoPlayer } from "../overlays/VideoPlayer";
import { autolink } from "../utils/autolink";
import { CollapsibleText } from "../CollapsibleText";
import { downloadAttachment } from "../utils/downloadFile";
import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
import {
videoMetadataPreviewSource,
videoPreviewSource,
} from "../utils/videoPreviewSource";
import { useToast } from "../../Toast";
const MAX_VISIBLE = 4;
@@ -59,9 +67,8 @@ function VideoAttachmentCard({
const [playing, setPlaying] = useState(false);
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
const duration = formatDuration(attachment.durationSec);
const previewVideoUrl = attachment.url.includes("#")
? attachment.url
: `${attachment.url}#t=0.1`;
const videoSrc = useVideoPreviewSource(attachment);
const previewVideoUrl = videoMetadataPreviewSource(videoSrc);
return (
<div
@@ -208,6 +215,8 @@ function VideoListDialog({
onClose: () => void;
onPick: (attachment: Attachment) => void;
}) {
const useMobilePreview = useShouldUseMobilePreview();
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
@@ -242,9 +251,9 @@ function VideoListDialog({
<div className="max-h-[70vh] overflow-y-auto p-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{videos.map((video, index) => {
const thumb = video.posterUrl ?? video.thumbnailUrl;
const previewVideoUrl = video.url.includes("#")
? video.url
: `${video.url}#t=0.1`;
const previewVideoUrl = videoMetadataPreviewSource(
videoPreviewSource(video, useMobilePreview),
);
const duration = formatDuration(video.durationSec);
return (
<div

View File

@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import type { Attachment } from "../../../types/post";
import {
mobilePreviewMediaQuery,
videoPreviewSource,
} from "../utils/videoPreviewSource";
function getMatchesMobilePreview(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return false;
return window.matchMedia(mobilePreviewMediaQuery).matches;
}
export function useShouldUseMobilePreview(): boolean {
const [useMobilePreview, setUseMobilePreview] = useState(
getMatchesMobilePreview,
);
useEffect(() => {
if (typeof window === "undefined" || !window.matchMedia) return;
const media = window.matchMedia(mobilePreviewMediaQuery);
const update = () => setUseMobilePreview(media.matches);
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
return useMobilePreview;
}
export function useVideoPreviewSource(attachment: Attachment): string {
return videoPreviewSource(attachment, useShouldUseMobilePreview());
}

View File

@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import type { Attachment } from "../../../types/post";
import {
videoMetadataPreviewSource,
videoPreviewSource,
} from "./videoPreviewSource";
const attachment: Attachment = {
id: "att-1",
kind: "video",
url: "/uploads/desktop-preview.mp4",
mobilePreviewUrl: "/uploads/mobile-540p-preview.mp4",
mime: "video/mp4",
filename: "original.mp4",
sizeBytes: 1024,
};
describe("videoPreviewSource", () => {
it("uses the desktop preview by default", () => {
expect(videoPreviewSource(attachment, false)).toBe(
"/uploads/desktop-preview.mp4",
);
});
it("uses mobilePreviewUrl only when mobile preview is active", () => {
expect(videoPreviewSource(attachment, true)).toBe(
"/uploads/mobile-540p-preview.mp4",
);
});
it("falls back to the desktop preview when mobilePreviewUrl is absent", () => {
expect(
videoPreviewSource({ ...attachment, mobilePreviewUrl: undefined }, true),
).toBe("/uploads/desktop-preview.mp4");
});
it("adds a metadata seek fragment only when the URL has no fragment", () => {
expect(videoMetadataPreviewSource("/uploads/video.mp4")).toBe(
"/uploads/video.mp4#t=0.1",
);
expect(videoMetadataPreviewSource("/uploads/video.mp4#t=2")).toBe(
"/uploads/video.mp4#t=2",
);
});
});

View File

@@ -0,0 +1,17 @@
import type { Attachment } from "../../../types/post";
export const mobilePreviewMediaQuery = "(max-width: 760px)";
export function videoPreviewSource(
attachment: Attachment,
useMobilePreview: boolean,
): string {
if (useMobilePreview && attachment.mobilePreviewUrl) {
return attachment.mobilePreviewUrl;
}
return attachment.url;
}
export function videoMetadataPreviewSource(url: string): string {
return url.includes("#") ? url : `${url}#t=0.1`;
}

View File

@@ -33,3 +33,55 @@ export function homePathForLang(lang: Lang): string {
if (lang === "en") return "/";
return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? "/";
}
/** Returns the URL prefix for a language (e.g. "/malay"), or "" for English. */
export function langPathPrefix(lang: Lang): string {
if (lang === "en") return "";
return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? "";
}
/** Detects which language a URL belongs to by inspecting the path prefix. */
export function languageFromPathname(pathname: string): Lang {
const normalized = normalizePathname(pathname);
for (const route of localizedHomeRoutes) {
if (normalized === route.path || normalized.startsWith(route.path + "/")) {
return route.lang;
}
}
return "en";
}
/**
* Prepends a language prefix to a path. Path may include `?query` or `#hash`;
* the prefix is inserted before the pathname only.
*
* localizePath("/browse", "ms") -> "/malay/browse"
* localizePath("/", "ms") -> "/malay"
* localizePath("/browse", "en") -> "/browse"
*/
export function localizePath(path: string, lang: Lang): string {
const prefix = langPathPrefix(lang);
if (!prefix) return path;
if (!path.startsWith("/")) path = "/" + path;
if (path === "/") return prefix;
return prefix + path;
}
/**
* Removes any known language prefix from a pathname. Useful when comparing
* the current route against canonical (unprefixed) paths.
*
* stripLangPrefix("/malay/browse") -> "/browse"
* stripLangPrefix("/malay") -> "/"
* stripLangPrefix("/browse") -> "/browse"
*/
export function stripLangPrefix(pathname: string): string {
const normalized = normalizePathname(pathname);
for (const route of localizedHomeRoutes) {
if (normalized === route.path) return "/";
if (normalized.startsWith(route.path + "/")) {
return normalized.slice(route.path.length);
}
}
return normalized;
}

View File

@@ -11,7 +11,13 @@ 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";
import {
homePathForLang,
isHomePathname,
languageFromPathname,
stripLangPrefix,
} from "../languageRoutes";
import { useLocalizedPath } from "../useLocalizedPath";
type PublicNavWhich =
| "home"
@@ -29,25 +35,26 @@ function navIsActive(
which: PublicNavWhich,
): boolean {
const sp = new URLSearchParams(search);
const stripped = stripLangPrefix(pathname);
switch (which) {
case "home":
return isHomePathname(pathname);
case "browseAll":
return pathname === "/browse" && !sp.has("sort");
return stripped === "/browse" && !sp.has("sort");
case "categories":
return (
pathname === "/categories" ||
stripped === "/categories" ||
(isHomePathname(pathname) && hash === "#categories")
);
case "browseLatest":
return pathname === "/browse" && sp.get("sort") === "latest";
return stripped === "/browse" && sp.get("sort") === "latest";
case "browseRecommended":
return pathname === "/official-recommendations";
return stripped === "/official-recommendations";
case "browsePopular":
return pathname === "/browse" && sp.get("sort") === "popular";
return stripped === "/browse" && sp.get("sort") === "popular";
case "favorites":
return (
pathname === "/favorites" ||
stripped === "/favorites" ||
(isHomePathname(pathname) && hash === "#favorites")
);
default:
@@ -295,6 +302,15 @@ export function PublicLayout() {
const desktopSearchRef = useRef<HTMLDivElement>(null);
const desktopSearchPanelRef = useRef<HTMLDivElement>(null);
const nav = useNavigate();
const lp = useLocalizedPath();
// Keep i18n state in sync with URL so deep links (`/malay/browse`) flip the
// UI language even if the user navigated via address bar or shared link.
useEffect(() => {
const urlLang = languageFromPathname(pathname);
if (urlLang !== lang) setLang(urlLang);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
const na = (which: PublicNavWhich) =>
navIsActive(pathname, search, hash, which);
@@ -304,7 +320,7 @@ export function PublicLayout() {
setLang(nextLang);
if (isHome) nav(homePathForLang(nextLang), { replace: true });
};
const footerInContentFlow = pathname === "/browse";
const footerInContentFlow = stripLangPrefix(pathname) === "/browse";
// Current page name shown in the header brand slot (falls back to the brand).
const pageTitle = usePageTitle();
@@ -344,12 +360,12 @@ export function PublicLayout() {
if (idleId) ric.cancelIdleCallback?.(idleId);
};
}, [lang]);
const popularHref = "/browse?sort=popular";
const popularHref = lp("/browse?sort=popular");
const goSearch = () => {
const s = q.trim();
if (!s) return;
nav(`/browse?q=${encodeURIComponent(s)}`);
nav(lp(`/browse?q=${encodeURIComponent(s)}`));
setOpen(false);
setMobileSearchOpen(false);
setDesktopSearchOpen(false);
@@ -574,35 +590,35 @@ export function PublicLayout() {
aria-label={t("mainNav")}
>
<Link
to="/browse"
to={lp("/browse")}
className={navClassName(na("browseAll"))}
aria-current={na("browseAll") ? "page" : undefined}
>
{t("all")}
</Link>
<Link
to="/categories"
to={lp("/categories")}
className={navClassName(na("categories"))}
aria-current={na("categories") ? "page" : undefined}
>
{t("categories")}
</Link>
<Link
to="/official-recommendations"
to={lp("/official-recommendations")}
className={navClassName(na("browseRecommended"))}
aria-current={na("browseRecommended") ? "page" : undefined}
>
{t("official")}
</Link>
<Link
to="/browse?sort=latest"
to={lp("/browse?sort=latest")}
className={navClassName(na("browseLatest"))}
aria-current={na("browseLatest") ? "page" : undefined}
>
{t("latest")}
</Link>
<Link
to="/favorites"
to={lp("/favorites")}
className={navClassName(na("favorites"))}
aria-current={na("favorites") ? "page" : undefined}
>
@@ -661,7 +677,7 @@ export function PublicLayout() {
className={`${headerMenuAnimationClass} fixed inset-x-0 top-[64px] z-50 grid gap-2 bg-[#08070c] px-4 py-3 shadow-2xl shadow-black/50 min-[440px]:px-5 sm:px-6 md:top-[70px] md:px-9 min-[1000px]:hidden`}
>
<Link
to="/browse"
to={lp("/browse")}
className={mobileMenuNavClassName(na("browseAll"))}
aria-current={na("browseAll") ? "page" : undefined}
onClick={() => setOpen(false)}
@@ -669,7 +685,7 @@ export function PublicLayout() {
{t("all")}
</Link>
<Link
to="/categories"
to={lp("/categories")}
className={mobileMenuNavClassName(na("categories"))}
aria-current={na("categories") ? "page" : undefined}
onClick={() => setOpen(false)}
@@ -677,7 +693,7 @@ export function PublicLayout() {
{t("categories")}
</Link>
<Link
to="/official-recommendations"
to={lp("/official-recommendations")}
className={mobileMenuNavClassName(na("browseRecommended"))}
aria-current={na("browseRecommended") ? "page" : undefined}
onClick={() => setOpen(false)}
@@ -685,7 +701,7 @@ export function PublicLayout() {
{t("official")}
</Link>
<Link
to="/browse?sort=latest"
to={lp("/browse?sort=latest")}
className={mobileMenuNavClassName(na("browseLatest"))}
aria-current={na("browseLatest") ? "page" : undefined}
onClick={() => setOpen(false)}
@@ -693,7 +709,7 @@ export function PublicLayout() {
{t("latest")}
</Link>
<Link
to="/favorites"
to={lp("/favorites")}
className={mobileMenuNavClassName(na("favorites"))}
aria-current={na("favorites") ? "page" : undefined}
onClick={() => setOpen(false)}
@@ -767,15 +783,16 @@ export function PublicLayout() {
active={isHome}
/>
<BottomNavIcon
to="/browse"
to={lp("/browse")}
label={t("all")}
icon="document"
active={
pathname === "/browse" && !new URLSearchParams(search).get("sort")
stripLangPrefix(pathname) === "/browse" &&
!new URLSearchParams(search).get("sort")
}
/>
<BottomNavIcon
to="/favorites"
to={lp("/favorites")}
label={t("favorites")}
icon="bookmark"
active={na("favorites")}
@@ -789,7 +806,7 @@ export function PublicLayout() {
</div>
</nav>
{pathname === "/browse" ? <BackToTop /> : null}
{stripLangPrefix(pathname) === "/browse" ? <BackToTop /> : null}
</div>
);
}

View File

@@ -5,6 +5,7 @@ import { CategoryIcon } from "../../components/CategoryIcon";
import { useSetPageTitle } from "../../components/PageTitleContext";
import { Skeleton } from "../../components/Skeleton";
import { langQuery, useI18n } from "../../i18n";
import { useLocalizedPath } from "../../useLocalizedPath";
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
import { Reveal } from "../../motion";
@@ -32,6 +33,7 @@ function figmaCategoryRank(category: Category): number {
export function CategoriesPage() {
const { t, lang } = useI18n();
const lp = useLocalizedPath();
useSetPageTitle(t("categories"));
const [cats, setCats] = useState<Category[]>([]);
const [err, setErr] = useState<string | null>(null);
@@ -89,7 +91,7 @@ export function CategoriesPage() {
className="h-[88px]"
>
<Link
to={`/category/${encodeURIComponent(category.slug)}`}
to={lp(`/category/${encodeURIComponent(category.slug)}`)}
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
<CategoryIcon

View File

@@ -9,6 +9,7 @@ import { RecommendedCard } from "../../components/RecommendedCard";
import { SectionHeader } from "../../components/SectionHeader";
import { MessageBubble } from "../../components/messageStream/MessageBubble";
import { langQuery, useI18n } from "../../i18n";
import { useLocalizedPath } from "../../useLocalizedPath";
import { sourceLanguageQuery } from "../../i18nLanguages";
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
import {
@@ -42,6 +43,7 @@ function figmaCategoryRank(category: Category): number {
export function Home() {
const { t, lang } = useI18n();
const lp = useLocalizedPath();
const { hash } = useLocation();
const [cats, setCats] = useState<Category[]>([]);
const [rec, setRec] = useState<PostBackedResource[]>([]);
@@ -298,7 +300,7 @@ export function Home() {
{page.map((c) => (
<Link
key={c.id}
to={`/category/${encodeURIComponent(c.slug)}`}
to={lp(`/category/${encodeURIComponent(c.slug)}`)}
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
<CategoryIcon
@@ -350,7 +352,7 @@ export function Home() {
{figmaOrderedCategories.map((c, index) => (
<Reveal key={c.id} delay={Math.min(index, 8) * 0.05}>
<Link
to={`/category/${encodeURIComponent(c.slug)}`}
to={lp(`/category/${encodeURIComponent(c.slug)}`)}
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
<CategoryIcon

View File

@@ -35,6 +35,8 @@ export type Attachment = {
posterUrl?: string;
thumbUrl?: string;
thumbnailUrl?: string;
/** Optional 540p/mobile-friendly preview video. Downloads still use the original endpoint. */
mobilePreviewUrl?: string;
};
/**

13
src/useLocalizedPath.ts Normal file
View File

@@ -0,0 +1,13 @@
import { useCallback } from "react";
import { useI18n } from "./i18n";
import { localizePath } from "./languageRoutes";
/**
* Returns a stable `(path) => localized path` function bound to the current
* UI language. Use this anywhere a `<Link to>` or `navigate()` target needs to
* preserve the active language prefix (e.g. `/malay/browse`).
*/
export function useLocalizedPath() {
const { lang } = useI18n();
return useCallback((path: string) => localizePath(path, lang), [lang]);
}