From e7a5952d58aa4be1feef99325cbbc58b1778f99c Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 26 May 2026 07:36:53 +0800 Subject: [PATCH] feat: align frontend languages with posts api --- ...05-25-frontend-backend-api-requirements.md | 103 +-- .../specs/2026-05-25-posts-api-contract.md | 81 +- README.md | 18 +- src/admin/useAdminT.ts | 4 +- src/components/messageStream/FilterChips.tsx | 13 +- .../messageStream/MessageStream.tsx | 3 +- .../hooks/useGroupedByDay.test.ts | 6 +- .../messageStream/hooks/useGroupedByDay.ts | 19 +- .../messageStream/hooks/usePostStream.ts | 1 + .../messageStream/utils/formatTime.ts | 13 +- src/i18n.tsx | 705 +++++++++--------- src/i18nLanguages.ts | 17 + src/layouts/PublicLayout.tsx | 24 +- src/mocks/mockPosts.ts | 26 +- src/pages/SearchPage.tsx | 13 +- src/pages/admin/AdminResourceForm.tsx | 15 +- src/pages/admin/AdminResources.tsx | 2 +- src/resourceTypeLabels.test.ts | 26 +- src/resourceTypeLabels.ts | 20 +- 19 files changed, 565 insertions(+), 544 deletions(-) create mode 100644 src/i18nLanguages.ts diff --git a/.unipi/docs/specs/2026-05-25-frontend-backend-api-requirements.md b/.unipi/docs/specs/2026-05-25-frontend-backend-api-requirements.md index 25238f5..e1f9c90 100644 --- a/.unipi/docs/specs/2026-05-25-frontend-backend-api-requirements.md +++ b/.unipi/docs/specs/2026-05-25-frontend-backend-api-requirements.md @@ -15,7 +15,7 @@ status: draft - API base:前端通过 `VITE_API_URL` 指向后端;本地可同源 `/api`。 - 上传文件可通过 `/uploads/...` 或完整 URL 返回;前端会用 `assetUrl()` 处理相对路径。 - 所有时间字段使用 ISO 8601 字符串,例如 `2026-05-24T14:42:00.000Z`。 -- 语言字段:`zh-TW` / `zh-CN` / `en`。 +- 语言字段:`zh` / `en` / `ja` / `ko` / `vi` / `id` / `ms`;默认语言为 `en`。中文只有简体 `zh`,没有繁体中文。 - 错误格式:非 2xx + text/plain 或 JSON 均可;前端会显示错误文本。 - Admin 接口需要 `Authorization: Bearer `。 @@ -26,10 +26,10 @@ status: draft ```ts type Category = { id: number; - slug: string; // 用于 /category/:slug 和 GET /api/posts?category= - name: string; // 已按 lang 返回本地化名称 + slug: string; // 用于 /category/:slug 和 GET /api/posts?category= + name: string; // 已按 lang 返回本地化名称 description?: string; - iconKey: string; // folder/calendar/megaphone/video/image 等,前端已有 icon map + iconKey: string; // folder/calendar/megaphone/video/image 等,前端已有 icon map sortOrder: number; }; ``` @@ -42,22 +42,22 @@ type AttachmentKind = "image" | "video" | "document"; type Attachment = { id: string; kind: AttachmentKind; - url: string; // 原始文件或可访问文件 URL - mime: string; // image/jpeg, application/pdf, video/mp4, ... + url: string; // 原始文件或可访问文件 URL + mime: string; // image/jpeg, application/pdf, video/mp4, ... filename: string; sizeBytes: number; - width?: number; // image/video 建议提供 + width?: number; // image/video 建议提供 height?: number; - durationSec?: number; // video 建议提供 - posterUrl?: string; // video preview - thumbnailUrl?: string; // image/document preview + durationSec?: number; // video 建议提供 + posterUrl?: string; // video preview + thumbnailUrl?: string; // image/document preview }; type Post = { id: string; categoryId: number; categorySlug: string; - language: "zh-TW" | "zh-CN" | "en"; + language: "zh" | "en" | "ja" | "ko" | "vi" | "id" | "ms"; text?: string; attachments: Attachment[]; isRecommended: boolean; @@ -88,7 +88,7 @@ type PostListResponse = { ### 2.1 分类列表 ```http -GET /api/categories?lang=zh-CN +GET /api/categories?lang=en ``` Response: @@ -104,29 +104,30 @@ Category[] ### 2.2 全部资料 / 分类资料流 ```http -GET /api/posts?lang=zh-CN&limit=20&cursor=&type=all&language=&category= +GET /api/posts?lang=en&limit=20&cursor=&type=all&language=&category= ``` Query: -| 参数 | 必填 | 说明 | -|---|---:|---| -| `lang` | 是 | UI 语言 | -| `limit` | 否 | 默认 20,最大建议 50 | -| `cursor` | 否 | 后端返回的不透明 cursor | -| `category` | 否 | 不传 = 全部资料;传 slug = 单分类 | -| `type` | 否 | `all/image/video/ppt/pdf/text/link/archive` | -| `language` | 否 | 资料语言:`zh-TW/zh-CN/en` | +| 参数 | 必填 | 说明 | +| ---------- | ---: | ------------------------------------------- | +| `lang` | 是 | UI 语言 | +| `limit` | 否 | 默认 20,最大建议 50 | +| `cursor` | 否 | 后端返回的不透明 cursor | +| `category` | 否 | 不传 = 全部资料;传 slug = 单分类 | +| `type` | 否 | `all/image/video/ppt/pdf/text/link/archive` | +| `language` | 否 | 资料语言:`zh/en/ja/ko/vi/id/ms` | Response: ```ts -PostListResponse +PostListResponse; ``` 排序:`publishedAt DESC`。 用途: + - `/browse`:不传 `category` - `/category/:slug`:传 `category=` @@ -135,7 +136,7 @@ PostListResponse ### 2.3 Home 推荐资料 ```http -GET /api/posts/recommended?lang=zh-CN&limit=12 +GET /api/posts/recommended?lang=en&limit=12 ``` Response: @@ -153,7 +154,7 @@ Response: ### 2.4 Home 最新资料 ```http -GET /api/posts/latest?lang=zh-CN&limit=8 +GET /api/posts/latest?lang=en&limit=8 ``` Response: @@ -177,7 +178,7 @@ GET /api/posts/:id Response: ```ts -Post +Post; ``` 用途:旧 `/resource/:id` 前端重定向:拿 `categorySlug` 后跳到 `/category/#post-`。 @@ -189,13 +190,13 @@ Post 建议新接口: ```http -GET /api/posts/search?q=&lang=zh-CN&type=all&language=&cursor=&limit=20 +GET /api/posts/search?q=&lang=en&type=all&language=&cursor=&limit=20 ``` Response: ```ts -PostListResponse +PostListResponse; ``` 搜索范围建议:`text`、`filename`、`categoryName`、tags。 @@ -245,16 +246,16 @@ Response:204 或 `{ ok: true }`。 `GET /api/posts` 的 `type` 参数建议按以下规则命中: -| type | 命中条件 | -|---|---| -| `all` | 全部 | -| `image` | 任一 attachment `kind === "image"` | -| `video` | 任一 attachment `kind === "video"` 或 `mime.startsWith("video/")` | -| `pdf` | 任一 attachment 扩展名 `pdf` 或 `mime === "application/pdf"` | -| `ppt` | 任一 attachment 扩展名 `ppt/pptx/key` 或 mime 含 `presentation` | -| `archive` | 任一 attachment 扩展名 `zip/rar/7z/tar/gz` | -| `text` | `text` 非空 | -| `link` | `text` 中包含 `https?://` | +| type | 命中条件 | +| --------- | ----------------------------------------------------------------- | +| `all` | 全部 | +| `image` | 任一 attachment `kind === "image"` | +| `video` | 任一 attachment `kind === "video"` 或 `mime.startsWith("video/")` | +| `pdf` | 任一 attachment 扩展名 `pdf` 或 `mime === "application/pdf"` | +| `ppt` | 任一 attachment 扩展名 `ppt/pptx/key` 或 mime 含 `presentation` | +| `archive` | 任一 attachment 扩展名 `zip/rar/7z/tar/gz` | +| `text` | `text` 非空 | +| `link` | `text` 中包含 `https?://` | ## 4. Wallet Auth API @@ -270,7 +271,9 @@ Content-Type: application/json Response: ```ts -{ message: string } +{ + message: string; +} ``` ### 4.2 验证签名并签发 token @@ -289,7 +292,9 @@ Content-Type: application/json Response: ```ts -{ token: string } +{ + token: string; +} ``` ### 4.3 验证当前 wallet session @@ -302,7 +307,9 @@ Authorization: Bearer Response: ```ts -{ wallet: string } +{ + wallet: string; +} ``` ## 5. Admin API @@ -319,7 +326,9 @@ Content-Type: application/json Response: ```ts -{ token: string } +{ + token: string; +} ``` --- @@ -335,18 +344,18 @@ Response: ```ts type AdminDashboard = { - totalResources: number; // 若迁移到 Post,可理解为 totalPosts + totalResources: number; // 若迁移到 Post,可理解为 totalPosts published: number; todayNew: number; totalViews: number; totalDownloads: number; - totalFavorites: number; // 收藏下线后可返回 0,避免旧 admin UI 崩 + totalFavorites: number; // 收藏下线后可返回 0,避免旧 admin UI 崩 totalShares: number; hotResources: { id: string; title: string; downloads: number; - favorites: number; // 可返回 0 + favorites: number; // 可返回 0 views: number; }[]; }; @@ -367,7 +376,9 @@ file= 最低 Response: ```ts -{ url: string } +{ + url: string; +} ``` 建议 Response(更方便前端自动建 Attachment): @@ -438,7 +449,7 @@ Request: ```ts type UpsertPostPayload = { categoryId: number; - language: "zh-TW" | "zh-CN" | "en"; + language: "zh" | "en" | "ja" | "ko" | "vi" | "id" | "ms"; text?: string; attachments: Attachment[]; isPublic: boolean; diff --git a/.unipi/docs/specs/2026-05-25-posts-api-contract.md b/.unipi/docs/specs/2026-05-25-posts-api-contract.md index cdf2987..7f084eb 100644 --- a/.unipi/docs/specs/2026-05-25-posts-api-contract.md +++ b/.unipi/docs/specs/2026-05-25-posts-api-contract.md @@ -16,34 +16,34 @@ status: draft type AttachmentKind = "image" | "video" | "document"; type Attachment = { - id: string; // 唯一 id - kind: AttachmentKind; // 三大类,前端按此分支渲染 - url: string; // 原始文件地址 - mime: string; // image/jpeg, application/pdf, video/mp4, ... - filename: string; // 显示用文件名,含扩展名 - sizeBytes: number; // 字节数;前端格式化为 "3.5 MB" - width?: number; // image/video 用于占位比例(CLS 优化) + id: string; // 唯一 id + kind: AttachmentKind; // 三大类,前端按此分支渲染 + url: string; // 原始文件地址 + mime: string; // image/jpeg, application/pdf, video/mp4, ... + filename: string; // 显示用文件名,含扩展名 + sizeBytes: number; // 字节数;前端格式化为 "3.5 MB" + width?: number; // image/video 用于占位比例(CLS 优化) height?: number; - durationSec?: number; // video 专用 - posterUrl?: string; // video 海报缩略图 - thumbnailUrl?: string; // image 缩略,列表用减少流量 + durationSec?: number; // video 专用 + posterUrl?: string; // video 海报缩略图 + thumbnailUrl?: string; // image 缩略,列表用减少流量 }; type Post = { id: string; categoryId: number; categorySlug: string; - language: string; // "zh-TW" | "zh-CN" | "en" - text?: string; // 可选,纯文本/图说;前端做 https → 链接自动识别 - attachments: Attachment[]; // 0~N;text-only post 时为 [] + language: string; // "zh" | "en" | "ja" | "ko" | "vi" | "id" | "ms" + text?: string; // 可选,纯文本/图说;前端做 https → 链接自动识别 + attachments: Attachment[]; // 0~N;text-only post 时为 [] isRecommended: boolean; - publishedAt: string; // ISO 8601;用于排序 + 日期分组 + publishedAt: string; // ISO 8601;用于排序 + 日期分组 updatedAt: string; }; type PostListResponse = { items: Post[]; - nextCursor?: string; // 不透明 cursor;undefined = 没有下一页 + nextCursor?: string; // 不透明 cursor;undefined = 没有下一页 }; ``` @@ -67,14 +67,14 @@ GET /api/posts Query 参数: -| 参数 | 必填 | 说明 | -|---|---|---| -| `lang` | 是 | UI 语言;后端可据此选择不同语言版本的 `text` | -| `category` | 否 | category slug;不传 = 全部分类 | -| `type` | 否 | `all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`;语义见 §3 | -| `language` | 否 | 资源语言:`zh-TW` / `zh-CN` / `en` | -| `cursor` | 否 | 上一次返回的 `nextCursor`;不传 = 第一页 | -| `limit` | 否 | 默认 20,最大 50 | +| 参数 | 必填 | 说明 | +| ---------- | ---- | ---------------------------------------------------------------------------------- | +| `lang` | 是 | UI 语言;后端可据此选择不同语言版本的 `text` | +| `category` | 否 | category slug;不传 = 全部分类 | +| `type` | 否 | `all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`;语义见 §3 | +| `language` | 否 | 资源语言:`zh` / `en` / `ja` / `ko` / `vi` / `id` / `ms` | +| `cursor` | 否 | 上一次返回的 `nextCursor`;不传 = 第一页 | +| `limit` | 否 | 默认 20,最大 50 | 返回:`PostListResponse` @@ -117,6 +117,7 @@ GET /api/admin/posts?... (含未发布草稿) ``` 需求: + - 支持多附件上传(一次 multipart 或先 `POST /api/admin/upload` 拿到 url 再创建 Post)。 - Admin UI 需要一个开关:「图片以图片形式呈现 / 以文档形式呈现」,对应 attachment.kind 的 image vs document。 - 支持发布/隐藏、置顶/官方推荐。 @@ -127,28 +128,28 @@ GET /api/admin/posts?... (含未发布草稿) 一个 Post 命中某个 `type`,规则: -| type | 命中条件 | -|---|---| -| `all` | 全部 | -| `image` | `attachments` 中至少一个 `kind === "image"` 或 `mime.startsWith("image/")` | -| `video` | 至少一个 `kind === "video"` 或 `mime.startsWith("video/")` | -| `pdf` | 至少一个 `mime === "application/pdf"` 或扩展名为 `pdf` | -| `ppt` | 至少一个扩展名为 `ppt` / `pptx` / `key` 或 mime 含 `presentation` | -| `archive` | 至少一个扩展名为 `zip` / `rar` / `7z` / `tar` / `gz` | -| `text` | `text` 非空 | -| `link` | `text` 非空且匹配 `https?://` | +| type | 命中条件 | +| --------- | -------------------------------------------------------------------------- | +| `all` | 全部 | +| `image` | `attachments` 中至少一个 `kind === "image"` 或 `mime.startsWith("image/")` | +| `video` | 至少一个 `kind === "video"` 或 `mime.startsWith("video/")` | +| `pdf` | 至少一个 `mime === "application/pdf"` 或扩展名为 `pdf` | +| `ppt` | 至少一个扩展名为 `ppt` / `pptx` / `key` 或 mime 含 `presentation` | +| `archive` | 至少一个扩展名为 `zip` / `rar` / `7z` / `tar` / `gz` | +| `text` | `text` 非空 | +| `link` | `text` 非空且匹配 `https?://` | 前端 mock 已按此规则过滤,便于切真接口时口径一致。 ## 4. 删除 / 废弃 -| 项 | 处理 | -|---|---| -| `POST /api/resources/:id/favorite` | 删除 | -| `GET /api/favorites` / 收藏列表 | 删除(前端 `/favorites` 路由已移除) | -| `/r/:id` 老前端路由 | 已合并到 `/resource/:id` 重定向 | -| 老 `/api/resources*` 系列 | 后端可保留过渡期。建议提供数据迁移脚本:每个老 Resource → 一个 Post(带 1 个 attachment 或 text-only)。`isRecommended` / `language` / `categorySlug` 字段迁移;`favorite count` 字段丢弃。 | -| Resource.coverImage 与 Resource.fileUrl 二选一 | 转为 attachments[0](kind 由后端判断 image vs document)| +| 项 | 处理 | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST /api/resources/:id/favorite` | 删除 | +| `GET /api/favorites` / 收藏列表 | 删除(前端 `/favorites` 路由已移除) | +| `/r/:id` 老前端路由 | 已合并到 `/resource/:id` 重定向 | +| 老 `/api/resources*` 系列 | 后端可保留过渡期。建议提供数据迁移脚本:每个老 Resource → 一个 Post(带 1 个 attachment 或 text-only)。`isRecommended` / `language` / `categorySlug` 字段迁移;`favorite count` 字段丢弃。 | +| Resource.coverImage 与 Resource.fileUrl 二选一 | 转为 attachments[0](kind 由后端判断 image vs document) | ## 5. Search diff --git a/README.md b/README.md index 228de98..c909612 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,14 @@ npm test Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template. -| Variable | Purpose | -| --- | --- | -| `VITE_API_URL` | API/upload origin. Empty means same-origin and Vite dev proxy handles local `/api` and `/uploads`. Production deploy currently uses `https://api.ark-library.com`. | -| `VITE_WALLETCONNECT_PROJECT_ID` | Reown / WalletConnect project id. Needed for QR/mobile wallet connection. | -| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. | -| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. | -| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. | -| `VITE_USE_MOCK_POSTS` | Telegram-style resource stream (`/browse`, `/category/:slug`) uses mock posts from `src/mocks/mockPosts.ts` when set to `true` (default while backend `/api/posts` is not yet shipped). Set to `"false"` to hit the real API. See `.unipi/docs/specs/2026-05-25-posts-api-contract.md`. | +| Variable | Purpose | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `VITE_API_URL` | API/upload origin. Empty means same-origin and Vite dev proxy handles local `/api` and `/uploads`. Production deploy currently uses `https://api.ark-library.com`. | +| `VITE_WALLETCONNECT_PROJECT_ID` | Reown / WalletConnect project id. Needed for QR/mobile wallet connection. | +| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. | +| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. | +| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. | +| `VITE_USE_MOCK_POSTS` | Telegram-style resource stream (`/browse`, `/category/:slug`) uses mock posts from `src/mocks/mockPosts.ts` when set to `true` (default while backend `/api/posts` is not yet shipped). Set to `"false"` to hit the real API. See `.unipi/docs/specs/2026-05-25-posts-api-contract.md`. | ## Project layout @@ -68,7 +68,7 @@ src/ App.tsx # public app + optional admin routes AppAdminOnly.tsx # admin-only app entry api.ts # fetch helpers and shared API types - i18n.tsx # zh-TW / zh-CN / en copy dictionary + i18n.tsx # zh / en / ja / ko / vi / id / ms dictionary adminPaths.ts # admin UI prefix logic adminRouteTree.tsx # admin routes components/ # reusable public components diff --git a/src/admin/useAdminT.ts b/src/admin/useAdminT.ts index bb4c4d0..81904cf 100644 --- a/src/admin/useAdminT.ts +++ b/src/admin/useAdminT.ts @@ -1,7 +1,7 @@ import { useCallback } from "react"; import { tLang } from "../i18n"; -/** Admin area always uses 繁體中文, independent of site language. */ +/** Admin area always uses Chinese, independent of site language. */ export function useAdminT() { - return useCallback((key: string) => tLang("zh-TW", key), []); + return useCallback((key: string) => tLang("zh", key), []); } diff --git a/src/components/messageStream/FilterChips.tsx b/src/components/messageStream/FilterChips.tsx index fac399b..37bc863 100644 --- a/src/components/messageStream/FilterChips.tsx +++ b/src/components/messageStream/FilterChips.tsx @@ -1,10 +1,12 @@ import { useI18n } from "../../i18n"; +import { LANG_OPTIONS, languageLabel } from "../../i18nLanguages"; import { typeFilterLabel } from "../../resourceTypeLabels"; const TYPE_FILTERS = [ "all", "image", "video", + "music", "ppt", "pdf", "text", @@ -12,14 +14,7 @@ const TYPE_FILTERS = [ "archive", ] as const; -const LANG_FILTERS = ["", "zh-TW", "zh-CN", "en"] as const; - -function langLabel(t: (k: string) => string, code: string) { - if (!code) return t("filterLanguageAll"); - if (code === "zh-TW") return t("lang_zh_TW"); - if (code === "zh-CN") return t("lang_zh_CN"); - return t("lang_en"); -} +const LANG_FILTERS = ["", ...LANG_OPTIONS.map((x) => x.code)] as const; export type FilterChipsProps = { type: string; @@ -70,7 +65,7 @@ export function FilterChips({ : "border-ark-line text-neutral-300 hover:border-ark-gold/50" }`} > - {langLabel(t, code)} + {languageLabel(t, code)} ); })} diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index 8e18ec9..b473f27 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -26,8 +26,7 @@ export function MessageStream({ scope }: MessageStreamProps) { const { items, isLoading, error, hasMore, loadMore, reset } = usePostStream(params); const groups = useGroupedByDay(items, lang); - const retryLabel = - lang === "zh-TW" ? "重試" : lang === "zh-CN" ? "重试" : "Retry"; + const retryLabel = lang === "zh" ? "重试" : "Retry"; const sentinelRef = useRef(null); const hasMoreRef = useRef(hasMore); diff --git a/src/components/messageStream/hooks/useGroupedByDay.test.ts b/src/components/messageStream/hooks/useGroupedByDay.test.ts index 6c33ee5..487c1c4 100644 --- a/src/components/messageStream/hooks/useGroupedByDay.test.ts +++ b/src/components/messageStream/hooks/useGroupedByDay.test.ts @@ -8,7 +8,7 @@ function makePost(id: string, isoDate: string): Post { id, categoryId: 1, categorySlug: "x", - language: "zh-CN", + language: "zh", attachments: [], isRecommended: false, publishedAt: isoDate, @@ -25,7 +25,7 @@ describe("useGroupedByDay", () => { makePost("c", "2026-02-28T01:00:00.000Z"), makePost("d", "2026-05-16T12:00:00.000Z"), ]; - const { result } = renderHook(() => useGroupedByDay(posts, "zh-CN")); + const { result } = renderHook(() => useGroupedByDay(posts, "zh")); expect(result.current.length).toBeGreaterThanOrEqual(2); const allIds = result.current.flatMap((g) => g.items.map((p) => p.id)); expect(allIds).toEqual(["a", "b", "c", "d"]); @@ -47,7 +47,7 @@ describe("useGroupedByDay", () => { }); it("returns empty array for empty input", () => { - const { result } = renderHook(() => useGroupedByDay([], "zh-CN")); + const { result } = renderHook(() => useGroupedByDay([], "zh")); expect(result.current).toEqual([]); }); }); diff --git a/src/components/messageStream/hooks/useGroupedByDay.ts b/src/components/messageStream/hooks/useGroupedByDay.ts index c757668..2837a99 100644 --- a/src/components/messageStream/hooks/useGroupedByDay.ts +++ b/src/components/messageStream/hooks/useGroupedByDay.ts @@ -8,9 +8,16 @@ export type DayGroup = { }; function localeFor(lang: string): string { - if (lang === "zh-TW") return "zh-TW"; - if (lang === "zh-CN") return "zh-CN"; - return "en-US"; + const locales: Record = { + zh: "zh-CN", + en: "en-US", + ja: "ja-JP", + ko: "ko-KR", + vi: "vi-VN", + id: "id-ID", + ms: "ms-MY", + }; + return locales[lang] ?? "en-US"; } function dayKey(iso: string): string { @@ -28,12 +35,10 @@ function dayLabel(iso: string, lang: string): string { a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); if (isSameDay(d, today)) { - if (lang === "en") return "Today"; - return "今天"; + return lang === "zh" ? "今天" : "Today"; } if (isSameDay(d, yesterday)) { - if (lang === "en") return "Yesterday"; - return "昨天"; + return lang === "zh" ? "昨天" : "Yesterday"; } return new Intl.DateTimeFormat(localeFor(lang), { month: "long", diff --git a/src/components/messageStream/hooks/usePostStream.ts b/src/components/messageStream/hooks/usePostStream.ts index 28af1b4..c25dddb 100644 --- a/src/components/messageStream/hooks/usePostStream.ts +++ b/src/components/messageStream/hooks/usePostStream.ts @@ -35,6 +35,7 @@ function postMatchesType(post: Post, type: string): boolean { return a.kind === "image" || a.mime.startsWith("image/"); if (type === "video") return a.kind === "video" || a.mime.startsWith("video/"); + if (type === "music") return a.mime.startsWith("audio/") || ext === "mp3"; if (type === "pdf") return ext === "pdf" || a.mime === "application/pdf"; if (type === "ppt") return ( diff --git a/src/components/messageStream/utils/formatTime.ts b/src/components/messageStream/utils/formatTime.ts index acf5133..4e07164 100644 --- a/src/components/messageStream/utils/formatTime.ts +++ b/src/components/messageStream/utils/formatTime.ts @@ -1,7 +1,14 @@ function localeFor(lang: string): string { - if (lang === "zh-TW") return "zh-TW"; - if (lang === "zh-CN") return "zh-CN"; - return "en-US"; + const locales: Record = { + zh: "zh-CN", + en: "en-US", + ja: "ja-JP", + ko: "ko-KR", + vi: "vi-VN", + id: "id-ID", + ms: "ms-MY", + }; + return locales[lang] ?? "en-US"; } function formatDate(iso: string, lang: string): string { diff --git a/src/i18n.tsx b/src/i18n.tsx index ae191e7..31a70d9 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -6,377 +6,340 @@ import React, { useState, } from "react"; -export type Lang = "zh-TW" | "zh-CN" | "en"; +export type Lang = "zh" | "en" | "ja" | "ko" | "vi" | "id" | "ms"; type Dict = Record; -const dict: Record = { - "zh-TW": { - brand: "ARK 資料庫", - mainNav: "網站導覽", - home: "首頁", - all: "全部資料", - categories: "分類瀏覽", - latest: "最新更新", - official: "官方推薦", - popular: "熱門資料", - search: "搜尋", - searchPlaceholder: "搜尋資料...", - searchNow: "立即搜尋資料", - viewAll: "查看全部", - heroTitle: "ARK 官方資料庫", - heroSub: - "集中、分類、管理 ARK 資料庫,讓你快速找到所需資源,推動社群共識與成長。", - categorySection: "資料分類", - officialSection: "官方推薦", - latestSection: "最新更新", - popularSection: "熱門資料", - preview: "預覽", - download: "下載", - favorite: "收藏", - share: "分享", - profile: "個人中心", - 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: "下載", - wallet: "錢包", - walletPageTitle: "錢包登入", - walletPageIntro: - "連接 Web3 錢包以使用會員相關功能。採用標準簽名登入,不會發送交易、不消耗 gas。", - walletStepExtension: - "電腦已安裝擴充錢包(如 MetaMask)時,可直接在瀏覽器連線。", - walletStepQR: - "電腦未安裝錢包時:在連線視窗選擇 WalletConnect,用手機錢包掃描畫面上的 QR Code 即可連線。", - walletStepSign: - "連線成功後,點「簽署登入」並在錢包內簽署訊息,即完成網站身分驗證。", - signInWallet: "簽署登入", - walletSignedIn: "已驗證登入", - walletLogout: "登出錢包", - walletMissingProjectId: - "請設定 VITE_WALLETCONNECT_PROJECT_ID(Reown Cloud 免費申請),否則無法使用 WalletConnect/手機掃碼。", - walletSetupNeeded: "錢包掃碼未啟用(請於伺服器設定環境變數)", - lang_zh_TW: "繁體中文", - lang_zh_CN: "简体中文", +const zhDict: Dict = { + brand: "ARK 数据库", + mainNav: "网站导航", + home: "首页", + all: "全部资料", + categories: "分类浏览", + latest: "最新更新", + official: "官方推荐", + popular: "热门资料", + search: "搜索", + searchPlaceholder: "搜索资料...", + searchNow: "立即搜索资料", + viewAll: "查看全部", + heroTitle: "ARK 官方数据库", + heroSub: + "集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。", + categorySection: "资料分类", + officialSection: "官方推荐", + latestSection: "最新更新", + popularSection: "热门资料", + preview: "预览", + download: "下载", + share: "分享", + profile: "个人中心", + 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: "下载", + wallet: "钱包", + walletPageTitle: "钱包登录", + walletPageIntro: + "连接 Web3 钱包以使用会员相关功能。采用标准签名登录,不发送交易、不消耗 gas。", + walletStepExtension: + "电脑已安装浏览器扩展钱包(如 MetaMask)时,可直接连接。", + walletStepQR: + "电脑未安装钱包时:在连接窗口选择 WalletConnect,用手机钱包扫描 QR Code。", + walletStepSign: "连接成功后,点击「签署登录」并在钱包内签名即可完成验证。", + signInWallet: "签署登录", + walletSignedIn: "已验证登录", + walletLogout: "退出钱包", + walletMissingProjectId: + "请配置 VITE_WALLETCONNECT_PROJECT_ID(Reown Cloud),否则无法使用 WalletConnect/扫码。", + walletSetupNeeded: "钱包扫码未启用(请在服务器配置环境变量)", + lang_zh: "中文", + 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: "全部语言", + aboutTitle: "关于本站", + aboutIntro: + "ARK 数据库汇总官方教材、公告、视频与常用文件,帮助社区快速获取一致版本的可信内容。\n\n本站仅供展示与索引;权利归属以官方公告为准。", + footerAbout: "关于本站", + footerAdminLogin: "管理员登录", + adminSearchLogs: "搜索记录", + adminMetricShares: "分享", + adminSearchQuery: "查询词", + adminSearchTime: "时间", + adminSearchId: "编号", +}; + +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...", + searchNow: "Search now", + viewAll: "View all", + 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", + share: "Share", + profile: "Profile", + 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", + wallet: "Wallet", + walletPageTitle: "Wallet sign-in", + walletPageIntro: + "Connect a Web3 wallet for member features. This uses a standard signed message — no transaction and no gas.", + walletStepExtension: + "On desktop with a browser extension (e.g. MetaMask), connect directly.", + walletStepQR: + "On desktop without an extension: choose WalletConnect in the modal and scan the QR code with your mobile wallet.", + walletStepSign: + 'After connecting, tap "Sign in" and approve the message in your wallet to verify.', + signInWallet: "Sign in", + walletSignedIn: "Signed in", + walletLogout: "Disconnect", + walletMissingProjectId: + "Set VITE_WALLETCONNECT_PROJECT_ID (free on Reown Cloud). Required for WalletConnect / QR login.", + walletSetupNeeded: "Wallet QR login disabled (set env on server)", + lang_zh: "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", + aboutTitle: "About this site", + aboutIntro: + "The ARK library brings together official decks, announcements, videos, and common files so the community can find consistent, trustworthy versions quickly.\n\nThis site is for discovery and indexing only; rights remain with official notices.", + footerAbout: "About", + footerAdminLogin: "Admin sign-in", + adminSearchLogs: "Search logs", + adminMetricShares: "Shares", + adminSearchQuery: "Query", + adminSearchTime: "Time", + adminSearchId: "ID", +}; + +const languageNames: Record = { + zh: { + lang_zh: "中文", lang_en: "English", - filterAll: "全部", - sortPublished: "發布時間", - type_ppt: "PPT", - 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: "全部語言", - aboutTitle: "關於本站", - aboutIntro: - "ARK 資料庫彙整官方教材、公告、影片與常用檔案,協助社群快速取得一致版本的可信內容。\n\n本站僅作展示與索引;資料權利仍以官方公告為準。", - footerAbout: "關於本站", - footerAdminLogin: "管理員登入", - adminSearchLogs: "搜尋紀錄", - adminMetricShares: "分享", - adminSearchQuery: "查詢詞", - adminSearchTime: "時間", - adminSearchId: "編號", - }, - "zh-CN": { - brand: "ARK 数据库", - mainNav: "网站导航", - home: "首页", - all: "全部资料", - categories: "分类浏览", - latest: "最新更新", - official: "官方推荐", - popular: "热门资料", - search: "搜索", - searchPlaceholder: "搜索资料...", - searchNow: "立即搜索资料", - viewAll: "查看全部", - heroTitle: "ARK 官方数据库", - heroSub: - "集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。", - categorySection: "资料分类", - officialSection: "官方推荐", - latestSection: "最新更新", - popularSection: "热门资料", - preview: "预览", - download: "下载", - favorite: "收藏", - share: "分享", - profile: "个人中心", - 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: "下载", - wallet: "钱包", - walletPageTitle: "钱包登录", - walletPageIntro: - "连接 Web3 钱包以使用会员相关功能。采用标准签名登录,不发送交易、不消耗 gas。", - walletStepExtension: - "电脑已安装浏览器扩展钱包(如 MetaMask)时,可直接连接。", - walletStepQR: - "电脑未安装钱包时:在连接窗口选择 WalletConnect,用手机钱包扫描 QR Code。", - walletStepSign: "连接成功后,点击「签署登录」并在钱包内签名即可完成验证。", - signInWallet: "签署登录", - walletSignedIn: "已验证登录", - walletLogout: "退出钱包", - walletMissingProjectId: - "请配置 VITE_WALLETCONNECT_PROJECT_ID(Reown Cloud),否则无法使用 WalletConnect/扫码。", - walletSetupNeeded: "钱包扫码未启用(请在服务器配置环境变量)", - lang_zh_TW: "繁体中文", - lang_zh_CN: "简体中文", - lang_en: "English", - filterAll: "全部", - sortPublished: "发布时间", - type_ppt: "PPT", - 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: "全部语言", - aboutTitle: "关于本站", - aboutIntro: - "ARK 数据库汇总官方教材、公告、视频与常用文件,帮助社区快速获取一致版本的可信内容。\n\n本站仅供展示与索引;权利归属以官方公告为准。", - footerAbout: "关于本站", - footerAdminLogin: "管理员登录", - adminSearchLogs: "搜索记录", - adminMetricShares: "分享", - adminSearchQuery: "查询词", - adminSearchTime: "时间", - adminSearchId: "编号", + lang_ja: "日本語", + lang_ko: "한국어", + lang_vi: "Tiếng Việt", + lang_id: "Bahasa Indonesia", + lang_ms: "Bahasa Melayu", }, en: { - 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...", - searchNow: "Search now", - viewAll: "View all", - 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", - share: "Share", - profile: "Profile", - 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", - wallet: "Wallet", - walletPageTitle: "Wallet sign-in", - walletPageIntro: - "Connect a Web3 wallet for member features. This uses a standard signed message — no transaction and no gas.", - walletStepExtension: - "On desktop with a browser extension (e.g. MetaMask), connect directly.", - walletStepQR: - "On desktop without an extension: choose WalletConnect in the modal and scan the QR code with your mobile wallet.", - walletStepSign: - 'After connecting, tap "Sign in" and approve the message in your wallet to verify.', - signInWallet: "Sign in", - walletSignedIn: "Signed in", - walletLogout: "Disconnect", - walletMissingProjectId: - "Set VITE_WALLETCONNECT_PROJECT_ID (free on Reown Cloud). Required for WalletConnect / QR login.", - walletSetupNeeded: "Wallet QR login disabled (set env on server)", - lang_zh_TW: "Traditional Chinese", - lang_zh_CN: "Simplified Chinese", + lang_zh: "Chinese", lang_en: "English", - filterAll: "All types", - sortPublished: "Published date", - type_ppt: "PPT", - 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", - aboutTitle: "About this site", - aboutIntro: - "The ARK library brings together official decks, announcements, videos, and common files so the community can find consistent, trustworthy versions quickly.\n\nThis site is for discovery and indexing only; rights remain with official notices.", - footerAbout: "About", - footerAdminLogin: "Admin sign-in", - adminSearchLogs: "Search logs", - adminMetricShares: "Shares", - adminSearchQuery: "Query", - adminSearchTime: "Time", - adminSearchId: "ID", + lang_ja: "Japanese", + lang_ko: "Korean", + lang_vi: "Vietnamese", + lang_id: "Indonesian", + lang_ms: "Malay", + }, + ja: { + lang_zh: "中国語", + lang_en: "英語", + lang_ja: "日本語", + lang_ko: "韓国語", + lang_vi: "ベトナム語", + lang_id: "インドネシア語", + lang_ms: "マレー語", + }, + ko: { + lang_zh: "중국어", + lang_en: "영어", + lang_ja: "일본어", + lang_ko: "한국어", + lang_vi: "베트남어", + lang_id: "인도네시아어", + lang_ms: "말레이어", + }, + vi: { + lang_zh: "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: { + lang_zh: "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: { + lang_zh: "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", }, }; -/** Fixed locale lookup (for admin UI always in Traditional Chinese). */ +const dict: Record = { + zh: { ...zhDict, ...languageNames.zh }, + en: { ...enDict, ...languageNames.en }, + ja: { ...enDict, ...languageNames.ja }, + ko: { ...enDict, ...languageNames.ko }, + vi: { ...enDict, ...languageNames.vi }, + id: { ...enDict, ...languageNames.id }, + ms: { ...enDict, ...languageNames.ms }, +}; + +/** Fixed locale lookup (admin UI uses Simplified Chinese). */ export function tLang(lang: Lang, key: string): string { - return dict[lang][key] || dict["zh-TW"][key] || key; + return dict[lang][key] || dict.en[key] || key; } type Ctx = { lang: Lang; setLang: (l: Lang) => void; t: (k: string) => string }; @@ -387,16 +350,26 @@ const LANG_KEY = "ark_lang"; export function I18nProvider({ children }: { children: React.ReactNode }) { const [lang, setLangState] = useState(() => { - const s = localStorage.getItem(LANG_KEY) as Lang | null; - if (s === "zh-CN" || s === "en" || s === "zh-TW") return s; - return "zh-TW"; + const s = localStorage.getItem(LANG_KEY); + if (s === "zh-CN" || s === "zh-TW") return "zh"; + if ( + s === "zh" || + s === "en" || + s === "ja" || + s === "ko" || + s === "vi" || + s === "id" || + s === "ms" + ) + return s; + return "en"; }); const setLang = (l: Lang) => { localStorage.setItem(LANG_KEY, l); setLangState(l); }; const t = useCallback( - (k: string) => dict[lang][k] || dict["zh-TW"][k] || k, + (k: string) => dict[lang][k] || dict.en[k] || k, [lang], ); const v = useMemo(() => ({ lang, setLang, t }), [lang, t]); @@ -410,7 +383,5 @@ export function useI18n() { } export function langQuery(lang: Lang) { - if (lang === "zh-TW") return "zh-TW"; - if (lang === "zh-CN") return "zh-CN"; - return "en"; + return lang; } diff --git a/src/i18nLanguages.ts b/src/i18nLanguages.ts new file mode 100644 index 0000000..261ad1d --- /dev/null +++ b/src/i18nLanguages.ts @@ -0,0 +1,17 @@ +import type { Lang } from "./i18n"; + +export const LANG_OPTIONS: { code: Lang; label: string }[] = [ + { code: "zh", label: "中文" }, + { code: "en", label: "English" }, + { code: "ja", label: "日本語" }, + { code: "ko", label: "한국어" }, + { code: "vi", label: "Tiếng Việt" }, + { code: "id", label: "Bahasa Indonesia" }, + { code: "ms", label: "Bahasa Melayu" }, +]; + +export function languageLabel(t: (key: string) => string, code: string) { + if (!code) return t("filterLanguageAll"); + const label = t(`lang_${code}`); + return label === `lang_${code}` ? code : label; +} diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx index 738b9e6..ddf3d1d 100644 --- a/src/layouts/PublicLayout.tsx +++ b/src/layouts/PublicLayout.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { ArkLogoMark } from "../components/ArkLogoMark"; import { useI18n, type Lang } from "../i18n"; +import { LANG_OPTIONS } from "../i18nLanguages"; import { adminUiPrefix } from "../adminPaths"; type PublicNavWhich = @@ -165,9 +166,11 @@ export function PublicLayout() { onChange={(e) => setLang(e.target.value as Lang)} aria-label={t("langLabel")} > - - - + {LANG_OPTIONS.map((option) => ( + + ))} ))} diff --git a/src/pages/admin/AdminResourceForm.tsx b/src/pages/admin/AdminResourceForm.tsx index f74925e..c23bd3c 100644 --- a/src/pages/admin/AdminResourceForm.tsx +++ b/src/pages/admin/AdminResourceForm.tsx @@ -17,6 +17,7 @@ import { useAdminRouterMode } from "../../adminRouterMode"; const types = [ "image", "video", + "music", "ppt", "pdf", "text", @@ -36,7 +37,7 @@ export function AdminResourceForm() { const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [rtype, setRtype] = useState("image"); - const [language, setLanguage] = useState("zh-TW"); + const [language, setLanguage] = useState("zh"); const [categoryId, setCategoryId] = useState(1); const [coverImage, setCoverImage] = useState(""); const [fileUrl, setFileUrl] = useState(""); @@ -53,7 +54,7 @@ export function AdminResourceForm() { const [err, setErr] = useState(null); useEffect(() => { - getJSON("/api/categories?lang=zh-TW") + getJSON("/api/categories?lang=zh") .then(setCats) .catch(() => setCats([])); }, []); @@ -65,7 +66,7 @@ export function AdminResourceForm() { setTitle(r.title || ""); setDescription(r.description || ""); setRtype(r.type || "image"); - setLanguage(r.language || "zh-TW"); + setLanguage(r.language || "zh"); setCategoryId(r.categoryId || 1); setCoverImage(r.coverImage || ""); setFileUrl(r.fileUrl || ""); @@ -182,9 +183,13 @@ export function AdminResourceForm() { value={language} onChange={(e) => setLanguage(e.target.value)} > - - + + + + + + diff --git a/src/pages/admin/AdminResources.tsx b/src/pages/admin/AdminResources.tsx index d50e189..39ac255 100644 --- a/src/pages/admin/AdminResources.tsx +++ b/src/pages/admin/AdminResources.tsx @@ -32,7 +32,7 @@ export function AdminResources() { const [total, setTotal] = useState(0); useEffect(() => { - getJSON("/api/categories?lang=zh-TW") + getJSON("/api/categories?lang=zh") .then((cats) => { const m: Record = {}; for (const c of cats) m[c.id] = c.name; diff --git a/src/resourceTypeLabels.test.ts b/src/resourceTypeLabels.test.ts index 3931bee..54b4e6f 100644 --- a/src/resourceTypeLabels.test.ts +++ b/src/resourceTypeLabels.test.ts @@ -9,25 +9,29 @@ import { const t = (key: string) => ({ filterAll: "全部", - type_image: "圖片", - type_video: "影片", - lang_zh_TW: "繁中", - lang_zh_CN: "簡中", - lang_en: "英文", + type_image: "图片", + type_video: "视频", + type_music: "音乐", + lang_zh: "中文", + lang_en: "English", + lang_ja: "日本語", })[key] ?? key; describe("resource labels", () => { it("localizes known resource types and falls back to raw type", () => { expect(typeFilterLabel(t, "all")).toBe("全部"); - expect(resourceTypeLabel(t, "image")).toBe("圖片"); - expect(resourceTypeDisplay(t, "video")).toBe("影片"); + expect(resourceTypeLabel(t, "image")).toBe("图片"); + expect(resourceTypeDisplay(t, "video")).toBe("视频"); + expect(resourceTypeLabel(t, "music")).toBe("音乐"); expect(resourceTypeLabel(t, "unknown")).toBe("unknown"); }); it("normalizes resource language codes", () => { - expect(resourceLanguageLabel(t, "zh-TW")).toBe("繁中"); - expect(resourceLanguageLabel(t, "zh-hans")).toBe("簡中"); - expect(resourceLanguageLabel(t, "EN")).toBe("英文"); - expect(resourceLanguageLabel(t, "ja")).toBe("ja"); + expect(resourceLanguageLabel(t, "zh-TW")).toBe("中文"); + expect(resourceLanguageLabel(t, "zh-CN")).toBe("中文"); + expect(resourceLanguageLabel(t, "zh-hans")).toBe("中文"); + expect(resourceLanguageLabel(t, "EN")).toBe("English"); + expect(resourceLanguageLabel(t, "ja")).toBe("日本語"); + expect(resourceLanguageLabel(t, "xx")).toBe("xx"); }); }); diff --git a/src/resourceTypeLabels.ts b/src/resourceTypeLabels.ts index a4d0c9b..348cf75 100644 --- a/src/resourceTypeLabels.ts +++ b/src/resourceTypeLabels.ts @@ -27,23 +27,15 @@ export function resourceTypeLabel( return label !== key ? label : type; } -/** Localized label for resource `language` code (zh-TW, en, …). */ +/** Localized label for resource language code (zh, en, ja, ko, vi, id, ms). */ export function resourceLanguageLabel( t: (key: string) => string, langCode: string, ): string { const lc = langCode.trim().toLowerCase(); - const key = - lc === "zh-tw" - ? "lang_zh_TW" - : lc === "zh-cn" || lc === "zh-hans" - ? "lang_zh_CN" - : lc === "en" - ? "lang_en" - : ""; - if (key) { - const label = t(key); - if (label !== key) return label; - } - return langCode.trim(); + const normalized = + lc === "zh-cn" || lc === "zh-tw" || lc === "zh-hans" ? "zh" : lc; + const key = `lang_${normalized}`; + const label = t(key); + return label !== key ? label : langCode.trim(); }