Merge pull request 'terry-staging' (#1) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 56s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-05-26 06:53:11 +00:00
67 changed files with 3986 additions and 1737 deletions

View File

@@ -12,3 +12,7 @@ VITE_ADMIN_ONLY=false
# Optional admin UI base path. Leave empty to use default app behavior. # Optional admin UI base path. Leave empty to use default app behavior.
VITE_ADMIN_UI_PREFIX= VITE_ADMIN_UI_PREFIX=
# Use mock Post data (Telegram-style resource stream) only when explicitly enabled.
# Default production/staging behavior should hit the real /api/posts API.
VITE_USE_MOCK_POSTS=false

6
.gitignore vendored
View File

@@ -30,3 +30,9 @@ pnpm-debug.log*
coverage/ coverage/
.cache/ .cache/
.vite/ .vite/
# Agent local state / workflow noise
.oh-my-opencode-pi-*
.omc/
.unipi/ralph/
.unipi/logs/

View File

@@ -0,0 +1,210 @@
---
title: "Telegram-style Resource Stream — Implementation Plan"
type: plan
date: 2026-05-25
workbranch: feat/telegram-stream
specs:
- .unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md
---
# Telegram-style Resource Stream — Implementation Plan
## Overview
实现 `/browse``/category/:slug` 的 Telegram-style 消息流重构。前端先用 mock data 完成全部视觉与交互,等后端 `/api/posts` 系列接口 ready 后切换。同时收尾删除收藏功能与 `ResourceDetail` 详情页。
分支:`feat/telegram-stream`(在当前目录新建,不走 worktree。完成后由 Terry 显式确认才 merge。
## Sequencing
```
Task 1 (基础类型 + mock + utils)
Task 2 (hooks) ─────────────────┐
Task 3 (overlays) ─────┐ │
↓ │
Task 4 (bubbles)
Task 5 (stream 容器)
Task 6 (页面改写)
Task 7 (清理收藏 / 详情页) ── 可与 1-5 并行,但合并到 Task 6 之前完成
Task 8 (验证 + 文档 + API 契约) ── 最后
```
依赖关键路径1 → 2/3 → 4 → 5 → 6 → 8。Task 7 独立,建议早做以减少 imports 残留。
## Tasks
- unstarted: Task 0 — 创建分支
- Description: 在当前目录创建并切到 `feat/telegram-stream` 分支
- Dependencies: 无
- Acceptance Criteria: `git branch --show-current` 输出 `feat/telegram-stream``git status` 干净
- Steps:
1. `git status --short --branch` 确认无未提交改动
2. `git checkout -b feat/telegram-stream`
3. 确认 `.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md``.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md` 已在该分支
- unstarted: Task 1 — 类型定义 + Mock data + 纯函数 utils
- Description: 建立 Post / Attachment 类型,写覆盖 7 种 bubble 的 mock 样本,写 3 个无依赖纯函数 util
- Dependencies: Task 0
- Acceptance Criteria:
- `src/types/post.ts` 导出 `Post``Attachment``PostListResponse``PostScope`
- `src/mocks/mockPosts.ts` 至少 12 条 Post 覆盖2 图片当文档 + 2 PDF/AI + 2 纯文本+链接 + 1 视频 + 2 单图 + 1 图+文 + 1 三图相册 + 1 七图相册publishedAt 跨 ≥3 个不同日期
- `formatBytes(3549239)``"3.4 MB"`unit tests pass
- `autolink("点 https://x.com 看")` 返回 React 节点数组,链接处为 `<a target="_blank" rel="noopener noreferrer">`unit tests pass
- `fileIcon({ mime, filename })` 返回 `{ Icon, color }`PDF 红、AI 橙、PPT 红、图片走 thumbnail 不返回 Icon
- `npx tsc --noEmit` 通过
- Steps:
1.`src/types/post.ts`
2.`src/components/messageStream/utils/formatBytes.ts` + `.test.ts`
3.`src/components/messageStream/utils/autolink.tsx` + `.test.tsx`
4.`src/components/messageStream/utils/fileIcon.ts`
5.`src/mocks/mockPosts.ts`(图片用 picsum.photos 占位,视频用公开 sample mp4 + 一张占位 poster
6.`npm test``npx tsc --noEmit`
- unstarted: Task 2 — Stream hooksusePostStream + useGroupedByDay
- Description: 数据层抽象mock/real 双模式 + 按日期分组
- Dependencies: Task 1
- Acceptance Criteria:
- `usePostStream({ scope, type, language })``VITE_USE_MOCK_POSTS !== "false"` 时从 `MOCK_POSTS` 过滤 + 倒序 + cursor 切片(每页 20+ 200ms 假延迟
- 真接口分支调用 `getJSON</api/posts?...>`(占位即可,后端未 ready 时不会走到)
- 返回 `{ items, isLoading, error, hasMore, loadMore, reset }`
- `useGroupedByDay(posts, lang)` 返回 `Array<{ dayLabel: string; items: Post[] }>`按本地时区日期分组dayLabel 通过 `Intl.DateTimeFormat` 按 lang 切换zh-TW/zh-CN/en
- 单元测试useGroupedByDay 在跨天数据上分出正确组数
- Steps:
1.`src/components/messageStream/hooks/usePostStream.ts`
2.`src/components/messageStream/hooks/useGroupedByDay.ts` + `.test.ts`
3.`.env.example``VITE_USE_MOCK_POSTS=true` 注释
4.`npm test``npx tsc --noEmit`
- unstarted: Task 3 — Overlay 基础设施ImageLightbox + VideoPlayer
- Description: 全屏画廊与视频播放器portal 渲染,单一入口 context
- Dependencies: Task 1
- Acceptance Criteria:
- `<ImageLightboxProvider>` 包在 App 根,暴露 `useLightbox()``openLightbox(images, startIndex)`
- `ImageLightbox` 支持:左右切换(按钮 + 键盘 ← → + 触屏左右滑、ESC/点遮罩关闭、右上角下载按钮
- `VideoPlayer` 支持:全屏遮罩 + `<video controls autoPlay>`、接 `currentTime` 参数避免重新加载、ESC/点遮罩关闭
- 两个 overlay 在手机端不溢出、不被底部 nav 遮挡
- `npx tsc --noEmit` 通过
- Steps:
1.`src/components/messageStream/overlays/ImageLightbox.tsx` + Provider/Context
2.`src/components/messageStream/overlays/VideoPlayer.tsx`
3.`App.tsx` / 根布局挂 Provider
4. 手动验证:在 dev console 临时调用 `openLightbox` 看是否正确呈现
- unstarted: Task 4 — Bubble 子组件6 个 + 分发器)
- Description: 按截图实现 6 种气泡 + `MessageBubble` 分发
- Dependencies: Task 1, Task 3
- Acceptance Criteria:
- `FileDocBubble` 处理 `kind: "document"`
-`mime.startsWith("image/")` → 左侧用 `thumbnailUrl` 缩略 + ↓ 覆盖;右侧 filename.ext + size截图 1
- 否则 → 左侧蓝圆 ↓ 图标(按 mime 取色)+ 右侧 filename + size截图 2
- `TextBubble` 渲染 `text`,调 `autolink`(截图 3
- `VideoBubble` 初始显示 posterUrl + ▶️ + 时长,第一次点 inline `<video controls autoPlay>`,第二次点(已播放)调 `openVideoPlayer`(截图 4
- `ImageBubble` 单张图,点击调 `openLightbox([att], 0)`(截图 5
- `ImageWithTextBubble` 单图 + 下方文本autolink截图 6
- `AlbumBubble` 2-4 格 grid间距 2pxattachments.length > 4 时第 4 格 `bg-black/45 backdrop-blur-sm` 覆盖 `+N`;点任一格调 `openLightbox(images, index)`(截图 7
- `MessageBubble` 实现 spec §4 的 `pickBubble` 分发,右下角绝对定位时间戳 `text-[11px] text-neutral-500`
- 所有 bubble 容器:`rounded-2xl bg-ark-panel p-3`(文本气泡 `px-4 py-2.5`),左对齐,无头像
- `npx tsc --noEmit` 通过
- Steps:
1.`MessageBubble.tsx`(含 pickBubble
2.`bubbles/TextBubble.tsx`
3.`bubbles/FileDocBubble.tsx`
4.`bubbles/ImageBubble.tsx`
5.`bubbles/ImageWithTextBubble.tsx`
6.`bubbles/AlbumBubble.tsx`
7.`bubbles/VideoBubble.tsx`
8. 在 dev 中临时挂一个 demo route 跑 MOCK_POSTS 全量渲染,目视检查 7 张截图对照
- unstarted: Task 5 — Stream 容器 + FilterChips + DaySeparator
- Description: 顶层组件接管 fetch、分组、无限滚动、sticky 筛选
- Dependencies: Task 2, Task 4
- Acceptance Criteria:
- `FilterChips`sticky top横向滚动 `overflow-x-auto whitespace-nowrap`,类型 chipsall/image/video/ppt/pdf/text/link/archive沿用 `typeFilterLabel`+ 语言 chipsall/zh-TW/zh-CN/en改变时 reset cursor 并同步 URL `?type=&language=`
- `DaySeparator`:胶囊样式,居中
- `MessageStream`
-`scope: { kind: "all" } | { kind: "category", slug: string }`
- 调用 `usePostStream` + `useGroupedByDay`
-`IntersectionObserver` 监听底部 sentinel触发 `loadMore`loadingRef 守护避免重复)
- 容器:手机 `max-w-full px-3 mx-auto`md+ `max-w-[640px] mx-auto`
- 空状态用 `t("noResults")`,错误用红色 inline 横幅 + 重试按钮
- `npx tsc --noEmit` 通过
- Steps:
1.`FilterChips.tsx`
2.`DaySeparator.tsx`
3.`MessageStream.tsx`
4. 在 dev demo route 挂 `<MessageStream scope={{ kind: "all" }} />` 验证滚动 + 筛选 + 分组
- unstarted: Task 6 — 重写 CategoryPage 与 Browse
- Description: 两个页面瘦身为单一组件调用
- Dependencies: Task 5, Task 7
- Acceptance Criteria:
- `src/pages/CategoryPage.tsx`:仅渲染分类标题 + `<MessageStream scope={{ kind: "category", slug }} />`,行数 < 30
- `src/pages/Browse.tsx`:仅渲染页面标题 + `<MessageStream scope={{ kind: "all" }} />`,行数 < 30不再读 `sort` / `tag` / `page` 参数
- 排序 tabs 全部去掉
- 移除对 `ResourceCard` / `ResourceListFooter` 的 import
- `App.tsx` 路由保持 `/browse``/category/:slug` 不变
- `npx tsc --noEmit` 通过
- Steps:
1. 改写 `CategoryPage.tsx`
2. 改写 `Browse.tsx`
3. 跑 dev server 在 `/browse``/category/<slug>` 验证
- unstarted: Task 7 — 移除收藏功能 + ResourceDetail
- Description: 整套清理
- Dependencies: Task 0可与 Task 1-5 并行)
- Acceptance Criteria:
- 删除文件:`src/pages/FavoritesPage.tsx``src/pages/ResourceDetail.tsx``src/components/ResourceCard.tsx``src/components/ResourceListFooter.tsx`
- `App.tsx` 移除 `/favorites` 路由;`/r/:id` 改为新组件 `PostRedirect`mock 模式下从 `MOCK_POSTS` 找 slug找不到 → navigate `/browse`
- `src/api.ts` 移除 `postFavoriteDelta`
- 全代码无 `postFavoriteDelta` / `FavoritesPage` / `ResourceDetail` / `ResourceCard` / `ResourceListFooter` 引用
- Home 中的 `/favorites` 入口(如有)移除
- `src/i18n.tsx` 移除 `favorites` / `addFavorite` / `removeFavorite` 等收藏 key三语言同步
- `npx tsc --noEmit` 通过(无 unused / 未引用错误)
- Steps:
1. `grep -rn "postFavoriteDelta\|FavoritesPage\|ResourceDetail\|ResourceCard\|ResourceListFooter\|/favorites\|/r/:id" src/` 列出所有引用点
2.`src/pages/PostRedirect.tsx`,挂到 `/r/:id` 路由
3. 删除 4 个文件 + 修改 `App.tsx` + `api.ts` + `i18n.tsx`
4. 再 grep 一次确认清零
5. `npx tsc --noEmit`
- unstarted: Task 8 — 验证 + 文档 + API 契约抽出
- Description: 全量验证 + 给后端的接口文档
- Dependencies: Task 6, Task 7
- Acceptance Criteria:
- `npx tsc --noEmit` 通过
- `npm run format:check` 通过(若不通过先 `npm run format`
- `npm test` 全绿
- `npm run build` 成功
- 手动视觉验证Chrome DevTools iPhone 14 Pro 视口逐一对照 7 张参考截图
- 新增 `.unipi/docs/specs/2026-05-25-posts-api-contract.md`:从主 spec §1-§2 抽出后端需要的所有内容Post/Attachment schema、6 个 endpoint、删除清单、迁移要求
- 更新 `README.md` 增加 `VITE_USE_MOCK_POSTS` 段落
- 不 commit、不 push等 Terry 显式确认)
- Steps:
1. 跑全套检查命令
2. dev server 手机视口检查
3. 写 API 契约文档
4. 改 README
5. 报告完成,等 Terry 审阅与 commit 指令
## Risks
- **mock 数据视觉与真实数据偏差**mock 用 picsum 占位图可能掩盖真实图片不同宽高比的边界情况。缓解mockPosts 中包含横图 / 竖图 / 接近正方形三种比例样本。
- **video poster 在 mock 模式不易获取**:用一张本地 SVG 占位即可,避免依赖外部链接。
- **i18n 删除收藏 key 后未使用的引用**tsconfig 的 `noUnusedLocals` 不覆盖 i18n key 的字符串引用,需手动 grep。
- **`PostRedirect` 在真接口模式下的实现**:当前先写 mock 分支,真接口分支 TODO 注释标明等 `/api/posts/:id` ready 后补。
- **infinite scroll + URL 同步**:用户改 filter chip 时既要 reset cursor 又要更新 URL注意避免 `setSearchParams` 触发额外 effect 循环。
- **后端最终 schema 与本 spec 偏差**:如有偏差,必须先回 spec 改契约,再调前端类型,避免散点修改。
## Out of Scope本 plan 不涵盖,遵循 spec
- Home 页布局调整
- Admin 后台 Post 编辑器
- 真实后端 API 实现 + 数据迁移
- 长按菜单 / 评论 / Reaction
- 桌面端多列布局
- SEO 优化

View File

@@ -0,0 +1,592 @@
---
title: "ARK Library Frontend — Backend API Requirements"
type: api-requirements
date: 2026-05-25
audience: backend
status: draft
---
# ARK Library Frontend — Backend API Requirements
这份文档列出前端接下来需要后端提供的**全部接口**。重点是新的 Telegram-style 资料流;旧 `resources` 接口可作为过渡,但最终建议统一到 `posts` 模型。
## 0. 通用约定
- API base前端通过 `VITE_API_URL` 指向后端;本地可同源 `/api`
- 上传文件可通过 `/uploads/...` 或完整 URL 返回;前端会用 `assetUrl()` 处理相对路径。
- 所有时间字段使用 ISO 8601 字符串,例如 `2026-05-24T14:42:00.000Z`
- 语言字段:`zh-CN` / `en` / `ja` / `ko` / `vi` / `id` / `ms`;默认语言为 `en`。中文只有简体 `zh-CN`,没有繁体中文。
- 错误格式:非 2xx + text/plain 或 JSON 均可;前端会显示错误文本。
- Admin 接口需要 `Authorization: Bearer <token>`
## 1. 核心数据模型
### 1.1 Category
```ts
type Category = {
id: number;
slug: string; // 用于 /category/:slug 和 GET /api/posts?category=<slug>
name: string; // 已按 lang 返回本地化名称
description?: string;
iconKey: string; // folder/calendar/megaphone/video/image 等,前端已有 icon map
sortOrder: number;
};
```
### 1.2 Post新资料流核心
```ts
type AttachmentKind = "image" | "video" | "document";
type Attachment = {
id: string;
kind: AttachmentKind;
url: string; // 原始文件或可访问文件 URL
mime: string; // image/jpeg, application/pdf, video/mp4, ...
filename: string;
sizeBytes: number;
width?: number; // image/video 建议提供
height?: number;
durationSec?: number; // video 建议提供
posterUrl?: string; // video preview
thumbnailUrl?: string; // image/document preview
};
type Post = {
id: string;
categoryId: number;
categorySlug: string;
language: "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
text?: string;
attachments: Attachment[];
isRecommended: boolean;
publishedAt: string;
updatedAt: string;
};
type PostListResponse = {
items: Post[];
nextCursor?: string;
};
```
### 1.3 Post 显示规则(后端必须按这个模型返回)
- 纯文字/链接:`attachments: []``text` 非空。
- 单张图片:`attachments.length === 1``kind: "image"`
- 图片 + 文字:`kind: "image"` + `text`
- 视频:`kind: "video"`,建议提供 `posterUrl` / `durationSec`
- 文件:`kind: "document"`,前端显示下载卡。
- 图片当文件上传:`kind: "document"``mime` 是 image前端会显示缩略图 + 下载按钮。
- 多图:
- 2 / 3 / 4 张图:前端会独立纵向显示每张图,同一个 Post 只显示一次时间。
- 超过 4 张图:前端显示 2×2 合并格,第 4 格模糊并显示 `+N`
## 2. Public API前台用户
### 2.1 分类列表
```http
GET /api/categories?lang=en
```
Response:
```ts
Category[]
```
用途Home 资料分类、CategoryPage 标题、Admin 表单分类选择。
---
### 2.2 全部资料 / 分类资料流
```http
GET /api/posts?lang=en&limit=20&cursor=<cursor>&type=all&language=&category=<slug>
```
Query:
| 参数 | 必填 | 说明 |
| ---------- | ---: | ------------------------------------------- |
| `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;
```
排序:`publishedAt DESC`
用途:
- `/browse`:不传 `category`
- `/category/:slug`:传 `category=<slug>`
---
### 2.3 Home 推荐资料
```http
GET /api/posts/recommended?lang=en&limit=12
```
Response:
```ts
{ items: Post[] }
```
用途Home「官方推荐」section。按 `sortOrder ASC` + `publishedAt DESC` 或后端自定义推荐顺序。
> 过渡期:当前前端 Home 仍可接受旧 `/api/resources/recommended`,但建议后端新做 `posts/recommended` 后前端再切换。
---
### 2.4 Home 最新资料
```http
GET /api/posts/latest?lang=en&limit=8
```
Response:
```ts
{ items: Post[] }
```
用途Home「最新更新」section。按 `publishedAt DESC`
> 过渡期:当前前端 Home 仍可接受旧 `/api/resources/latest`。
---
### 2.5 单条 Post旧链接落地
```http
GET /api/posts/:id
```
Response:
```ts
Post;
```
用途:旧 `/resource/:id` 前端重定向:拿 `categorySlug` 后跳到 `/category/<slug>#post-<id>`
---
### 2.6 搜索
建议新接口:
```http
GET /api/posts/search?q=<keyword>&lang=en&type=all&language=&cursor=&limit=20
```
Response:
```ts
PostListResponse;
```
搜索范围建议:`text``filename``categoryName`、tags。
过渡期当前前端仍使用:
```http
GET /api/resources?q=<keyword>&lang=&type=&language=&limit=50
```
Response:
```ts
{ items: Resource[] }
```
---
### 2.7 搜索日志
```http
POST /api/search-log
Content-Type: application/json
{ "query": "ARK" }
```
Response204 或 `{ ok: true }`
用途:记录用户搜索词;失败不阻断用户体验。
---
### 2.8 下载统计(可选)
文件下载目前前端可直接打开 `Attachment.url`。如果后端需要统计下载,提供:
```http
POST /api/posts/:postId/attachments/:attachmentId/download
```
Response204 或 `{ ok: true }`
> 过渡期旧 Home 推荐卡还可能调用 `POST /api/resources/:id/download`。
## 3. Filter 语义
`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?://` |
## 4. Wallet Auth API
### 4.1 取得签名 nonce/message
```http
POST /api/auth/wallet/nonce
Content-Type: application/json
{ "address": "0x..." }
```
Response:
```ts
{
message: string;
}
```
### 4.2 验证签名并签发 token
```http
POST /api/auth/wallet/verify
Content-Type: application/json
{
"address": "0x...",
"message": "...",
"signature": "0x..."
}
```
Response:
```ts
{
token: string;
}
```
### 4.3 验证当前 wallet session
```http
GET /api/auth/wallet/me
Authorization: Bearer <wallet-token>
```
Response:
```ts
{
wallet: string;
}
```
## 5. Admin API
### 5.1 Admin 登录
```http
POST /api/admin/login
Content-Type: application/json
{ "username": "...", "password": "..." }
```
Response:
```ts
{
token: string;
}
```
---
### 5.2 Admin dashboard
```http
GET /api/admin/dashboard
Authorization: Bearer <admin-token>
```
Response:
```ts
type AdminDashboard = {
totalResources: number; // 若迁移到 Post可理解为 totalPosts
published: number;
todayNew: number;
totalViews: number;
totalDownloads: number;
totalFavorites: number; // 收藏下线后可返回 0避免旧 admin UI 崩
totalShares: number;
hotResources: {
id: string;
title: string;
downloads: number;
favorites: number; // 可返回 0
views: number;
}[];
};
```
---
### 5.3 文件上传
```http
POST /api/admin/upload
Authorization: Bearer <admin-token>
Content-Type: multipart/form-data
file=<File>
```
最低 Response:
```ts
{
url: string;
}
```
建议 Response更方便前端自动建 Attachment
```ts
{
url: string;
mime: string;
filename: string;
sizeBytes: number;
width?: number;
height?: number;
durationSec?: number;
thumbnailUrl?: string;
posterUrl?: string;
}
```
---
### 5.4 Admin Post 列表
```http
GET /api/admin/posts?limit=25&page=1&status=&category=&type=&q=
Authorization: Bearer <admin-token>
```
Response:
```ts
{
items: AdminPost[];
total: number;
}
type AdminPost = Post & {
isPublic: boolean;
sortOrder: number;
status: "draft" | "published" | "archived";
viewCount?: number;
downloadCount?: number;
};
```
---
### 5.5 Admin Post 详情
```http
GET /api/admin/posts/:id
Authorization: Bearer <admin-token>
```
Response`AdminPost`
---
### 5.6 创建 Post
```http
POST /api/admin/posts
Authorization: Bearer <admin-token>
Content-Type: application/json
```
Request:
```ts
type UpsertPostPayload = {
categoryId: number;
language: "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
text?: string;
attachments: Attachment[];
isPublic: boolean;
isRecommended: boolean;
sortOrder: number;
status: "draft" | "published" | "archived";
publishedAt?: string;
tags?: string[];
};
```
Response`AdminPost`
---
### 5.7 更新 Post
```http
PUT /api/admin/posts/:id
Authorization: Bearer <admin-token>
Content-Type: application/json
```
Request`UpsertPostPayload`
Response`AdminPost`
---
### 5.8 删除 Post
```http
DELETE /api/admin/posts/:id
Authorization: Bearer <admin-token>
```
Response204 或 `{ ok: true }`
---
### 5.9 Admin 搜索日志
```http
GET /api/admin/search-logs?limit=300
Authorization: Bearer <admin-token>
```
Response:
```ts
{
items: {
id: string;
query: string;
createdAt: string;
count?: number;
}[];
}
```
## 6. 过渡期旧 Resource API如果 admin 尚未迁移)
当前部分前端/admin 代码仍可能使用旧接口。后端可以短期保留,或前端后续再统一切到 Post
```http
GET /api/resources?lang=&limit=&page=&sort=&q=&type=&language=&tag=
GET /api/resources/recommended?lang=&limit=
GET /api/resources/latest?lang=&limit=
POST /api/resources/:id/download
GET /api/admin/resources?limit=&page=
GET /api/admin/resources/:id
POST /api/admin/resources
PUT /api/admin/resources/:id
```
`Resource` shape
```ts
type Resource = {
id: string;
title: string;
description?: string;
type: string;
language: string;
categoryId: number;
categorySlug: string;
categoryName: string;
coverImage?: string;
fileUrl?: string;
previewUrl?: string;
externalUrl?: string;
bodyText?: string;
badgeLabel?: string;
isDownloadable: boolean;
isRecommended: boolean;
publishedAt?: string;
updatedAt: string;
tags?: string[];
};
```
## 7. 已下线 / 不需要实现
- 用户收藏:`/favorites` 页面已移除。
- `POST /api/resources/:id/favorite` 不需要。
- Reaction / 点赞 / 评论不需要。
- Telegram 管理员标签、头像、群组名不需要。
## 8. 前后端切换计划
1. 后端先实现 `/api/posts``/api/posts/:id``/api/categories`
2. 前端 staging 设置:`VITE_USE_MOCK_POSTS=false`
3. 确认 `/browse``/category/:slug` 正常拉真数据。
4. 再实现 `/api/posts/recommended``/api/posts/latest`,前端把 Home 从旧 resources 切到 posts。
5. 最后迁移 Admin 从 `/api/admin/resources``/api/admin/posts`
## 9. 最小可上线优先级
### P0前台资料流必需
- `GET /api/categories`
- `GET /api/posts`
- `GET /api/posts/:id`
- `POST /api/admin/upload`
- `POST /api/admin/posts`
- `PUT /api/admin/posts/:id`
- `GET /api/admin/posts`
- `GET /api/admin/posts/:id`
### P1首页/搜索/统计)
- `GET /api/posts/recommended`
- `GET /api/posts/latest`
- `GET /api/posts/search`
- `POST /api/search-log`
- `GET /api/admin/dashboard`
- `GET /api/admin/search-logs`
### P2账户
- Wallet nonce / verify / me

View File

@@ -0,0 +1,176 @@
---
title: "Posts API Contract (for backend)"
type: api-contract
date: 2026-05-25
audience: backend
status: draft
---
# Posts API Contract
> 这份文档是从 `2026-05-25-telegram-style-resource-stream-design.md` §1§2 抽出,供后端实现使用。前端已用 mock data 完成视觉;上线时把 `VITE_USE_MOCK_POSTS=false` 即可切真接口。
## 1. 数据模型
```ts
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 优化)
height?: number;
durationSec?: number; // video 专用
posterUrl?: string; // video 海报缩略图
thumbnailUrl?: string; // image 缩略,列表用减少流量
};
type Post = {
id: string;
categoryId: number;
categorySlug: string;
language: string; // "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms"
text?: string; // 可选,纯文本/图说;前端做 https → 链接自动识别
attachments: Attachment[]; // 0~Ntext-only post 时为 []
isRecommended: boolean;
publishedAt: string; // ISO 8601用于排序 + 日期分组
updatedAt: string;
};
type PostListResponse = {
items: Post[];
nextCursor?: string; // 不透明 cursorundefined = 没有下一页
};
```
### 关键约定
- **图片当文档**(在前端显示为「文件下载卡」):`kind === "document"``mime.startsWith("image/")`。Admin 上传时通过开关决定走 image 还是 document 通道。
- **图片当图片**(前端显示为图片预览):`kind === "image"`
- **多图相册**:一个 Post 带多个 `kind === "image"` 的 attachments。前端会在 2-4 grid 中渲染attachments.length > 4 时第 4 格模糊 + `+N`
- **图片 + 文字**Post 同时有 `text` 与 attachments。
- **纯文本 / 链接**Post 仅有 `text``attachments: []`
- **视频**`kind === "video"` 单 attachment。`posterUrl` 用于预览,`durationSec` 用于角标。
- Attachment 内不携带任何「上传者头像 / 管理员标签」等社交字段(前端已下线)。
## 2. Endpoints
### 2.1 列表(核心)
```
GET /api/posts
```
Query 参数:
| 参数 | 必填 | 说明 |
| ---------- | ---- | ---------------------------------------------------------------------------------- |
| `lang` | 是 | UI 语言;后端可据此选择不同语言版本的 `text` |
| `category` | 否 | category slug不传 = 全部分类 |
| `type` | 否 | `all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`;语义见 §3 |
| `language` | 否 | 资源语言:`zh-CN` / `en` / `ja` / `ko` / `vi` / `id` / `ms` |
| `cursor` | 否 | 上一次返回的 `nextCursor`;不传 = 第一页 |
| `limit` | 否 | 默认 20最大 50 |
返回:`PostListResponse`
排序:`publishedAt DESC`
### 2.2 Home 用聚合接口(可选,沿用现状)
```
GET /api/posts/recommended?lang=&limit=
GET /api/posts/latest?lang=&limit=
```
返回:`{ items: Post[] }`(不分页)
### 2.3 单条(用于老链接 301 落地)
```
GET /api/posts/:id
```
返回:`Post`(或 404
前端 `/resource/:id` 现在是轻量重定向:拿到 `categorySlug``/category/<slug>#post-<id>` 锚点滚动。
### 2.4 分类(不变)
```
GET /api/categories?lang=
```
返回:现有 `Category[]`
### 2.5 Admin CRUD
```
POST /api/admin/posts
PUT /api/admin/posts/:id
DELETE /api/admin/posts/:id
GET /api/admin/posts?... (含未发布草稿)
```
需求:
- 支持多附件上传(一次 multipart 或先 `POST /api/admin/upload` 拿到 url 再创建 Post
- Admin UI 需要一个开关:「图片以图片形式呈现 / 以文档形式呈现」,对应 attachment.kind 的 image vs document。
- 支持发布/隐藏、置顶/官方推荐。
> Admin UI 改造单独建 spec / plan本契约仅说明后端必须支持这些字段。
## 3. `type` 参数语义
一个 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?://` |
前端 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 |
## 5. Search
`GET /api/resources?q=...` 当前仍被 SearchPage 使用(在新 schema 上线前过渡)。后端可视情况:
- 短期:保留旧接口
- 长期:新增 `GET /api/posts/search?q=...` 返回 `PostListResponse`,前端再切
## 6. 错误格式
沿用现状HTTP 状态码 + 文本 body。前端 `getJSON` 会把非 2xx 当作 `Error(text)` 抛出,`MessageStream` 显示红色横幅 + 重试按钮。
## 7. 兼容性 / 灰度
后端 ready 时步骤:
1. 把示例数据导入到 Posts 表
2. `/api/posts` 在 staging 通过
3. 前端 staging 部署设 `VITE_USE_MOCK_POSTS=false`,跑通后再 prod
4. 前端代码层面:删除 `src/mocks/mockPosts.ts``usePostStream.ts` 中的 mock 分支,或保留 mock 用于本地离线开发
## 8. 联系
前端Terry。Spec 主文档:`.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md`

View File

@@ -0,0 +1,327 @@
---
title: "Telegram-style Resource Stream资料分类查看全部 UI 重构)"
type: brainstorm
date: 2026-05-25
---
# Telegram-style Resource Stream
## Problem Statement
当前"查看全部"打开的资料列表(`/browse``/category/:slug`)使用统一的卡片网格,无法表达"admin 上传的内容本质是不同类型的消息"(一张图、一段文字+链接、一个视频、一个 PDF、4+ 张图相册等)。手机端用户体验偏弱,缺少 Telegram 那种按类型差异化呈现的直观感受。
本次重构目标:把 `/browse``/category/:slug` 改成**单列、按时间倒序、按日期分组、按上传类型差异化渲染**的 Telegram-style 消息流,手机优先,保留 ARK 既有色系(深底 + 金色高亮)。
> 后端 endpoint 尚未实现。本次前端先用 mock data 完成视觉与交互,验收后再交接 API 契约给后端。
## Context
### 当前实现
- `src/pages/Home.tsx`:分类 section 头部"查看全部" → `/browse`;分类卡片 → `/category/<slug>`
- `src/pages/Browse.tsx`221 行):含排序 tabs最新/推荐/热门/发布)+ 类型 chips + 语言 chips + tag 过滤 + 分页(每页 24
- `src/pages/CategoryPage.tsx`156 行):含类型 chips + 语言 chips + 分页。
- `src/pages/ResourceDetail.tsx`229 行):`/r/:id` 资源详情独立路由。
- `src/pages/FavoritesPage.tsx` + `postFavoriteDelta`:收藏功能。
- `src/components/ResourceCard.tsx`:统一卡片,不区分资源类型。
- `Resource` schema`src/api.ts`扁平1 个 resource = 1 个文件(`coverImage` / `fileUrl`),无 attachments 数组。
### 设计参考(用户提供 7 张 Telegram 截图)
1. 图片当文档上传:缩略图 + ↓ + filename.ext + size + 右下时间,无头像、无 reaction。
2. PDF / AI / PPT 等文档:蓝圆 ↓ + filename + size。
3. 纯文本 + 链接:`https://...` 自动识别为可点链接。
4. 视频:海报 + ▶️,第一次点 inline 播放预览,第二次点全屏;下方可有 admin 写的说明文字。
5. 单张图片:直接显示,点全屏。
6. 图片 + 文字:图片上方/下方文字,文字内链接 autolink。
7. 4+ 张图相册1-4 格 grid第 4 张模糊 + `+N`;点开后全屏画廊。
## Chosen Approach
**方案 A自建 `MessageStream` + 多态 `MessageBubble` 家族 + Mock-data layer**
- 新建一个 `MessageStream` 容器组件,被 `Browse.tsx``CategoryPage.tsx` 共用,差异通过 `scope` props 注入。
- `MessageBubble` 内根据 `Post.attachments` 的形状分发到 6 个子组件FileDoc / Text / Video / Image / ImageWithText / Album
- 全屏交互(图片画廊 / 视频全屏)走 portal overlay。
- 数据层用 `usePostStream` hook 抽象,默认走 `src/mocks/mockPosts.ts`(受 `VITE_USE_MOCK_POSTS` 控制),后端 ready 后切换为真 API。
- 同时**收尾**收藏功能与详情页:删除 `/favorites``/r/:id`、heart 按钮、`postFavoriteDelta`
## Why This Approach
### 拒绝方案 B聊天 UI 库 `@chatscope/chat-ui-kit-react` 等)
- 默认主题与 ARK 深底+金色严重冲突,改主题成本 ≈ 自己写。
- 不支持"4+ 图相册 +N 模糊"自定义。
- 增加包体积与灰盒 bug 风险。
### 拒绝方案 C重样 `ResourceCard`
- 当前卡片统一渲染,无法满足"按类型差异化"(视频内嵌播放器 vs 多图相册 vs 文本+链接)。
- 改动表面但不达 Telegram 风格。
### 拒绝其他子选项
- **保留排序 tabs**Telegram 流天然按时间倒序,多余 tabs 破坏隐喻。Home 页仍保留"官方推荐 / 最新更新" section 作为入口。
- **保留收藏功能在列表/详情页**:用户明确要求"不需要 reaction",且收藏与 Telegram 隐喻不符;整体下线最干净。
- **保留 `ResourceDetail` 作 fallback**:所有交互(下载 / 全屏 / 链接)都能就地完成,独立详情页冗余;老 `/r/:id` 改 301 重定向到 `/category/<slug>#post-<id>` 锚点。
- **`kind` 枚举铺开**:后端枚举膨胀难维护;前端按 `mime` / 文件后缀做细分图标更灵活。
## Design
### 1. 数据模型(前端使用 + 后端接口契约)
```ts
type Post = {
id: string;
categoryId: number;
categorySlug: string;
language: string; // zh-TW | zh-CN | en
text?: string; // 可选;纯文本/图说,前端自动识别 https 链接
attachments: Attachment[]; // 0~Ntext-only post 时为 []
isRecommended: boolean;
publishedAt: string; // ISO用于排序 + 日期分组
updatedAt: string;
};
type Attachment = {
id: string;
kind: "image" | "video" | "document";
url: string;
mime: string; // image/jpeg | application/pdf | video/mp4 | ...
filename: string; // "ARK项目一图读懂-01.jpg"
sizeBytes: number;
width?: number;
height?: number;
durationSec?: number; // video 专用
posterUrl?: string; // video 海报
thumbnailUrl?: string; // image 缩略
};
```
关键约定:
- `kind: "document" + mime.startsWith("image/")` = 图片当文档上传(截图 1
- `kind: "image"` = 图片当图片呈现(截图 5、6、7。该开关在 admin 上传 UI 决定,传到后端落库。
- 多图相册 = 一个 Post 带多个 `kind: "image"` 的 attachments。
- 图片+文字 = Post 同时有 `text` 与 attachments。
- 纯文本+链接 = Post 仅有 `text``attachments: []`
### 2. 后端 API 契约(移交给后端)
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | `/api/posts?category=<slug>&lang=&type=&language=&cursor=&limit=20` | 分类内消息流 |
| GET | `/api/posts?lang=&type=&language=&cursor=&limit=20` | 全部消息流(`/browse` |
| GET | `/api/posts/recommended?lang=&limit=` | Home 推荐 section |
| GET | `/api/posts/latest?lang=&limit=` | Home 最新 section |
| GET | `/api/posts/:id` | 单条(用于老 `/r/:id` 301 重定向落地,前端拿到 `categorySlug` 后跳锚点) |
| GET | `/api/categories` | 不变 |
| POST/PUT/DELETE | `/api/admin/posts/...` | Admin CRUD支持多附件 + 文本 + "图片是否以文档呈现"开关 |
废弃:
- `/api/resources/:id/favorite`
-`/api/resources*` 系列保留过渡期,由后端写迁移脚本:每个老 Resource → 一个 Post。
返回结构:`{ items: Post[], nextCursor?: string }`cursor 由后端不透明字符串提供。
`type` 参数语义:`all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`。一个 Post 命中条件 = `attachments[*].mime``text` 满足;具体由后端定义。
### 3. 组件结构
```
src/pages/
CategoryPage.tsx ← 重写:<MessageStream scope={{ kind:'category', slug }} />
Browse.tsx ← 重写:<MessageStream scope={{ kind:'all' }} />
src/components/messageStream/
MessageStream.tsx 顶层fetch + 无限滚动 + 日期分组 + sticky filter chips
FilterChips.tsx 类型 + 语言 chips横向滚动sticky top
DaySeparator.tsx "2 月 27 日" 胶囊
MessageBubble.tsx 单条 Post 容器:决定子组件 + 右下角时间戳
bubbles/
FileDocBubble.tsx 截图 1 + 2文档图片当文档 / pdf / ai / ppt / docx
TextBubble.tsx 截图 3纯文本 + autolink
VideoBubble.tsx 截图 4海报 + ▶️,先 inline 后全屏
ImageBubble.tsx 截图 5单张图片
ImageWithTextBubble.tsx 截图 6图片 + 文本 + autolink
AlbumBubble.tsx 截图 72-4 格 grid4+ 时第 4 格模糊 + `+N`
overlays/
ImageLightbox.tsx 全屏画廊(左右滑、缩放、关闭、下载)
VideoPlayer.tsx 全屏视频播放器
hooks/
usePostStream.ts cursor 分页 + IntersectionObservermock/real 切换
useGroupedByDay.ts 按 publishedAt 本地日期分组
utils/
autolink.tsx 文本中 https?://... → <a target="_blank" rel="noopener">
fileIcon.ts 按 mime/扩展名返回图标 + 颜色
formatBytes.ts 3,549,239 → "3.5 MB"
src/mocks/
mockPosts.ts 覆盖 7 种 bubble 类型的样本数据(图片用 picsum 占位或本地)
```
### 4. Bubble 分发逻辑
```ts
function pickBubble(post: Post) {
const a = post.attachments;
if (a.length === 0) return TextBubble;
if (a.length >= 2 && a.every(x => x.kind === "image")) return AlbumBubble;
const only = a[0];
if (only.kind === "video") return VideoBubble;
if (only.kind === "image") {
return post.text ? ImageWithTextBubble : ImageBubble;
}
return FileDocBubble; // document含图片当文档内部用 thumbnail 替代蓝圆图标)
}
```
### 5. 移动端布局规范
- 容器宽度:手机 `max-w-full px-3`md+ `max-w-[640px] mx-auto`。桌面端不做多列,保持单列聊天流(左右大留白)。
- 气泡:`rounded-2xl bg-ark-panel`,左对齐,无头像,内边距 `p-3`(文本 `px-4 py-2.5`)。
- 时间戳:右下角 `text-[11px] text-neutral-500`,绝对定位。
- 文档下载按钮:圆形 36×36金色 `bg-ark-gold` + 黑色 ↓。
- Day separator胶囊 `rounded-full bg-ark-panel/70 backdrop-blur px-3 py-1 text-xs text-neutral-400`居中、sticky 在 FilterChips 下。
- 多图 grid宽度 100%2×2间距 2px4+ 时第 4 格 `relative``bg-black/45 backdrop-blur-sm` + `+N` 居中文字。
- FilterChips 容器:`sticky top-0 z-10 bg-ark-bg/90 backdrop-blur` + 横向滚动 `overflow-x-auto whitespace-nowrap`
### 6. 交互
| 交互 | 行为 |
|---|---|
| 点击文档下载按钮 | `window.open(attachment.url, "_blank")` 触发浏览器下载 |
| 点击单张图片 | 打开 `ImageLightbox`(单图) |
| 点击相册任一图 / `+N` | 打开 `ImageLightbox`,可左右切换 |
| 点击视频海报 | 第一次bubble 内 `<video controls autoPlay>` inline 播放 |
| 点击播放中的视频 | 打开 `VideoPlayer` 全屏 overlay |
| 文本中的链接 | `target="_blank" rel="noopener noreferrer"` 新标签打开 |
| 滚动到底部 | IntersectionObserver 触发下一页 cursor 拉取 |
| 筛选 chips 变化 | 重置 cursor重新拉取同步 URL `?type=&language=` |
| 长按气泡 | 暂不做,列入 Open Questions |
### 7. Mock data 层
`src/mocks/mockPosts.ts` 导出 `MOCK_POSTS: Post[]`,至少包含:
- 2 条"图片当文档"(不同 mimejpg、png
- 2 条文档pdf、ai
- 2 条纯文本+链接(含中文 + 多链接 + emoji
- 1 条视频(带 posterUrl + duration
- 2 条单图(不同宽高比)
- 1 条图+文字
- 1 条 3 图相册
- 1 条 7 图相册(验证 `+N` 行为)
- 跨多天的 `publishedAt`,验证 DaySeparator
`usePostStream` 行为:
```ts
const useMock = import.meta.env.VITE_USE_MOCK_POSTS !== "false";
if (useMock) {
// 1. 按 scope.slug / type / language 过滤 MOCK_POSTS
// 2. 按 publishedAt 倒序
// 3. 按 cursor数字 offset 字符串)切 20 条
// 4. setTimeout 200ms 模拟延迟
// 5. 返回 nextCursor = offset+20 或 undefined
} else {
// fetch /api/posts?... 真接口
}
```
切真接口时只需在部署环境设 `VITE_USE_MOCK_POSTS=false`(或干脆删 mock 分支)。
### 8. 锚点 + 分享
- 每个 bubble 渲染为 `<article id="post-${post.id}">`
- 老路由 `/r/:id` 改为一个轻量重定向组件fetch `/api/posts/:id` 拿到 `categorySlug``navigate(/category/${slug}#post-${id}, { replace: true })``scrollIntoView`
- Mock 模式下从 `MOCK_POSTS` 找。
### 9. 移除清单
文件:
- `src/pages/ResourceDetail.tsx`
- `src/pages/FavoritesPage.tsx`
- `src/components/ResourceCard.tsx`
- `src/components/ResourceListFooter.tsx`
代码:
- `postFavoriteDelta` 及所有调用点
- i18n keys`favorites`, `addFavorite`, `removeFavorite` 等收藏相关
- Home 中的 `/favorites` 入口
路由:
- `/favorites`:删除
- `/r/:id`:保留为轻量重定向
### 10. 测试 / 验证策略
- 视觉验证:本地 `npm run dev`手机模拟器Chrome DevTools iPhone 14 Pro 视口)逐一对照 7 张截图。
- 单元测试:`pickBubble` 分发逻辑、`autolink` 正则、`formatBytes``useGroupedByDay`
- 类型检查:`npx tsc --noEmit`(项目 strict
- 格式化:`npm run format`
- 删除后回归:确认 `/favorites``/r/:id` 老链接不报 404 而是合理跳转或 410。
### 11. 风险与缓解
- **真接口 schema 与 mock 不一致**spec 是合同;后端实现时若需偏离,必须先回来改 spec。前端 hook 内 `Post` 类型从 `src/types/post.ts` 单一来源导入。
- **`+N` 相册和单图 lightbox 的状态管理混乱**:用 React Context`<ImageLightboxProvider>`)暴露 `openLightbox(images, startIndex)` 单一入口,所有 bubble 调它。
- **视频 inline → 全屏切换的状态丢失**:全屏 overlay 接 `currentTime` 参数,避免重新加载。
- **scroll restoration**cursor 分页页内来回滑动时 IntersectionObserver 容易重复触发;用 `loadingRef` 守护。
## Implementation Checklist
> 全部项已被 `.unipi/docs/plans/2026-05-25-telegram-style-resource-stream-plan.md` 覆盖。
- [x] 定义 `src/types/post.ts``Post``Attachment``PostListResponse`
- [x] 创建 `src/mocks/mockPosts.ts`:覆盖 7 种 bubble 类型 + 跨日期样本
- [x] 创建 `src/components/messageStream/hooks/usePostStream.ts`mock + real 双模式 + cursor 分页 + IntersectionObserver
- [x] 创建 `src/components/messageStream/hooks/useGroupedByDay.ts`
- [x] 创建 `src/components/messageStream/utils/autolink.tsx`
- [x] 创建 `src/components/messageStream/utils/fileIcon.ts`
- [x] 创建 `src/components/messageStream/utils/formatBytes.ts`
- [x] 创建 `FilterChips.tsx`sticky + 横向滚动)
- [x] 创建 `DaySeparator.tsx`
- [x] 创建 `MessageBubble.tsx`(含 `pickBubble` 分发)
- [x] 创建 `bubbles/FileDocBubble.tsx`(图片当文档 + pdf/ai/ppt
- [x] 创建 `bubbles/TextBubble.tsx`autolink
- [x] 创建 `bubbles/VideoBubble.tsx`inline 播放 + 全屏触发)
- [x] 创建 `bubbles/ImageBubble.tsx`
- [x] 创建 `bubbles/ImageWithTextBubble.tsx`
- [x] 创建 `bubbles/AlbumBubble.tsx`2-4 grid + `+N`
- [x] 创建 `overlays/ImageLightbox.tsx` + `ImageLightboxProvider` context
- [x] 创建 `overlays/VideoPlayer.tsx`
- [x] 创建 `MessageStream.tsx` 顶层组件
- [x] 重写 `src/pages/CategoryPage.tsx``<MessageStream scope={{ kind:'category', slug }} />`
- [x] 重写 `src/pages/Browse.tsx``<MessageStream scope={{ kind:'all' }} />`
- [x] 删除 `src/pages/ResourceDetail.tsx`,将 `/r/:id` 改为重定向组件mock 模式下从 `MOCK_POSTS` 查)
- [x] 删除 `src/pages/FavoritesPage.tsx``src/components/ResourceCard.tsx``src/components/ResourceListFooter.tsx`
- [x] 移除 `postFavoriteDelta` 及全部调用点
- [x] 移除 `App.tsx``/favorites` 路由 + Home 入口
- [x] 清理 i18n favorites 相关 keys
- [x] 单元测试:`pickBubble``autolink``formatBytes``useGroupedByDay`
- [x] 视觉对照 7 张参考截图iPhone 14 Pro 视口)
- [x] 运行 `npx tsc --noEmit && npm run format:check && npm test`
- [x] 文档:在 README 注明 `VITE_USE_MOCK_POSTS` 用法
- [x] 交付后端 API 契约文档(本 spec 的 §2 部分单独抽出 markdown 给后端)
## Open Questions
- **长按 / 右键菜单**:是否需要"复制链接"、"举报"、"分享"v2 决定。
- **`type` 筛选语义边界**:一个 Post 含多种 attachment 时(图+视频混合,目前 mock 不出现),`type=video` 命中规则由后端定,前端按返回展示即可。
- **空状态文案**:消息流为空时显示什么?目前沿用 `t("noResults")`
- **错误重试**:网络失败时是否提供"重试"按钮?建议下方加一个 inline 重试条。
- **视频自动暂停**:滚出视口时是否自动暂停?建议做,体验更顺。
- **i18n 时间戳格式**:是否需要适配繁体/简体/英文不同的日期分组格式?沿用 `Intl.DateTimeFormat``lang` 切换。
- **SEO**:删除 `/r/:id` 详情页后,搜索引擎抓取深度受影响吗?目前站点未做强 SEO可忽略如需保留可让 `/r/:id` 渲染服务端可解析的 `<noscript>` 摘要后再 JS 重定向。
- **Admin 上传 UI 改造**本次只覆盖前台浏览端admin 端 Post 编辑器(多附件 + 文本 + 图片呈现方式开关)需要单独的 spec / 任务。
## Out of Scope
- Home 页面布局调整(分类卡片网格、推荐/最新 section 保持不变)
- Admin 后台 UI 改造(单独 spec
- 真实 API 实现(后端工作)
- 后端数据迁移脚本
- 长按菜单、举报、分享等社交功能
- 评论 / Reaction
- 离线缓存 / Service Worker
- 桌面端多列布局

View File

@@ -51,13 +51,14 @@ npm test
Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template. Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template.
| Variable | Purpose | | 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_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_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_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_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_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` only when set to `"true"`. Leave unset or set to `"false"` to hit the real `/api/posts` API. See `.unipi/docs/specs/2026-05-25-posts-api-contract.md`. |
## Project layout ## Project layout
@@ -67,7 +68,7 @@ src/
App.tsx # public app + optional admin routes App.tsx # public app + optional admin routes
AppAdminOnly.tsx # admin-only app entry AppAdminOnly.tsx # admin-only app entry
api.ts # fetch helpers and shared API types api.ts # fetch helpers and shared API types
i18n.tsx # zh-TW / zh-CN / en copy dictionary i18n.tsx # zh-CN / en / ja / ko / vi / id / ms dictionary
adminPaths.ts # admin UI prefix logic adminPaths.ts # admin UI prefix logic
adminRouteTree.tsx # admin routes adminRouteTree.tsx # admin routes
components/ # reusable public components components/ # reusable public components

View File

@@ -4,17 +4,18 @@ import { I18nProvider } from "./i18n";
import { PublicLayout } from "./layouts/PublicLayout"; import { PublicLayout } from "./layouts/PublicLayout";
import { Home } from "./pages/Home"; import { Home } from "./pages/Home";
import { Browse } from "./pages/Browse"; import { Browse } from "./pages/Browse";
import { CategoryPage } from "./pages/CategoryPage"; import { CategoryPage } from "./pages/Category";
import { SearchPage } from "./pages/SearchPage"; import { SearchPage } from "./pages/Search";
import { FavoritesPage } from "./pages/FavoritesPage"; import { PostRedirect } from "./pages/PostRedirect";
import { ResourceDetail } from "./pages/ResourceDetail"; import { AboutPage } from "./pages/About";
import { AboutPage } from "./pages/AboutPage";
import { adminUiPrefix } from "./adminPaths"; import { adminUiPrefix } from "./adminPaths";
import { AdminRouteTree } from "./adminRouteTree"; import { AdminRouteTree } from "./adminRouteTree";
import { AdminRouterModeProvider } from "./adminRouterMode"; import { AdminRouterModeProvider } from "./adminRouterMode";
import { ImageLightboxProvider } from "./components/messageStream/overlays/ImageLightbox";
import { VideoPlayerProvider } from "./components/messageStream/overlays/VideoPlayer";
const WalletPage = lazy(() => const WalletPage = lazy(() =>
import("./pages/WalletPage").then((module) => ({ import("./pages/Wallet").then((module) => ({
default: module.WalletPage, default: module.WalletPage,
})), })),
); );
@@ -25,38 +26,41 @@ export default function App() {
return ( return (
<I18nProvider> <I18nProvider>
<AdminRouterModeProvider value="absolute"> <AdminRouterModeProvider value="absolute">
<BrowserRouter> <ImageLightboxProvider>
<Routes> <VideoPlayerProvider>
<Route element={<PublicLayout />}> <BrowserRouter>
<Route path="/" element={<Home />} /> <Routes>
<Route path="/browse" element={<Browse />} /> <Route element={<PublicLayout />}>
<Route path="/category/:slug" element={<CategoryPage />} /> <Route path="/" element={<Home />} />
<Route path="/search" element={<SearchPage />} /> <Route path="/browse" element={<Browse />} />
<Route path="/favorites" element={<FavoritesPage />} /> <Route path="/category/:slug" element={<CategoryPage />} />
<Route path="/resource/:id" element={<ResourceDetail />} /> <Route path="/search" element={<SearchPage />} />
<Route <Route path="/resource/:id" element={<PostRedirect />} />
path="/wallet" <Route
element={ path="/wallet"
<Suspense fallback={null}> element={
<WalletPage /> <Suspense fallback={null}>
</Suspense> <WalletPage />
} </Suspense>
/> }
<Route path="/about" element={<AboutPage />} /> />
</Route> <Route path="/about" element={<AboutPage />} />
</Route>
{adminEnabled ? ( {adminEnabled ? (
AdminRouteTree() AdminRouteTree()
) : ( ) : (
<Route <Route
path={`${adminUiPrefix}/*`} path={`${adminUiPrefix}/*`}
element={<Navigate to="/" replace />} element={<Navigate to="/" replace />}
/> />
)} )}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</VideoPlayerProvider>
</ImageLightboxProvider>
</AdminRouterModeProvider> </AdminRouterModeProvider>
</I18nProvider> </I18nProvider>
); );

View File

@@ -3,11 +3,11 @@ import { I18nProvider } from "./i18n";
import { adminUiPrefix } from "./adminPaths"; import { adminUiPrefix } from "./adminPaths";
import { AdminRouterModeProvider } from "./adminRouterMode"; import { AdminRouterModeProvider } from "./adminRouterMode";
import { AdminLayout } from "./layouts/AdminLayout"; import { AdminLayout } from "./layouts/AdminLayout";
import { AdminLogin } from "./pages/admin/AdminLogin"; import { AdminLogin } from "./pages/admin/Login";
import { AdminDashboard } from "./pages/admin/AdminDashboard"; import { AdminDashboard } from "./pages/admin/Dashboard";
import { AdminResources } from "./pages/admin/AdminResources"; import { AdminResources } from "./pages/admin/Resources";
import { AdminResourceForm } from "./pages/admin/AdminResourceForm"; import { AdminResourceForm } from "./pages/admin/ResourceForm";
import { AdminSearchLogs } from "./pages/admin/AdminSearchLogs"; import { AdminSearchLogs } from "./pages/admin/SearchLogs";
function NotFound() { function NotFound() {
return ( return (

View File

@@ -1,7 +1,7 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { tLang } from "../i18n"; import { tLang } from "../i18n";
/** Admin area always uses 繁體中文, independent of site language. */ /** Admin area always uses Chinese, independent of site language. */
export function useAdminT() { export function useAdminT() {
return useCallback((key: string) => tLang("zh-TW", key), []); return useCallback((key: string) => tLang("zh-CN", key), []);
} }

View File

@@ -1,11 +1,11 @@
import { Route } from "react-router-dom"; import { Route } from "react-router-dom";
import { adminUiPrefix } from "./adminPaths"; import { adminUiPrefix } from "./adminPaths";
import { AdminLayout } from "./layouts/AdminLayout"; import { AdminLayout } from "./layouts/AdminLayout";
import { AdminLogin } from "./pages/admin/AdminLogin"; import { AdminLogin } from "./pages/admin/Login";
import { AdminDashboard } from "./pages/admin/AdminDashboard"; import { AdminDashboard } from "./pages/admin/Dashboard";
import { AdminResources } from "./pages/admin/AdminResources"; import { AdminResources } from "./pages/admin/Resources";
import { AdminResourceForm } from "./pages/admin/AdminResourceForm"; import { AdminResourceForm } from "./pages/admin/ResourceForm";
import { AdminSearchLogs } from "./pages/admin/AdminSearchLogs"; import { AdminSearchLogs } from "./pages/admin/SearchLogs";
/** Shared between full `App` (when admin enabled) and `AppAdminOnly`. */ /** Shared between full `App` (when admin enabled) and `AppAdminOnly`. */
export function AdminRouteTree() { export function AdminRouteTree() {

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
async function loadApi(apiUrl = "") { async function loadApi(apiUrl = "") {
vi.resetModules(); vi.resetModules();
vi.stubEnv("VITE_API_URL", apiUrl); vi.stubEnv("VITE_API_URL", apiUrl);
vi.stubEnv("VITE_API_PREFIX", "");
return import("./api"); return import("./api");
} }

View File

@@ -44,9 +44,9 @@ export async function postJSON<T>(
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
/** Best-effort favorite counter sync (anonymous; matches localStorage favorite). */ export async function postNoBody(path: string): Promise<void> {
export function postFavoriteDelta(id: string, add: boolean) { const res = await fetch(`${apiBase}${path}`, { method: "POST" });
return postJSON(`/api/resources/${id}/favorite`, { add }).catch(() => {}); if (!res.ok) throw new Error(await res.text());
} }
export async function putJSON<T>( export async function putJSON<T>(

View File

@@ -1,7 +1,7 @@
import { Download } from "lucide-react"; import { Download } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Resource } from "../api"; import type { Resource } from "../api";
import { assetUrl, postJSON } from "../api"; import { assetUrl, postJSON, postNoBody } from "../api";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useMemo } from "react"; import { useMemo } from "react";
import { formatDateYmd } from "../utils/format"; import { formatDateYmd } from "../utils/format";
@@ -14,11 +14,16 @@ function isPlaceholderAsset(path: string | undefined | null) {
const CARD_CLASS = const CARD_CLASS =
"group flex w-[232px] shrink-0 flex-col overflow-hidden rounded-xl border border-ark-line bg-ark-panel transition hover:border-ark-gold/55 max-[439px]:w-[232px] min-[440px]:w-[230px] sm:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]"; "group flex w-[232px] shrink-0 flex-col overflow-hidden rounded-xl border border-ark-line bg-ark-panel transition hover:border-ark-gold/55 max-[439px]:w-[232px] min-[440px]:w-[230px] sm:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]";
type RecommendedResource = Resource & {
downloadPostId?: string;
downloadAttachmentId?: string;
};
export function RecommendedCard({ export function RecommendedCard({
r, r,
visualIndex = 0, visualIndex = 0,
}: { }: {
r: Resource; r: RecommendedResource;
visualIndex?: number; visualIndex?: number;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
@@ -83,7 +88,13 @@ export function RecommendedCard({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
try { try {
await postJSON(`/api/resources/${r.id}/download`, {}); if (r.downloadPostId && r.downloadAttachmentId) {
await postNoBody(
`/api/posts/${r.downloadPostId}/attachments/${r.downloadAttachmentId}/download`,
);
} else {
await postJSON(`/api/resources/${r.id}/download`, {});
}
} catch { } catch {
/* ignore */ /* ignore */
} }

View File

@@ -1,100 +0,0 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import type { Resource } from "../api";
import { I18nProvider } from "../i18n";
import { ResourceCard } from "./ResourceCard";
const resource: Resource = {
id: "resource-1",
title: "Demo Resource",
description: "Short description",
type: "pdf",
language: "zh-TW",
categoryId: 1,
categorySlug: "docs",
categoryName: "文件",
coverImage: "/uploads/cover.png",
fileUrl: "/uploads/demo.pdf",
previewUrl: "/uploads/preview.pdf",
badgeLabel: "官方",
isDownloadable: true,
isRecommended: true,
updatedAt: "2026-05-16T00:00:00Z",
tags: ["ark"],
};
function renderCard(onFavoriteToggle = vi.fn()) {
return {
user: userEvent.setup(),
onFavoriteToggle,
...render(
<MemoryRouter>
<I18nProvider>
<ResourceCard r={resource} onFavoriteToggle={onFavoriteToggle} />
</I18nProvider>
</MemoryRouter>,
),
};
}
function okResponse() {
return new Response(JSON.stringify({}), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
describe("ResourceCard", () => {
it("renders resource summary and detail link", () => {
renderCard();
expect(screen.getByText("Demo Resource")).toBeInTheDocument();
expect(screen.getByText("文件")).toBeInTheDocument();
expect(screen.getByText("官方")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /預覽/ })).toHaveAttribute(
"href",
"/resource/resource-1",
);
});
it("toggles local favorite state and syncs best-effort API delta", async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal("fetch", fetchMock);
const { user, onFavoriteToggle } = renderCard();
await user.click(screen.getByRole("button", { name: /收藏/ }));
expect(JSON.parse(localStorage.getItem("ark_favorites") || "[]")).toEqual([
"resource-1",
]);
expect(fetchMock).toHaveBeenCalledWith(
"/api/resources/resource-1/favorite",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ add: true }),
}),
);
expect(onFavoriteToggle).toHaveBeenCalledTimes(1);
});
it("tracks downloads then opens the downloadable file", async () => {
const fetchMock = vi.fn().mockResolvedValue(okResponse());
vi.stubGlobal("fetch", fetchMock);
const openMock = vi.spyOn(window, "open").mockImplementation(() => null);
const { user } = renderCard();
await user.click(screen.getByRole("button", { name: /下載/ }));
expect(fetchMock).toHaveBeenCalledWith(
"/api/resources/resource-1/download",
expect.objectContaining({ method: "POST" }),
);
expect(openMock).toHaveBeenCalledWith(
"/uploads/demo.pdf",
"_blank",
"noopener,noreferrer",
);
});
});

View File

@@ -1,99 +0,0 @@
import { Download, Eye, Heart } from "lucide-react";
import { Link } from "react-router-dom";
import type { Resource } from "../api";
import { assetUrl, postJSON, postFavoriteDelta } from "../api";
import { isFavorite, toggleFavorite } from "../favorites";
import { useI18n } from "../i18n";
import { useMemo, useState } from "react";
export function ResourceCard({
r,
onFavoriteToggle,
}: {
r: Resource;
onFavoriteToggle?: () => void;
}) {
const { t } = useI18n();
const [fav, setFav] = useState(() => isFavorite(r.id));
const cover = useMemo(
() => assetUrl(r.coverImage || r.previewUrl),
[r.coverImage, r.previewUrl],
);
return (
<div className="rounded-2xl border border-ark-line bg-ark-panel overflow-hidden flex flex-col">
<div className="relative aspect-video bg-black">
{cover ? (
<img
src={cover}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="h-full w-full bg-gradient-to-br from-neutral-900 to-neutral-800" />
)}
{r.badgeLabel ? (
<span className="absolute left-3 top-3 rounded-full bg-ark-gold/90 px-3 py-1 text-xs font-semibold text-black">
{r.badgeLabel}
</span>
) : null}
</div>
<div className="p-4 flex flex-col gap-2 flex-1">
<div className="text-sm text-ark-muted">{r.categoryName}</div>
<div className="text-lg font-semibold leading-snug line-clamp-2">
{r.title}
</div>
<div className="text-xs text-ark-muted">
{r.type.toUpperCase()} · {new Date(r.updatedAt).toLocaleDateString()}
</div>
{r.description ? (
<p className="text-sm text-neutral-300 line-clamp-2">
{r.description}
</p>
) : null}
<div className="mt-auto flex flex-wrap gap-2 pt-2">
<Link
to={`/resource/${r.id}`}
className="inline-flex items-center gap-1 rounded-lg border border-ark-line px-3 py-2 text-sm outline-none hover:border-ark-gold hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
<Eye size={16} /> {t("preview")}
</Link>
<button
type="button"
className={`inline-flex items-center gap-1 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
fav
? "border-ark-gold text-ark-gold2"
: "border-ark-line hover:border-ark-gold"
}`}
onClick={() => {
const on = toggleFavorite(r.id);
setFav(on);
void postFavoriteDelta(r.id, on);
onFavoriteToggle?.();
}}
>
<Heart size={16} /> {t("favorite")}
</button>
{r.isDownloadable && (r.fileUrl || r.previewUrl) ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg bg-ark-gold px-3 py-2 text-sm font-semibold text-black outline-none hover:bg-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold2 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
onClick={async () => {
const u = assetUrl(r.fileUrl || r.previewUrl);
try {
await postJSON(`/api/resources/${r.id}/download`, {});
} catch {
/* ignore */
}
window.open(u, "_blank", "noopener,noreferrer");
}}
>
<Download size={16} /> {t("download")}
</button>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -1,52 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { ResourceListFooter } from "./ResourceListFooter";
const t = (key: string) =>
({
listRange: "顯示 {{from}}{{to}},共 {{total}} 筆",
paginationPrev: "上一頁",
paginationNext: "下一頁",
pageIndicator: "{{c}} / {{p}} 頁",
})[key] ?? key;
describe("ResourceListFooter", () => {
it("renders range, page count, and disabled prev on first page", () => {
render(
<ResourceListFooter
page={1}
limit={24}
total={50}
t={t}
onPrev={vi.fn()}
onNext={vi.fn()}
/>,
);
expect(screen.getByText("顯示 124共 50 筆")).toBeInTheDocument();
expect(screen.getByText("1 / 3 頁")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "上一頁" })).toBeDisabled();
expect(screen.getByRole("button", { name: "下一頁" })).toBeEnabled();
});
it("calls navigation handlers when buttons are enabled", () => {
const onPrev = vi.fn();
const onNext = vi.fn();
render(
<ResourceListFooter
page={2}
limit={24}
total={50}
t={t}
onPrev={onPrev}
onNext={onNext}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "上一頁" }));
fireEvent.click(screen.getByRole("button", { name: "下一頁" }));
expect(onPrev).toHaveBeenCalledTimes(1);
expect(onNext).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,54 +0,0 @@
type T = (k: string) => string;
export function ResourceListFooter({
page,
limit,
total,
t,
onPrev,
onNext,
}: {
page: number;
limit: number;
total: number;
t: T;
onPrev: () => void;
onNext: () => void;
}) {
const pages = Math.max(1, Math.ceil(total / limit));
const from = total === 0 ? 0 : (page - 1) * limit + 1;
const to = Math.min(page * limit, total);
return (
<div className="flex flex-col items-center justify-between gap-3 border-t border-ark-line pt-6 sm:flex-row">
<p className="text-sm text-neutral-400">
{t("listRange")
.replace("{{from}}", String(from))
.replace("{{to}}", String(to))
.replace("{{total}}", String(total))}
</p>
<div className="flex items-center gap-2">
<button
type="button"
disabled={page <= 1}
onClick={onPrev}
className="rounded-full border border-ark-line px-4 py-2 text-sm text-neutral-200 outline-none transition hover:border-ark-gold disabled:cursor-not-allowed disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
{t("paginationPrev")}
</button>
<span className="text-sm text-ark-muted tabular-nums">
{t("pageIndicator")
.replace("{{c}}", String(page))
.replace("{{p}}", String(pages))}
</span>
<button
type="button"
disabled={page >= pages}
onClick={onNext}
className="rounded-full border border-ark-line px-4 py-2 text-sm text-neutral-200 outline-none transition hover:border-ark-gold disabled:cursor-not-allowed disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
{t("paginationNext")}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export function DaySeparator({ label }: { label: string }) {
return (
<div className="sticky top-[58px] z-[5] flex justify-center py-2">
<span className="rounded-full bg-ark-panel/80 px-3 py-1 text-xs text-neutral-300 backdrop-blur">
{label}
</span>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { useI18n } from "../../i18n";
import { typeFilterLabel } from "../../resourceTypeLabels";
const TYPE_FILTERS = [
"all",
"image",
"video",
"music",
"ppt",
"pdf",
"text",
"link",
"archive",
] as const;
export type FilterChipsProps = {
type: string;
onTypeChange: (next: string) => void;
};
export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
const { t } = useI18n();
return (
<div className="sticky top-0 z-10 -mx-3 border-b border-ark-line bg-ark-bg/90 px-3 py-2 backdrop-blur md:-mx-0 md:rounded-t-xl">
<div className="flex gap-1.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{TYPE_FILTERS.map((tp) => {
const active = type === tp;
return (
<button
key={tp}
type="button"
onClick={() => onTypeChange(tp)}
className={`shrink-0 rounded-full border px-3 py-1 text-xs transition ${
active
? "border-ark-gold bg-ark-gold/10 text-ark-gold2"
: "border-ark-line text-neutral-300 hover:border-ark-gold/50"
}`}
>
{typeFilterLabel(t, tp)}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import type { ComponentType } from "react";
import type { Post } from "../../types/post";
import { useI18n } from "../../i18n";
import { TextBubble } from "./bubbles/TextBubble";
import { FileDocBubble } from "./bubbles/FileDocBubble";
import { ImageBubble } from "./bubbles/ImageBubble";
import { ImageWithTextBubble } from "./bubbles/ImageWithTextBubble";
import { AlbumBubble } from "./bubbles/AlbumBubble";
import { VideoBubble } from "./bubbles/VideoBubble";
import { formatDateTime } from "./utils/formatTime";
type BubbleComponent = ComponentType<{ post: Post }>;
export function pickBubble(post: Post): BubbleComponent {
const a = post.attachments;
if (a.length === 0) return TextBubble;
if (a.length >= 2 && a.every((x) => x.kind === "image")) return AlbumBubble;
const only = a[0];
if (only.kind === "video") return VideoBubble;
if (only.kind === "image") {
return post.text ? ImageWithTextBubble : ImageBubble;
}
return FileDocBubble;
}
export function MessageBubble({ post }: { post: Post }) {
const { lang } = useI18n();
const Bubble = pickBubble(post);
const isTextOnly = post.attachments.length === 0;
const isVisual = post.attachments.some(
(a) => a.kind === "image" || a.kind === "video",
);
return (
<article
id={`post-${post.id}`}
className={`relative self-start rounded-2xl bg-ark-panel text-left shadow-sm ${
isVisual
? "w-[82vw] max-w-[320px] md:w-[52vw] md:max-w-[420px] lg:w-[46vw] lg:max-w-[520px]"
: "inline-block max-w-[92%] md:max-w-[680px]"
} ${isTextOnly ? "px-3 py-2" : "p-2"}`}
>
<Bubble post={post} />
<time
dateTime={post.publishedAt}
className="ml-2 mt-1 inline-block float-right text-[10.5px] leading-none text-neutral-500"
>
{formatDateTime(post.publishedAt, lang)}
</time>
<span className="block clear-both" />
</article>
);
}

View File

@@ -0,0 +1,105 @@
import { useEffect, useMemo, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { useI18n } from "../../i18n";
import type { PostScope } from "../../types/post";
import { FilterChips } from "./FilterChips";
import { MessageBubble } from "./MessageBubble";
import { useGroupedByDay } from "./hooks/useGroupedByDay";
import { usePostStream } from "./hooks/usePostStream";
export type MessageStreamProps = {
scope: PostScope;
};
export function MessageStream({ scope }: MessageStreamProps) {
const { t, lang } = useI18n();
const [sp, setSp] = useSearchParams();
const type = sp.get("type") || "all";
const params = useMemo(() => ({ scope, type, lang }), [scope, type, lang]);
const { items, isLoading, error, hasMore, loadMore, reset } =
usePostStream(params);
const groups = useGroupedByDay(items, lang);
const retryLabel = lang === "zh-CN" ? "重试" : "Retry";
const sentinelRef = useRef<HTMLDivElement>(null);
const hasMoreRef = useRef(hasMore);
const isLoadingRef = useRef(isLoading);
useEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
useEffect(() => {
isLoadingRef.current = isLoading;
}, [isLoading]);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (
entry.isIntersecting &&
hasMoreRef.current &&
!isLoadingRef.current
) {
loadMore();
}
}
},
{ rootMargin: "200px" },
);
io.observe(el);
return () => io.disconnect();
}, [loadMore]);
const updateParam = (key: string, value: string) => {
const n = new URLSearchParams(sp);
if (!value || value === "all") n.delete(key);
else n.set(key, value);
setSp(n, { replace: true });
};
return (
<div className="mx-auto max-w-full px-3 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
<div className="flex flex-col gap-2 pb-10 pt-2">
{groups.map((group) => (
<div key={group.dayKey} className="flex flex-col gap-2">
{group.items.map((post) => (
<MessageBubble key={post.id} post={post} />
))}
</div>
))}
{!isLoading && !error && items.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-400">
{t("noResults")}
</p>
) : null}
{error ? (
<div className="my-4 flex items-center justify-between gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200">
<span className="break-all">{error}</span>
<button
type="button"
onClick={() => reset()}
className="shrink-0 rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500"
>
{retryLabel}
</button>
</div>
) : null}
{isLoading ? (
<div className="py-4 text-center text-xs text-neutral-500"></div>
) : null}
<div ref={sentinelRef} aria-hidden className="h-1" />
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useI18n } from "../../../i18n";
import type { Attachment, Post } from "../../../types/post";
import { useLightbox } from "../overlays/ImageLightbox";
import { autolink } from "../utils/autolink";
import { postDisplayText } from "../utils/postText";
const MAX_VISIBLE = 4;
function imageRatio(att: Attachment) {
return att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
}
export function AlbumBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const { lang } = useI18n();
const images = post.attachments;
const text = postDisplayText(post, lang);
const shouldMerge = images.length > MAX_VISIBLE;
if (!shouldMerge) {
return (
<div className="flex flex-col gap-1.5">
{images.map((att, i) => (
<button
key={att.id}
type="button"
onClick={() => openLightbox(images, i, text, post.id)}
className="relative block max-h-[180px] w-full overflow-hidden rounded-xl min-[440px]:max-h-[200px] md:max-h-[240px] lg:max-h-[280px]"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="h-full w-full object-cover"
style={{ aspectRatio: imageRatio(att) }}
/>
</button>
))}
{text ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{autolink(text)}
</div>
) : null}
</div>
);
}
const visible = images.slice(0, MAX_VISIBLE);
const extra = images.length - MAX_VISIBLE;
return (
<div className="flex flex-col gap-1.5">
<div className="grid h-[220px] grid-cols-2 grid-rows-2 gap-[2px] overflow-hidden rounded-xl min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]">
{visible.map((att, i) => {
const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0;
return (
<button
key={att.id}
type="button"
onClick={() => openLightbox(images, i, text, post.id)}
className="relative block h-full w-full overflow-hidden"
aria-label={att.filename}
>
<img
src={att.thumbnailUrl ?? att.url}
alt={att.filename}
loading="lazy"
className={`h-full w-full object-cover ${
isLastSlot ? "blur-sm scale-105" : ""
}`}
/>
{isLastSlot ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-3xl font-semibold text-white">
+{extra}
</div>
) : null}
</button>
);
})}
</div>
{text ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{autolink(text)}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { Download } from "lucide-react";
import { postNoBody } from "../../../api";
import { useI18n } from "../../../i18n";
import type { Attachment, Post } from "../../../types/post";
import { fileIcon } from "../utils/fileIcon";
import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
const isImageAsDoc = att.mime.startsWith("image/");
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
return (
<a
href={att.url}
download={att.filename}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2 rounded-xl px-1 py-0.5 transition hover:bg-white/5"
onClick={() => {
void postNoBody(`/api/posts/${postId}/attachments/${att.id}/download`);
}}
>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full md:h-12 md:w-12">
{isImageAsDoc && att.thumbnailUrl ? (
<>
<img
src={att.thumbnailUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
<Download className="h-4 w-4 text-white" />
</div>
</>
) : (
<div
className="flex h-full w-full items-center justify-center"
style={{ backgroundColor: color }}
>
<Icon className="h-5 w-5 text-white" strokeWidth={2.2} />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-medium text-ark-gold2 group-hover:text-ark-gold">
{att.filename}
</div>
<div className="text-[11px] text-neutral-400">
{formatBytes(att.sizeBytes)}
</div>
</div>
</a>
);
}
export function FileDocBubble({ post }: { post: Post }) {
const { lang } = useI18n();
const text = postDisplayText(post, lang);
return (
<div className="flex flex-col gap-1">
{post.attachments.map((att) => (
<AttachmentRow key={att.id} postId={post.id} att={att} />
))}
{text ? (
<div className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{text}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import type { Post } from "../../../types/post";
import { useLightbox } from "../overlays/ImageLightbox";
export function ImageBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const att = post.attachments[0];
if (!att) return null;
const ratio =
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
return (
<button
type="button"
onClick={() => openLightbox([att], 0, undefined, post.id)}
className="relative block w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="h-full w-full object-cover"
style={{ aspectRatio: ratio }}
/>
</button>
);
}

View File

@@ -0,0 +1,41 @@
import { useI18n } from "../../../i18n";
import type { Post } from "../../../types/post";
import { useLightbox } from "../overlays/ImageLightbox";
import { autolink } from "../utils/autolink";
import { postDisplayText } from "../utils/postText";
export function ImageWithTextBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const { lang } = useI18n();
const att = post.attachments[0];
const text = postDisplayText(post, lang);
if (!att) return null;
const ratio =
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
return (
<div className="relative overflow-hidden rounded-xl bg-black/20">
<button
type="button"
onClick={() => openLightbox([att], 0, text, post.id)}
className="block w-full"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="block h-auto w-full"
style={{ aspectRatio: ratio }}
/>
</button>
{text ? (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/85 via-black/55 to-transparent px-4 pb-4 pt-16 text-[14px] leading-snug text-neutral-100">
<div className="whitespace-pre-wrap break-words">
{autolink(text)}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import type { Post } from "../../../types/post";
import { useI18n } from "../../../i18n";
import { autolink } from "../utils/autolink";
import { postDisplayText } from "../utils/postText";
export function TextBubble({ post }: { post: Post }) {
const { lang } = useI18n();
return (
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{autolink(postDisplayText(post, lang))}
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { Download, Play } from "lucide-react";
import { useRef, useState } from "react";
import { postNoBody } from "../../../api";
import { useI18n } from "../../../i18n";
import type { Post } from "../../../types/post";
import { useVideoPlayer } from "../overlays/VideoPlayer";
import { autolink } from "../utils/autolink";
import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
function formatDuration(sec: number | undefined): string {
if (!sec || sec <= 0) return "";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function VideoBubble({ post }: { post: Post }) {
const { openVideo } = useVideoPlayer();
const { lang } = useI18n();
const att = post.attachments[0];
const [playing, setPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const text = postDisplayText(post, lang);
if (!att) return null;
const ratio =
att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9";
const posterUrl = att.posterUrl ?? att.thumbnailUrl;
const previewVideoUrl = att.url.includes("#") ? att.url : `${att.url}#t=0.1`;
return (
<div className="flex flex-col gap-1.5">
<div
className="relative max-h-[220px] w-full overflow-hidden rounded-xl bg-black min-[440px]:max-h-[250px] md:max-h-[300px] lg:max-h-[340px]"
style={{ aspectRatio: ratio }}
onClick={() => {
if (playing) {
const v = videoRef.current;
openVideo(att, v?.currentTime ?? 0);
}
}}
>
{playing ? (
<video
ref={videoRef}
src={att.url}
poster={att.posterUrl}
controls
playsInline
autoPlay
className="absolute inset-0 h-full w-full"
/>
) : (
<>
{posterUrl ? (
<img
src={posterUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<video
src={previewVideoUrl}
preload="metadata"
muted
playsInline
className="absolute inset-0 h-full w-full object-cover"
aria-hidden="true"
/>
)}
<div className="absolute left-3 top-3 z-10 flex items-center gap-1.5 text-xs text-white">
<a
href={att.url}
download={att.filename}
target="_blank"
rel="noreferrer"
onClick={(e) => {
e.stopPropagation();
void postNoBody(
`/api/posts/${post.id}/attachments/${att.id}/download`,
);
}}
className="flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur transition hover:bg-black/75"
aria-label={`Download ${att.filename}`}
>
<Download className="h-4 w-4" />
</a>
<div className="flex items-center gap-1.5 rounded-full bg-black/55 px-2.5 py-1.5">
{formatDuration(att.durationSec) ? (
<>
<span>{formatDuration(att.durationSec)}</span>
<span className="opacity-70">·</span>
</>
) : null}
<span>{formatBytes(att.sizeBytes)}</span>
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setPlaying(true);
}}
className="absolute inset-0 flex items-center justify-center"
aria-label="Play video"
>
<span className="relative z-10 flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur md:h-14 md:w-14">
<Play className="h-5 w-5 translate-x-0.5 fill-white md:h-6 md:w-6" />
</span>
</button>
</>
)}
</div>
{text ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{autolink(text)}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import { useGroupedByDay } from "./useGroupedByDay";
import type { Post } from "../../../types/post";
function makePost(id: string, isoDate: string): Post {
return {
id,
categoryId: 1,
categorySlug: "x",
language: "zh-CN",
attachments: [],
isRecommended: false,
publishedAt: isoDate,
updatedAt: isoDate,
text: id,
};
}
describe("useGroupedByDay", () => {
it("groups posts by local date", () => {
const posts: Post[] = [
makePost("a", "2026-02-27T10:00:00.000Z"),
makePost("b", "2026-02-27T23:00:00.000Z"),
makePost("c", "2026-02-28T01:00:00.000Z"),
makePost("d", "2026-05-16T12:00:00.000Z"),
];
const { result } = renderHook(() => useGroupedByDay(posts, "zh-CN"));
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"]);
});
it("preserves input order within groups", () => {
const posts: Post[] = [
makePost("first", "2026-03-01T10:00:00.000Z"),
makePost("second", "2026-03-01T11:00:00.000Z"),
makePost("third", "2026-03-01T12:00:00.000Z"),
];
const { result } = renderHook(() => useGroupedByDay(posts, "en"));
expect(result.current).toHaveLength(1);
expect(result.current[0].items.map((p) => p.id)).toEqual([
"first",
"second",
"third",
]);
});
it("returns empty array for empty input", () => {
const { result } = renderHook(() => useGroupedByDay([], "zh-CN"));
expect(result.current).toEqual([]);
});
});

View File

@@ -0,0 +1,67 @@
import { useMemo } from "react";
import type { Post } from "../../../types/post";
export type DayGroup = {
dayKey: string;
dayLabel: string;
items: Post[];
};
function localeFor(lang: string): string {
const locales: Record<string, string> = {
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 {
const d = new Date(iso);
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
}
function dayLabel(iso: string, lang: string): string {
const d = new Date(iso);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
const isSameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
if (isSameDay(d, today)) {
return lang === "zh-CN" ? "今天" : "Today";
}
if (isSameDay(d, yesterday)) {
return lang === "zh-CN" ? "昨天" : "Yesterday";
}
return new Intl.DateTimeFormat(localeFor(lang), {
month: "long",
day: "numeric",
}).format(d);
}
export function useGroupedByDay(posts: Post[], lang: string): DayGroup[] {
return useMemo(() => {
const groups: DayGroup[] = [];
const seen = new Map<string, DayGroup>();
for (const p of posts) {
const k = dayKey(p.publishedAt);
let g = seen.get(k);
if (!g) {
g = { dayKey: k, dayLabel: dayLabel(p.publishedAt, lang), items: [] };
seen.set(k, g);
groups.push(g);
}
g.items.push(p);
}
return groups;
}, [posts, lang]);
}
export { dayKey as _dayKey, dayLabel as _dayLabel };

View File

@@ -0,0 +1,163 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getJSON } from "../../../api";
import { langQuery, type Lang } from "../../../i18n";
import { sourceLanguageQuery } from "../../../i18nLanguages";
import { MOCK_POSTS } from "../../../mocks/mockPosts";
import type { Post, PostListResponse, PostScope } from "../../../types/post";
const PAGE_SIZE = 20;
const MOCK_DELAY_MS = 200;
const USE_MOCK = import.meta.env.VITE_USE_MOCK_POSTS === "true";
export type PostStreamParams = {
scope: PostScope;
type?: string;
language?: string;
lang: Lang;
};
export type PostStreamResult = {
items: Post[];
isLoading: boolean;
error: string | null;
hasMore: boolean;
loadMore: () => void;
reset: () => void;
};
function postMatchesType(post: Post, type: string): boolean {
if (!type || type === "all") return true;
if (type === "text" || type === "link") {
return !!post.text && post.text.length > 0;
}
return post.attachments.some((a) => {
const ext = a.filename.split(".").pop()?.toLowerCase() ?? "";
if (type === "image")
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 (
["ppt", "pptx", "key"].includes(ext) || a.mime.includes("presentation")
);
if (type === "archive")
return ["zip", "rar", "7z", "tar", "gz"].includes(ext);
return false;
});
}
function filterMock(params: PostStreamParams): Post[] {
return MOCK_POSTS.filter((p) => {
if (
params.scope.kind === "category" &&
p.categorySlug !== params.scope.slug
)
return false;
if (params.language && p.language !== params.language) return false;
if (!postMatchesType(p, params.type ?? "all")) return false;
return true;
}).sort(
(a, b) =>
new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),
);
}
function buildRealUrl(params: PostStreamParams, cursor?: string): string {
const sp = new URLSearchParams();
sp.set("lang", langQuery(params.lang));
sp.set("limit", String(PAGE_SIZE));
if (params.scope.kind === "category") sp.set("category", params.scope.slug);
if (params.type && params.type !== "all") sp.set("type", params.type);
if (params.language) sp.set("language", sourceLanguageQuery(params.language));
if (cursor) sp.set("cursor", cursor);
return `/api/posts?${sp.toString()}`;
}
export function usePostStream(params: PostStreamParams): PostStreamResult {
const [items, setItems] = useState<Post[]>([]);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const reqIdRef = useRef(0);
const cursorRef = useRef<string | undefined>(undefined);
const hasMoreRef = useRef(true);
const loadingRef = useRef(false);
const fetchPage = useCallback(
async (resetting: boolean) => {
if (loadingRef.current) return;
if (!resetting && !hasMoreRef.current) return;
loadingRef.current = true;
setIsLoading(true);
setError(null);
const myReq = ++reqIdRef.current;
try {
if (USE_MOCK) {
await new Promise((r) => setTimeout(r, MOCK_DELAY_MS));
const all = filterMock(params);
const offset = resetting ? 0 : Number(cursorRef.current ?? "0");
const slice = all.slice(offset, offset + PAGE_SIZE);
const nextOffset = offset + slice.length;
const more = nextOffset < all.length;
if (myReq !== reqIdRef.current) return;
setItems((prev) => (resetting ? slice : [...prev, ...slice]));
const nextCursor = more ? String(nextOffset) : undefined;
cursorRef.current = nextCursor;
setHasMore(more);
hasMoreRef.current = more;
} else {
const url = buildRealUrl(
params,
resetting ? undefined : cursorRef.current,
);
const res = await getJSON<PostListResponse>(url);
if (myReq !== reqIdRef.current) return;
setItems((prev) => (resetting ? res.items : [...prev, ...res.items]));
cursorRef.current = res.nextCursor;
const more = !!res.nextCursor;
setHasMore(more);
hasMoreRef.current = more;
}
} catch (e) {
if (myReq !== reqIdRef.current) return;
setError(String(e));
} finally {
if (myReq === reqIdRef.current) setIsLoading(false);
loadingRef.current = false;
}
},
[params],
);
useEffect(() => {
setItems([]);
cursorRef.current = undefined;
setHasMore(true);
hasMoreRef.current = true;
fetchPage(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
params.scope.kind,
params.scope.kind === "category" ? params.scope.slug : "",
params.type,
params.language,
params.lang,
]);
const loadMore = useCallback(() => {
fetchPage(false);
}, [fetchPage]);
const reset = useCallback(() => {
fetchPage(true);
}, [fetchPage]);
return { items, isLoading, error, hasMore, loadMore, reset };
}
export { USE_MOCK as POST_STREAM_USES_MOCK };

View File

@@ -0,0 +1,222 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type PropsWithChildren,
} from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
import { postNoBody } from "../../../api";
import type { Attachment } from "../../../types/post";
import { autolink } from "../utils/autolink";
type LightboxState = {
images: Attachment[];
index: number;
caption?: string;
postId?: string;
} | null;
type Ctx = {
openLightbox: (
images: Attachment[],
startIndex?: number,
caption?: string,
postId?: string,
) => void;
closeLightbox: () => void;
};
const LightboxContext = createContext<Ctx | null>(null);
export function useLightbox(): Ctx {
const ctx = useContext(LightboxContext);
if (!ctx)
throw new Error("useLightbox must be used inside ImageLightboxProvider");
return ctx;
}
export function ImageLightboxProvider({ children }: PropsWithChildren) {
const [state, setState] = useState<LightboxState>(null);
const openLightbox = useCallback(
(
images: Attachment[],
startIndex = 0,
caption?: string,
postId?: string,
) => {
if (!images.length) return;
const i = Math.min(Math.max(0, startIndex), images.length - 1);
setState({ images, index: i, caption, postId });
},
[],
);
const closeLightbox = useCallback(() => setState(null), []);
return (
<LightboxContext.Provider value={{ openLightbox, closeLightbox }}>
{children}
{state ? (
<LightboxView
images={state.images}
startIndex={state.index}
caption={state.caption}
postId={state.postId}
onClose={closeLightbox}
/>
) : null}
</LightboxContext.Provider>
);
}
function LightboxView({
images,
startIndex,
caption: captionText,
postId,
onClose,
}: {
images: Attachment[];
startIndex: number;
caption?: string;
postId?: string;
onClose: () => void;
}) {
const [index, setIndex] = useState(startIndex);
const touchStartX = useRef<number | null>(null);
const goPrev = useCallback(
() => setIndex((i) => (i - 1 + images.length) % images.length),
[images.length],
);
const goNext = useCallback(
() => setIndex((i) => (i + 1) % images.length),
[images.length],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft") goPrev();
if (e.key === "ArrowRight") goNext();
};
window.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [goPrev, goNext, onClose]);
const current = images[index];
const caption = captionText?.trim();
if (!current) return null;
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
<a
href={current.url}
download={current.filename}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.stopPropagation();
if (postId) {
void postNoBody(
`/api/posts/${postId}/attachments/${current.id}/download`,
);
}
}}
className="absolute right-16 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
aria-label="Download"
>
<Download className="h-5 w-5" />
</a>
{images.length > 1 ? (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
className="absolute left-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 md:left-6"
aria-label="Previous"
>
<ChevronLeft className="h-6 w-6" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
goNext();
}}
className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 md:right-6"
aria-label="Next"
>
<ChevronRight className="h-6 w-6" />
</button>
<div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
{index + 1} / {images.length}
</div>
</>
) : null}
<div
className="relative inline-block max-h-[92vh] max-w-[92vw]"
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => {
touchStartX.current = e.touches[0].clientX;
}}
onTouchEnd={(e) => {
if (touchStartX.current == null) return;
const dx = e.changedTouches[0].clientX - touchStartX.current;
if (Math.abs(dx) > 40) {
if (dx > 0) goPrev();
else goNext();
}
touchStartX.current = null;
}}
>
<img
src={current.url}
alt={current.filename}
className="max-h-[92vh] max-w-[92vw] object-contain select-none"
draggable={false}
/>
{caption ? (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent px-4 pb-4 pt-12 text-sm leading-snug text-white sm:px-5 sm:pb-5">
<div className="max-h-[32vh] overflow-y-auto whitespace-pre-wrap break-words">
{autolink(caption)}
</div>
</div>
) : null}
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,118 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type PropsWithChildren,
} from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import type { Attachment } from "../../../types/post";
type PlayerState = {
attachment: Attachment;
currentTime: number;
} | null;
type Ctx = {
openVideo: (attachment: Attachment, currentTime?: number) => void;
closeVideo: () => void;
};
const VideoPlayerContext = createContext<Ctx | null>(null);
export function useVideoPlayer(): Ctx {
const ctx = useContext(VideoPlayerContext);
if (!ctx)
throw new Error("useVideoPlayer must be used inside VideoPlayerProvider");
return ctx;
}
export function VideoPlayerProvider({ children }: PropsWithChildren) {
const [state, setState] = useState<PlayerState>(null);
const openVideo = useCallback(
(attachment: Attachment, currentTime = 0) =>
setState({ attachment, currentTime }),
[],
);
const closeVideo = useCallback(() => setState(null), []);
return (
<VideoPlayerContext.Provider value={{ openVideo, closeVideo }}>
{children}
{state ? (
<PlayerView
attachment={state.attachment}
startAt={state.currentTime}
onClose={closeVideo}
/>
) : null}
</VideoPlayerContext.Provider>
);
}
function PlayerView({
attachment,
startAt,
onClose,
}: {
attachment: Attachment;
startAt: number;
onClose: () => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
if (startAt > 0) v.currentTime = startAt;
v.play().catch(() => {});
}, [startAt]);
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
<video
ref={videoRef}
src={attachment.url}
poster={attachment.posterUrl}
controls
playsInline
className="max-h-[92vh] max-w-[96vw] outline-none"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { autolink } from "./autolink";
describe("autolink", () => {
it("returns empty array for empty input", () => {
expect(autolink("")).toEqual([]);
});
it("returns plain text when no urls", () => {
const { container } = render(<>{autolink("普通文本,没有链接")}</>);
expect(container.textContent).toBe("普通文本,没有链接");
expect(container.querySelector("a")).toBeNull();
});
it("wraps a single https url in an anchor with safe attrs", () => {
const { container } = render(<>{autolink("点 https://x.com/path 看")}</>);
const anchor = container.querySelector("a");
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute("href")).toBe("https://x.com/path");
expect(anchor?.getAttribute("target")).toBe("_blank");
expect(anchor?.getAttribute("rel")).toBe("noopener noreferrer");
expect(container.textContent).toBe("点 https://x.com/path 看");
});
it("handles multiple urls in one string", () => {
const { container } = render(
<>{autolink("a https://a.com b https://b.com c")}</>,
);
const anchors = container.querySelectorAll("a");
expect(anchors).toHaveLength(2);
expect(anchors[0].getAttribute("href")).toBe("https://a.com");
expect(anchors[1].getAttribute("href")).toBe("https://b.com");
});
it("trims trailing punctuation outside the url", () => {
const { container } = render(<>{autolink("see https://x.com.")}</>);
const anchor = container.querySelector("a");
expect(anchor?.getAttribute("href")).toBe("https://x.com");
expect(container.textContent).toBe("see https://x.com.");
});
});

View File

@@ -0,0 +1,42 @@
import { Fragment, type ReactNode } from "react";
const URL_REGEX = /(https?:\/\/[^\s<>"]+[^\s<>".,;:!?)\]}'])/gi;
export function autolink(text: string): ReactNode[] {
if (!text) return [];
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
URL_REGEX.lastIndex = 0;
while ((match = URL_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(
<Fragment key={`t-${lastIndex}`}>
{text.slice(lastIndex, match.index)}
</Fragment>,
);
}
const url = match[0];
parts.push(
<a
key={`a-${match.index}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-ark-gold underline underline-offset-2 break-all hover:text-ark-gold2"
>
{url}
</a>,
);
lastIndex = match.index + url.length;
}
if (lastIndex < text.length) {
parts.push(
<Fragment key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Fragment>,
);
}
return parts;
}

View File

@@ -0,0 +1,56 @@
import {
FileText,
FileImage,
FileVideo,
FileArchive,
File as FileIcon,
Presentation,
type LucideIcon,
} from "lucide-react";
export type FileIconInfo = {
Icon: LucideIcon;
color: string;
};
const PDF = { Icon: FileText, color: "#ef4444" };
const AI = { Icon: FileImage, color: "#f97316" };
const PPT = { Icon: Presentation, color: "#dc2626" };
const DOC = { Icon: FileText, color: "#2563eb" };
const VIDEO = { Icon: FileVideo, color: "#8b5cf6" };
const IMAGE = { Icon: FileImage, color: "#10b981" };
const ARCHIVE = { Icon: FileArchive, color: "#a16207" };
const GENERIC = { Icon: FileIcon, color: "#6b7280" };
export function fileIcon(input: {
mime: string;
filename: string;
}): FileIconInfo {
const ext = input.filename.split(".").pop()?.toLowerCase() ?? "";
const mime = (input.mime || "").toLowerCase();
if (mime === "application/pdf" || ext === "pdf") return PDF;
if (ext === "ai" || mime === "application/illustrator") return AI;
if (
mime.includes("presentation") ||
ext === "ppt" ||
ext === "pptx" ||
ext === "key"
)
return PPT;
if (mime.includes("word") || ext === "doc" || ext === "docx") return DOC;
if (mime.startsWith("video/")) return VIDEO;
if (mime.startsWith("image/")) return IMAGE;
if (
mime.includes("zip") ||
mime.includes("rar") ||
mime.includes("tar") ||
ext === "zip" ||
ext === "rar" ||
ext === "7z" ||
ext === "tar" ||
ext === "gz"
)
return ARCHIVE;
return GENERIC;
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { formatBytes } from "./formatBytes";
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");
});
it("formats KB with one decimal when small", () => {
expect(formatBytes(1024)).toBe("1 KB");
expect(formatBytes(1536)).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");
});
it("drops decimals once value >= 100", () => {
expect(formatBytes(150 * 1024 * 1024)).toBe("150 MB");
});
it("handles GB and TB", () => {
expect(formatBytes(2 * 1024 ** 3)).toBe("2 GB");
expect(formatBytes(3 * 1024 ** 4)).toBe("3 TB");
});
it("guards against invalid input", () => {
expect(formatBytes(-1)).toBe("0 B");
expect(formatBytes(Number.NaN)).toBe("0 B");
});
});

View File

@@ -0,0 +1,15 @@
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`;
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < UNITS.length - 1) {
value /= 1024;
unitIndex += 1;
}
const rounded =
value >= 100 ? Math.round(value) : Math.round(value * 10) / 10;
return `${rounded} ${UNITS[unitIndex]}`;
}

View File

@@ -0,0 +1,34 @@
function localeFor(lang: string): string {
const locales: Record<string, string> = {
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 {
const d = new Date(iso);
return new Intl.DateTimeFormat(localeFor(lang), {
year: "numeric",
month: lang === "en" ? "short" : "numeric",
day: "numeric",
}).format(d);
}
export function formatTime(iso: string, lang: string): string {
const d = new Date(iso);
return new Intl.DateTimeFormat(localeFor(lang), {
hour: "numeric",
minute: "2-digit",
hour12: lang === "en",
}).format(d);
}
export function formatDateTime(iso: string, lang: string): string {
return `${formatDate(iso, lang)} ${formatTime(iso, lang)}`;
}

View File

@@ -0,0 +1,13 @@
import { localizationKey } from "../../../i18nLanguages";
import type { Post } from "../../../types/post";
export function postDisplayText(post: Post, lang: string): string {
const key = localizationKey(lang);
return (
post.localizations?.[
key as keyof typeof post.localizations
]?.text?.trim() ||
post.text?.trim() ||
""
);
}

View File

@@ -1,27 +0,0 @@
import { describe, expect, it } from "vitest";
import { isFavorite, readFavorites, toggleFavorite } from "./favorites";
describe("favorites localStorage helpers", () => {
it("returns an empty list for missing, invalid, or non-array values", () => {
expect(readFavorites()).toEqual([]);
localStorage.setItem("ark_favorites", "not-json");
expect(readFavorites()).toEqual([]);
localStorage.setItem("ark_favorites", JSON.stringify({ id: "r1" }));
expect(readFavorites()).toEqual([]);
});
it("filters non-string entries and toggles favorite ids", () => {
localStorage.setItem("ark_favorites", JSON.stringify(["r1", 123, "r2"]));
expect(readFavorites()).toEqual(["r1", "r2"]);
expect(toggleFavorite("r3")).toBe(true);
expect(isFavorite("r3")).toBe(true);
expect(readFavorites()).toEqual(["r1", "r2", "r3"]);
expect(toggleFavorite("r1")).toBe(false);
expect(isFavorite("r1")).toBe(false);
expect(readFavorites()).toEqual(["r2", "r3"]);
});
});

View File

@@ -1,28 +0,0 @@
const KEY = "ark_favorites";
export function readFavorites(): string[] {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return [];
const v = JSON.parse(raw);
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
} catch {
return [];
}
}
export function toggleFavorite(id: string): boolean {
const cur = new Set(readFavorites());
if (cur.has(id)) {
cur.delete(id);
} else {
cur.add(id);
}
const next = [...cur];
localStorage.setItem(KEY, JSON.stringify(next));
return cur.has(id);
}
export function isFavorite(id: string) {
return readFavorites().includes(id);
}

View File

@@ -6,384 +6,340 @@ import React, {
useState, useState,
} from "react"; } from "react";
export type Lang = "zh-TW" | "zh-CN" | "en"; export type Lang = "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
type Dict = Record<string, string>; type Dict = Record<string, string>;
const dict: Record<Lang, Dict> = { const zhDict: Dict = {
"zh-TW": { brand: "ARK 数据库",
brand: "ARK 資料庫", mainNav: "网站导航",
mainNav: "網站導覽", home: "首页",
home: "首頁", all: "全部资料",
all: "全部資料", categories: "分类浏览",
categories: "分類瀏覽", latest: "最新更新",
latest: "最新更新", official: "官方推荐",
official: "官方推薦", popular: "热门资料",
popular: "熱門資料", search: "搜索",
favorites: "我的收藏", searchPlaceholder: "搜索资料...",
search: "搜尋", searchNow: "立即搜索资料",
searchPlaceholder: "搜尋資料...", viewAll: "查看全部",
searchNow: "立即搜尋資料", heroTitle: "ARK 官方数据库",
viewAll: "查看全部", heroSub:
heroTitle: "ARK 官方資料庫", "集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。",
heroSub: categorySection: "资料分类",
"集中、分類、管理 ARK 資料庫,讓你快速找到所需資源,推動社群共識與成長。", officialSection: "官方推荐",
categorySection: "資料分類", latestSection: "最新更新",
officialSection: "官方推薦", popularSection: "热门资料",
latestSection: "最新更新", preview: "预览",
popularSection: "熱門資料", download: "下载",
preview: "預覽", share: "分享",
download: "下載", profile: "个人中心",
favorite: "收藏", langLabel: "语言",
share: "分享", admin: "后台",
profile: "個人中心", login: "登录",
langLabel: "語言", logout: "退出",
admin: "後台", email: "邮箱",
login: "登入", password: "密码",
logout: "登出", dashboard: "仪表盘",
email: "電子郵件", resources: "资料管理",
password: "密碼", newResource: "新增资料",
dashboard: "儀表板", save: "保存",
resources: "資料管理", title: "标题",
newResource: "新增資料", description: "简介",
save: "儲存", type: "类型",
title: "標題", language: "语言",
description: "簡介", category: "分类",
type: "類型", status: "状态",
language: "語言", public: "公开",
category: "分類", downloadable: "可下载",
status: "狀態", recommended: "首页推荐",
public: "公開", cover: "封面图 URL",
downloadable: "可下載", fileUrl: "文件 URL",
recommended: "首頁推薦", externalUrl: "外部链接",
cover: "封面圖 URL", body: "文案内容",
fileUrl: "檔案 URL", badge: "推荐标签",
externalUrl: "外部連結", published: "已发布",
body: "文案內容", draft: "草稿",
badge: "推薦標籤", archived: "归档",
published: "已發布", noResults: "找不到符合的资料,请换个关键字或浏览分类。",
draft: "草稿", copyLink: "复制链接",
archived: "封存", related: "相关资料",
noResults: "找不到符合的資料,請換個關鍵字或瀏覽分類。", total: "总资料",
copyLink: "複製連結", views: "浏览",
related: "相關資料", downloads: "下载",
total: "總資料", wallet: "钱包",
views: "瀏覽", walletPageTitle: "钱包登录",
downloads: "下載", walletPageIntro:
wallet: "錢包", "连接 Web3 钱包以使用会员相关功能。采用标准签名登录,不发送交易、不消耗 gas。",
walletPageTitle: "錢包登入", walletStepExtension:
walletPageIntro: "电脑已安装浏览器扩展钱包(如 MetaMask可直接连接。",
"連接 Web3 錢包以使用會員相關功能。採用標準簽名登入,不會發送交易、不消耗 gas。", walletStepQR:
walletStepExtension: "电脑未安装钱包时:在连接窗口选择 WalletConnect用手机钱包扫描 QR Code。",
"電腦已安裝擴充錢包(如 MetaMask可直接在瀏覽器連線。", walletStepSign: "连接成功后,点击「签署登录」并在钱包内签名即可完成验证。",
walletStepQR: signInWallet: "签署登录",
"電腦未安裝錢包時:在連線視窗選擇 WalletConnect用手機錢包掃描畫面上的 QR Code 即可連線。", walletSignedIn: "已验证登录",
walletStepSign: walletLogout: "退出钱包",
"連線成功後,點「簽署登入」並在錢包內簽署訊息,即完成網站身分驗證。", walletMissingProjectId:
signInWallet: "簽署登入", "请配置 VITE_WALLETCONNECT_PROJECT_IDReown Cloud否则无法使用 WalletConnect/扫码。",
walletSignedIn: "已驗證登入", walletSetupNeeded: "钱包扫码未启用(请在服务器配置环境变量)",
walletLogout: "登出錢包", lang_zh_CN: "中文",
walletMissingProjectId: lang_en: "English",
"請設定 VITE_WALLETCONNECT_PROJECT_IDReown Cloud 免費申請),否則無法使用 WalletConnect手機掃碼。", lang_ja: "日本語",
walletSetupNeeded: "錢包掃碼未啟用(請於伺服器設定環境變數)", lang_ko: "한국어",
lang_zh_TW: "繁體中文", lang_vi: "Tiếng Việt",
lang_zh_CN: "简体中文", lang_id: "Bahasa Indonesia",
lang_en: "English", lang_ms: "Bahasa Melayu",
filterAll: "全部", filterAll: "全部",
sortPublished: "發布時間", sortPublished: "发布时间",
type_ppt: "PPT", type_ppt: "PPT",
type_video: "影片", type_music: "音乐",
type_image: "圖片", type_video: "视频",
type_pdf: "PDF", type_image: "图片",
type_link: "連結", type_pdf: "PDF",
type_text: "文字", type_link: "链接",
type_archive: "壓縮檔", type_text: "文字",
type_zip: "ZIP", type_archive: "压缩包",
adminLoginTitle: "管理後台登入", type_zip: "ZIP",
adminEditResource: "編輯資料", adminLoginTitle: "管理后台登录",
adminVideoFileHint: adminEditResource: "编辑资料",
"上傳影片檔MP4/WebM/MOV 等),類型請選「影片」;儲存後前台會自動播放(預設靜音,可點喇叭開聲音)。", adminVideoFileHint:
adminStatTodayNew: "今日新增", "上传视频文件MP4/WebM/MOV 等),类型请选择「视频」;保存后前台自动播放(默认静音,可点喇叭开声音)。",
adminStatFavorites: "收藏", adminStatTodayNew: "今日新增",
adminMetricDownloads: "下載", adminStatFavorites: "收藏",
adminMetricFavorites: "收藏", adminMetricDownloads: "下载",
adminMetricViews: "瀏覽", adminMetricFavorites: "收藏",
edit: "編輯", adminMetricViews: "浏览",
backToList: "返回列表", edit: "编辑",
sortOrderLabel: "排序權重", backToList: "返回列表",
previewUrlLabel: "預覽網址", sortOrderLabel: "排序权重",
tagsCommaLabel: "標籤(逗號分隔)", previewUrlLabel: "预览网址",
uploadFile: "上傳檔案", tagsCommaLabel: "标签(逗号分隔)",
loading: "載入中…", uploadFile: "上传文件",
favoritesEmpty: "尚未加入收藏。", loading: "加载中…",
paginationPrev: "上一", paginationPrev: "上一",
paginationNext: "下一", paginationNext: "下一",
listRange: "示 {{from}}{{to}},共 {{total}} ", listRange: "示 {{from}}{{to}},共 {{total}} ",
pageIndicator: "{{c}} / {{p}} ", pageIndicator: "{{c}} / {{p}} ",
resourceLangFilter: "資料語言", resourceLangFilter: "资料语言",
filterTagClear: "清除標籤", filterTagClear: "清除标签",
filterLanguageAll: "全部言", filterLanguageAll: "全部言",
aboutTitle: "關於本站", aboutTitle: "关于本站",
aboutIntro: aboutIntro:
"ARK 資料庫彙整官方教材、公告、影片與常用檔案,協助社快速取一致版本的可信容。\n\n本站僅作展示索引;資料權利仍以官方公告為準。", "ARK 数据库汇总官方教材、公告、视频与常用文件,帮助社快速取一致版本的可信容。\n\n本站仅供展示索引;权利归属以官方公告为准。",
footerAbout: "關於本站", footerAbout: "关于本站",
footerAdminLogin: "管理員登入", footerAdminLogin: "管理员登录",
adminSearchLogs: "搜尋紀錄", adminSearchLogs: "搜索记录",
adminMetricShares: "分享", adminMetricShares: "分享",
adminSearchQuery: "查詢詞", adminSearchQuery: "查询词",
adminSearchTime: "時間", adminSearchTime: "时间",
adminSearchId: "編號", 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_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",
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<Lang, Dict> = {
"zh-CN": { "zh-CN": {
brand: "ARK 数据库", lang_zh_CN: "中文",
mainNav: "网站导航",
home: "首页",
all: "全部资料",
categories: "分类浏览",
latest: "最新更新",
official: "官方推荐",
popular: "热门资料",
favorites: "我的收藏",
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_IDReown Cloud否则无法使用 WalletConnect/扫码。",
walletSetupNeeded: "钱包扫码未启用(请在服务器配置环境变量)",
lang_zh_TW: "繁体中文",
lang_zh_CN: "简体中文",
lang_en: "English", lang_en: "English",
filterAll: "全部", lang_ja: "日本語",
sortPublished: "发布时间", lang_ko: "한국어",
type_ppt: "PPT", lang_vi: "Tiếng Việt",
type_video: "视频", lang_id: "Bahasa Indonesia",
type_image: "图片", lang_ms: "Bahasa Melayu",
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: "加载中…",
favoritesEmpty: "还没有收藏。",
paginationPrev: "上一页",
paginationNext: "下一页",
listRange: "显示 {{from}}{{to}},共 {{total}} 条",
pageIndicator: "{{c}} / {{p}} 页",
resourceLangFilter: "资料语言",
filterTagClear: "清除标签",
filterLanguageAll: "全部语言",
aboutTitle: "关于本站",
aboutIntro:
"ARK 数据库汇总官方教材、公告、视频与常用文件,帮助社区快速获取一致版本的可信内容。\n\n本站仅供展示与索引权利归属以官方公告为准。",
footerAbout: "关于本站",
footerAdminLogin: "管理员登录",
adminSearchLogs: "搜索记录",
adminMetricShares: "分享",
adminSearchQuery: "查询词",
adminSearchTime: "时间",
adminSearchId: "编号",
}, },
en: { en: {
brand: "ARK Library", lang_zh_CN: "Chinese",
mainNav: "Site menu",
home: "Home",
all: "All assets",
categories: "Categories",
latest: "Latest",
official: "Official picks",
popular: "Popular",
favorites: "Favorites",
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",
favorite: "Favorite",
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_en: "English", lang_en: "English",
filterAll: "All types", lang_ja: "Japanese",
sortPublished: "Published date", lang_ko: "Korean",
type_ppt: "PPT", lang_vi: "Vietnamese",
type_video: "Video", lang_id: "Indonesian",
type_image: "Image", lang_ms: "Malay",
type_pdf: "PDF", },
type_link: "Link", ja: {
type_text: "Text", lang_zh_CN: "中国語",
type_archive: "Archive", lang_en: "英語",
type_zip: "ZIP", lang_ja: "日本語",
adminLoginTitle: "Admin sign in", lang_ko: "韓国語",
adminEditResource: "Edit resource", lang_vi: "ベトナム語",
adminVideoFileHint: lang_id: "インドネシア語",
"Upload a video file (MP4/WebM/MOV, etc.) and set type to Video; the site will autoplay (muted by default — user can unmute).", lang_ms: "マレー語",
adminStatTodayNew: "New today", },
adminStatFavorites: "Favorites", ko: {
adminMetricDownloads: "Downloads", lang_zh_CN: "중국어",
adminMetricFavorites: "Favorites", lang_en: "영어",
adminMetricViews: "Views", lang_ja: "일본어",
edit: "Edit", lang_ko: "한국어",
backToList: "Back to list", lang_vi: "베트남어",
sortOrderLabel: "Sort order", lang_id: "인도네시아어",
previewUrlLabel: "Preview URL", lang_ms: "말레이어",
tagsCommaLabel: "Tags (comma-separated)", },
uploadFile: "Upload", vi: {
loading: "Loading…", lang_zh_CN: "Tiếng Trung",
favoritesEmpty: "No favorites yet.", lang_en: "Tiếng Anh",
paginationPrev: "Previous", lang_ja: "Tiếng Nhật",
paginationNext: "Next", lang_ko: "Tiếng Hàn",
listRange: "Showing {{from}}{{to}} of {{total}}", lang_vi: "Tiếng Việt",
pageIndicator: "Page {{c}} / {{p}}", lang_id: "Tiếng Indonesia",
resourceLangFilter: "Resource language", lang_ms: "Tiếng Mã Lai",
filterTagClear: "Clear tag", },
filterLanguageAll: "All languages", id: {
aboutTitle: "About this site", lang_zh_CN: "Bahasa Tionghoa",
aboutIntro: lang_en: "Bahasa Inggris",
"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.", lang_ja: "Bahasa Jepang",
footerAbout: "About", lang_ko: "Bahasa Korea",
footerAdminLogin: "Admin sign-in", lang_vi: "Bahasa Vietnam",
adminSearchLogs: "Search logs", lang_id: "Bahasa Indonesia",
adminMetricShares: "Shares", lang_ms: "Bahasa Melayu",
adminSearchQuery: "Query", },
adminSearchTime: "Time", ms: {
adminSearchId: "ID", 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",
}, },
}; };
/** Fixed locale lookup (for admin UI always in Traditional Chinese). */ const dict: Record<Lang, Dict> = {
"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 },
};
/** Fixed locale lookup (admin UI uses Simplified Chinese). */
export function tLang(lang: Lang, key: string): string { 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 }; type Ctx = { lang: Lang; setLang: (l: Lang) => void; t: (k: string) => string };
@@ -394,16 +350,26 @@ const LANG_KEY = "ark_lang";
export function I18nProvider({ children }: { children: React.ReactNode }) { export function I18nProvider({ children }: { children: React.ReactNode }) {
const [lang, setLangState] = useState<Lang>(() => { const [lang, setLangState] = useState<Lang>(() => {
const s = localStorage.getItem(LANG_KEY) as Lang | null; const s = localStorage.getItem(LANG_KEY);
if (s === "zh-CN" || s === "en" || s === "zh-TW") return s; if (s === "zh" || s === "zh-TW") return "zh-CN";
return "zh-TW"; if (
s === "zh-CN" ||
s === "en" ||
s === "ja" ||
s === "ko" ||
s === "vi" ||
s === "id" ||
s === "ms"
)
return s;
return "en";
}); });
const setLang = (l: Lang) => { const setLang = (l: Lang) => {
localStorage.setItem(LANG_KEY, l); localStorage.setItem(LANG_KEY, l);
setLangState(l); setLangState(l);
}; };
const t = useCallback( const t = useCallback(
(k: string) => dict[lang][k] || dict["zh-TW"][k] || k, (k: string) => dict[lang][k] || dict.en[k] || k,
[lang], [lang],
); );
const v = useMemo(() => ({ lang, setLang, t }), [lang, t]); const v = useMemo(() => ({ lang, setLang, t }), [lang, t]);
@@ -417,7 +383,5 @@ export function useI18n() {
} }
export function langQuery(lang: Lang) { export function langQuery(lang: Lang) {
if (lang === "zh-TW") return "zh-TW"; return lang;
if (lang === "zh-CN") return "zh-CN";
return "en";
} }

26
src/i18nLanguages.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { Lang } from "./i18n";
export const LANG_OPTIONS: { code: Lang; label: string }[] = [
{ code: "zh-CN", 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 key = `lang_${code.replace("-", "_")}`;
const label = t(key);
return label === key ? code : label;
}
export function sourceLanguageQuery(code: string) {
return code === "zh-CN" ? "zh" : code;
}
export function localizationKey(code: string) {
return code === "zh-CN" || code.startsWith("zh") ? "zh" : code;
}

View File

@@ -3,6 +3,7 @@ import { useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom"; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { ArkLogoMark } from "../components/ArkLogoMark"; import { ArkLogoMark } from "../components/ArkLogoMark";
import { useI18n, type Lang } from "../i18n"; import { useI18n, type Lang } from "../i18n";
import { LANG_OPTIONS } from "../i18nLanguages";
import { adminUiPrefix } from "../adminPaths"; import { adminUiPrefix } from "../adminPaths";
type PublicNavWhich = type PublicNavWhich =
@@ -12,7 +13,6 @@ type PublicNavWhich =
| "browseLatest" | "browseLatest"
| "browseRecommended" | "browseRecommended"
| "browsePopular" | "browsePopular"
| "favorites"
| "wallet" | "wallet"
| "about"; | "about";
@@ -36,8 +36,6 @@ function navIsActive(
return pathname === "/browse" && sp.get("sort") === "recommended"; return pathname === "/browse" && sp.get("sort") === "recommended";
case "browsePopular": case "browsePopular":
return pathname === "/browse" && sp.get("sort") === "popular"; return pathname === "/browse" && sp.get("sort") === "popular";
case "favorites":
return pathname === "/favorites";
case "wallet": case "wallet":
return pathname === "/wallet"; return pathname === "/wallet";
case "about": case "about":
@@ -136,13 +134,6 @@ export function PublicLayout() {
> >
{t("popular")} {t("popular")}
</Link> </Link>
<Link
to="/favorites"
className={navClassName(na("favorites"))}
aria-current={na("favorites") ? "page" : undefined}
>
{t("favorites")}
</Link>
<Link <Link
to="/wallet" to="/wallet"
className={navClassName(na("wallet"))} className={navClassName(na("wallet"))}
@@ -175,9 +166,11 @@ export function PublicLayout() {
onChange={(e) => setLang(e.target.value as Lang)} onChange={(e) => setLang(e.target.value as Lang)}
aria-label={t("langLabel")} aria-label={t("langLabel")}
> >
<option value="zh-TW"></option> {LANG_OPTIONS.map((option) => (
<option value="zh-CN"></option> <option key={option.code} value={option.code}>
<option value="en">English</option> {option.label}
</option>
))}
</select> </select>
</div> </div>
<button <button
@@ -204,6 +197,21 @@ export function PublicLayout() {
className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]" className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]"
/> />
</div> </div>
<div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2">
<Globe size={16} className="shrink-0 text-ark-gold/80" />
<select
className="w-full bg-transparent text-sm text-neutral-200 outline-none"
value={lang}
onChange={(e) => setLang(e.target.value as Lang)}
aria-label={t("langLabel")}
>
{LANG_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.label}
</option>
))}
</select>
</div>
<Link <Link
to="/" to="/"
className={navClassName(na("home"))} className={navClassName(na("home"))}
@@ -228,14 +236,6 @@ export function PublicLayout() {
> >
{t("categories")} {t("categories")}
</Link> </Link>
<Link
to="/favorites"
className={navClassName(na("favorites"))}
aria-current={na("favorites") ? "page" : undefined}
onClick={() => setOpen(false)}
>
{t("favorites")}
</Link>
<Link <Link
to="/browse?sort=latest" to="/browse?sort=latest"
className={navClassName(na("browseLatest"))} className={navClassName(na("browseLatest"))}
@@ -326,10 +326,10 @@ export function PublicLayout() {
} }
/> />
<BottomNavIcon <BottomNavIcon
to="/favorites" to="/wallet"
label={t("favorites")} label={t("wallet")}
icon="heart" icon="profile"
active={pathname === "/favorites"} active={pathname === "/wallet"}
/> />
<BottomNavIcon <BottomNavIcon
to="/browse?sort=latest" to="/browse?sort=latest"
@@ -356,7 +356,7 @@ function BottomNavIcon({
}: { }: {
to: string; to: string;
label: string; label: string;
icon: "home" | "document" | "heart" | "update"; icon: "home" | "document" | "profile" | "update";
active: boolean; active: boolean;
}) { }) {
const src = active const src = active

342
src/mocks/mockPosts.ts Normal file
View File

@@ -0,0 +1,342 @@
import type { Post } from "../types/post";
const SAMPLE_VIDEO_URL =
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4";
const SAMPLE_VIDEO_POSTER =
"data:image/svg+xml;utf8," +
encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 360'>
<defs>
<linearGradient id='g' x1='0' x2='1' y1='0' y2='1'>
<stop offset='0' stop-color='%23eeb726'/>
<stop offset='1' stop-color='%231f1f24'/>
</linearGradient>
</defs>
<rect width='640' height='360' fill='url(%23g)'/>
<text x='50%' y='52%' font-family='sans-serif' font-size='44' font-weight='700' fill='%23111' text-anchor='middle'>ARK · 视频示例</text>
</svg>`,
);
function img(seed: number, w = 800, h = 600): string {
return `https://picsum.photos/seed/ark-${seed}/${w}/${h}`;
}
function thumb(seed: number): string {
return `https://picsum.photos/seed/ark-${seed}/200/200`;
}
export const MOCK_POSTS: Post[] = [
// 1) 图片当文档image as document— jpg
{
id: "p-001",
categoryId: 1,
categorySlug: "project",
language: "zh-CN",
attachments: [
{
id: "a-001",
kind: "document",
mime: "image/jpeg",
url: img(11, 1200, 1600),
thumbnailUrl: thumb(11),
filename: "ARK项目一图读懂-01.jpg",
sizeBytes: 3_549_239,
width: 1200,
height: 1600,
},
],
isRecommended: false,
publishedAt: "2026-02-27T17:58:00.000Z",
updatedAt: "2026-02-27T17:58:00.000Z",
},
// 2) 图片当文档 — png
{
id: "p-002",
categoryId: 1,
categorySlug: "project",
language: "zh-CN",
attachments: [
{
id: "a-002",
kind: "document",
mime: "image/png",
url: img(12, 1080, 1080),
thumbnailUrl: thumb(12),
filename: "ARK主网核心合约地址-BSC链.png",
sizeBytes: 2_134_000,
width: 1080,
height: 1080,
},
],
isRecommended: false,
publishedAt: "2026-02-28T11:30:00.000Z",
updatedAt: "2026-02-28T11:30:00.000Z",
},
// 3) PPT 文档(项目资料分类,验证首页「项目资料(PPT)」预选 PPT
{
id: "p-013",
categoryId: 1,
categorySlug: "project",
language: "zh-CN",
attachments: [
{
id: "a-013",
kind: "document",
mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
url: "https://example.com/files/ARK-project-deck.pptx",
filename: "ARK 项目资料介绍.pptx",
sizeBytes: 5_432_100,
},
],
isRecommended: true,
publishedAt: "2026-03-01T10:00:00.000Z",
updatedAt: "2026-03-01T10:00:00.000Z",
},
// 4) PDF 文档
{
id: "p-003",
categoryId: 2,
categorySlug: "guide",
language: "zh-CN",
attachments: [
{
id: "a-003",
kind: "document",
mime: "application/pdf",
url: "https://example.com/files/ARKIE-AI-fold.pdf",
filename: "ARKIE AI-三折页.pdf",
sizeBytes: 2_097_152,
},
],
isRecommended: true,
publishedAt: "2026-05-16T11:14:00.000Z",
updatedAt: "2026-05-16T11:14:00.000Z",
},
// 4) AI 文件
{
id: "p-004",
categoryId: 2,
categorySlug: "guide",
language: "zh-CN",
attachments: [
{
id: "a-004",
kind: "document",
mime: "application/postscript",
url: "https://example.com/files/ARKIE-AI-fold-source.ai",
filename: "ARKIE AI-三折页 - 转曲.ai",
sizeBytes: 32_087_654,
},
],
isRecommended: false,
publishedAt: "2026-05-16T11:14:30.000Z",
updatedAt: "2026-05-16T11:14:30.000Z",
},
// 5) 纯文本 + 链接(市场数据平台)
{
id: "p-005",
categoryId: 3,
categorySlug: "data",
language: "zh-CN",
text:
"📊 ARK DeFAI 各大平台现已上线 🔥\n\n" +
"🔷 市场数据平台\n" +
"✅ CoinMarketCap (CMC) https://coinmarketcap.com/currencies/ark-defai/\n" +
"✅ CoinGecko https://www.coingecko.com/en/coins/ark-3\n\n" +
"🔷 链上数据与图表工具\n" +
"✅ Dexscreener (BSC) https://dexscreener.com/bsc/0xcaaf3c41a40103a23eeaa4bba468af3cf5b0e0d8\n" +
"✅ DexTools https://www.dextools.io/app/en/token/arkdefai\n" +
"✅ AVE https://ave.ai/token/0xcae117ca6bc8a341d2e7207f30e180f0e1n",
attachments: [],
isRecommended: false,
publishedAt: "2026-01-19T16:20:00.000Z",
updatedAt: "2026-01-19T16:20:00.000Z",
},
// 6) 纯文本 + 单链接(简短公告)
{
id: "p-006",
categoryId: 3,
categorySlug: "data",
language: "zh-CN",
text:
"📌 收取协议固定 2.5% 手续费。\n\n" +
"🔷 贡献值合约\n0x7736b5B84cADDB7661D250D10e60E31F3C905c99\n📌 用于新贡献值机制的 USDT 购买与资金流向管理(通缩销毁 / 储备 RBS",
attachments: [],
isRecommended: false,
publishedAt: "2026-05-16T01:50:00.000Z",
updatedAt: "2026-05-16T01:50:00.000Z",
},
// 7) 视频(带海报 + 时长 + 说明文字)
{
id: "p-007",
categoryId: 4,
categorySlug: "videos",
language: "zh-CN",
text: "ARK 山东·东营社区 招商复盘·势位重塑\n🔥 ARK DeFai 相位偏移锁死增值弧度。质能裂变诱发认知风暴,海岱动能正于中原合围!🚀",
attachments: [
{
id: "a-007",
kind: "video",
mime: "video/mp4",
url: SAMPLE_VIDEO_URL,
posterUrl: SAMPLE_VIDEO_POSTER,
filename: "ARK-中国-山东-东营-2月27日.mp4",
sizeBytes: 2_726_297,
width: 1280,
height: 720,
durationSec: 29,
},
],
isRecommended: true,
publishedAt: "2026-02-27T15:19:00.000Z",
updatedAt: "2026-02-27T15:19:00.000Z",
},
// 8) 单张图片(横图)
{
id: "p-008",
categoryId: 5,
categorySlug: "poster",
language: "zh-CN",
attachments: [
{
id: "a-008",
kind: "image",
mime: "image/jpeg",
url: img(21, 1600, 900),
thumbnailUrl: thumb(21),
filename: "ark-banner-horizontal.jpg",
sizeBytes: 1_280_000,
width: 1600,
height: 900,
},
],
isRecommended: false,
publishedAt: "2026-05-22T22:37:00.000Z",
updatedAt: "2026-05-22T22:37:00.000Z",
},
// 9) 单张图片(竖图,每日海报)
{
id: "p-009",
categoryId: 5,
categorySlug: "poster",
language: "zh-CN",
attachments: [
{
id: "a-009",
kind: "image",
mime: "image/jpeg",
url: img(22, 720, 1280),
thumbnailUrl: thumb(22),
filename: "good-morning-poster.jpg",
sizeBytes: 980_000,
width: 720,
height: 1280,
},
],
isRecommended: false,
publishedAt: "2026-05-22T22:42:00.000Z",
updatedAt: "2026-05-22T22:42:00.000Z",
},
// 10) 图片 + 文字(含链接)
{
id: "p-010",
categoryId: 6,
categorySlug: "meeting",
language: "zh-CN",
text: "📌 ARK DeFAI 方舟晨间时刻\n\n🧠 会议主题:市场概况交流 & 市场问题讨论。\n🕙 会议时间3月1日10:00\n🎬 直播腾讯会议链接https://meeting.tencent.com/l/G718S4Sedm38",
attachments: [
{
id: "a-010",
kind: "image",
mime: "image/jpeg",
url: img(31, 1200, 1800),
thumbnailUrl: thumb(31),
filename: "ark-defai-morning-poster.jpg",
sizeBytes: 2_345_678,
width: 1200,
height: 1800,
},
],
isRecommended: true,
publishedAt: "2026-05-22T16:42:00.000Z",
updatedAt: "2026-05-22T16:42:00.000Z",
},
// 11) 3 图相册
{
id: "p-011",
categoryId: 5,
categorySlug: "poster",
language: "zh-CN",
attachments: [
{
id: "a-011a",
kind: "image",
mime: "image/jpeg",
url: img(41, 1080, 1080),
thumbnailUrl: thumb(41),
filename: "album-1.jpg",
sizeBytes: 850_000,
width: 1080,
height: 1080,
},
{
id: "a-011b",
kind: "image",
mime: "image/jpeg",
url: img(42, 1080, 1080),
thumbnailUrl: thumb(42),
filename: "album-2.jpg",
sizeBytes: 720_000,
width: 1080,
height: 1080,
},
{
id: "a-011c",
kind: "image",
mime: "image/jpeg",
url: img(43, 1080, 1080),
thumbnailUrl: thumb(43),
filename: "album-3.jpg",
sizeBytes: 690_000,
width: 1080,
height: 1080,
},
],
isRecommended: false,
publishedAt: "2026-05-23T10:15:00.000Z",
updatedAt: "2026-05-23T10:15:00.000Z",
},
// 12) 7 图相册(触发 +N 模糊)
{
id: "p-012",
categoryId: 5,
categorySlug: "poster",
language: "zh-CN",
attachments: Array.from({ length: 7 }).map((_, i) => ({
id: `a-012-${i}`,
kind: "image" as const,
mime: "image/jpeg",
url: img(50 + i, 1080, 1080),
thumbnailUrl: thumb(50 + i),
filename: `gallery-${i + 1}.jpg`,
sizeBytes: 700_000 + i * 10_000,
width: 1080,
height: 1080,
})),
isRecommended: false,
publishedAt: "2026-05-24T14:42:00.000Z",
updatedAt: "2026-05-24T14:42:00.000Z",
},
];

View File

@@ -1,4 +1,4 @@
import { useI18n } from "../i18n"; import { useI18n } from "../../i18n";
export function AboutPage() { export function AboutPage() {
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -1,221 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { getJSON, itemsOrEmpty, type Resource } from "../api";
import { ResourceCard } from "../components/ResourceCard";
import { ResourceListFooter } from "../components/ResourceListFooter";
import { useI18n } from "../i18n";
import { typeFilterLabel } from "../resourceTypeLabels";
const types = [
"all",
"image",
"video",
"ppt",
"pdf",
"text",
"link",
"archive",
] as const;
const resourceLangCodes = ["", "zh-TW", "zh-CN", "en"] as const;
function resourceLangLabel(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");
}
export function Browse() {
const { t, lang } = useI18n();
const [sp, setSp] = useSearchParams();
const sort = sp.get("sort") || "latest";
const type = sp.get("type") || "all";
const tag = (sp.get("tag") || "").trim();
const resourceLang = sp.get("language") || "";
const page = Math.max(1, parseInt(sp.get("page") || "1", 10) || 1);
const limit = 24;
const [items, setItems] = useState<Resource[]>([]);
const [total, setTotal] = useState(0);
const [err, setErr] = useState<string | null>(null);
const query = useMemo(() => {
const p = new URLSearchParams();
p.set("lang", lang);
p.set("sort", sort);
p.set("limit", String(limit));
p.set("page", String(page));
if (type && type !== "all") p.set("type", type);
if (resourceLang) p.set("language", resourceLang);
if (tag) p.set("tag", tag);
return p.toString();
}, [lang, sort, type, resourceLang, tag, page]);
useEffect(() => {
setErr(null);
if (sort === "recommended") {
const p = new URLSearchParams();
p.set("lang", lang);
p.set("limit", "100");
getJSON<{ items: Resource[] }>(`/api/resources/recommended?${p}`)
.then((r) => {
const tagLower = tag.toLowerCase();
const officialItems = itemsOrEmpty(r.items)
.filter((item) => item.isRecommended)
.filter((item) => type === "all" || item.type === type)
.filter((item) => !resourceLang || item.language === resourceLang)
.filter(
(item) =>
!tagLower ||
item.tags?.some(
(itemTag) => itemTag.toLowerCase() === tagLower,
),
);
setTotal(officialItems.length);
setItems(officialItems.slice((page - 1) * limit, page * limit));
})
.catch((e) => setErr(String(e)));
return;
}
getJSON<{ items: Resource[]; total?: number }>(`/api/resources?${query}`)
.then((r) => {
setItems(itemsOrEmpty(r.items));
setTotal(typeof r.total === "number" ? r.total : 0);
})
.catch((e) => setErr(String(e)));
}, [lang, limit, page, query, resourceLang, sort, tag, type]);
const setPage = (next: number) => {
const n = new URLSearchParams(sp);
if (next <= 1) n.delete("page");
else n.set("page", String(next));
setSp(n);
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="text-2xl font-bold">{t("all")}</h1>
<div className="flex flex-wrap gap-2">
{(
[
["latest", t("latest")],
["recommended", t("official")],
["popular", t("popular")],
["published", t("sortPublished")],
] as const
).map(([k, label]) => (
<button
key={k}
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
n.set("sort", k);
n.delete("page");
setSp(n);
}}
className={`rounded-full border px-3 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
sort === k
? "border-ark-gold text-ark-gold2"
: "border-ark-line"
}`}
>
{label}
</button>
))}
</div>
</div>
<div className="flex flex-wrap gap-2">
{types.map((tp) => (
<button
key={tp}
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
n.delete("page");
if (tp === "all") n.delete("type");
else n.set("type", tp);
setSp(n);
}}
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
type === tp || (tp === "all" && !sp.get("type"))
? "border-ark-gold text-ark-gold2"
: "border-ark-line"
}`}
>
{typeFilterLabel(t, tp)}
</button>
))}
</div>
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-wide text-neutral-500">
{t("resourceLangFilter")}
</div>
<div className="flex flex-wrap gap-2">
{resourceLangCodes.map((code) => (
<button
key={code || "all"}
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
n.delete("page");
if (!code) n.delete("language");
else n.set("language", code);
setSp(n);
}}
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
(code === "" && !resourceLang) || resourceLang === code
? "border-ark-gold text-ark-gold2"
: "border-ark-line"
}`}
>
{resourceLangLabel(t, code)}
</button>
))}
</div>
</div>
{tag ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-neutral-400">
{t("search")}: <span className="text-ark-gold2">{tag}</span>
</span>
<button
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
n.delete("tag");
n.delete("page");
setSp(n);
}}
className="rounded-full border border-ark-line px-3 py-1 text-xs text-neutral-200 outline-none hover:border-ark-gold focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
{t("filterTagClear")}
</button>
</div>
) : null}
{err ? <div className="text-red-300">{err}</div> : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((r) => (
<ResourceCard key={r.id} r={r} />
))}
</div>
<ResourceListFooter
page={page}
limit={limit}
total={total}
t={t}
onPrev={() => setPage(page - 1)}
onNext={() => setPage(page + 1)}
/>
</div>
);
}

View File

@@ -0,0 +1,14 @@
import { MessageStream } from "../../components/messageStream/MessageStream";
import { useI18n } from "../../i18n";
export function Browse() {
const { t } = useI18n();
return (
<section className="space-y-3">
<h1 className="mx-auto max-w-full px-3 text-2xl font-bold md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
{t("all")}
</h1>
<MessageStream scope={{ kind: "all" }} />
</section>
);
}

View File

@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { getJSON, itemsOrEmpty, type Category } from "../../api";
import { MessageStream } from "../../components/messageStream/MessageStream";
import { langQuery, useI18n } from "../../i18n";
export function CategoryPage() {
const { slug = "" } = useParams();
const { lang } = useI18n();
const [title, setTitle] = useState<string>("");
useEffect(() => {
if (!slug) return;
getJSON<Category[]>(
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`,
)
.then((cats) =>
setTitle(itemsOrEmpty(cats).find((x) => x.slug === slug)?.name ?? slug),
)
.catch(() => setTitle(slug));
}, [slug, lang]);
return (
<section className="space-y-3">
<h1 className="mx-auto max-w-full px-3 text-2xl font-bold md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
{title || slug}
</h1>
<MessageStream scope={{ kind: "category", slug }} />
</section>
);
}

View File

@@ -1,156 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { getJSON, itemsOrEmpty, type Category, type Resource } from "../api";
import { ResourceCard } from "../components/ResourceCard";
import { ResourceListFooter } from "../components/ResourceListFooter";
import { useI18n } from "../i18n";
import { typeFilterLabel } from "../resourceTypeLabels";
const TYPE_FILTERS = [
"all",
"image",
"video",
"ppt",
"pdf",
"text",
"link",
"archive",
] as const;
const resourceLangCodes = ["", "zh-TW", "zh-CN", "en"] as const;
function resourceLangLabel(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");
}
export function CategoryPage() {
const { slug } = useParams();
const { t, lang } = useI18n();
const [sp, setSp] = useSearchParams();
const type = sp.get("type") || "all";
const resourceLang = sp.get("language") || "";
const page = Math.max(1, parseInt(sp.get("page") || "1", 10) || 1);
const limit = 24;
const [items, setItems] = useState<Resource[]>([]);
const [total, setTotal] = useState(0);
const [categoryTitle, setCategoryTitle] = useState<string>("");
const [err, setErr] = useState<string | null>(null);
const query = useMemo(() => {
const p = new URLSearchParams();
p.set("lang", lang);
p.set("category", slug || "");
p.set("limit", String(limit));
p.set("page", String(page));
if (type !== "all") p.set("type", type);
if (resourceLang) p.set("language", resourceLang);
return p.toString();
}, [lang, slug, type, resourceLang, page]);
useEffect(() => {
if (!slug) return;
setErr(null);
getJSON<{ items: Resource[]; total?: number }>(`/api/resources?${query}`)
.then((r) => {
setItems(itemsOrEmpty(r.items));
setTotal(typeof r.total === "number" ? r.total : 0);
})
.catch((e) => setErr(String(e)));
}, [query, slug]);
useEffect(() => {
if (!slug) return;
const langQ = encodeURIComponent(lang);
getJSON<Category[]>(`/api/categories?lang=${langQ}`)
.then((cats) => {
const c = itemsOrEmpty(cats).find((x) => x.slug === slug);
setCategoryTitle(c?.name ?? slug);
})
.catch(() => setCategoryTitle(slug));
}, [slug, lang]);
const setPage = (next: number) => {
const n = new URLSearchParams(sp);
if (next <= 1) n.delete("page");
else n.set("page", String(next));
setSp(n);
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{categoryTitle || slug}</h1>
<div className="flex flex-wrap gap-2">
{TYPE_FILTERS.map((tp) => (
<button
key={tp}
type="button"
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
type === tp || (tp === "all" && !sp.get("type"))
? "border-ark-gold text-ark-gold2"
: "border-ark-line"
}`}
onClick={() => {
const n = new URLSearchParams(sp);
n.delete("page");
if (tp === "all") n.delete("type");
else n.set("type", tp);
setSp(n);
}}
>
{typeFilterLabel(t, tp)}
</button>
))}
</div>
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-wide text-neutral-500">
{t("resourceLangFilter")}
</div>
<div className="flex flex-wrap gap-2">
{resourceLangCodes.map((code) => (
<button
key={code || "all"}
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
n.delete("page");
if (!code) n.delete("language");
else n.set("language", code);
setSp(n);
}}
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
(code === "" && !resourceLang) || resourceLang === code
? "border-ark-gold text-ark-gold2"
: "border-ark-line"
}`}
>
{resourceLangLabel(t, code)}
</button>
))}
</div>
</div>
{err ? <div className="text-red-300">{err}</div> : null}
{!err && items.length === 0 ? (
<p className="text-neutral-400">{t("noResults")}</p>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((r) => (
<ResourceCard key={r.id} r={r} />
))}
</div>
<ResourceListFooter
page={page}
limit={limit}
total={total}
t={t}
onPrev={() => setPage(page - 1)}
onNext={() => setPage(page + 1)}
/>
</div>
);
}

View File

@@ -1,46 +0,0 @@
import { useEffect, useState } from "react";
import { getJSON, type Resource } from "../api";
import { ResourceCard } from "../components/ResourceCard";
import { readFavorites } from "../favorites";
import { useI18n } from "../i18n";
export function FavoritesPage() {
const { t, lang } = useI18n();
const [items, setItems] = useState<Resource[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
const ids = readFavorites();
const out: Resource[] = [];
for (const id of ids) {
try {
const r = await getJSON<Resource>(
`/api/resources/${id}?lang=${encodeURIComponent(lang)}`,
);
out.push(r);
} catch {
// ignore missing
}
}
if (!cancelled) setItems(out);
})();
return () => {
cancelled = true;
};
}, [lang]);
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">{t("favorites")}</h1>
{items.length === 0 ? (
<p className="text-neutral-400">{t("favoritesEmpty")}</p>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((r) => (
<ResourceCard key={r.id} r={r} />
))}
</div>
</div>
);
}

View File

@@ -1,43 +1,56 @@
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { getJSON, itemsOrEmpty, type Category, type Resource } from "../api"; import { getJSON, itemsOrEmpty, type Category } from "../../api";
import { CategoryIcon } from "../components/CategoryIcon"; import { CategoryIcon } from "../../components/CategoryIcon";
import { FigmaBanner } from "../components/FigmaBanner"; import { FigmaBanner } from "../../components/FigmaBanner";
import { import {
ComingSoonLatestUpdateRow, ComingSoonLatestUpdateRow,
LatestUpdateRow, LatestUpdateRow,
} from "../components/LatestUpdateRow"; } from "../../components/LatestUpdateRow";
import { RecommendedCard } from "../components/RecommendedCard"; import { RecommendedCard } from "../../components/RecommendedCard";
import { SectionHeader } from "../components/SectionHeader"; import { SectionHeader } from "../../components/SectionHeader";
import { useI18n } from "../i18n"; import { langQuery, useI18n } from "../../i18n";
import { categoryCardLines } from "../utils/categoryDisplay"; import { categoryCardLines } from "../../utils/categoryDisplay";
import {
postToResource,
type PostBackedResource,
} from "../../utils/postResourceAdapter";
import type { Post } from "../../types/post";
export function Home() { export function Home() {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
const [cats, setCats] = useState<Category[]>([]); const [cats, setCats] = useState<Category[]>([]);
const [rec, setRec] = useState<Resource[]>([]); const [rec, setRec] = useState<PostBackedResource[]>([]);
const [latest, setLatest] = useState<Resource[]>([]); const [latest, setLatest] = useState<PostBackedResource[]>([]);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const recRowRef = useRef<HTMLDivElement>(null); const recRowRef = useRef<HTMLDivElement>(null);
const [canScrollRec, setCanScrollRec] = useState(false); const [canScrollRec, setCanScrollRec] = useState(false);
useEffect(() => { useEffect(() => {
const q = `?lang=${encodeURIComponent(lang)}`; const q = `?lang=${encodeURIComponent(langQuery(lang))}`;
Promise.all([ Promise.all([
getJSON<Category[]>(`/api/categories${q}`), getJSON<Category[]>(`/api/categories${q}`),
getJSON<{ items: Resource[] }>(`/api/resources/recommended${q}&limit=12`), getJSON<{ items: Post[] }>(`/api/posts/recommended${q}&limit=12`),
getJSON<{ items: Resource[] }>(`/api/resources/latest${q}&limit=8`), getJSON<{ items: Post[] }>(`/api/posts/latest${q}&limit=8`),
]) ])
.then(([c, r, l]) => { .then(([c, r, l]) => {
setCats(itemsOrEmpty(c)); setCats(itemsOrEmpty(c));
setRec(itemsOrEmpty(r.items)); setRec(
setLatest(itemsOrEmpty(l.items)); itemsOrEmpty(r.items).map((post) =>
postToResource(post, lang, itemsOrEmpty(c)),
),
);
setLatest(
itemsOrEmpty(l.items).map((post) =>
postToResource(post, lang, itemsOrEmpty(c)),
),
);
}) })
.catch((e) => setErr(String(e))); .catch((e) => setErr(String(e)));
}, [lang]); }, [lang]);
const iconKeyForResource = (r: Resource) => const iconKeyForResource = (r: PostBackedResource) =>
cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder"; cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder";
useEffect(() => { useEffect(() => {

View File

@@ -0,0 +1,41 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { getJSON } from "../../api";
import { langQuery, useI18n } from "../../i18n";
import { MOCK_POSTS } from "../../mocks/mockPosts";
import { POST_STREAM_USES_MOCK } from "../../components/messageStream/hooks/usePostStream";
import type { Post } from "../../types/post";
export function PostRedirect() {
const { id } = useParams();
const { lang } = useI18n();
const navigate = useNavigate();
useEffect(() => {
if (!id) {
navigate("/browse", { replace: true });
return;
}
if (POST_STREAM_USES_MOCK) {
const post = MOCK_POSTS.find((p) => p.id === id);
navigate(
post ? `/category/${post.categorySlug}#post-${post.id}` : "/browse",
{ replace: true },
);
return;
}
getJSON<Post>(
`/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`,
)
.then((post) => {
navigate(`/category/${post.categorySlug}#post-${post.id}`, {
replace: true,
});
})
.catch(() => navigate("/browse", { replace: true }));
}, [id, lang, navigate]);
return <div className="text-neutral-400"></div>;
}

View File

@@ -1,229 +0,0 @@
import { Copy, Download, Share2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
assetUrl,
getJSON,
itemsOrEmpty,
postJSON,
postFavoriteDelta,
type Resource,
} from "../api";
import {
resourceLanguageLabel,
resourceTypeLabel,
} from "../resourceTypeLabels";
import { ResourceCard } from "../components/ResourceCard";
import { isFavorite, toggleFavorite } from "../favorites";
import { useI18n } from "../i18n";
import { isLikelyVideoPath } from "../video";
export function ResourceDetail() {
const { id } = useParams();
const { t, lang } = useI18n();
const [r, setR] = useState<Resource | null>(null);
const [rel, setRel] = useState<Resource[]>([]);
const [fav, setFav] = useState(false);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
setErr(null);
setFav(isFavorite(id));
postJSON(`/api/resources/${id}/view`, {}).catch(() => {});
getJSON<Resource>(`/api/resources/${id}?lang=${encodeURIComponent(lang)}`)
.then(setR)
.catch((e) => setErr(String(e)));
getJSON<{ items: Resource[] }>(
`/api/resources/${id}/related?lang=${encodeURIComponent(lang)}`,
)
.then((x) => setRel(itemsOrEmpty(x.items)))
.catch(() => setRel([]));
}, [id, lang]);
const share = async () => {
if (!r) return;
const url = window.location.href;
try {
await postJSON(`/api/resources/${r.id}/share`, {});
} catch {
// ignore
}
if (navigator.share) {
try {
await navigator.share({ title: r.title, text: r.description, url });
return;
} catch {
// fall through
}
}
await navigator.clipboard.writeText(url);
alert(t("copyLink"));
};
const download = async () => {
if (!r) return;
try {
await postJSON(`/api/resources/${r.id}/download`, {});
} catch {
// ignore
}
const u = assetUrl(r.fileUrl || r.previewUrl);
if (u) window.open(u, "_blank");
};
if (err) return <div className="text-red-300">{err}</div>;
if (!r) return <div className="text-neutral-400"></div>;
const cover = assetUrl(r.coverImage || r.previewUrl);
const rawVideo = r.fileUrl || r.previewUrl || "";
const showVideo =
!!rawVideo && (r.type === "video" || isLikelyVideoPath(rawVideo));
return (
<div className="space-y-8">
<div className="grid gap-6 lg:grid-cols-2">
<div className="rounded-3xl border border-ark-line bg-black overflow-hidden">
{showVideo ? (
<video
key={rawVideo}
className="w-full aspect-video bg-black"
controls
playsInline
preload="metadata"
poster={r.coverImage ? assetUrl(r.coverImage) : undefined}
autoPlay
muted
src={assetUrl(rawVideo)}
/>
) : r.type === "image" ? (
<img
src={cover}
alt=""
className="w-full object-contain max-h-[520px]"
/>
) : r.previewUrl && r.previewUrl.endsWith(".pdf") ? (
<iframe
title="pdf"
className="h-[520px] w-full"
src={assetUrl(r.previewUrl)}
/>
) : (
<div className="p-6 text-neutral-300">
{r.bodyText ? (
<pre className="whitespace-pre-wrap font-sans">
{r.bodyText}
</pre>
) : (
<p>{r.description}</p>
)}
</div>
)}
</div>
<div className="space-y-4">
<div className="text-sm text-ark-muted">
<Link
className="rounded-sm outline-none hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
to={`/category/${r.categorySlug}`}
>
{r.categoryName}
</Link>{" "}
· {resourceTypeLabel(t, r.type)} ·{" "}
{resourceLanguageLabel(t, r.language)}
</div>
<h1 className="text-3xl font-bold leading-tight">{r.title}</h1>
{r.description ? (
<p className="text-neutral-300 leading-relaxed">{r.description}</p>
) : null}
{r.externalUrl ? (
<a
className="text-ark-gold2 underline"
href={r.externalUrl}
target="_blank"
rel="noreferrer"
>
{r.externalUrl}
</a>
) : null}
{r.tags && r.tags.length ? (
<div className="flex flex-wrap gap-2">
{r.tags.map((x) => (
<Link
key={x}
to={`/browse?tag=${encodeURIComponent(x)}`}
className="rounded-full border border-ark-line px-3 py-1 text-xs text-neutral-300 outline-none transition hover:border-ark-gold/60 hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
{x}
</Link>
))}
</div>
) : null}
<div className="flex flex-wrap gap-2 pt-2">
<button
type="button"
className={`inline-flex items-center gap-2 rounded-xl border px-4 py-2 text-sm ${
fav ? "border-ark-gold text-ark-gold2" : "border-ark-line"
}`}
onClick={() => {
const on = toggleFavorite(r.id);
setFav(on);
void postFavoriteDelta(r.id, on);
}}
>
{t("favorite")}
</button>
{r.isDownloadable ? (
<button
type="button"
onClick={download}
className="inline-flex items-center gap-2 rounded-xl bg-ark-gold px-4 py-2 text-sm font-semibold text-black"
>
<Download size={16} />
{t("download")}
</button>
) : (
<div className="text-sm text-neutral-400">
</div>
)}
<button
type="button"
onClick={share}
className="inline-flex items-center gap-2 rounded-xl border border-ark-line px-4 py-2 text-sm"
>
<Share2 size={16} />
{t("share")}
</button>
<button
type="button"
onClick={async () => {
try {
await postJSON(`/api/resources/${r.id}/share`, {});
} catch {
// ignore
}
await navigator.clipboard.writeText(window.location.href);
alert(t("copyLink"));
}}
className="inline-flex items-center gap-2 rounded-xl border border-ark-line px-4 py-2 text-sm"
>
<Copy size={16} />
{t("copyLink")}
</button>
</div>
</div>
</div>
<section className="space-y-3">
<h2 className="text-xl font-semibold">{t("related")}</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{rel.map((x) => (
<ResourceCard key={x.id} r={x} />
))}
</div>
</section>
</div>
);
}

126
src/pages/Search/index.tsx Normal file
View File

@@ -0,0 +1,126 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { getJSON, itemsOrEmpty, postJSON } from "../../api";
import { langQuery, useI18n } from "../../i18n";
import {
LANG_OPTIONS,
languageLabel,
sourceLanguageQuery,
} from "../../i18nLanguages";
import { MessageBubble } from "../../components/messageStream/MessageBubble";
import { typeFilterLabel } from "../../resourceTypeLabels";
import type { Post } from "../../types/post";
const types = [
"all",
"image",
"video",
"music",
"ppt",
"pdf",
"text",
"link",
"archive",
] as const;
const resourceLangCodes = ["", ...LANG_OPTIONS.map((x) => x.code)] as const;
export function SearchPage() {
const { t, lang } = useI18n();
const [sp, setSp] = useSearchParams();
const q = sp.get("q") || "";
const type = sp.get("type") || "all";
const resourceLang = sp.get("language") || "";
const [items, setItems] = useState<Post[]>([]);
const [err, setErr] = useState<string | null>(null);
const query = useMemo(() => {
const p = new URLSearchParams();
p.set("lang", langQuery(lang));
p.set("limit", "50");
p.set("q", q);
if (type && type !== "all") p.set("type", type);
if (resourceLang) p.set("language", sourceLanguageQuery(resourceLang));
return p.toString();
}, [lang, q, type, resourceLang]);
useEffect(() => {
setErr(null);
if (!q.trim()) {
setItems([]);
return;
}
postJSON("/api/search-log", { query: q }).catch(() => {});
getJSON<{ items: Post[] }>(`/api/posts/search?${query}`)
.then((r) => setItems(itemsOrEmpty(r.items)))
.catch((e) => setErr(String(e)));
}, [query, q]);
return (
<div className="mx-auto max-w-[640px] space-y-4 px-3">
<h1 className="text-2xl font-bold">
{t("search")}: {q || "—"}
</h1>
{q ? (
<>
<div className="flex flex-wrap gap-2">
{types.map((tp) => (
<button
key={tp}
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
if (tp === "all") n.delete("type");
else n.set("type", tp);
setSp(n, { replace: true });
}}
className={`rounded-full border px-3 py-1 text-xs transition ${
type === tp || (tp === "all" && !sp.get("type"))
? "border-ark-gold text-ark-gold2"
: "border-ark-line text-neutral-300"
}`}
>
{typeFilterLabel(t, tp)}
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
{resourceLangCodes.map((code) => (
<button
key={code || "all"}
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
if (!code) n.delete("language");
else n.set("language", code);
setSp(n, { replace: true });
}}
className={`rounded-full border px-3 py-1 text-xs transition ${
(code === "" && !resourceLang) || resourceLang === code
? "border-ark-gold text-ark-gold2"
: "border-ark-line text-neutral-300"
}`}
>
{languageLabel(t, code)}
</button>
))}
</div>
</>
) : null}
{err ? <div className="text-red-300">{err}</div> : null}
{!q ? <p className="text-neutral-400">{t("noResults")}</p> : null}
{q && items.length === 0 && !err ? (
<p className="text-neutral-400">{t("noResults")}</p>
) : null}
<div className="flex flex-col gap-2">
{items.map((post) => (
<MessageBubble key={post.id} post={post} />
))}
</div>
</div>
);
}

View File

@@ -1,191 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { getJSON, itemsOrEmpty, postJSON, type Resource } from "../api";
import { ResourceCard } from "../components/ResourceCard";
import { ResourceListFooter } from "../components/ResourceListFooter";
import { useI18n } from "../i18n";
import { typeFilterLabel } from "../resourceTypeLabels";
const types = [
"all",
"image",
"video",
"ppt",
"pdf",
"text",
"link",
"archive",
] as const;
const resourceLangCodes = ["", "zh-TW", "zh-CN", "en"] as const;
function resourceLangLabel(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");
}
export function SearchPage() {
const { t, lang } = useI18n();
const [sp, setSp] = useSearchParams();
const q = sp.get("q") || "";
const sort = sp.get("sort") || "latest";
const type = sp.get("type") || "all";
const resourceLang = sp.get("language") || "";
const page = Math.max(1, parseInt(sp.get("page") || "1", 10) || 1);
const limit = 24;
const [items, setItems] = useState<Resource[]>([]);
const [total, setTotal] = useState(0);
const [err, setErr] = useState<string | null>(null);
const query = useMemo(() => {
const p = new URLSearchParams();
p.set("lang", lang);
p.set("limit", String(limit));
p.set("page", String(page));
p.set("sort", sort);
if (q) p.set("q", q);
if (type && type !== "all") p.set("type", type);
if (resourceLang) p.set("language", resourceLang);
return p.toString();
}, [lang, q, sort, type, resourceLang, page]);
useEffect(() => {
setErr(null);
if (!q) {
setItems([]);
setTotal(0);
return;
}
postJSON("/api/search-log", { query: q }).catch(() => {});
getJSON<{ items: Resource[]; total?: number }>(`/api/resources?${query}`)
.then((r) => {
setItems(itemsOrEmpty(r.items));
setTotal(typeof r.total === "number" ? r.total : 0);
})
.catch((e) => setErr(String(e)));
}, [query, q]);
const setPage = (next: number) => {
const n = new URLSearchParams(sp);
if (next <= 1) n.delete("page");
else n.set("page", String(next));
setSp(n);
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">
{t("search")}: {q || "—"}
</h1>
{q ? (
<>
<div className="flex flex-wrap gap-2">
{(
[
["latest", t("latest")],
["recommended", t("official")],
["popular", t("popular")],
["published", t("sortPublished")],
] as const
).map(([k, label]) => (
<button
key={k}
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
n.set("sort", k);
n.delete("page");
setSp(n);
}}
className={`rounded-full border px-3 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
sort === k
? "border-ark-gold text-ark-gold2"
: "border-ark-line"
}`}
>
{label}
</button>
))}
</div>
<div className="flex flex-wrap gap-2">
{types.map((tp) => (
<button
key={tp}
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
n.delete("page");
if (tp === "all") n.delete("type");
else n.set("type", tp);
setSp(n);
}}
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
type === tp || (tp === "all" && !sp.get("type"))
? "border-ark-gold text-ark-gold2"
: "border-ark-line"
}`}
>
{typeFilterLabel(t, tp)}
</button>
))}
</div>
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-wide text-neutral-500">
{t("resourceLangFilter")}
</div>
<div className="flex flex-wrap gap-2">
{resourceLangCodes.map((code) => (
<button
key={code || "all"}
type="button"
onClick={() => {
const n = new URLSearchParams(sp);
n.delete("page");
if (!code) n.delete("language");
else n.set("language", code);
setSp(n);
}}
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
(code === "" && !resourceLang) || resourceLang === code
? "border-ark-gold text-ark-gold2"
: "border-ark-line"
}`}
>
{resourceLangLabel(t, code)}
</button>
))}
</div>
</div>
</>
) : null}
{err ? <div className="text-red-300">{err}</div> : null}
{!q ? <p className="text-neutral-400">{t("noResults")}</p> : null}
{q && items.length === 0 && !err ? (
<p className="text-neutral-400">{t("noResults")}</p>
) : null}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((r) => (
<ResourceCard key={r.id} r={r} />
))}
</div>
{q ? (
<ResourceListFooter
page={page}
limit={limit}
total={total}
t={t}
onPrev={() => setPage(page - 1)}
onNext={() => setPage(page + 1)}
/>
) : null}
</div>
);
}

View File

@@ -1,9 +1,9 @@
import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit"; import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit";
import { WagmiProvider } from "wagmi"; import { WagmiProvider } from "wagmi";
import "@rainbow-me/rainbowkit/styles.css"; import "@rainbow-me/rainbowkit/styles.css";
import { WalletLoginControls } from "../components/WalletLoginControls"; import { WalletLoginControls } from "../../components/WalletLoginControls";
import { useI18n } from "../i18n"; import { useI18n } from "../../i18n";
import { wagmiConfig } from "../wagmiConfig"; import { wagmiConfig } from "../../wagmiConfig";
export function WalletPage() { export function WalletPage() {
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getJSONAuth } from "../../api"; import { getJSONAuth } from "../../../api";
import { getToken } from "../../admin/token"; import { getToken } from "../../../admin/token";
import { useAdminT } from "../../admin/useAdminT"; import { useAdminT } from "../../../admin/useAdminT";
type Dash = { type Dash = {
totalResources: number; totalResources: number;

View File

@@ -1,10 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { postJSON } from "../../api"; import { postJSON } from "../../../api";
import { setToken } from "../../admin/token"; import { setToken } from "../../../admin/token";
import { useAdminT } from "../../admin/useAdminT"; import { useAdminT } from "../../../admin/useAdminT";
import { useAdminRouterMode } from "../../adminRouterMode"; import { useAdminRouterMode } from "../../../adminRouterMode";
import { adminUiPrefix } from "../../adminPaths"; import { adminUiPrefix } from "../../../adminPaths";
export function AdminLogin() { export function AdminLogin() {
const t = useAdminT(); const t = useAdminT();

View File

@@ -7,16 +7,17 @@ import {
putJSON, putJSON,
uploadFile, uploadFile,
type Category, type Category,
} from "../../api"; } from "../../../api";
import { getToken } from "../../admin/token"; import { getToken } from "../../../admin/token";
import { useAdminT } from "../../admin/useAdminT"; import { useAdminT } from "../../../admin/useAdminT";
import { resourceTypeDisplay } from "../../resourceTypeLabels"; import { resourceTypeDisplay } from "../../../resourceTypeLabels";
import { adminUiPrefix } from "../../adminPaths"; import { adminUiPrefix } from "../../../adminPaths";
import { useAdminRouterMode } from "../../adminRouterMode"; import { useAdminRouterMode } from "../../../adminRouterMode";
const types = [ const types = [
"image", "image",
"video", "video",
"music",
"ppt", "ppt",
"pdf", "pdf",
"text", "text",
@@ -36,7 +37,7 @@ export function AdminResourceForm() {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [rtype, setRtype] = useState<string>("image"); const [rtype, setRtype] = useState<string>("image");
const [language, setLanguage] = useState("zh-TW"); const [language, setLanguage] = useState("zh-CN");
const [categoryId, setCategoryId] = useState(1); const [categoryId, setCategoryId] = useState(1);
const [coverImage, setCoverImage] = useState(""); const [coverImage, setCoverImage] = useState("");
const [fileUrl, setFileUrl] = useState(""); const [fileUrl, setFileUrl] = useState("");
@@ -53,7 +54,7 @@ export function AdminResourceForm() {
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
getJSON<Category[]>("/api/categories?lang=zh-TW") getJSON<Category[]>("/api/categories?lang=zh-CN")
.then(setCats) .then(setCats)
.catch(() => setCats([])); .catch(() => setCats([]));
}, []); }, []);
@@ -65,7 +66,7 @@ export function AdminResourceForm() {
setTitle(r.title || ""); setTitle(r.title || "");
setDescription(r.description || ""); setDescription(r.description || "");
setRtype(r.type || "image"); setRtype(r.type || "image");
setLanguage(r.language || "zh-TW"); setLanguage(r.language || "zh-CN");
setCategoryId(r.categoryId || 1); setCategoryId(r.categoryId || 1);
setCoverImage(r.coverImage || ""); setCoverImage(r.coverImage || "");
setFileUrl(r.fileUrl || ""); setFileUrl(r.fileUrl || "");
@@ -182,9 +183,13 @@ export function AdminResourceForm() {
value={language} value={language}
onChange={(e) => setLanguage(e.target.value)} onChange={(e) => setLanguage(e.target.value)}
> >
<option value="zh-TW">{t("lang_zh_TW")}</option>
<option value="zh-CN">{t("lang_zh_CN")}</option> <option value="zh-CN">{t("lang_zh_CN")}</option>
<option value="en">{t("lang_en")}</option> <option value="en">{t("lang_en")}</option>
<option value="ja">{t("lang_ja")}</option>
<option value="ko">{t("lang_ko")}</option>
<option value="vi">{t("lang_vi")}</option>
<option value="id">{t("lang_id")}</option>
<option value="ms">{t("lang_ms")}</option>
</select> </select>
</Field> </Field>
<Field label={t("status")}> <Field label={t("status")}>

View File

@@ -6,12 +6,12 @@ import {
itemsOrEmpty, itemsOrEmpty,
type AdminResource, type AdminResource,
type Category, type Category,
} from "../../api"; } from "../../../api";
import { getToken } from "../../admin/token"; import { getToken } from "../../../admin/token";
import { resourceTypeDisplay } from "../../resourceTypeLabels"; import { resourceTypeDisplay } from "../../../resourceTypeLabels";
import { useAdminT } from "../../admin/useAdminT"; import { useAdminT } from "../../../admin/useAdminT";
import { adminUiPrefix } from "../../adminPaths"; import { adminUiPrefix } from "../../../adminPaths";
import { useAdminRouterMode } from "../../adminRouterMode"; import { useAdminRouterMode } from "../../../adminRouterMode";
function statusLabel(t: (k: string) => string, s: string) { function statusLabel(t: (k: string) => string, s: string) {
if (s === "published") return t("published"); if (s === "published") return t("published");
@@ -32,7 +32,7 @@ export function AdminResources() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
useEffect(() => { useEffect(() => {
getJSON<Category[]>("/api/categories?lang=zh-TW") getJSON<Category[]>("/api/categories?lang=zh-CN")
.then((cats) => { .then((cats) => {
const m: Record<number, string> = {}; const m: Record<number, string> = {};
for (const c of cats) m[c.id] = c.name; for (const c of cats) m[c.id] = c.name;

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getJSONAuth } from "../../api"; import { getJSONAuth } from "../../../api";
import { getToken } from "../../admin/token"; import { getToken } from "../../../admin/token";
import { useAdminT } from "../../admin/useAdminT"; import { useAdminT } from "../../../admin/useAdminT";
type Row = { id: number; query: string; createdAt: string }; type Row = { id: number; query: string; createdAt: string };

View File

@@ -9,25 +9,29 @@ import {
const t = (key: string) => const t = (key: string) =>
({ ({
filterAll: "全部", filterAll: "全部",
type_image: "片", type_image: "片",
type_video: "影片", type_video: "视频",
lang_zh_TW: "繁中", type_music: "音乐",
lang_zh_CN: "中", lang_zh_CN: "中",
lang_en: "英文", lang_en: "English",
lang_ja: "日本語",
})[key] ?? key; })[key] ?? key;
describe("resource labels", () => { describe("resource labels", () => {
it("localizes known resource types and falls back to raw type", () => { it("localizes known resource types and falls back to raw type", () => {
expect(typeFilterLabel(t, "all")).toBe("全部"); expect(typeFilterLabel(t, "all")).toBe("全部");
expect(resourceTypeLabel(t, "image")).toBe("片"); expect(resourceTypeLabel(t, "image")).toBe("片");
expect(resourceTypeDisplay(t, "video")).toBe("影片"); expect(resourceTypeDisplay(t, "video")).toBe("视频");
expect(resourceTypeLabel(t, "music")).toBe("音乐");
expect(resourceTypeLabel(t, "unknown")).toBe("unknown"); expect(resourceTypeLabel(t, "unknown")).toBe("unknown");
}); });
it("normalizes resource language codes", () => { it("normalizes resource language codes", () => {
expect(resourceLanguageLabel(t, "zh-TW")).toBe("中"); expect(resourceLanguageLabel(t, "zh-TW")).toBe("中");
expect(resourceLanguageLabel(t, "zh-hans")).toBe("中"); expect(resourceLanguageLabel(t, "zh-CN")).toBe("中");
expect(resourceLanguageLabel(t, "EN")).toBe("文"); expect(resourceLanguageLabel(t, "zh-hans")).toBe("文");
expect(resourceLanguageLabel(t, "ja")).toBe("ja"); expect(resourceLanguageLabel(t, "EN")).toBe("English");
expect(resourceLanguageLabel(t, "ja")).toBe("日本語");
expect(resourceLanguageLabel(t, "xx")).toBe("xx");
}); });
}); });

View File

@@ -27,23 +27,17 @@ export function resourceTypeLabel(
return label !== key ? label : type; 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( export function resourceLanguageLabel(
t: (key: string) => string, t: (key: string) => string,
langCode: string, langCode: string,
): string { ): string {
const lc = langCode.trim().toLowerCase(); const lc = langCode.trim().toLowerCase();
const key = const normalized =
lc === "zh-tw" lc === "zh" || lc === "zh-cn" || lc === "zh-tw" || lc === "zh-hans"
? "lang_zh_TW" ? "zh-CN"
: lc === "zh-cn" || lc === "zh-hans" : lc;
? "lang_zh_CN" const key = `lang_${normalized.replace("-", "_")}`;
: lc === "en" const label = t(key);
? "lang_en" return label !== key ? label : langCode.trim();
: "";
if (key) {
const label = t(key);
if (label !== key) return label;
}
return langCode.trim();
} }

62
src/types/post.ts Normal file
View File

@@ -0,0 +1,62 @@
export type PostLocaleCode = "zh" | "en" | "ja" | "ko" | "vi" | "id" | "ms";
export type PostType =
| "image"
| "video"
| "music"
| "ppt"
| "pdf"
| "link"
| "text"
| "archive";
export type PostTypeFilter = PostType | "all";
export type AttachmentKind = "image" | "video" | "document";
export type PostLocaleTexts = {
text: string;
};
export type PostLocalizations = Record<PostLocaleCode, PostLocaleTexts>;
export type Attachment = {
id: string;
kind: AttachmentKind;
url: string;
mime: string;
filename: string;
sizeBytes: number;
width?: number;
height?: number;
durationSec?: number;
posterUrl?: string;
thumbnailUrl?: string;
};
export type Post = {
id: string;
postType?: PostType | string;
categoryId: number;
categorySlug: string;
language: string;
sourceLanguage?: string;
text?: string;
localizations?: Partial<PostLocalizations>;
attachments: Attachment[];
isRecommended: boolean;
publishedAt: string;
updatedAt?: string;
createdAt?: string;
tags?: string[];
};
export type PostListResponse = {
items: Post[];
nextCursor?: string;
};
export type PostDownloadResponse = {
ok: true;
};
export type PostScope = { kind: "all" } | { kind: "category"; slug: string };

View File

@@ -0,0 +1,63 @@
import type { Category, Resource } from "../api";
import type { Attachment, Post } from "../types/post";
import { postDisplayText } from "../components/messageStream/utils/postText";
export type PostBackedResource = Resource & {
downloadPostId?: string;
downloadAttachmentId?: string;
};
function inferType(post: Post, att: Attachment | undefined): string {
if (post.postType) return post.postType;
if (!att) return post.text?.includes("http") ? "link" : "text";
if (att.kind === "video") return "video";
if (att.kind === "image") return "image";
const ext = att.filename.split(".").pop()?.toLowerCase() ?? "";
if (["ppt", "pptx", "key"].includes(ext) || att.mime.includes("presentation"))
return "ppt";
if (ext === "pdf" || att.mime === "application/pdf") return "pdf";
if (att.mime.startsWith("audio/") || ext === "mp3") return "music";
if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) return "archive";
return "text";
}
function coverFor(att: Attachment | undefined) {
if (!att) return "";
if (att.kind === "image") return att.thumbnailUrl || att.url;
if (att.kind === "video") return att.posterUrl || att.thumbnailUrl || "";
if (att.mime.startsWith("image/")) return att.thumbnailUrl || att.url;
return "";
}
export function postToResource(
post: Post,
lang: string,
categories: Category[] = [],
): PostBackedResource {
const first = post.attachments[0];
const title = postDisplayText(post, lang) || first?.filename || post.id;
const category = categories.find((c) => c.id === post.categoryId);
return {
id: post.id,
title,
description: postDisplayText(post, lang),
type: inferType(post, first),
language: post.language,
categoryId: post.categoryId,
categorySlug: post.categorySlug,
categoryName: category?.name || post.categorySlug,
coverImage: coverFor(first),
fileUrl: first?.url,
previewUrl: first?.posterUrl || first?.thumbnailUrl,
externalUrl: undefined,
bodyText: postDisplayText(post, lang),
badgeLabel: post.isRecommended ? "Recommended" : undefined,
isDownloadable: !!first,
isRecommended: post.isRecommended,
publishedAt: post.publishedAt,
updatedAt: post.updatedAt || post.publishedAt,
tags: post.tags,
downloadPostId: post.id,
downloadAttachmentId: first?.id,
};
}