feat: support mobile video previews
This commit is contained in:
41
src/App.tsx
41
src/App.tsx
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
34
src/components/messageStream/hooks/useVideoPreviewSource.ts
Normal file
34
src/components/messageStream/hooks/useVideoPreviewSource.ts
Normal 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());
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
17
src/components/messageStream/utils/videoPreviewSource.ts
Normal file
17
src/components/messageStream/utils/videoPreviewSource.ts
Normal 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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
13
src/useLocalizedPath.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user