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 />
|
<ScrollToTop />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<PublicLayout />}>
|
<Route element={<PublicLayout />}>
|
||||||
|
{/* English (root, no prefix) */}
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={<LocalizedHomePage targetLang="en" />}
|
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"
|
||||||
@@ -67,6 +59,37 @@ export default function App() {
|
|||||||
element={<PostRedirect />}
|
element={<PostRedirect />}
|
||||||
/>
|
/>
|
||||||
<Route path="/favorites" element={<Favorites />} />
|
<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>
|
</Route>
|
||||||
|
|
||||||
{adminEnabled ? (
|
{adminEnabled ? (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Link } from "react-router-dom";
|
|||||||
import type { Resource } from "../api";
|
import type { Resource } from "../api";
|
||||||
import { CategoryIcon } from "./CategoryIcon";
|
import { CategoryIcon } from "./CategoryIcon";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
|
import { useLocalizedPath } from "../useLocalizedPath";
|
||||||
import { resourceTypeLabel } from "../resourceTypeLabels";
|
import { resourceTypeLabel } from "../resourceTypeLabels";
|
||||||
import { formatDateYmd } from "../utils/format";
|
import { formatDateYmd } from "../utils/format";
|
||||||
|
|
||||||
@@ -16,10 +17,11 @@ export function LatestUpdateRow({
|
|||||||
iconKey: string;
|
iconKey: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const dateStr = formatDateYmd(r.updatedAt);
|
const dateStr = formatDateYmd(r.updatedAt);
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex shrink-0 items-center justify-center pt-0.5">
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
iconKey={iconKey}
|
iconKey={iconKey}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { assetUrl, type Category } from "../api";
|
import { assetUrl, type Category } from "../api";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
|
import { useLocalizedPath } from "../useLocalizedPath";
|
||||||
import { resourceTypeLabel } from "../resourceTypeLabels";
|
import { resourceTypeLabel } from "../resourceTypeLabels";
|
||||||
import { cleanCategoryDisplayName } from "../utils/categoryDisplay";
|
import { cleanCategoryDisplayName } from "../utils/categoryDisplay";
|
||||||
import { formatDateYmd } from "../utils/format";
|
import { formatDateYmd } from "../utils/format";
|
||||||
@@ -91,6 +92,7 @@ function PopularRankRow({
|
|||||||
}) {
|
}) {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [coverFailed, setCoverFailed] = useState(false);
|
const [coverFailed, setCoverFailed] = useState(false);
|
||||||
@@ -120,7 +122,9 @@ function PopularRankRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`)
|
navigate(
|
||||||
|
lp(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
aria-label={r.title}
|
aria-label={r.title}
|
||||||
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
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 type { Resource } from "../api";
|
||||||
import { assetUrl } from "../api";
|
import { assetUrl } from "../api";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
|
import { useLocalizedPath } from "../useLocalizedPath";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { formatDateYmd } from "../utils/format";
|
import { formatDateYmd } from "../utils/format";
|
||||||
import { DownloadCloudIcon } from "./icons/DownloadCloudIcon";
|
import { DownloadCloudIcon } from "./icons/DownloadCloudIcon";
|
||||||
@@ -49,6 +50,7 @@ export function RecommendedCard({
|
|||||||
layout?: "carousel" | "grid";
|
layout?: "carousel" | "grid";
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const figmaCover =
|
const figmaCover =
|
||||||
@@ -105,7 +107,7 @@ export function RecommendedCard({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to={`/resource/${r.id}`}
|
to={lp(`/resource/${r.id}`)}
|
||||||
aria-label={displayTitle}
|
aria-label={displayTitle}
|
||||||
className="absolute inset-0 z-10 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
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 { Link } from "react-router-dom";
|
||||||
import { getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
import { getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
||||||
import { langQuery, type Lang } from "../i18n";
|
import { langQuery, type Lang } from "../i18n";
|
||||||
|
import { useLocalizedPath } from "../useLocalizedPath";
|
||||||
import type { Post, PostListResponse } from "../types/post";
|
import type { Post, PostListResponse } from "../types/post";
|
||||||
import { MessageBubble } from "./messageStream/MessageBubble";
|
import { MessageBubble } from "./messageStream/MessageBubble";
|
||||||
import { postDisplayText, postTitleText } from "./messageStream/utils/postText";
|
import { postDisplayText, postTitleText } from "./messageStream/utils/postText";
|
||||||
@@ -126,6 +127,7 @@ export function SearchPanel({
|
|||||||
onResultClick,
|
onResultClick,
|
||||||
}: SearchPanelProps) {
|
}: SearchPanelProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const [tags, setTags] = useState<TagItem[]>([]);
|
const [tags, setTags] = useState<TagItem[]>([]);
|
||||||
const [selectedTag, setSelectedTag] = useState("");
|
const [selectedTag, setSelectedTag] = useState("");
|
||||||
const [tagPosts, setTagPosts] = useState<Post[]>([]);
|
const [tagPosts, setTagPosts] = useState<Post[]>([]);
|
||||||
@@ -334,7 +336,7 @@ export function SearchPanel({
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={post.id}
|
key={post.id}
|
||||||
to={`/browse?post=${encodeURIComponent(post.id)}`}
|
to={lp(`/browse?post=${encodeURIComponent(post.id)}`)}
|
||||||
onClick={onResultClick}
|
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"
|
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";
|
} from "react";
|
||||||
import type { Attachment } from "../../types/post";
|
import type { Attachment } from "../../types/post";
|
||||||
import { AttachmentDownloadPill } from "./AttachmentDownloadPill";
|
import { AttachmentDownloadPill } from "./AttachmentDownloadPill";
|
||||||
|
import { useVideoPreviewSource } from "./hooks/useVideoPreviewSource";
|
||||||
import { useVideoPlayer } from "./overlays/VideoPlayer";
|
import { useVideoPlayer } from "./overlays/VideoPlayer";
|
||||||
|
|
||||||
function pad2(n: number): string {
|
function pad2(n: number): string {
|
||||||
@@ -127,6 +128,7 @@ export function MessageInlineVideo({
|
|||||||
const [snapProgress, setSnapProgress] = useState(false);
|
const [snapProgress, setSnapProgress] = useState(false);
|
||||||
|
|
||||||
const t = TOKENS[size];
|
const t = TOKENS[size];
|
||||||
|
const videoSrc = useVideoPreviewSource(attachment);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
@@ -270,7 +272,7 @@ export function MessageInlineVideo({
|
|||||||
<>
|
<>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={attachment.url}
|
src={videoSrc}
|
||||||
poster={attachment.posterUrl}
|
poster={attachment.posterUrl}
|
||||||
playsInline
|
playsInline
|
||||||
autoPlay={autoPlay}
|
autoPlay={autoPlay}
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import { useI18n } from "../../../i18n";
|
|||||||
import type { Attachment, Post } from "../../../types/post";
|
import type { Attachment, Post } from "../../../types/post";
|
||||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
import { MessageInlineVideo } from "../MessageInlineVideo";
|
import { MessageInlineVideo } from "../MessageInlineVideo";
|
||||||
|
import {
|
||||||
|
useShouldUseMobilePreview,
|
||||||
|
useVideoPreviewSource,
|
||||||
|
} from "../hooks/useVideoPreviewSource";
|
||||||
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
import { useVideoPlayer } from "../overlays/VideoPlayer";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
import { CollapsibleText } from "../CollapsibleText";
|
import { CollapsibleText } from "../CollapsibleText";
|
||||||
import { downloadAttachment } from "../utils/downloadFile";
|
import { downloadAttachment } from "../utils/downloadFile";
|
||||||
import { formatBytes } from "../utils/formatBytes";
|
import { formatBytes } from "../utils/formatBytes";
|
||||||
import { postDisplayText } from "../utils/postText";
|
import { postDisplayText } from "../utils/postText";
|
||||||
|
import {
|
||||||
|
videoMetadataPreviewSource,
|
||||||
|
videoPreviewSource,
|
||||||
|
} from "../utils/videoPreviewSource";
|
||||||
import { useToast } from "../../Toast";
|
import { useToast } from "../../Toast";
|
||||||
|
|
||||||
const MAX_VISIBLE = 4;
|
const MAX_VISIBLE = 4;
|
||||||
@@ -59,9 +67,8 @@ function VideoAttachmentCard({
|
|||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
|
const posterUrl = attachment.posterUrl ?? attachment.thumbnailUrl;
|
||||||
const duration = formatDuration(attachment.durationSec);
|
const duration = formatDuration(attachment.durationSec);
|
||||||
const previewVideoUrl = attachment.url.includes("#")
|
const videoSrc = useVideoPreviewSource(attachment);
|
||||||
? attachment.url
|
const previewVideoUrl = videoMetadataPreviewSource(videoSrc);
|
||||||
: `${attachment.url}#t=0.1`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -208,6 +215,8 @@ function VideoListDialog({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPick: (attachment: Attachment) => void;
|
onPick: (attachment: Attachment) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const useMobilePreview = useShouldUseMobilePreview();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (event: KeyboardEvent) => {
|
const onKey = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") onClose();
|
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">
|
<div className="max-h-[70vh] overflow-y-auto p-2 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{videos.map((video, index) => {
|
{videos.map((video, index) => {
|
||||||
const thumb = video.posterUrl ?? video.thumbnailUrl;
|
const thumb = video.posterUrl ?? video.thumbnailUrl;
|
||||||
const previewVideoUrl = video.url.includes("#")
|
const previewVideoUrl = videoMetadataPreviewSource(
|
||||||
? video.url
|
videoPreviewSource(video, useMobilePreview),
|
||||||
: `${video.url}#t=0.1`;
|
);
|
||||||
const duration = formatDuration(video.durationSec);
|
const duration = formatDuration(video.durationSec);
|
||||||
return (
|
return (
|
||||||
<div
|
<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 "/";
|
if (lang === "en") return "/";
|
||||||
return localizedHomeRoutes.find((route) => route.lang === lang)?.path ?? "/";
|
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 { 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";
|
import {
|
||||||
|
homePathForLang,
|
||||||
|
isHomePathname,
|
||||||
|
languageFromPathname,
|
||||||
|
stripLangPrefix,
|
||||||
|
} from "../languageRoutes";
|
||||||
|
import { useLocalizedPath } from "../useLocalizedPath";
|
||||||
|
|
||||||
type PublicNavWhich =
|
type PublicNavWhich =
|
||||||
| "home"
|
| "home"
|
||||||
@@ -29,25 +35,26 @@ function navIsActive(
|
|||||||
which: PublicNavWhich,
|
which: PublicNavWhich,
|
||||||
): boolean {
|
): boolean {
|
||||||
const sp = new URLSearchParams(search);
|
const sp = new URLSearchParams(search);
|
||||||
|
const stripped = stripLangPrefix(pathname);
|
||||||
switch (which) {
|
switch (which) {
|
||||||
case "home":
|
case "home":
|
||||||
return isHomePathname(pathname);
|
return isHomePathname(pathname);
|
||||||
case "browseAll":
|
case "browseAll":
|
||||||
return pathname === "/browse" && !sp.has("sort");
|
return stripped === "/browse" && !sp.has("sort");
|
||||||
case "categories":
|
case "categories":
|
||||||
return (
|
return (
|
||||||
pathname === "/categories" ||
|
stripped === "/categories" ||
|
||||||
(isHomePathname(pathname) && hash === "#categories")
|
(isHomePathname(pathname) && hash === "#categories")
|
||||||
);
|
);
|
||||||
case "browseLatest":
|
case "browseLatest":
|
||||||
return pathname === "/browse" && sp.get("sort") === "latest";
|
return stripped === "/browse" && sp.get("sort") === "latest";
|
||||||
case "browseRecommended":
|
case "browseRecommended":
|
||||||
return pathname === "/official-recommendations";
|
return stripped === "/official-recommendations";
|
||||||
case "browsePopular":
|
case "browsePopular":
|
||||||
return pathname === "/browse" && sp.get("sort") === "popular";
|
return stripped === "/browse" && sp.get("sort") === "popular";
|
||||||
case "favorites":
|
case "favorites":
|
||||||
return (
|
return (
|
||||||
pathname === "/favorites" ||
|
stripped === "/favorites" ||
|
||||||
(isHomePathname(pathname) && hash === "#favorites")
|
(isHomePathname(pathname) && hash === "#favorites")
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
@@ -295,6 +302,15 @@ export function PublicLayout() {
|
|||||||
const desktopSearchRef = useRef<HTMLDivElement>(null);
|
const desktopSearchRef = useRef<HTMLDivElement>(null);
|
||||||
const desktopSearchPanelRef = useRef<HTMLDivElement>(null);
|
const desktopSearchPanelRef = useRef<HTMLDivElement>(null);
|
||||||
const nav = useNavigate();
|
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) =>
|
const na = (which: PublicNavWhich) =>
|
||||||
navIsActive(pathname, search, hash, which);
|
navIsActive(pathname, search, hash, which);
|
||||||
@@ -304,7 +320,7 @@ export function PublicLayout() {
|
|||||||
setLang(nextLang);
|
setLang(nextLang);
|
||||||
if (isHome) nav(homePathForLang(nextLang), { replace: true });
|
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).
|
// Current page name shown in the header brand slot (falls back to the brand).
|
||||||
const pageTitle = usePageTitle();
|
const pageTitle = usePageTitle();
|
||||||
|
|
||||||
@@ -344,12 +360,12 @@ export function PublicLayout() {
|
|||||||
if (idleId) ric.cancelIdleCallback?.(idleId);
|
if (idleId) ric.cancelIdleCallback?.(idleId);
|
||||||
};
|
};
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
const popularHref = "/browse?sort=popular";
|
const popularHref = lp("/browse?sort=popular");
|
||||||
|
|
||||||
const goSearch = () => {
|
const goSearch = () => {
|
||||||
const s = q.trim();
|
const s = q.trim();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
nav(`/browse?q=${encodeURIComponent(s)}`);
|
nav(lp(`/browse?q=${encodeURIComponent(s)}`));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setMobileSearchOpen(false);
|
setMobileSearchOpen(false);
|
||||||
setDesktopSearchOpen(false);
|
setDesktopSearchOpen(false);
|
||||||
@@ -574,35 +590,35 @@ export function PublicLayout() {
|
|||||||
aria-label={t("mainNav")}
|
aria-label={t("mainNav")}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/browse"
|
to={lp("/browse")}
|
||||||
className={navClassName(na("browseAll"))}
|
className={navClassName(na("browseAll"))}
|
||||||
aria-current={na("browseAll") ? "page" : undefined}
|
aria-current={na("browseAll") ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{t("all")}
|
{t("all")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/categories"
|
to={lp("/categories")}
|
||||||
className={navClassName(na("categories"))}
|
className={navClassName(na("categories"))}
|
||||||
aria-current={na("categories") ? "page" : undefined}
|
aria-current={na("categories") ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{t("categories")}
|
{t("categories")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/official-recommendations"
|
to={lp("/official-recommendations")}
|
||||||
className={navClassName(na("browseRecommended"))}
|
className={navClassName(na("browseRecommended"))}
|
||||||
aria-current={na("browseRecommended") ? "page" : undefined}
|
aria-current={na("browseRecommended") ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{t("official")}
|
{t("official")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/browse?sort=latest"
|
to={lp("/browse?sort=latest")}
|
||||||
className={navClassName(na("browseLatest"))}
|
className={navClassName(na("browseLatest"))}
|
||||||
aria-current={na("browseLatest") ? "page" : undefined}
|
aria-current={na("browseLatest") ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{t("latest")}
|
{t("latest")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/favorites"
|
to={lp("/favorites")}
|
||||||
className={navClassName(na("favorites"))}
|
className={navClassName(na("favorites"))}
|
||||||
aria-current={na("favorites") ? "page" : undefined}
|
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`}
|
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
|
<Link
|
||||||
to="/browse"
|
to={lp("/browse")}
|
||||||
className={mobileMenuNavClassName(na("browseAll"))}
|
className={mobileMenuNavClassName(na("browseAll"))}
|
||||||
aria-current={na("browseAll") ? "page" : undefined}
|
aria-current={na("browseAll") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -669,7 +685,7 @@ export function PublicLayout() {
|
|||||||
{t("all")}
|
{t("all")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/categories"
|
to={lp("/categories")}
|
||||||
className={mobileMenuNavClassName(na("categories"))}
|
className={mobileMenuNavClassName(na("categories"))}
|
||||||
aria-current={na("categories") ? "page" : undefined}
|
aria-current={na("categories") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -677,7 +693,7 @@ export function PublicLayout() {
|
|||||||
{t("categories")}
|
{t("categories")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/official-recommendations"
|
to={lp("/official-recommendations")}
|
||||||
className={mobileMenuNavClassName(na("browseRecommended"))}
|
className={mobileMenuNavClassName(na("browseRecommended"))}
|
||||||
aria-current={na("browseRecommended") ? "page" : undefined}
|
aria-current={na("browseRecommended") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -685,7 +701,7 @@ export function PublicLayout() {
|
|||||||
{t("official")}
|
{t("official")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/browse?sort=latest"
|
to={lp("/browse?sort=latest")}
|
||||||
className={mobileMenuNavClassName(na("browseLatest"))}
|
className={mobileMenuNavClassName(na("browseLatest"))}
|
||||||
aria-current={na("browseLatest") ? "page" : undefined}
|
aria-current={na("browseLatest") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -693,7 +709,7 @@ export function PublicLayout() {
|
|||||||
{t("latest")}
|
{t("latest")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/favorites"
|
to={lp("/favorites")}
|
||||||
className={mobileMenuNavClassName(na("favorites"))}
|
className={mobileMenuNavClassName(na("favorites"))}
|
||||||
aria-current={na("favorites") ? "page" : undefined}
|
aria-current={na("favorites") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
@@ -767,15 +783,16 @@ export function PublicLayout() {
|
|||||||
active={isHome}
|
active={isHome}
|
||||||
/>
|
/>
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to="/browse"
|
to={lp("/browse")}
|
||||||
label={t("all")}
|
label={t("all")}
|
||||||
icon="document"
|
icon="document"
|
||||||
active={
|
active={
|
||||||
pathname === "/browse" && !new URLSearchParams(search).get("sort")
|
stripLangPrefix(pathname) === "/browse" &&
|
||||||
|
!new URLSearchParams(search).get("sort")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to="/favorites"
|
to={lp("/favorites")}
|
||||||
label={t("favorites")}
|
label={t("favorites")}
|
||||||
icon="bookmark"
|
icon="bookmark"
|
||||||
active={na("favorites")}
|
active={na("favorites")}
|
||||||
@@ -789,7 +806,7 @@ export function PublicLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{pathname === "/browse" ? <BackToTop /> : null}
|
{stripLangPrefix(pathname) === "/browse" ? <BackToTop /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CategoryIcon } from "../../components/CategoryIcon";
|
|||||||
import { useSetPageTitle } from "../../components/PageTitleContext";
|
import { useSetPageTitle } from "../../components/PageTitleContext";
|
||||||
import { Skeleton } from "../../components/Skeleton";
|
import { Skeleton } from "../../components/Skeleton";
|
||||||
import { langQuery, useI18n } from "../../i18n";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
|
import { useLocalizedPath } from "../../useLocalizedPath";
|
||||||
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
||||||
import { Reveal } from "../../motion";
|
import { Reveal } from "../../motion";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ function figmaCategoryRank(category: Category): number {
|
|||||||
|
|
||||||
export function CategoriesPage() {
|
export function CategoriesPage() {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
useSetPageTitle(t("categories"));
|
useSetPageTitle(t("categories"));
|
||||||
const [cats, setCats] = useState<Category[]>([]);
|
const [cats, setCats] = useState<Category[]>([]);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
@@ -89,7 +91,7 @@ export function CategoriesPage() {
|
|||||||
className="h-[88px]"
|
className="h-[88px]"
|
||||||
>
|
>
|
||||||
<Link
|
<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"
|
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
|
<CategoryIcon
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { RecommendedCard } from "../../components/RecommendedCard";
|
|||||||
import { SectionHeader } from "../../components/SectionHeader";
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
import { MessageBubble } from "../../components/messageStream/MessageBubble";
|
import { MessageBubble } from "../../components/messageStream/MessageBubble";
|
||||||
import { langQuery, useI18n } from "../../i18n";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
|
import { useLocalizedPath } from "../../useLocalizedPath";
|
||||||
import { sourceLanguageQuery } from "../../i18nLanguages";
|
import { sourceLanguageQuery } from "../../i18nLanguages";
|
||||||
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +43,7 @@ function figmaCategoryRank(category: Category): number {
|
|||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
|
const lp = useLocalizedPath();
|
||||||
const { hash } = useLocation();
|
const { hash } = useLocation();
|
||||||
const [cats, setCats] = useState<Category[]>([]);
|
const [cats, setCats] = useState<Category[]>([]);
|
||||||
const [rec, setRec] = useState<PostBackedResource[]>([]);
|
const [rec, setRec] = useState<PostBackedResource[]>([]);
|
||||||
@@ -298,7 +300,7 @@ export function Home() {
|
|||||||
{page.map((c) => (
|
{page.map((c) => (
|
||||||
<Link
|
<Link
|
||||||
key={c.id}
|
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"
|
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
|
<CategoryIcon
|
||||||
@@ -350,7 +352,7 @@ export function Home() {
|
|||||||
{figmaOrderedCategories.map((c, index) => (
|
{figmaOrderedCategories.map((c, index) => (
|
||||||
<Reveal key={c.id} delay={Math.min(index, 8) * 0.05}>
|
<Reveal key={c.id} delay={Math.min(index, 8) * 0.05}>
|
||||||
<Link
|
<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"
|
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
|
<CategoryIcon
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export type Attachment = {
|
|||||||
posterUrl?: string;
|
posterUrl?: string;
|
||||||
thumbUrl?: string;
|
thumbUrl?: string;
|
||||||
thumbnailUrl?: 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