{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 (
{
+ 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());
+}
diff --git a/src/components/messageStream/utils/formatBytes.test.ts b/src/components/messageStream/utils/formatBytes.test.ts
index b95248e..1e24ee5 100644
--- a/src/components/messageStream/utils/formatBytes.test.ts
+++ b/src/components/messageStream/utils/formatBytes.test.ts
@@ -5,26 +5,26 @@ describe("formatBytes", () => {
it("returns bytes under 1 KB unchanged", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(512)).toBe("512 B");
- expect(formatBytes(1023)).toBe("1023 B");
+ expect(formatBytes(999)).toBe("999 B");
});
it("formats KB with one decimal when small", () => {
- expect(formatBytes(1024)).toBe("1 KB");
- expect(formatBytes(1536)).toBe("1.5 KB");
+ expect(formatBytes(1000)).toBe("1 KB");
+ expect(formatBytes(1500)).toBe("1.5 KB");
});
it("formats MB with one decimal", () => {
- expect(formatBytes(3_549_239)).toBe("3.4 MB");
- expect(formatBytes(4_800_000)).toBe("4.6 MB");
+ expect(formatBytes(3_400_000)).toBe("3.4 MB");
+ expect(formatBytes(4_600_000)).toBe("4.6 MB");
});
it("drops decimals once value >= 100", () => {
- expect(formatBytes(150 * 1024 * 1024)).toBe("150 MB");
+ expect(formatBytes(150 * 1000 * 1000)).toBe("150 MB");
});
it("handles GB and TB", () => {
- expect(formatBytes(2 * 1024 ** 3)).toBe("2 GB");
- expect(formatBytes(3 * 1024 ** 4)).toBe("3 TB");
+ expect(formatBytes(2 * 1000 ** 3)).toBe("2 GB");
+ expect(formatBytes(3 * 1000 ** 4)).toBe("3 TB");
});
it("guards against invalid input", () => {
diff --git a/src/components/messageStream/utils/formatBytes.ts b/src/components/messageStream/utils/formatBytes.ts
index b9d8fb1..2b0c229 100644
--- a/src/components/messageStream/utils/formatBytes.ts
+++ b/src/components/messageStream/utils/formatBytes.ts
@@ -2,11 +2,11 @@ const UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
- if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1000) return `${bytes} B`;
let value = bytes;
let unitIndex = 0;
- while (value >= 1024 && unitIndex < UNITS.length - 1) {
- value /= 1024;
+ while (value >= 1000 && unitIndex < UNITS.length - 1) {
+ value /= 1000;
unitIndex += 1;
}
const rounded =
diff --git a/src/components/messageStream/utils/videoPreviewSource.test.ts b/src/components/messageStream/utils/videoPreviewSource.test.ts
new file mode 100644
index 0000000..544d4c9
--- /dev/null
+++ b/src/components/messageStream/utils/videoPreviewSource.test.ts
@@ -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",
+ );
+ });
+});
diff --git a/src/components/messageStream/utils/videoPreviewSource.ts b/src/components/messageStream/utils/videoPreviewSource.ts
new file mode 100644
index 0000000..503c1c9
--- /dev/null
+++ b/src/components/messageStream/utils/videoPreviewSource.ts
@@ -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`;
+}
diff --git a/src/i18n.tsx b/src/i18n.tsx
index 3c5952a..c3efa17 100644
--- a/src/i18n.tsx
+++ b/src/i18n.tsx
@@ -5,359 +5,30 @@ import React, {
useMemo,
useState,
} from "react";
-import { languageForHomePathname } from "./languageRoutes";
+import {
+ languageForHomePathname,
+ languageFromPathname,
+ langPathPrefix,
+} from "./languageRoutes";
+import type { Dict } from "./locales/types";
+import { zhDict } from "./locales/zh-CN";
+import { enDict } from "./locales/en";
+import { jaDict } from "./locales/ja";
+import { koDict } from "./locales/ko";
+import { viDict } from "./locales/vi";
+import { idDict } from "./locales/id";
+import { msDict } from "./locales/ms";
export type Lang = "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
-type Dict = Record;
-
-const zhDict: Dict = {
- brand: "ARK 资料库",
- mainNav: "网站导航",
- home: "首页",
- all: "全部资料",
- categories: "资料分类",
- latest: "最新更新",
- official: "官方推荐",
- popular: "热门资料",
- search: "搜索",
- searchPlaceholder: "搜索资料...",
- searchPanelPlaceholder: "搜索资料...",
- searchNow: "立即搜索资料",
- searchSubmit: "搜索",
- cancel: "取消",
- clear: "清除",
- searchPanelHint: "支持搜索 标题・分类・标签・简介・文件类型・正文",
- currentTags: "现有标签",
- noTagsAvailable: "暂无可选择的标签。",
- tagPostsTitle: "#{{tag}} 相关资料",
- noTagPosts: "暂时找不到带有此标签的资料。",
- viewAll: "查看全部",
- backToTop: "回到顶部",
- heroTitle: "ARK 官方数据库",
- heroSub:
- "集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。",
- categorySection: "资料分类",
- officialSection: "官方推荐",
- latestSection: "最新更新",
- popularSection: "热门资料",
- preview: "预览",
- download: "下载",
- downloading: "下载中…",
- downloadOk: "下载完成",
- downloadFail: "下载失败,请重试",
- longPressImageSave: "长按图片保存到相册",
- showMore: "展开全部",
- showLess: "收起全部",
- share: "分享",
- langLabel: "语言",
- admin: "后台",
- login: "登录",
- logout: "退出",
- email: "邮箱",
- password: "密码",
- dashboard: "仪表盘",
- resources: "资料管理",
- newResource: "新增资料",
- save: "保存",
- title: "标题",
- description: "简介",
- type: "类型",
- language: "语言",
- category: "分类",
- status: "状态",
- public: "公开",
- downloadable: "可下载",
- recommended: "首页推荐",
- cover: "封面图 URL",
- fileUrl: "文件 URL",
- externalUrl: "外部链接",
- body: "文案内容",
- badge: "推荐标签",
- published: "已发布",
- draft: "草稿",
- archived: "归档",
- noResults: "找不到符合的资料,请换个关键字或浏览分类。",
- copyLink: "复制链接",
- related: "相关资料",
- total: "总资料",
- views: "浏览",
- downloads: "下载",
- lang_zh_CN: "中文",
- lang_en: "English",
- lang_ja: "日本語",
- lang_ko: "한국어",
- lang_vi: "Tiếng Việt",
- lang_id: "Bahasa Indonesia",
- lang_ms: "Bahasa Melayu",
- filterAll: "全部",
- sortPublished: "发布时间",
- type_ppt: "PPT",
- type_music: "音乐",
- type_video: "视频",
- type_image: "图片",
- type_pdf: "PDF",
- type_link: "链接",
- type_text: "文字",
- type_archive: "压缩包",
- type_zip: "ZIP",
- adminLoginTitle: "管理后台登录",
- adminEditResource: "编辑资料",
- adminVideoFileHint:
- "上传视频文件(MP4/WebM/MOV 等),类型请选择「视频」;保存后前台自动播放(默认静音,可点喇叭开声音)。",
- adminStatTodayNew: "今日新增",
- adminStatFavorites: "收藏",
- adminMetricDownloads: "下载",
- adminMetricFavorites: "收藏",
- adminMetricViews: "浏览",
- edit: "编辑",
- backToList: "返回列表",
- sortOrderLabel: "排序权重",
- previewUrlLabel: "预览网址",
- tagsCommaLabel: "标签(逗号分隔)",
- uploadFile: "上传文件",
- loading: "加载中…",
- paginationPrev: "上一页",
- paginationNext: "下一页",
- listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
- pageIndicator: "{{c}} / {{p}} 页",
- resourceLangFilter: "资料语言",
- filterTagClear: "清除标签",
- filterLanguageAll: "全部语言",
- footerAdminLogin: "管理员登录",
- adminSearchLogs: "搜索记录",
- adminMetricShares: "分享",
- adminSearchQuery: "查询词",
- adminSearchTime: "时间",
- adminSearchId: "编号",
- favorites: "我的收藏",
- favoritesComingSoon: "功能即将推出",
- favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
- featureUnavailable: "未开放",
- featureUnavailableDesc: "该功能暂未开放。",
- confirm: "知道了",
- backToHome: "返回首页",
-};
-
-const enDict: Dict = {
- brand: "ARK Library",
- mainNav: "Site menu",
- home: "Home",
- all: "All assets",
- categories: "Categories",
- latest: "Latest",
- official: "Official picks",
- popular: "Popular",
- search: "Search",
- searchPlaceholder: "Search resources...",
- searchPanelPlaceholder: "Search assets...",
- searchNow: "Search now",
- searchSubmit: "Search",
- cancel: "Cancel",
- clear: "Clear",
- searchPanelHint:
- "Search supports title, category, tags, summary, file type, and body text.",
- currentTags: "Available tags",
- noTagsAvailable: "No tags available yet.",
- tagPostsTitle: "#{{tag}} related posts",
- noTagPosts: "No posts with this tag yet.",
- viewAll: "View all",
- backToTop: "Back to top",
- heroTitle: "ARK Official Library",
- heroSub:
- "Centralize, organize, and manage the ARK library so you can find what you need fast and help the community grow together.",
- categorySection: "Categories",
- officialSection: "Official recommendations",
- latestSection: "Latest updates",
- popularSection: "Popular assets",
- preview: "Preview",
- download: "Download",
- downloading: "Downloading…",
- downloadOk: "Download complete",
- downloadFail: "Download failed, please retry",
- longPressImageSave: "Long-press image to save",
- showMore: "Show all",
- showLess: "Show less",
- share: "Share",
- langLabel: "Language",
- admin: "Admin",
- login: "Sign in",
- logout: "Sign out",
- email: "Email",
- password: "Password",
- dashboard: "Dashboard",
- resources: "Resources",
- newResource: "New resource",
- save: "Save",
- title: "Title",
- description: "Description",
- type: "Type",
- language: "Language",
- category: "Category",
- status: "Status",
- public: "Public",
- downloadable: "Downloadable",
- recommended: "Featured",
- cover: "Cover image URL",
- fileUrl: "File URL",
- externalUrl: "External URL",
- body: "Text body",
- badge: "Badge label",
- published: "Published",
- draft: "Draft",
- archived: "Archived",
- noResults: "No results. Try another keyword or browse categories.",
- copyLink: "Copy link",
- related: "Related",
- total: "Total items",
- views: "Views",
- downloads: "Downloads",
- lang_zh_CN: "Chinese",
- lang_en: "English",
- lang_ja: "Japanese",
- lang_ko: "Korean",
- lang_vi: "Vietnamese",
- lang_id: "Indonesian",
- lang_ms: "Malay",
- filterAll: "All types",
- sortPublished: "Published date",
- type_ppt: "PPT",
- type_music: "Music",
- type_video: "Video",
- type_image: "Image",
- type_pdf: "PDF",
- type_link: "Link",
- type_text: "Text",
- type_archive: "Archive",
- type_zip: "ZIP",
- adminLoginTitle: "Admin sign in",
- adminEditResource: "Edit resource",
- adminVideoFileHint:
- "Upload a video file (MP4/WebM/MOV, etc.) and set type to Video; the site will autoplay (muted by default — user can unmute).",
- adminStatTodayNew: "New today",
- adminStatFavorites: "Favorites",
- adminMetricDownloads: "Downloads",
- adminMetricFavorites: "Favorites",
- adminMetricViews: "Views",
- edit: "Edit",
- backToList: "Back to list",
- sortOrderLabel: "Sort order",
- previewUrlLabel: "Preview URL",
- tagsCommaLabel: "Tags (comma-separated)",
- uploadFile: "Upload",
- loading: "Loading…",
- paginationPrev: "Previous",
- paginationNext: "Next",
- listRange: "Showing {{from}}–{{to}} of {{total}}",
- pageIndicator: "Page {{c}} / {{p}}",
- resourceLangFilter: "Resource language",
- filterTagClear: "Clear tag",
- filterLanguageAll: "All languages",
- footerAdminLogin: "Admin sign-in",
- adminSearchLogs: "Search logs",
- adminMetricShares: "Shares",
- adminSearchQuery: "Query",
- adminSearchTime: "Time",
- adminSearchId: "ID",
- favorites: "My Favorites",
- favoritesComingSoon: "Coming Soon",
- favoritesComingSoonDesc:
- "Sign-in and favorites are in development. Stay tuned.",
- featureUnavailable: "Not available yet",
- featureUnavailableDesc: "This feature is not available yet.",
- confirm: "Got it",
- backToHome: "Back to Home",
-};
-
-const languageNames: Record = {
- "zh-CN": {
- lang_zh_CN: "中文",
- lang_en: "English",
- lang_ja: "日本語",
- lang_ko: "한국어",
- lang_vi: "Tiếng Việt",
- lang_id: "Bahasa Indonesia",
- lang_ms: "Bahasa Melayu",
- },
- en: {
- lang_zh_CN: "Chinese",
- lang_en: "English",
- lang_ja: "Japanese",
- lang_ko: "Korean",
- lang_vi: "Vietnamese",
- lang_id: "Indonesian",
- lang_ms: "Malay",
- },
- ja: {
- brand: "ARK ライブラリー",
- showMore: "すべて表示",
- showLess: "閉じる",
- lang_zh_CN: "中国語",
- lang_en: "英語",
- lang_ja: "日本語",
- lang_ko: "韓国語",
- lang_vi: "ベトナム語",
- lang_id: "インドネシア語",
- lang_ms: "マレー語",
- },
- ko: {
- brand: "ARK 라이브러리",
- showMore: "모두 보기",
- showLess: "접기",
- lang_zh_CN: "중국어",
- lang_en: "영어",
- lang_ja: "일본어",
- lang_ko: "한국어",
- lang_vi: "베트남어",
- lang_id: "인도네시아어",
- lang_ms: "말레이어",
- },
- vi: {
- brand: "Thư viện ARK",
- showMore: "Xem tất cả",
- showLess: "Thu gọn",
- lang_zh_CN: "Tiếng Trung",
- lang_en: "Tiếng Anh",
- lang_ja: "Tiếng Nhật",
- lang_ko: "Tiếng Hàn",
- lang_vi: "Tiếng Việt",
- lang_id: "Tiếng Indonesia",
- lang_ms: "Tiếng Mã Lai",
- },
- id: {
- brand: "Perpustakaan ARK",
- showMore: "Lihat semua",
- showLess: "Tutup",
- lang_zh_CN: "Bahasa Tionghoa",
- lang_en: "Bahasa Inggris",
- lang_ja: "Bahasa Jepang",
- lang_ko: "Bahasa Korea",
- lang_vi: "Bahasa Vietnam",
- lang_id: "Bahasa Indonesia",
- lang_ms: "Bahasa Melayu",
- },
- ms: {
- brand: "Perpustakaan ARK",
- showMore: "Lihat semua",
- showLess: "Tutup",
- lang_zh_CN: "Bahasa Cina",
- lang_en: "Bahasa Inggeris",
- lang_ja: "Bahasa Jepun",
- lang_ko: "Bahasa Korea",
- lang_vi: "Bahasa Vietnam",
- lang_id: "Bahasa Indonesia",
- lang_ms: "Bahasa Melayu",
- },
-};
-
const dict: Record = {
- "zh-CN": { ...zhDict, ...languageNames["zh-CN"] },
- en: { ...enDict, ...languageNames.en },
- ja: { ...enDict, ...languageNames.ja },
- ko: { ...enDict, ...languageNames.ko },
- vi: { ...enDict, ...languageNames.vi },
- id: { ...enDict, ...languageNames.id },
- ms: { ...enDict, ...languageNames.ms },
+ "zh-CN": zhDict,
+ en: enDict,
+ ja: jaDict,
+ ko: koDict,
+ vi: viDict,
+ id: idDict,
+ ms: msDict,
};
/** Fixed locale lookup (admin UI uses Simplified Chinese). */
@@ -373,9 +44,14 @@ const LANG_KEY = "ark_lang";
export function I18nProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState(() => {
- const routeLang = languageForHomePathname(window.location.pathname);
- if (routeLang) return routeLang;
- if (window.location.pathname === "/") return "en";
+ const path = window.location.pathname;
+ // Any URL whose first path segment is a known language prefix wins
+ // (covers /malay, /malay/browse, /korean/category/foo, etc.).
+ const homeLang = languageForHomePathname(path);
+ if (homeLang) return homeLang;
+ const deepLang = languageFromPathname(path);
+ if (langPathPrefix(deepLang)) return deepLang;
+ if (path === "/") return "en";
const s = localStorage.getItem(LANG_KEY);
if (s === "zh" || s === "zh-TW") return "zh-CN";
diff --git a/src/languageRoutes.ts b/src/languageRoutes.ts
index b990a7b..106f730 100644
--- a/src/languageRoutes.ts
+++ b/src/languageRoutes.ts
@@ -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;
+}
diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx
index a609d44..ff983f8 100644
--- a/src/layouts/PublicLayout.tsx
+++ b/src/layouts/PublicLayout.tsx
@@ -11,7 +11,14 @@ 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,
+ localizePath,
+ stripLangPrefix,
+} from "../languageRoutes";
+import { useLocalizedPath } from "../useLocalizedPath";
type PublicNavWhich =
| "home"
@@ -29,25 +36,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 +303,15 @@ export function PublicLayout() {
const desktopSearchRef = useRef(null);
const desktopSearchPanelRef = useRef(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);
@@ -302,9 +319,17 @@ export function PublicLayout() {
const homePath = homePathForLang(lang);
const changeLang = (nextLang: Lang) => {
setLang(nextLang);
- if (isHome) nav(homePathForLang(nextLang), { replace: true });
+ if (isHome) {
+ nav(homePathForLang(nextLang), { replace: true });
+ } else {
+ // Preserve sub-path and query/hash; only swap the language prefix.
+ const canonical = stripLangPrefix(pathname);
+ nav(localizePath(canonical, nextLang) + search + hash, {
+ 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 +369,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);
@@ -563,7 +588,7 @@ export function PublicLayout() {
@@ -574,35 +599,35 @@ export function PublicLayout() {
aria-label={t("mainNav")}
>
{t("all")}
{t("categories")}
{t("official")}
{t("latest")}
@@ -661,7 +686,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`}
>
setOpen(false)}
@@ -669,7 +694,7 @@ export function PublicLayout() {
{t("all")}
setOpen(false)}
@@ -677,7 +702,7 @@ export function PublicLayout() {
{t("categories")}
setOpen(false)}
@@ -685,7 +710,7 @@ export function PublicLayout() {
{t("official")}
setOpen(false)}
@@ -693,7 +718,7 @@ export function PublicLayout() {
{t("latest")}
setOpen(false)}
@@ -767,15 +792,16 @@ export function PublicLayout() {
active={isHome}
/>
- {pathname === "/browse" ? : null}
+ {stripLangPrefix(pathname) === "/browse" ? : null}
);
}
diff --git a/src/locales/en.ts b/src/locales/en.ts
new file mode 100644
index 0000000..7689bdf
--- /dev/null
+++ b/src/locales/en.ts
@@ -0,0 +1,131 @@
+import type { Dict } from "./types";
+
+export const enDict: Dict = {
+ brand: "ARK Library",
+ mainNav: "Site menu",
+ home: "Home",
+ all: "All assets",
+ categories: "Categories",
+ latest: "Latest",
+ official: "Official picks",
+ popular: "Popular",
+ search: "Search",
+ searchPlaceholder: "Search resources...",
+ searchPanelPlaceholder: "Search assets...",
+ searchNow: "Search now",
+ searchSubmit: "Search",
+ cancel: "Cancel",
+ clear: "Clear",
+ searchPanelHint:
+ "Search supports title, category, tags, summary, file type, and body text.",
+ currentTags: "Available tags",
+ noTagsAvailable: "No tags available yet.",
+ tagPostsTitle: "#{{tag}} related posts",
+ noTagPosts: "No posts with this tag yet.",
+ viewAll: "View all",
+ backToTop: "Back to top",
+ heroTitle: "ARK Official Library",
+ heroSub:
+ "Centralize, organize, and manage the ARK library so you can find what you need fast and help the community grow together.",
+ categorySection: "Categories",
+ officialSection: "Official recommendations",
+ latestSection: "Latest updates",
+ popularSection: "Popular assets",
+ preview: "Preview",
+ download: "Download",
+ downloading: "Downloading…",
+ downloadOk: "Download complete",
+ downloadFail: "Download failed, please retry",
+ longPressImageSave: "Long-press image to save",
+ showMore: "Show all",
+ showLess: "Show less",
+ share: "Share",
+ langLabel: "Language",
+ admin: "Admin",
+ login: "Sign in",
+ logout: "Sign out",
+ email: "Email",
+ password: "Password",
+ dashboard: "Dashboard",
+ resources: "Resources",
+ newResource: "New resource",
+ save: "Save",
+ title: "Title",
+ description: "Description",
+ type: "Type",
+ language: "Language",
+ category: "Category",
+ status: "Status",
+ public: "Public",
+ downloadable: "Downloadable",
+ recommended: "Featured",
+ cover: "Cover image URL",
+ fileUrl: "File URL",
+ externalUrl: "External URL",
+ body: "Text body",
+ badge: "Badge label",
+ published: "Published",
+ draft: "Draft",
+ archived: "Archived",
+ noResults: "No results. Try another keyword or browse categories.",
+ copyLink: "Copy link",
+ related: "Related",
+ total: "Total items",
+ views: "Views",
+ downloads: "Downloads",
+ lang_zh_CN: "Chinese",
+ lang_en: "English",
+ lang_ja: "Japanese",
+ lang_ko: "Korean",
+ lang_vi: "Vietnamese",
+ lang_id: "Indonesian",
+ lang_ms: "Malay",
+ filterAll: "All types",
+ sortPublished: "Published date",
+ type_ppt: "PPT",
+ type_music: "Music",
+ type_video: "Video",
+ type_image: "Image",
+ type_pdf: "PDF",
+ type_link: "Link",
+ type_text: "Text",
+ type_archive: "Archive",
+ type_zip: "ZIP",
+ adminLoginTitle: "Admin sign in",
+ adminEditResource: "Edit resource",
+ adminVideoFileHint:
+ "Upload a video file (MP4/WebM/MOV, etc.) and set type to Video; the site will autoplay (muted by default — user can unmute).",
+ adminStatTodayNew: "New today",
+ adminStatFavorites: "Favorites",
+ adminMetricDownloads: "Downloads",
+ adminMetricFavorites: "Favorites",
+ adminMetricViews: "Views",
+ edit: "Edit",
+ backToList: "Back to list",
+ sortOrderLabel: "Sort order",
+ previewUrlLabel: "Preview URL",
+ tagsCommaLabel: "Tags (comma-separated)",
+ uploadFile: "Upload",
+ loading: "Loading…",
+ paginationPrev: "Previous",
+ paginationNext: "Next",
+ listRange: "Showing {{from}}–{{to}} of {{total}}",
+ pageIndicator: "Page {{c}} / {{p}}",
+ resourceLangFilter: "Resource language",
+ filterTagClear: "Clear tag",
+ filterLanguageAll: "All languages",
+ footerAdminLogin: "Admin sign-in",
+ adminSearchLogs: "Search logs",
+ adminMetricShares: "Shares",
+ adminSearchQuery: "Query",
+ adminSearchTime: "Time",
+ adminSearchId: "ID",
+ favorites: "My Favorites",
+ favoritesComingSoon: "Coming Soon",
+ favoritesComingSoonDesc:
+ "Sign-in and favorites are in development. Stay tuned.",
+ featureUnavailable: "Not available yet",
+ featureUnavailableDesc: "This feature is not available yet.",
+ confirm: "Got it",
+ backToHome: "Back to Home",
+};
diff --git a/src/locales/id.ts b/src/locales/id.ts
new file mode 100644
index 0000000..a1fd83c
--- /dev/null
+++ b/src/locales/id.ts
@@ -0,0 +1,131 @@
+import type { Dict } from "./types";
+
+export const idDict: Dict = {
+ brand: "Perpustakaan ARK",
+ mainNav: "Menu situs",
+ home: "Beranda",
+ all: "Semua aset",
+ categories: "Kategori",
+ latest: "Terbaru",
+ official: "Pilihan resmi",
+ popular: "Populer",
+ search: "Cari",
+ searchPlaceholder: "Cari sumber daya...",
+ searchPanelPlaceholder: "Cari aset...",
+ searchNow: "Cari sekarang",
+ searchSubmit: "Cari",
+ cancel: "Batal",
+ clear: "Hapus",
+ searchPanelHint:
+ "Pencarian mendukung judul, kategori, tag, ringkasan, jenis file, dan isi.",
+ currentTags: "Tag tersedia",
+ noTagsAvailable: "Belum ada tag yang tersedia.",
+ tagPostsTitle: "Postingan terkait #{{tag}}",
+ noTagPosts: "Belum ada postingan dengan tag ini.",
+ viewAll: "Lihat semua",
+ backToTop: "Kembali ke atas",
+ heroTitle: "Perpustakaan Resmi ARK",
+ heroSub:
+ "Memusatkan, mengatur, dan mengelola perpustakaan ARK agar Anda dapat menemukan yang dibutuhkan dengan cepat dan membantu komunitas tumbuh bersama.",
+ categorySection: "Kategori",
+ officialSection: "Rekomendasi resmi",
+ latestSection: "Pembaruan terbaru",
+ popularSection: "Aset populer",
+ preview: "Pratinjau",
+ download: "Unduh",
+ downloading: "Mengunduh…",
+ downloadOk: "Unduhan selesai",
+ downloadFail: "Unduhan gagal, silakan coba lagi",
+ longPressImageSave: "Tekan lama gambar untuk menyimpan",
+ showMore: "Lihat semua",
+ showLess: "Tutup",
+ share: "Bagikan",
+ langLabel: "Bahasa",
+ admin: "Admin",
+ login: "Masuk",
+ logout: "Keluar",
+ email: "Email",
+ password: "Kata sandi",
+ dashboard: "Dasbor",
+ resources: "Sumber daya",
+ newResource: "Sumber daya baru",
+ save: "Simpan",
+ title: "Judul",
+ description: "Deskripsi",
+ type: "Jenis",
+ language: "Bahasa",
+ category: "Kategori",
+ status: "Status",
+ public: "Publik",
+ downloadable: "Dapat diunduh",
+ recommended: "Unggulan",
+ cover: "URL gambar sampul",
+ fileUrl: "URL file",
+ externalUrl: "Tautan eksternal",
+ body: "Isi teks",
+ badge: "Label lencana",
+ published: "Diterbitkan",
+ draft: "Draf",
+ archived: "Diarsipkan",
+ noResults: "Tidak ada hasil. Coba kata kunci lain atau telusuri kategori.",
+ copyLink: "Salin tautan",
+ related: "Terkait",
+ total: "Total item",
+ views: "Tampilan",
+ downloads: "Unduhan",
+ lang_zh_CN: "Bahasa Tionghoa",
+ lang_en: "Bahasa Inggris",
+ lang_ja: "Bahasa Jepang",
+ lang_ko: "Bahasa Korea",
+ lang_vi: "Bahasa Vietnam",
+ lang_id: "Bahasa Indonesia",
+ lang_ms: "Bahasa Melayu",
+ filterAll: "Semua jenis",
+ sortPublished: "Tanggal terbit",
+ type_ppt: "PPT",
+ type_music: "Musik",
+ type_video: "Video",
+ type_image: "Gambar",
+ type_pdf: "PDF",
+ type_link: "Tautan",
+ type_text: "Teks",
+ type_archive: "Arsip",
+ type_zip: "ZIP",
+ adminLoginTitle: "Masuk admin",
+ adminEditResource: "Edit sumber daya",
+ adminVideoFileHint:
+ "Unggah file video (MP4/WebM/MOV, dll.) dan atur jenis ke Video; situs akan memutar otomatis (default tanpa suara — pengguna dapat membunyikan).",
+ adminStatTodayNew: "Baru hari ini",
+ adminStatFavorites: "Favorit",
+ adminMetricDownloads: "Unduhan",
+ adminMetricFavorites: "Favorit",
+ adminMetricViews: "Tampilan",
+ edit: "Edit",
+ backToList: "Kembali ke daftar",
+ sortOrderLabel: "Urutan",
+ previewUrlLabel: "URL pratinjau",
+ tagsCommaLabel: "Tag (dipisahkan koma)",
+ uploadFile: "Unggah",
+ loading: "Memuat…",
+ paginationPrev: "Sebelumnya",
+ paginationNext: "Berikutnya",
+ listRange: "Menampilkan {{from}}–{{to}} dari {{total}}",
+ pageIndicator: "Halaman {{c}} / {{p}}",
+ resourceLangFilter: "Bahasa sumber daya",
+ filterTagClear: "Hapus tag",
+ filterLanguageAll: "Semua bahasa",
+ footerAdminLogin: "Masuk admin",
+ adminSearchLogs: "Log pencarian",
+ adminMetricShares: "Berbagi",
+ adminSearchQuery: "Kueri",
+ adminSearchTime: "Waktu",
+ adminSearchId: "ID",
+ favorites: "Favorit Saya",
+ favoritesComingSoon: "Segera Hadir",
+ favoritesComingSoonDesc:
+ "Fitur masuk dan favorit sedang dikembangkan. Nantikan.",
+ featureUnavailable: "Belum tersedia",
+ featureUnavailableDesc: "Fitur ini belum tersedia.",
+ confirm: "Mengerti",
+ backToHome: "Kembali ke Beranda",
+};
diff --git a/src/locales/ja.ts b/src/locales/ja.ts
new file mode 100644
index 0000000..0a6968c
--- /dev/null
+++ b/src/locales/ja.ts
@@ -0,0 +1,131 @@
+import type { Dict } from "./types";
+
+export const jaDict: Dict = {
+ brand: "ARK ライブラリー",
+ mainNav: "サイトメニュー",
+ home: "ホーム",
+ all: "すべての資料",
+ categories: "カテゴリー",
+ latest: "最新",
+ official: "公式おすすめ",
+ popular: "人気",
+ search: "検索",
+ searchPlaceholder: "資料を検索...",
+ searchPanelPlaceholder: "資料を検索...",
+ searchNow: "今すぐ検索",
+ searchSubmit: "検索",
+ cancel: "キャンセル",
+ clear: "クリア",
+ searchPanelHint:
+ "タイトル・カテゴリー・タグ・概要・ファイル形式・本文の検索に対応",
+ currentTags: "利用可能なタグ",
+ noTagsAvailable: "現在利用可能なタグはありません。",
+ tagPostsTitle: "#{{tag}} 関連の資料",
+ noTagPosts: "このタグの資料はまだありません。",
+ viewAll: "すべて表示",
+ backToTop: "トップへ戻る",
+ heroTitle: "ARK 公式データベース",
+ heroSub:
+ "ARK ライブラリーを集約・整理・管理し、必要な資料をすばやく見つけてコミュニティの成長を促進します。",
+ categorySection: "カテゴリー",
+ officialSection: "公式おすすめ",
+ latestSection: "最新の更新",
+ popularSection: "人気の資料",
+ preview: "プレビュー",
+ download: "ダウンロード",
+ downloading: "ダウンロード中…",
+ downloadOk: "ダウンロード完了",
+ downloadFail: "ダウンロードに失敗しました。再試行してください",
+ longPressImageSave: "画像を長押しして保存",
+ showMore: "すべて表示",
+ showLess: "閉じる",
+ share: "シェア",
+ langLabel: "言語",
+ admin: "管理画面",
+ login: "ログイン",
+ logout: "ログアウト",
+ email: "メールアドレス",
+ password: "パスワード",
+ dashboard: "ダッシュボード",
+ resources: "資料管理",
+ newResource: "新規資料",
+ save: "保存",
+ title: "タイトル",
+ description: "説明",
+ type: "種類",
+ language: "言語",
+ category: "カテゴリー",
+ status: "ステータス",
+ public: "公開",
+ downloadable: "ダウンロード可",
+ recommended: "おすすめ",
+ cover: "カバー画像 URL",
+ fileUrl: "ファイル URL",
+ externalUrl: "外部リンク",
+ body: "本文",
+ badge: "推薦バッジ",
+ published: "公開済み",
+ draft: "下書き",
+ archived: "アーカイブ",
+ noResults:
+ "該当する資料が見つかりません。別のキーワードを試すか、カテゴリーをご覧ください。",
+ copyLink: "リンクをコピー",
+ related: "関連資料",
+ total: "総資料数",
+ views: "閲覧",
+ downloads: "ダウンロード",
+ lang_zh_CN: "中国語",
+ lang_en: "英語",
+ lang_ja: "日本語",
+ lang_ko: "韓国語",
+ lang_vi: "ベトナム語",
+ lang_id: "インドネシア語",
+ lang_ms: "マレー語",
+ filterAll: "すべての種類",
+ sortPublished: "公開日",
+ type_ppt: "PPT",
+ type_music: "音楽",
+ type_video: "動画",
+ type_image: "画像",
+ type_pdf: "PDF",
+ type_link: "リンク",
+ type_text: "テキスト",
+ type_archive: "アーカイブ",
+ type_zip: "ZIP",
+ adminLoginTitle: "管理画面ログイン",
+ adminEditResource: "資料を編集",
+ adminVideoFileHint:
+ "動画ファイル(MP4/WebM/MOV など)をアップロードし、種類を「動画」に設定すると、サイト上で自動再生されます(デフォルトはミュート、ユーザーが解除可能)。",
+ adminStatTodayNew: "本日の新規",
+ adminStatFavorites: "お気に入り",
+ adminMetricDownloads: "ダウンロード",
+ adminMetricFavorites: "お気に入り",
+ adminMetricViews: "閲覧",
+ edit: "編集",
+ backToList: "一覧へ戻る",
+ sortOrderLabel: "並び順",
+ previewUrlLabel: "プレビュー URL",
+ tagsCommaLabel: "タグ(カンマ区切り)",
+ uploadFile: "アップロード",
+ loading: "読み込み中…",
+ paginationPrev: "前へ",
+ paginationNext: "次へ",
+ listRange: "{{from}}–{{to}} / 全 {{total}} 件",
+ pageIndicator: "{{c}} / {{p}} ページ",
+ resourceLangFilter: "資料の言語",
+ filterTagClear: "タグをクリア",
+ filterLanguageAll: "すべての言語",
+ footerAdminLogin: "管理者ログイン",
+ adminSearchLogs: "検索履歴",
+ adminMetricShares: "シェア",
+ adminSearchQuery: "検索キーワード",
+ adminSearchTime: "時刻",
+ adminSearchId: "ID",
+ favorites: "お気に入り",
+ favoritesComingSoon: "近日公開",
+ favoritesComingSoonDesc: "ログインとお気に入り機能は開発中です。お楽しみに。",
+ featureUnavailable: "未公開",
+ featureUnavailableDesc: "この機能はまだご利用いただけません。",
+ confirm: "了解",
+ backToHome: "ホームへ戻る",
+};
diff --git a/src/locales/ko.ts b/src/locales/ko.ts
new file mode 100644
index 0000000..94fd472
--- /dev/null
+++ b/src/locales/ko.ts
@@ -0,0 +1,131 @@
+import type { Dict } from "./types";
+
+export const koDict: Dict = {
+ brand: "ARK 라이브러리",
+ mainNav: "사이트 메뉴",
+ home: "홈",
+ all: "전체 자료",
+ categories: "카테고리",
+ latest: "최신",
+ official: "공식 추천",
+ popular: "인기 자료",
+ search: "검색",
+ searchPlaceholder: "자료 검색...",
+ searchPanelPlaceholder: "자료 검색...",
+ searchNow: "지금 검색",
+ searchSubmit: "검색",
+ cancel: "취소",
+ clear: "지우기",
+ searchPanelHint: "제목, 카테고리, 태그, 요약, 파일 유형, 본문 검색 지원",
+ currentTags: "사용 가능한 태그",
+ noTagsAvailable: "사용 가능한 태그가 없습니다.",
+ tagPostsTitle: "#{{tag}} 관련 자료",
+ noTagPosts: "이 태그가 포함된 자료가 없습니다.",
+ viewAll: "전체 보기",
+ backToTop: "맨 위로",
+ heroTitle: "ARK 공식 데이터베이스",
+ heroSub:
+ "ARK 라이브러리를 한곳에 모아 정리·관리하여 필요한 자료를 빠르게 찾고 커뮤니티의 성장을 함께 이끌어 갑니다.",
+ categorySection: "카테고리",
+ officialSection: "공식 추천",
+ latestSection: "최신 업데이트",
+ popularSection: "인기 자료",
+ preview: "미리보기",
+ download: "다운로드",
+ downloading: "다운로드 중…",
+ downloadOk: "다운로드 완료",
+ downloadFail: "다운로드 실패, 다시 시도해 주세요",
+ longPressImageSave: "이미지를 길게 눌러 저장",
+ showMore: "모두 보기",
+ showLess: "접기",
+ share: "공유",
+ langLabel: "언어",
+ admin: "관리자",
+ login: "로그인",
+ logout: "로그아웃",
+ email: "이메일",
+ password: "비밀번호",
+ dashboard: "대시보드",
+ resources: "자료",
+ newResource: "새 자료",
+ save: "저장",
+ title: "제목",
+ description: "설명",
+ type: "유형",
+ language: "언어",
+ category: "카테고리",
+ status: "상태",
+ public: "공개",
+ downloadable: "다운로드 가능",
+ recommended: "추천",
+ cover: "커버 이미지 URL",
+ fileUrl: "파일 URL",
+ externalUrl: "외부 링크",
+ body: "본문",
+ badge: "추천 배지",
+ published: "게시됨",
+ draft: "초안",
+ archived: "보관됨",
+ noResults:
+ "검색 결과가 없습니다. 다른 키워드로 검색하거나 카테고리를 둘러보세요.",
+ copyLink: "링크 복사",
+ related: "관련 자료",
+ total: "전체 자료",
+ views: "조회수",
+ downloads: "다운로드 수",
+ lang_zh_CN: "중국어",
+ lang_en: "영어",
+ lang_ja: "일본어",
+ lang_ko: "한국어",
+ lang_vi: "베트남어",
+ lang_id: "인도네시아어",
+ lang_ms: "말레이어",
+ filterAll: "전체 유형",
+ sortPublished: "게시일",
+ type_ppt: "PPT",
+ type_music: "음악",
+ type_video: "동영상",
+ type_image: "이미지",
+ type_pdf: "PDF",
+ type_link: "링크",
+ type_text: "텍스트",
+ type_archive: "압축 파일",
+ type_zip: "ZIP",
+ adminLoginTitle: "관리자 로그인",
+ adminEditResource: "자료 편집",
+ adminVideoFileHint:
+ "동영상 파일을 업로드 (MP4/WebM/MOV 등)하고 유형을 '동영상'으로 설정하면 사이트에서 자동 재생됩니다 (기본 음소거, 사용자가 음소거 해제 가능).",
+ adminStatTodayNew: "오늘 신규",
+ adminStatFavorites: "즐겨찾기",
+ adminMetricDownloads: "다운로드",
+ adminMetricFavorites: "즐겨찾기",
+ adminMetricViews: "조회수",
+ edit: "편집",
+ backToList: "목록으로",
+ sortOrderLabel: "정렬 순서",
+ previewUrlLabel: "미리보기 URL",
+ tagsCommaLabel: "태그 (쉼표로 구분)",
+ uploadFile: "업로드",
+ loading: "로딩 중…",
+ paginationPrev: "이전",
+ paginationNext: "다음",
+ listRange: "{{from}}–{{to}} / 총 {{total}}건",
+ pageIndicator: "{{c}} / {{p}} 페이지",
+ resourceLangFilter: "자료 언어",
+ filterTagClear: "태그 지우기",
+ filterLanguageAll: "모든 언어",
+ footerAdminLogin: "관리자 로그인",
+ adminSearchLogs: "검색 기록",
+ adminMetricShares: "공유",
+ adminSearchQuery: "검색어",
+ adminSearchTime: "시간",
+ adminSearchId: "ID",
+ favorites: "내 즐겨찾기",
+ favoritesComingSoon: "출시 예정",
+ favoritesComingSoonDesc:
+ "로그인과 즐겨찾기 기능을 개발 중입니다. 많은 기대 부탁드립니다.",
+ featureUnavailable: "준비 중",
+ featureUnavailableDesc: "이 기능은 아직 사용할 수 없습니다.",
+ confirm: "확인",
+ backToHome: "홈으로",
+};
diff --git a/src/locales/ms.ts b/src/locales/ms.ts
new file mode 100644
index 0000000..65a9047
--- /dev/null
+++ b/src/locales/ms.ts
@@ -0,0 +1,131 @@
+import type { Dict } from "./types";
+
+export const msDict: Dict = {
+ brand: "Perpustakaan ARK",
+ mainNav: "Menu laman",
+ home: "Laman utama",
+ all: "Semua aset",
+ categories: "Kategori",
+ latest: "Terkini",
+ official: "Pilihan rasmi",
+ popular: "Popular",
+ search: "Cari",
+ searchPlaceholder: "Cari sumber...",
+ searchPanelPlaceholder: "Cari aset...",
+ searchNow: "Cari sekarang",
+ searchSubmit: "Cari",
+ cancel: "Batal",
+ clear: "Kosongkan",
+ searchPanelHint:
+ "Carian menyokong tajuk, kategori, tag, ringkasan, jenis fail dan isi teks.",
+ currentTags: "Tag tersedia",
+ noTagsAvailable: "Belum ada tag tersedia.",
+ tagPostsTitle: "Pos berkaitan #{{tag}}",
+ noTagPosts: "Belum ada pos dengan tag ini.",
+ viewAll: "Lihat semua",
+ backToTop: "Kembali ke atas",
+ heroTitle: "Perpustakaan Rasmi ARK",
+ heroSub:
+ "Memusatkan, menyusun dan mengurus perpustakaan ARK supaya anda dapat mencari apa yang diperlukan dengan cepat dan membantu komuniti berkembang bersama.",
+ categorySection: "Kategori",
+ officialSection: "Cadangan rasmi",
+ latestSection: "Kemas kini terkini",
+ popularSection: "Aset popular",
+ preview: "Pratonton",
+ download: "Muat turun",
+ downloading: "Memuat turun…",
+ downloadOk: "Muat turun selesai",
+ downloadFail: "Muat turun gagal, sila cuba lagi",
+ longPressImageSave: "Tekan lama imej untuk simpan",
+ showMore: "Lihat semua",
+ showLess: "Tutup",
+ share: "Kongsi",
+ langLabel: "Bahasa",
+ admin: "Pentadbir",
+ login: "Log masuk",
+ logout: "Log keluar",
+ email: "E-mel",
+ password: "Kata laluan",
+ dashboard: "Papan pemuka",
+ resources: "Sumber",
+ newResource: "Sumber baharu",
+ save: "Simpan",
+ title: "Tajuk",
+ description: "Penerangan",
+ type: "Jenis",
+ language: "Bahasa",
+ category: "Kategori",
+ status: "Status",
+ public: "Awam",
+ downloadable: "Boleh dimuat turun",
+ recommended: "Pilihan",
+ cover: "URL imej muka",
+ fileUrl: "URL fail",
+ externalUrl: "Pautan luar",
+ body: "Isi teks",
+ badge: "Label lencana",
+ published: "Diterbitkan",
+ draft: "Draf",
+ archived: "Diarkibkan",
+ noResults: "Tiada hasil. Cuba kata kunci lain atau imbas kategori.",
+ copyLink: "Salin pautan",
+ related: "Berkaitan",
+ total: "Jumlah item",
+ views: "Tontonan",
+ downloads: "Muat turun",
+ lang_zh_CN: "Bahasa Cina",
+ lang_en: "Bahasa Inggeris",
+ lang_ja: "Bahasa Jepun",
+ lang_ko: "Bahasa Korea",
+ lang_vi: "Bahasa Vietnam",
+ lang_id: "Bahasa Indonesia",
+ lang_ms: "Bahasa Melayu",
+ filterAll: "Semua jenis",
+ sortPublished: "Tarikh terbit",
+ type_ppt: "PPT",
+ type_music: "Muzik",
+ type_video: "Video",
+ type_image: "Imej",
+ type_pdf: "PDF",
+ type_link: "Pautan",
+ type_text: "Teks",
+ type_archive: "Arkib",
+ type_zip: "ZIP",
+ adminLoginTitle: "Log masuk pentadbir",
+ adminEditResource: "Sunting sumber",
+ adminVideoFileHint:
+ "Muat naik fail video (MP4/WebM/MOV, dll.) dan tetapkan jenis kepada Video; laman akan main automatik (asalnya senyap — pengguna boleh hidupkan bunyi).",
+ adminStatTodayNew: "Baharu hari ini",
+ adminStatFavorites: "Kegemaran",
+ adminMetricDownloads: "Muat turun",
+ adminMetricFavorites: "Kegemaran",
+ adminMetricViews: "Tontonan",
+ edit: "Sunting",
+ backToList: "Kembali ke senarai",
+ sortOrderLabel: "Susunan",
+ previewUrlLabel: "URL pratonton",
+ tagsCommaLabel: "Tag (dipisahkan koma)",
+ uploadFile: "Muat naik",
+ loading: "Memuatkan…",
+ paginationPrev: "Sebelum",
+ paginationNext: "Seterusnya",
+ listRange: "Menunjukkan {{from}}–{{to}} daripada {{total}}",
+ pageIndicator: "Halaman {{c}} / {{p}}",
+ resourceLangFilter: "Bahasa sumber",
+ filterTagClear: "Kosongkan tag",
+ filterLanguageAll: "Semua bahasa",
+ footerAdminLogin: "Log masuk pentadbir",
+ adminSearchLogs: "Log carian",
+ adminMetricShares: "Kongsi",
+ adminSearchQuery: "Kata kunci",
+ adminSearchTime: "Masa",
+ adminSearchId: "ID",
+ favorites: "Kegemaran Saya",
+ favoritesComingSoon: "Akan Hadir",
+ favoritesComingSoonDesc:
+ "Ciri log masuk dan kegemaran sedang dibangunkan. Nantikan.",
+ featureUnavailable: "Belum tersedia",
+ featureUnavailableDesc: "Ciri ini belum tersedia.",
+ confirm: "Faham",
+ backToHome: "Kembali ke Laman Utama",
+};
diff --git a/src/locales/types.ts b/src/locales/types.ts
new file mode 100644
index 0000000..e15c507
--- /dev/null
+++ b/src/locales/types.ts
@@ -0,0 +1 @@
+export type Dict = Record
;
diff --git a/src/locales/vi.ts b/src/locales/vi.ts
new file mode 100644
index 0000000..838e961
--- /dev/null
+++ b/src/locales/vi.ts
@@ -0,0 +1,131 @@
+import type { Dict } from "./types";
+
+export const viDict: Dict = {
+ brand: "Thư viện ARK",
+ mainNav: "Menu trang web",
+ home: "Trang chủ",
+ all: "Tất cả tài liệu",
+ categories: "Danh mục",
+ latest: "Mới nhất",
+ official: "Đề xuất chính thức",
+ popular: "Phổ biến",
+ search: "Tìm kiếm",
+ searchPlaceholder: "Tìm tài liệu...",
+ searchPanelPlaceholder: "Tìm tài liệu...",
+ searchNow: "Tìm ngay",
+ searchSubmit: "Tìm kiếm",
+ cancel: "Hủy",
+ clear: "Xóa",
+ searchPanelHint:
+ "Hỗ trợ tìm theo tiêu đề, danh mục, thẻ, tóm tắt, loại tệp và nội dung.",
+ currentTags: "Thẻ hiện có",
+ noTagsAvailable: "Chưa có thẻ nào.",
+ tagPostsTitle: "Tài liệu liên quan #{{tag}}",
+ noTagPosts: "Chưa có tài liệu nào với thẻ này.",
+ viewAll: "Xem tất cả",
+ backToTop: "Lên đầu trang",
+ heroTitle: "Thư viện chính thức ARK",
+ heroSub:
+ "Tập trung, phân loại và quản lý thư viện ARK để bạn nhanh chóng tìm thấy tài nguyên cần thiết và thúc đẩy sự phát triển của cộng đồng.",
+ categorySection: "Danh mục",
+ officialSection: "Đề xuất chính thức",
+ latestSection: "Cập nhật mới",
+ popularSection: "Tài liệu phổ biến",
+ preview: "Xem trước",
+ download: "Tải xuống",
+ downloading: "Đang tải xuống…",
+ downloadOk: "Tải xuống hoàn tất",
+ downloadFail: "Tải xuống thất bại, vui lòng thử lại",
+ longPressImageSave: "Nhấn giữ ảnh để lưu",
+ showMore: "Xem tất cả",
+ showLess: "Thu gọn",
+ share: "Chia sẻ",
+ langLabel: "Ngôn ngữ",
+ admin: "Quản trị",
+ login: "Đăng nhập",
+ logout: "Đăng xuất",
+ email: "Email",
+ password: "Mật khẩu",
+ dashboard: "Bảng điều khiển",
+ resources: "Tài liệu",
+ newResource: "Tài liệu mới",
+ save: "Lưu",
+ title: "Tiêu đề",
+ description: "Mô tả",
+ type: "Loại",
+ language: "Ngôn ngữ",
+ category: "Danh mục",
+ status: "Trạng thái",
+ public: "Công khai",
+ downloadable: "Có thể tải xuống",
+ recommended: "Nổi bật",
+ cover: "URL ảnh bìa",
+ fileUrl: "URL tệp",
+ externalUrl: "Liên kết bên ngoài",
+ body: "Nội dung",
+ badge: "Nhãn đề xuất",
+ published: "Đã xuất bản",
+ draft: "Bản nháp",
+ archived: "Đã lưu trữ",
+ noResults: "Không có kết quả. Hãy thử từ khóa khác hoặc duyệt danh mục.",
+ copyLink: "Sao chép liên kết",
+ related: "Tài liệu liên quan",
+ total: "Tổng số tài liệu",
+ views: "Lượt xem",
+ downloads: "Lượt tải",
+ lang_zh_CN: "Tiếng Trung",
+ lang_en: "Tiếng Anh",
+ lang_ja: "Tiếng Nhật",
+ lang_ko: "Tiếng Hàn",
+ lang_vi: "Tiếng Việt",
+ lang_id: "Tiếng Indonesia",
+ lang_ms: "Tiếng Mã Lai",
+ filterAll: "Tất cả loại",
+ sortPublished: "Ngày xuất bản",
+ type_ppt: "PPT",
+ type_music: "Âm nhạc",
+ type_video: "Video",
+ type_image: "Hình ảnh",
+ type_pdf: "PDF",
+ type_link: "Liên kết",
+ type_text: "Văn bản",
+ type_archive: "Tệp nén",
+ type_zip: "ZIP",
+ adminLoginTitle: "Đăng nhập quản trị",
+ adminEditResource: "Chỉnh sửa tài liệu",
+ adminVideoFileHint:
+ "Tải lên tệp video (MP4/WebM/MOV, v.v.) và đặt loại là Video; trang web sẽ tự động phát (mặc định tắt tiếng, người dùng có thể bật).",
+ adminStatTodayNew: "Mới hôm nay",
+ adminStatFavorites: "Yêu thích",
+ adminMetricDownloads: "Lượt tải",
+ adminMetricFavorites: "Yêu thích",
+ adminMetricViews: "Lượt xem",
+ edit: "Chỉnh sửa",
+ backToList: "Quay lại danh sách",
+ sortOrderLabel: "Thứ tự sắp xếp",
+ previewUrlLabel: "URL xem trước",
+ tagsCommaLabel: "Thẻ (cách nhau bằng dấu phẩy)",
+ uploadFile: "Tải lên",
+ loading: "Đang tải…",
+ paginationPrev: "Trước",
+ paginationNext: "Sau",
+ listRange: "Hiển thị {{from}}–{{to}} trên {{total}}",
+ pageIndicator: "Trang {{c}} / {{p}}",
+ resourceLangFilter: "Ngôn ngữ tài liệu",
+ filterTagClear: "Xóa thẻ",
+ filterLanguageAll: "Tất cả ngôn ngữ",
+ footerAdminLogin: "Đăng nhập quản trị",
+ adminSearchLogs: "Lịch sử tìm kiếm",
+ adminMetricShares: "Chia sẻ",
+ adminSearchQuery: "Từ khóa",
+ adminSearchTime: "Thời gian",
+ adminSearchId: "ID",
+ favorites: "Yêu thích của tôi",
+ favoritesComingSoon: "Sắp ra mắt",
+ favoritesComingSoonDesc:
+ "Tính năng đăng nhập và yêu thích đang phát triển. Hãy chờ đón.",
+ featureUnavailable: "Chưa khả dụng",
+ featureUnavailableDesc: "Tính năng này hiện chưa khả dụng.",
+ confirm: "Đã hiểu",
+ backToHome: "Về trang chủ",
+};
diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts
new file mode 100644
index 0000000..df3d915
--- /dev/null
+++ b/src/locales/zh-CN.ts
@@ -0,0 +1,129 @@
+import type { Dict } from "./types";
+
+export const zhDict: Dict = {
+ brand: "ARK 资料库",
+ mainNav: "网站导航",
+ home: "首页",
+ all: "全部资料",
+ categories: "资料分类",
+ latest: "最新更新",
+ official: "官方推荐",
+ popular: "热门资料",
+ search: "搜索",
+ searchPlaceholder: "搜索资料...",
+ searchPanelPlaceholder: "搜索资料...",
+ searchNow: "立即搜索资料",
+ searchSubmit: "搜索",
+ cancel: "取消",
+ clear: "清除",
+ searchPanelHint: "支持搜索 标题・分类・标签・简介・文件类型・正文",
+ currentTags: "现有标签",
+ noTagsAvailable: "暂无可选择的标签。",
+ tagPostsTitle: "#{{tag}} 相关资料",
+ noTagPosts: "暂时找不到带有此标签的资料。",
+ viewAll: "查看全部",
+ backToTop: "回到顶部",
+ heroTitle: "ARK 官方数据库",
+ heroSub:
+ "集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。",
+ categorySection: "资料分类",
+ officialSection: "官方推荐",
+ latestSection: "最新更新",
+ popularSection: "热门资料",
+ preview: "预览",
+ download: "下载",
+ downloading: "下载中…",
+ downloadOk: "下载完成",
+ downloadFail: "下载失败,请重试",
+ longPressImageSave: "长按图片保存到相册",
+ showMore: "展开全部",
+ showLess: "收起全部",
+ share: "分享",
+ langLabel: "语言",
+ admin: "后台",
+ login: "登录",
+ logout: "退出",
+ email: "邮箱",
+ password: "密码",
+ dashboard: "仪表盘",
+ resources: "资料管理",
+ newResource: "新增资料",
+ save: "保存",
+ title: "标题",
+ description: "简介",
+ type: "类型",
+ language: "语言",
+ category: "分类",
+ status: "状态",
+ public: "公开",
+ downloadable: "可下载",
+ recommended: "首页推荐",
+ cover: "封面图 URL",
+ fileUrl: "文件 URL",
+ externalUrl: "外部链接",
+ body: "文案内容",
+ badge: "推荐标签",
+ published: "已发布",
+ draft: "草稿",
+ archived: "归档",
+ noResults: "找不到符合的资料,请换个关键字或浏览分类。",
+ copyLink: "复制链接",
+ related: "相关资料",
+ total: "总资料",
+ views: "浏览",
+ downloads: "下载",
+ lang_zh_CN: "中文",
+ lang_en: "English",
+ lang_ja: "日本語",
+ lang_ko: "한국어",
+ lang_vi: "Tiếng Việt",
+ lang_id: "Bahasa Indonesia",
+ lang_ms: "Bahasa Melayu",
+ filterAll: "全部",
+ sortPublished: "发布时间",
+ type_ppt: "PPT",
+ type_music: "音乐",
+ type_video: "视频",
+ type_image: "图片",
+ type_pdf: "PDF",
+ type_link: "链接",
+ type_text: "文字",
+ type_archive: "压缩包",
+ type_zip: "ZIP",
+ adminLoginTitle: "管理后台登录",
+ adminEditResource: "编辑资料",
+ adminVideoFileHint:
+ "上传视频文件(MP4/WebM/MOV 等),类型请选择「视频」;保存后前台自动播放(默认静音,可点喇叭开声音)。",
+ adminStatTodayNew: "今日新增",
+ adminStatFavorites: "收藏",
+ adminMetricDownloads: "下载",
+ adminMetricFavorites: "收藏",
+ adminMetricViews: "浏览",
+ edit: "编辑",
+ backToList: "返回列表",
+ sortOrderLabel: "排序权重",
+ previewUrlLabel: "预览网址",
+ tagsCommaLabel: "标签(逗号分隔)",
+ uploadFile: "上传文件",
+ loading: "加载中…",
+ paginationPrev: "上一页",
+ paginationNext: "下一页",
+ listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
+ pageIndicator: "{{c}} / {{p}} 页",
+ resourceLangFilter: "资料语言",
+ filterTagClear: "清除标签",
+ filterLanguageAll: "全部语言",
+ footerAdminLogin: "管理员登录",
+ adminSearchLogs: "搜索记录",
+ adminMetricShares: "分享",
+ adminSearchQuery: "查询词",
+ adminSearchTime: "时间",
+ adminSearchId: "编号",
+ favorites: "我的收藏",
+ favoritesComingSoon: "功能即将推出",
+ favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
+ featureUnavailable: "未开放",
+ featureUnavailableDesc: "该功能暂未开放。",
+ confirm: "知道了",
+ backToHome: "返回首页",
+};
diff --git a/src/pages/Categories/index.tsx b/src/pages/Categories/index.tsx
index 16264e3..2edbe83 100644
--- a/src/pages/Categories/index.tsx
+++ b/src/pages/Categories/index.tsx
@@ -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([]);
const [err, setErr] = useState(null);
@@ -89,7 +91,7 @@ export function CategoriesPage() {
className="h-[88px]"
>
([]);
const [rec, setRec] = useState([]);
@@ -298,7 +300,7 @@ export function Home() {
{page.map((c) => (
(
{
if (!id) {
- navigate("/browse", { replace: true });
+ navigate(lp("/browse"), { replace: true });
return;
}
if (POST_STREAM_USES_MOCK) {
const post = MOCK_POSTS.find((p) => p.id === id);
navigate(
- post ? `/browse?post=${encodeURIComponent(post.id)}` : "/browse",
+ lp(post ? `/browse?post=${encodeURIComponent(post.id)}` : "/browse"),
{
replace: true,
},
@@ -32,12 +34,12 @@ export function PostRedirect() {
`/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`,
)
.then((post) => {
- navigate(`/browse?post=${encodeURIComponent(post.id)}`, {
+ navigate(lp(`/browse?post=${encodeURIComponent(post.id)}`), {
replace: true,
});
})
- .catch(() => navigate("/browse", { replace: true }));
- }, [id, lang, navigate]);
+ .catch(() => navigate(lp("/browse"), { replace: true }));
+ }, [id, lang, navigate, lp]);
return …
;
}
diff --git a/src/pages/Search/index.tsx b/src/pages/Search/index.tsx
index bb537f0..bf20d92 100644
--- a/src/pages/Search/index.tsx
+++ b/src/pages/Search/index.tsx
@@ -1,7 +1,9 @@
import { Navigate, useSearchParams } from "react-router-dom";
+import { useLocalizedPath } from "../../useLocalizedPath";
export function SearchPage() {
const [sp] = useSearchParams();
+ const lp = useLocalizedPath();
const query = sp.toString();
- return ;
+ return ;
}
diff --git a/src/types/post.ts b/src/types/post.ts
index 4ffe78b..f66c1de 100644
--- a/src/types/post.ts
+++ b/src/types/post.ts
@@ -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;
};
/**
diff --git a/src/useLocalizedPath.ts b/src/useLocalizedPath.ts
new file mode 100644
index 0000000..b145ffa
--- /dev/null
+++ b/src/useLocalizedPath.ts
@@ -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 `` 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]);
+}