Compare commits

..

23 Commits

Author SHA1 Message Date
5a3568820e Merge pull request 'Update deploy.yml' (#2) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 56s
Reviewed-on: #2
2026-05-26 07:33:03 +00:00
625d9fbb42 Merge branch 'main' into terry-staging 2026-05-26 07:31:07 +00:00
TerryM
31b7d53b69 Update deploy.yml 2026-05-26 15:26:40 +08:00
efc41fbd2f 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
2026-05-26 06:53:11 +00:00
TerryM
f6c0f30921 refactor: organize pages into folders 2026-05-26 14:46:05 +08:00
TerryM
78bdf73143 feat: enable real posts api by default 2026-05-26 14:07:10 +08:00
TerryM
d3c30795dc feat: wire public posts api 2026-05-26 12:07:13 +08:00
TerryM
f482a2ec38 fix: unify chinese language code as zh-CN 2026-05-26 10:03:12 +08:00
TerryM
f2e97c329e fix: map chinese language requests to zh-CN 2026-05-26 08:09:20 +08:00
TerryM
e7a5952d58 feat: align frontend languages with posts api 2026-05-26 07:36:53 +08:00
TerryM
453abfcec7 chore: ignore agent local state 2026-05-25 06:06:06 +08:00
TerryM
a784f159fe feat: add telegram-style resource stream 2026-05-25 05:25:57 +08:00
TerryM
aaebd7ccd1 chore: comment legacy api nginx route
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 47s
2026-05-24 00:43:40 +08:00
3f0a9f72d9 1
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 48s
2026-05-24 00:31:42 +08:00
769087ba4a Route same-origin API via /apnew/api to bypass ALB /api* rule.
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 53s
ALB sends /api/* to an unreachable backend target group (502 on apex).
Use VITE_API_PREFIX=/apnew with nginx proxy to backend-1 until the listener rule is removed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 17:56:38 +08:00
2c710e2e24 Same-origin API: empty VITE_API_URL with nginx proxy to backend-1.
Frontends call /api/ on ark-library.com; nginx forwards internally to 100.93.205.19.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 17:42:59 +08:00
TerryM
e6bc212c4e fix: align official recommendations behavior
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 47s
2026-05-19 00:34:29 +08:00
TerryM
40143afc39 FIX: Remove yellow ring
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 47s
2026-05-18 07:44:35 +08:00
TerryM
2c76039c44 feat: apply figma responsive home design
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 49s
2026-05-17 19:38:43 +08:00
TerryM
5b67279734 ci: pin node for jsdom tests
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 1m28s
2026-05-16 21:12:48 +08:00
TerryM
1c1ef4801b test: switch vitest to happy-dom
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 1m0s
2026-05-16 21:01:41 +08:00
TerryM
a29ec8ed92 test: add frontend test suite
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 43s
2026-05-16 18:21:37 +08:00
TerryM
f59d1e8e2a docs: add frontend onboarding docs
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 55s
2026-05-16 18:14:55 +08:00
89 changed files with 6116 additions and 1623 deletions

18
.env.example Normal file
View File

@@ -0,0 +1,18 @@
# API origin. Leave empty for same-origin/local Vite proxy.
VITE_API_URL=
# Reown / WalletConnect project id. Required for WalletConnect QR/mobile login.
VITE_WALLETCONNECT_PROJECT_ID=
# Public production deploy disables admin routes.
VITE_DISABLE_ADMIN=false
# Admin-only build mode.
VITE_ADMIN_ONLY=false
# Optional admin UI base path. Leave empty to use default app behavior.
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

View File

@@ -13,6 +13,12 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
@@ -22,10 +28,14 @@ jobs:
- name: Format check - name: Format check
run: npm run format:check run: npm run format:check
- name: Test
run: npm test
- name: Build - name: Build
run: npm run build run: npm run build
env: env:
VITE_API_URL: https://api.ark-library.com VITE_API_URL: ""
VITE_API_PREFIX: "/apnew"
VITE_DISABLE_ADMIN: "true" VITE_DISABLE_ADMIN: "true"
- name: Setup SSH key - name: Setup SSH key
@@ -38,6 +48,7 @@ jobs:
- name: Deploy to both servers - name: Deploy to both servers
run: | run: |
set -euo pipefail
deploy_to() { deploy_to() {
local HOST=$1 local HOST=$1
echo ">>> 部署到 $HOST" echo ">>> 部署到 $HOST"
@@ -48,25 +59,37 @@ jobs:
echo ">>> $HOST 部署完成" echo ">>> $HOST 部署完成"
} }
deploy_to "${{ secrets.FRONTEND_1_HOST }}" & deploy_to "${{ secrets.FRONTEND_1_HOST }}" &
PID1=$!
deploy_to "${{ secrets.FRONTEND_2_HOST }}" & deploy_to "${{ secrets.FRONTEND_2_HOST }}" &
wait PID2=$!
FAIL=0
wait $PID1 || { echo "ERROR: frontend-1 部署失败"; FAIL=1; }
wait $PID2 || { echo "ERROR: frontend-2 部署失败"; FAIL=1; }
[ $FAIL -eq 0 ] || exit 1
echo "=== 两台都部署完成 ===" echo "=== 两台都部署完成 ==="
- name: Verify both servers match - name: Verify both servers match local build
run: | run: |
set -euo pipefail
LOCAL=$(sha256sum dist/index.html | awk '{print $1}')
SUM1=$(ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \ SUM1=$(ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
ec2-user@${{ secrets.FRONTEND_1_HOST }} \ ec2-user@${{ secrets.FRONTEND_1_HOST }} \
"sha256sum /var/www/ark-library/index.html | awk '{print \$1}'") "sha256sum /var/www/ark-library/index.html | awk '{print \$1}'")
SUM2=$(ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \ SUM2=$(ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \
ec2-user@${{ secrets.FRONTEND_2_HOST }} \ ec2-user@${{ secrets.FRONTEND_2_HOST }} \
"sha256sum /var/www/ark-library/index.html | awk '{print \$1}'") "sha256sum /var/www/ark-library/index.html | awk '{print \$1}'")
echo "local: $LOCAL"
echo "frontend-1: $SUM1" echo "frontend-1: $SUM1"
echo "frontend-2: $SUM2" echo "frontend-2: $SUM2"
if [ "$SUM1" != "$SUM2" ]; then if [ "$SUM1" != "$LOCAL" ]; then
echo "ERROR: 两台 index.html 不一样!" echo "ERROR: frontend-1 不是本次构建的版本"
exit 1 exit 1
fi fi
echo "✓ 两台 checksum 一致,部署成功。" if [ "$SUM2" != "$LOCAL" ]; then
echo "ERROR: frontend-2 不是本次构建的版本"
exit 1
fi
echo "✓ 两台都已经更新到本次构建的版本。"
- name: Cleanup SSH key - name: Cleanup SSH key
if: always() if: always()

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,63 @@
---
name: arkie-frontend-onboarding
description: Onboard an AI agent to the Arkie Library frontend repo. Use when starting work in this repository, checking branch/deploy rules, or refreshing project context before coding.
---
# Arkie Frontend Onboarding
Use this skill before making non-trivial changes in the Arkie Library frontend repo.
## 1. Read project context
Read these files in order:
1. `README.md`
2. `AGENTS.md`
3. `docs/workflow.md`
4. `docs/deploy.md` if the task touches deploy, CI, environment variables, or `main` branch pushes.
## 2. Check current git state
Run:
```bash
git status --short --branch
git branch --show-current
```
Rules:
- `main` is the production deploy branch.
- `terry-staging` is the staging/work branch.
- Do not commit or push unless Terry explicitly asks.
## 3. Recall memory
Search project memory for relevant context before decisions, especially for:
- branch/deploy workflow
- frontend conventions
- admin UI behavior
- TypeScript/format failures
- prior fixes touching the same files
## 4. Validate before finishing
For code changes, run:
```bash
npx tsc --noEmit
npm run format:check
npm test
```
Run `npm run build` when changes affect routes, config, build, deploy, env vars, or dependencies.
## 5. Report clearly
Summarize:
- files changed
- commands run and results
- current branch/status
- whether anything needs pull/push/deploy

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
- 桌面端多列布局

78
AGENTS.md Normal file
View File

@@ -0,0 +1,78 @@
# AI Agent Instructions
This file is the first-stop context for AI coding agents working in this repo.
## Repository identity
- Project: Arkie Library Frontend / ARK database web UI.
- Package name: `ark-database-web`.
- Stack: React 18, TypeScript, Vite, Tailwind CSS, React Router, RainbowKit/Wagmi.
- Backend API is expected at `/api`; uploaded assets under `/uploads`.
## Branch rules
- Current deploy branch: `main`.
- Work/staging branch available: `terry-staging`.
- Do not commit or push unless Terry explicitly asks.
- Pushing to `main` triggers production frontend deploy via Gitea Actions.
- Before branch-changing or pulling, run `git status --short --branch` and preserve local work.
## Required checks
Before proposing a push or deploy, run:
```bash
npx tsc --noEmit
npm run format:check
npm test
```
If code formatting is needed, run:
```bash
npm run format
```
Notes:
- `tsconfig.json` has `strict`, `noUnusedLocals`, and `noUnusedParameters`; unused imports fail CI.
- `dist/` is build output and should not be edited manually.
## App structure quick map
- `src/main.tsx` chooses normal app vs admin-only build using `VITE_ADMIN_ONLY`.
- `src/App.tsx` contains public routes and conditionally exposes admin routes unless `VITE_DISABLE_ADMIN === "true"`.
- `src/api.ts` defines `apiBase`, fetch helpers, and shared resource/category types.
- `src/i18n.tsx` contains all UI copy for `zh-TW`, `zh-CN`, and `en`.
- `src/pages/admin/` contains admin UI screens.
- `src/adminPaths.ts` handles admin path prefix logic. Keep it in sync with backend/nginx admin host config if changed.
## Environment variables
See `.env.example` and `README.md` for details. Do not commit real secrets or private deployment keys.
Common production public build env:
```bash
VITE_API_URL=https://api.ark-library.com
VITE_DISABLE_ADMIN=true
```
## Deployment context
`.gitea/workflows/deploy.yml` deploys on push to `main`:
1. `npm ci`
2. `npx tsc --noEmit`
3. `npm run format:check`
4. `npm test`
5. `npm run build` with production public env
6. rsync `dist/` to both frontend servers
7. compare remote `index.html` checksums
## Agent behavior preferences
- Answer Terry in concise Chinese unless the task requires code/docs in English.
- Prefer small, direct fixes over broad refactors.
- Update README/docs/memory when learning non-obvious project facts.
- Search existing project memory before making decisions about workflow, deploy, or conventions.

98
README.md Normal file
View File

@@ -0,0 +1,98 @@
# Arkie Library Frontend
React + Vite frontend for the ARK Library / ARK database site. The app serves public resource browsing, search, favorites, wallet login UI, and an optional admin UI for resource management.
## Tech stack
- React 18 + TypeScript
- Vite 5
- React Router
- Tailwind CSS
- RainbowKit / Wagmi / Viem for wallet connection
- Gitea Actions deploy workflow on `main`
## Quick start
```bash
npm ci
npm run dev
```
Local dev server: <http://localhost:5173>
In development, Vite proxies these paths to the backend at `http://127.0.0.1:8080`:
- `/api`
- `/uploads`
If `VITE_API_URL` is set, API calls use that absolute base URL instead.
## Useful commands
```bash
npm run dev # start Vite dev server
npx tsc --noEmit # TypeScript check; CI requires this
npm run format:check # Prettier check; CI requires this
npm run format # format source files
npm test # run Vitest test suite
npm run build # production build to dist/
npm run preview # preview built app locally
```
Before pushing, run at least:
```bash
npx tsc --noEmit
npm run format:check
npm test
```
## Environment variables
Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template.
| Variable | Purpose |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VITE_API_URL` | API/upload origin. Empty means same-origin and Vite dev proxy handles local `/api` and `/uploads`. Production deploy currently uses `https://api.ark-library.com`. |
| `VITE_WALLETCONNECT_PROJECT_ID` | Reown / WalletConnect project id. Needed for QR/mobile wallet connection. |
| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. |
| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. |
| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. |
| `VITE_USE_MOCK_POSTS` | Telegram-style resource stream (`/browse`, `/category/:slug`) uses mock posts from `src/mocks/mockPosts.ts` 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
```text
src/
main.tsx # app entry; switches public vs admin-only build
App.tsx # public app + optional admin routes
AppAdminOnly.tsx # admin-only app entry
api.ts # fetch helpers and shared API types
i18n.tsx # zh-CN / en / ja / ko / vi / id / ms dictionary
adminPaths.ts # admin UI prefix logic
adminRouteTree.tsx # admin routes
components/ # reusable public components
layouts/ # public/admin layout shells
pages/ # public pages
pages/admin/ # admin pages
utils/ # formatting/display helpers
```
Important config files:
- `vite.config.ts` — Vite build and local backend proxy.
- `tailwind.config.js` — ARK color palette and font stack.
- `Dockerfile` / `nginx.conf` — container build and static SPA serving.
- `.gitea/workflows/deploy.yml` — deploys `main` to both frontend servers.
## Branch and deploy workflow
- `main` is the deploy branch. Pushing to `main` triggers `.gitea/workflows/deploy.yml`.
- `terry-staging` exists as a staging/work branch for later work.
- The deploy workflow runs `npm ci`, `npx tsc --noEmit`, `npm run format:check`, `npm test`, `npm run build`, then rsyncs `dist/` to both frontend servers and verifies matching checksums.
See also:
- `AGENTS.md` — instructions for AI coding agents.
- `docs/workflow.md` — recommended day-to-day workflow.
- `docs/deploy.md` — deploy details and troubleshooting.

View File

@@ -0,0 +1,105 @@
# Shared SPA locations. Browser calls same-origin /apnew/api/ (VITE_API_PREFIX=/apnew).
# /apnew/api/ avoids ALB listener rule that sends /api/* to an unreachable backend target group.
# Nginx proxies internally to ark-library-backend-1 (Tailscale); Host header for backend TLS.
# Legacy /api/ locations are commented below for reference only.
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 5;
gzip_types text/plain text/css application/javascript application/json application/xml image/svg+xml;
gzip_min_length 256;
location ^~ /apnew/api/admin {
return 404;
}
# Legacy same-origin /api admin block. Disabled while production uses /apnew/api.
# location ^~ /api/admin {
# return 404;
# }
location ^~ /admin {
return 404;
}
location ^~ /apnew/api/ {
proxy_pass https://100.93.205.19/api/;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header Host api.ark-library.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 512m;
client_body_timeout 600s;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
# Legacy same-origin /api proxy. Disabled while production uses /apnew/api.
# location ^~ /api/ {
# proxy_pass https://100.93.205.19/api/;
# proxy_http_version 1.1;
# proxy_ssl_server_name on;
# proxy_set_header Host api.ark-library.com;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# client_max_body_size 512m;
# client_body_timeout 600s;
# proxy_read_timeout 600s;
# proxy_send_timeout 600s;
# }
location ^~ /uploads/ {
proxy_pass https://100.93.205.19/uploads/;
proxy_http_version 1.1;
proxy_ssl_server_name on;
proxy_set_header Host api.ark-library.com;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
default_type text/plain;
return 200 "ok\n";
}
location = /healthz {
default_type text/plain;
return 200 "ok\n";
}
location = /index.html {
try_files $uri =404;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
# Exact `/` so the HTML shell is never edge-cached without validators (avoids stale index.html → 404 on hashed /index-*.js or /assets/*).
location = / {
try_files /index.html =404;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
location = /assets/logo-primary.webp {
try_files $uri =404;
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400" always;
}
location ^~ /assets/ark-library/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always;
}
location ^~ /assets/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# Hashed entry chunk at /index-[hash].js (Vite entryFileNames). Do not 308 to /assets — file lives here.
location ~* ^/index-[A-Za-z0-9_-]+\.(js|mjs)$ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location / {
try_files $uri $uri/ /index.html;
}

View File

@@ -0,0 +1,18 @@
# Native system nginx (not Docker). SPA root: /var/www/ark-library
# Snippet: /etc/nginx/snippets/ark-library-frontend.inc
# ALB terminates TLS; apex uses X-Forwarded-Proto so we do not 301-loop behind the LB.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name ark-library.com www.ark-library.com;
if ($http_x_forwarded_proto != "https") {
return 301 https://$host$request_uri;
}
root /var/www/ark-library;
index index.html;
include /etc/nginx/snippets/ark-library-frontend.inc;
}

83
docs/deploy.md Normal file
View File

@@ -0,0 +1,83 @@
# Deployment
Production frontend deploy is handled by Gitea Actions in `.gitea/workflows/deploy.yml`.
## Trigger
A push to `main` triggers the deploy workflow.
```yaml
on:
push:
branches:
- main
```
## CI/deploy steps
1. Checkout code.
2. Set up Node.js 22 with `actions/setup-node`.
3. Install dependencies with `npm ci`.
4. Type check with `npx tsc --noEmit`.
5. Check formatting with `npm run format:check`.
6. Run tests with `npm test`.
7. Build with `npm run build` using:
```bash
VITE_API_URL=https://api.ark-library.com
VITE_DISABLE_ADMIN=true
```
8. Configure SSH key from `DEPLOY_KEY` secret.
9. `rsync --delete` built `dist/` to both frontend servers:
```text
ec2-user@FRONTEND_1_HOST:/var/www/ark-library/
ec2-user@FRONTEND_2_HOST:/var/www/ark-library/
```
10. Verify both servers have matching `index.html` SHA-256 checksums.
11. Remove temporary SSH key.
## Required repository secrets
The workflow expects these Gitea secrets:
- `DEPLOY_KEY`
- `FRONTEND_1_HOST`
- `FRONTEND_2_HOST`
## Common failures
### Node version is too old
The workflow pins Node.js 22 using `actions/setup-node`. This keeps the self-hosted runner from using an older system Node version during `npm ci`, tests, and build.
If `actions/setup-node` cannot run on the self-hosted runner, upgrade the runner host's Node.js installation to Node 22 or at least Node 20.19 before restarting the runner service.
### TypeScript fails on unused imports
This repo uses `noUnusedLocals` and `noUnusedParameters`. Remove unused imports/variables and rerun:
```bash
npx tsc --noEmit
```
### Format check fails
Run:
```bash
npm run format
npm run format:check
```
Then commit the formatting changes.
### Build uses wrong API
Check `VITE_API_URL` in `.gitea/workflows/deploy.yml` or local `.env`.
### One frontend server differs from the other
The workflow compares remote `index.html` checksums. If it fails, inspect the rsync step and both `FRONTEND_*_HOST` values.

84
docs/workflow.md Normal file
View File

@@ -0,0 +1,84 @@
# Development Workflow
This repo is intentionally simple: make changes, validate locally, then push only when ready.
## Start a session
```bash
git status --short --branch
git fetch origin main
```
If working on staging:
```bash
git switch terry-staging
git pull --ff-only
```
If working directly on production deploy branch:
```bash
git switch main
git pull --ff-only origin main
```
## Make changes
Use the existing structure:
- Public pages: `src/pages/`
- Admin pages: `src/pages/admin/`
- Shared components: `src/components/`
- API helpers/types: `src/api.ts`
- Translations/copy: `src/i18n.tsx`
- Display helpers: `src/utils/`
Avoid editing generated `dist/` files.
## Validate
Run:
```bash
npx tsc --noEmit
npm run format:check
npm test
npm run build
```
For formatting fixes:
```bash
npm run format
```
## Commit
Check the diff first:
```bash
git diff
git status --short
```
Use concise commits, for example:
```bash
git add <files>
git commit -m "fix: remove unused imports"
```
## Push
Only push when Terry asks.
```bash
git push origin main
```
or for staging:
```bash
git push origin terry-staging
```

1225
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,9 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\"", "format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\"" "format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\"",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@rainbow-me/rainbowkit": "^2.2.11", "@rainbow-me/rainbowkit": "^2.2.11",
@@ -21,14 +23,19 @@
"wagmi": "^2.19.5" "wagmi": "^2.19.5"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"jsdom": "^29.1.1",
"postcss": "^8.4.47", "postcss": "^8.4.47",
"prettier": "^3.8.3", "prettier": "^3.8.3",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.10" "vite": "^5.4.10",
"vitest": "^2.1.9"
} }
} }

View File

@@ -7,6 +7,6 @@ Source: Figma file `uHDZkVHjAp7BXDKQKB0PM4`, responsive reference node `3761:109
- `banner-576.png` — mobile/tablet banner crop from node `3726:13099`. - `banner-576.png` — mobile/tablet banner crop from node `3726:13099`.
- `banner-440.png` — mobile banner crop from node `3726:14199`. - `banner-440.png` — mobile banner crop from node `3726:14199`.
- `banner-375.png` — mobile banner crop from node `3726:14238`. - `banner-375.png` — mobile banner crop from node `3726:14238`.
- `recommendation-1.png` ... `recommendation-5.png` — official recommendation cover exports from the 1920px frame card image nodes. - `official-recommendation-1.png` ... `official-recommendation-5.png` — official recommendation cover exports from the 1920px frame card image nodes; used only as fallback/placeholder covers so real resource cards keep accurate API-provided imagery.
These files are visual UI assets only. They do not change backend data or API contracts. These files are visual UI assets only. They do not change backend data or API contracts.

View File

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View File

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

View File

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 171 KiB

View File

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 153 KiB

View File

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 167 KiB

View File

@@ -1,17 +1,24 @@
import { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { I18nProvider } from "./i18n"; 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 { WalletPage } from "./pages/WalletPage";
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(() =>
import("./pages/Wallet").then((module) => ({
default: module.WalletPage,
})),
);
const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true"; const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true";
@@ -19,31 +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 path="/wallet" element={<WalletPage />} /> <Route path="/resource/:id" element={<PostRedirect />} />
<Route path="/about" element={<AboutPage />} /> <Route
</Route> path="/wallet"
element={
<Suspense fallback={null}>
<WalletPage />
</Suspense>
}
/>
<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() {

72
src/api.test.ts Normal file
View File

@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
async function loadApi(apiUrl = "") {
vi.resetModules();
vi.stubEnv("VITE_API_URL", apiUrl);
vi.stubEnv("VITE_API_PREFIX", "");
return import("./api");
}
function jsonResponse(body: unknown, init?: ResponseInit) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
describe("api helpers", () => {
it("normalizes nullish API arrays", async () => {
const { itemsOrEmpty } = await loadApi();
expect(itemsOrEmpty(null)).toEqual([]);
expect(itemsOrEmpty(undefined)).toEqual([]);
expect(itemsOrEmpty(["a"])).toEqual(["a"]);
});
it("builds asset URLs from API base while preserving absolute URLs", async () => {
const { assetUrl } = await loadApi("https://api.example.com");
expect(assetUrl("/uploads/file.png")).toBe(
"https://api.example.com/uploads/file.png",
);
expect(assetUrl("https://cdn.example.com/file.png")).toBe(
"https://cdn.example.com/file.png",
);
expect(assetUrl(undefined)).toBe("");
});
it("fetches JSON with API base and throws response text on failure", async () => {
const { getJSON } = await loadApi("https://api.example.com");
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ ok: true }))
.mockResolvedValueOnce(new Response("boom", { status: 500 }));
vi.stubGlobal("fetch", fetchMock);
await expect(getJSON("/api/resources")).resolves.toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/resources",
);
await expect(getJSON("/api/fail")).rejects.toThrow("boom");
});
it("posts JSON with optional bearer token", async () => {
const { postJSON } = await loadApi();
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ id: 1 }));
vi.stubGlobal("fetch", fetchMock);
await expect(
postJSON("/api/admin/resources", { title: "Demo" }, "token-123"),
).resolves.toEqual({ id: 1 });
expect(fetchMock).toHaveBeenCalledWith("/api/admin/resources", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer token-123",
},
body: JSON.stringify({ title: "Demo" }),
});
});
});

View File

@@ -1,4 +1,5 @@
export const apiBase = import.meta.env.VITE_API_URL || ""; export const apiPrefix = import.meta.env.VITE_API_PREFIX || "";
export const apiBase = (import.meta.env.VITE_API_URL || "") + apiPrefix;
/** Go JSON encodes nil slices as null — normalize before .map() */ /** Go JSON encodes nil slices as null — normalize before .map() */
export function itemsOrEmpty<T>(items: T[] | null | undefined): T[] { export function itemsOrEmpty<T>(items: T[] | null | undefined): T[] {
@@ -43,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,16 +1,16 @@
const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
export const recommendationCoverFallbacks = [ export const officialRecommendationCoverFallbacks = [
`${FIGMA_ASSET_BASE}/recommendation-1.png`, `${FIGMA_ASSET_BASE}/official-recommendation-1.png`,
`${FIGMA_ASSET_BASE}/recommendation-2.png`, `${FIGMA_ASSET_BASE}/official-recommendation-2.png`,
`${FIGMA_ASSET_BASE}/recommendation-3.png`, `${FIGMA_ASSET_BASE}/official-recommendation-3.png`,
`${FIGMA_ASSET_BASE}/recommendation-4.png`, `${FIGMA_ASSET_BASE}/official-recommendation-4.png`,
`${FIGMA_ASSET_BASE}/recommendation-5.png`, `${FIGMA_ASSET_BASE}/official-recommendation-5.png`,
] as const; ] as const;
export function FigmaBanner() { export function FigmaBanner() {
return ( return (
<picture className="block overflow-hidden border border-[#2a2a32] bg-black shadow-[0_24px_70px_rgba(0,0,0,0.18)] max-md:-mx-4 max-md:rounded-none max-md:border-x-0 md:rounded-xl"> <picture className="-mx-4 block overflow-hidden border border-[#2a2a32] bg-black shadow-[0_24px_70px_rgba(0,0,0,0.18)] min-[440px]:-mx-5 sm:-mx-6 md:mx-0 md:rounded-xl">
<source <source
media="(max-width: 439px)" media="(max-width: 439px)"
srcSet={`${FIGMA_ASSET_BASE}/banner-375.png`} srcSet={`${FIGMA_ASSET_BASE}/banner-375.png`}

View File

@@ -27,11 +27,11 @@ export function LatestUpdateRow({
className="h-10 w-10 text-ark-gold" className="h-10 w-10 text-ark-gold"
/> />
</div> </div>
<div className="min-w-0 flex-1 py-0.5"> <div className="flex min-w-0 flex-1 self-stretch py-0.5 flex-col">
<div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg"> <div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg">
{r.title} {r.title}
</div> </div>
<div className="mt-4 grid gap-1 text-sm text-[#9b9ca6] md:mt-6"> <div className="mt-auto grid gap-1 text-sm text-[#9b9ca6]">
<span>{r.categoryName}</span> <span>{r.categoryName}</span>
<span> <span>
{resourceTypeLabel(t, r.type)} {resourceTypeLabel(t, r.type)}
@@ -58,11 +58,11 @@ export function ComingSoonLatestUpdateRow({ index = 0 }: { index?: number }) {
<div className="flex shrink-0 items-center justify-center pt-0.5"> <div className="flex shrink-0 items-center justify-center pt-0.5">
<CategoryIcon iconKey={iconKey} className="h-10 w-10 text-ark-gold" /> <CategoryIcon iconKey={iconKey} className="h-10 w-10 text-ark-gold" />
</div> </div>
<div className="min-w-0 flex-1 py-0.5"> <div className="flex min-w-0 flex-1 self-stretch py-0.5 flex-col">
<div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg"> <div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg">
</div> </div>
<div className="mt-4 grid gap-1 text-sm text-[#9b9ca6] md:mt-6"> <div className="mt-auto grid gap-1 text-sm text-[#9b9ca6]">
<span></span> <span></span>
<span>Coming soon</span> <span>Coming soon</span>
</div> </div>

View File

@@ -1,32 +1,37 @@
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";
import { recommendationCoverFallbacks } from "./FigmaBanner"; import { officialRecommendationCoverFallbacks } from "./FigmaBanner";
function isPlaceholderAsset(path: string | undefined | null) { function isPlaceholderAsset(path: string | undefined | null) {
return !path || path.includes("placeholder-cover"); return !path || path.includes("placeholder-cover");
} }
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]"; "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();
const cover = useMemo(() => { const cover = useMemo(() => {
const original = r.coverImage || r.previewUrl; const original = r.coverImage || r.previewUrl;
if (isPlaceholderAsset(original)) { if (isPlaceholderAsset(original)) {
return recommendationCoverFallbacks[ return officialRecommendationCoverFallbacks[
visualIndex % recommendationCoverFallbacks.length visualIndex % officialRecommendationCoverFallbacks.length
]; ];
} }
return assetUrl(original); return assetUrl(original);
@@ -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 */
} }
@@ -105,8 +116,8 @@ export function ComingSoonRecommendedCard({
visualIndex?: number; visualIndex?: number;
}) { }) {
const cover = const cover =
recommendationCoverFallbacks[ officialRecommendationCoverFallbacks[
visualIndex % recommendationCoverFallbacks.length visualIndex % officialRecommendationCoverFallbacks.length
]; ];
return ( return (

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,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,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":
@@ -77,21 +75,21 @@ export function PublicLayout() {
return ( return (
<div className="min-h-full flex flex-col pb-20 md:pb-0"> <div className="min-h-full flex flex-col pb-20 md:pb-0">
<header className="sticky top-0 z-40 border-b border-ark-line bg-ark-nav/98 backdrop-blur-md"> <header className="sticky top-0 z-40 border-b border-ark-line bg-ark-nav/98 backdrop-blur-md">
<div className="mx-auto max-w-[1280px] px-4 py-[15px] md:px-8 xl:px-0"> <div className="mx-auto max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:px-9 xl:px-0">
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */} {/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
<div className="flex h-10 items-center gap-2 lg:gap-4"> <div className="flex h-10 items-center gap-2 min-[1200px]:gap-0 lg:gap-4">
<Link <Link
to="/" to="/"
className="flex min-w-0 shrink-0 items-center gap-2.5 rounded-sm text-xl font-bold tracking-wide text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg" className="flex min-w-0 shrink-0 items-center gap-2.5 rounded-sm text-xl font-bold tracking-wide text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
> >
<ArkLogoMark className="h-10 w-10 shrink-0" /> <ArkLogoMark className="h-10 w-10 shrink-0" />
<span className="max-w-[9rem] truncate text-ark-gold sm:inline md:max-w-[10rem] lg:max-w-none"> <span className="max-w-[8rem] truncate text-ark-gold sm:inline">
{t("brand")} {t("brand")}
</span> </span>
</Link> </Link>
<nav <nav
className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-1 md:flex lg:gap-5" className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-1 min-[1200px]:flex lg:gap-5"
aria-label={t("mainNav")} aria-label={t("mainNav")}
> >
<Link <Link
@@ -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"))}
@@ -152,15 +143,15 @@ export function PublicLayout() {
</Link> </Link>
</nav> </nav>
<div className="flex shrink-0 items-center justify-end gap-2"> <div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1200px]:flex-none">
<div className="hidden h-10 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] py-2 pl-3 pr-3 shadow-inner md:flex lg:pr-4"> <div className="hidden h-10 min-w-0 flex-1 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] py-2 pl-3 pr-3 shadow-inner md:flex min-[1200px]:w-44 min-[1200px]:flex-none lg:pr-4 xl:w-52">
<SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" /> <SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" />
<input <input
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && goSearch()} onKeyDown={(e) => e.key === "Enter" && goSearch()}
placeholder={t("searchPlaceholder")} placeholder={t("searchPlaceholder")}
className="w-24 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20] md:w-28 lg:w-44 xl:w-52" className="min-w-0 flex-1 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20]"
/> />
</div> </div>
<div className="hidden h-10 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-2 py-2 md:flex lg:px-3"> <div className="hidden h-10 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-2 py-2 md:flex lg:px-3">
@@ -170,19 +161,21 @@ export function PublicLayout() {
aria-hidden aria-hidden
/> />
<select <select
className="max-w-[6.5rem] cursor-pointer truncate rounded-md bg-transparent text-sm text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20] lg:max-w-none" className="max-w-[6.5rem] cursor-pointer truncate rounded-md bg-transparent text-sm text-neutral-200 outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 lg:max-w-none"
value={lang} value={lang}
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
type="button" type="button"
className="md:hidden inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg" className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1200px]:hidden"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
aria-label="menu" aria-label="menu"
> >
@@ -193,7 +186,7 @@ export function PublicLayout() {
</div> </div>
{open ? ( {open ? (
<div className="md:hidden border-t border-ark-line bg-ark-nav px-4 py-3 grid gap-2"> <div className="grid gap-2 border-t border-ark-line bg-ark-nav px-4 py-3 min-[440px]:px-5 sm:px-6 md:px-9 min-[1200px]:hidden">
<div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2"> <div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2">
<SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" /> <SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" />
<input <input
@@ -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"))}
@@ -280,12 +280,12 @@ export function PublicLayout() {
) : null} ) : null}
</header> </header>
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 py-6 md:px-8 md:py-10 xl:px-0"> <main className="mx-auto w-full max-w-[1280px] flex-1 px-4 py-6 min-[440px]:px-5 sm:px-6 md:px-9 md:py-10 xl:px-0">
<Outlet /> <Outlet />
</main> </main>
<footer className="mt-auto border-t border-ark-line bg-ark-nav/90 mb-20 md:mb-0"> <footer className="mt-auto border-t border-ark-line bg-ark-nav/90 mb-20 md:mb-0">
<div className="mx-auto flex max-w-[1280px] flex-wrap gap-x-6 gap-y-2 px-4 py-6 text-sm text-neutral-400 md:px-8 xl:px-0"> <div className="mx-auto flex max-w-[1280px] flex-wrap gap-x-6 gap-y-2 px-4 py-6 text-sm text-neutral-400 min-[440px]:px-5 sm:px-6 md:px-9 xl:px-0">
<Link <Link
to="/about" to="/about"
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" 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"
@@ -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

View File

@@ -1,11 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WagmiProvider } from "wagmi";
import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit";
import "./index.css"; import "./index.css";
import "@rainbow-me/rainbowkit/styles.css";
import { wagmiConfig } from "./wagmiConfig";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -27,20 +23,9 @@ void (async () => {
const { default: App } = await import("./App"); const { default: App } = await import("./App");
ReactDOM.createRoot(root).render( ReactDOM.createRoot(root).render(
<React.StrictMode> <React.StrictMode>
<WagmiProvider config={wagmiConfig}> <QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}> <App />
<RainbowKitProvider </QueryClientProvider>
theme={darkTheme({
accentColor: "#d4af37",
accentColorForeground: "#0a0a0a",
borderRadius: "medium",
})}
modalSize="wide"
>
<App />
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
</React.StrictMode>, </React.StrictMode>,
); );
})(); })();

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,194 +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);
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]);
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,52 +1,79 @@
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 { SectionHeader } from "../../components/SectionHeader";
import { langQuery, useI18n } from "../../i18n";
import { categoryCardLines } from "../../utils/categoryDisplay";
import { import {
ComingSoonRecommendedCard, postToResource,
RecommendedCard, type PostBackedResource,
} from "../components/RecommendedCard"; } from "../../utils/postResourceAdapter";
import { SectionHeader } from "../components/SectionHeader"; import type { Post } from "../../types/post";
import { useI18n } from "../i18n";
import { categoryCardLines } from "../utils/categoryDisplay";
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);
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(() => {
const row = recRowRef.current;
if (!row) {
setCanScrollRec(false);
return;
}
const updateCanScroll = () => {
setCanScrollRec(row.scrollWidth > row.clientWidth + 1);
};
updateCanScroll();
const resizeObserver = new ResizeObserver(updateCanScroll);
resizeObserver.observe(row);
return () => resizeObserver.disconnect();
}, [rec.length]);
const scrollRec = (dir: 1 | -1) => { const scrollRec = (dir: 1 | -1) => {
recRowRef.current?.scrollBy({ left: dir * 280, behavior: "smooth" }); recRowRef.current?.scrollBy({ left: dir * 280, behavior: "smooth" });
}; };
const recommendedPlaceholderCount = Math.max(0, 5 - rec.length);
const latestPlaceholderCount = Math.max(0, 5 - latest.length); const latestPlaceholderCount = Math.max(0, 5 - latest.length);
if (err) { if (err) {
@@ -58,7 +85,7 @@ export function Home() {
} }
return ( return (
<div className="space-y-12 pb-10 md:space-y-14 md:pb-16"> <div className="space-y-[30px] pb-10 md:space-y-10 md:pb-16 xl:space-y-[34px]">
<section className="-mt-6 md:mt-0"> <section className="-mt-6 md:mt-0">
<FigmaBanner /> <FigmaBanner />
</section> </section>
@@ -69,7 +96,7 @@ export function Home() {
viewAllTo="/browse" viewAllTo="/browse"
viewAllLabel={t("viewAll")} viewAllLabel={t("viewAll")}
/> />
<div className="mt-7 grid grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid-cols-5 md:gap-3 xl:grid-cols-7 xl:gap-4"> <div className="mt-7 grid grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
{cats.map((c) => { {cats.map((c) => {
const { line1, line2 } = categoryCardLines(c.name); const { line1, line2 } = categoryCardLines(c.name);
return ( return (
@@ -115,28 +142,20 @@ export function Home() {
<RecommendedCard r={r} visualIndex={index} /> <RecommendedCard r={r} visualIndex={index} />
</div> </div>
))} ))}
{Array.from({ length: recommendedPlaceholderCount }).map(
(_, index) => (
<div
key={`recommended-coming-soon-${index}`}
className="snap-start"
>
<ComingSoonRecommendedCard visualIndex={rec.length + index} />
</div>
),
)}
</div> </div>
<div className="h-1 rounded-full bg-black/80 md:hidden"> <div className="h-1 rounded-full bg-black/80 md:hidden">
<div className="h-full w-24 rounded-full bg-[#353740]" /> <div className="h-full w-24 rounded-full bg-[#353740]" />
</div> </div>
<button {canScrollRec ? (
type="button" <button
onClick={() => scrollRec(1)} type="button"
className="absolute right-0 top-[45%] hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-lg border border-ark-line bg-[#292a31]/95 text-neutral-200 shadow-lg backdrop-blur transition hover:border-ark-gold hover:text-ark-gold md:flex" onClick={() => scrollRec(1)}
aria-label={t("viewAll")} className="absolute right-0 top-[45%] hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-lg border border-ark-line bg-[#292a31]/95 text-neutral-200 shadow-lg backdrop-blur transition hover:border-ark-gold hover:text-ark-gold md:flex"
> aria-label={t("viewAll")}
<ChevronRight className="h-5 w-5" /> >
</button> <ChevronRight className="h-5 w-5" />
</button>
) : null}
</div> </div>
</section> </section>

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

@@ -0,0 +1,47 @@
import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit";
import { WagmiProvider } from "wagmi";
import "@rainbow-me/rainbowkit/styles.css";
import { WalletLoginControls } from "../../components/WalletLoginControls";
import { useI18n } from "../../i18n";
import { wagmiConfig } from "../../wagmiConfig";
export function WalletPage() {
const { t } = useI18n();
return (
<div className="mx-auto max-w-lg space-y-6">
<h1 className="text-2xl font-bold">{t("walletPageTitle")}</h1>
<p className="text-neutral-300 text-sm leading-relaxed">
{t("walletPageIntro")}
</p>
<ul className="text-sm text-neutral-400 space-y-2 list-disc pl-5">
<li>{t("walletStepExtension")}</li>
<li>{t("walletStepQR")}</li>
<li>{t("walletStepSign")}</li>
</ul>
<div className="rounded-2xl border border-ark-line bg-ark-panel p-6 space-y-4">
{import.meta.env.VITE_WALLETCONNECT_PROJECT_ID ? (
<WagmiProvider config={wagmiConfig}>
<RainbowKitProvider
theme={darkTheme({
accentColor: "#d4af37",
accentColorForeground: "#0a0a0a",
borderRadius: "medium",
})}
modalSize="wide"
>
<WalletLoginControls />
</RainbowKitProvider>
</WagmiProvider>
) : (
<p
className="text-sm text-amber-500/90 leading-relaxed"
title={t("walletMissingProjectId")}
>
{t("walletSetupNeeded")}
</p>
)}
</div>
</div>
);
}

View File

@@ -1,23 +0,0 @@
import { WalletLoginControls } from "../components/WalletLoginControls";
import { useI18n } from "../i18n";
export function WalletPage() {
const { t } = useI18n();
return (
<div className="mx-auto max-w-lg space-y-6">
<h1 className="text-2xl font-bold">{t("walletPageTitle")}</h1>
<p className="text-neutral-300 text-sm leading-relaxed">
{t("walletPageIntro")}
</p>
<ul className="text-sm text-neutral-400 space-y-2 list-disc pl-5">
<li>{t("walletStepExtension")}</li>
<li>{t("walletStepQR")}</li>
<li>{t("walletStepSign")}</li>
</ul>
<div className="rounded-2xl border border-ark-line bg-ark-panel p-6 space-y-4">
<WalletLoginControls />
</div>
</div>
);
}

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

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

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();
} }

49
src/test/setup.ts Normal file
View File

@@ -0,0 +1,49 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
function createLocalStorageMock(): Storage {
let store: Record<string, string> = {};
return {
get length() {
return Object.keys(store).length;
},
clear() {
store = {};
},
getItem(key: string) {
return Object.prototype.hasOwnProperty.call(store, key)
? store[key]
: null;
},
key(index: number) {
return Object.keys(store)[index] ?? null;
},
removeItem(key: string) {
delete store[key];
},
setItem(key: string, value: string) {
store[key] = String(value);
},
};
}
beforeEach(() => {
const storage = createLocalStorageMock();
Object.defineProperty(globalThis, "localStorage", {
value: storage,
configurable: true,
});
Object.defineProperty(window, "localStorage", {
value: storage,
configurable: true,
});
});
afterEach(() => {
cleanup();
globalThis.localStorage.clear();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});

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,23 @@
import { describe, expect, it } from "vitest";
import { categoryCardLines } from "./categoryDisplay";
describe("categoryCardLines", () => {
it("splits Chinese and ASCII parenthetical subtitles", () => {
expect(categoryCardLines("官方公告(繁中)")).toEqual({
line1: "官方公告",
line2: "(繁中)",
});
expect(categoryCardLines("Tutorials (EN)")).toEqual({
line1: "Tutorials",
line2: "(EN)",
});
});
it("uses trimmed description as subtitle when name has no parentheses", () => {
expect(categoryCardLines("影片", " 官方教學 ")).toEqual({
line1: "影片",
line2: "官方教學",
});
expect(categoryCardLines("文件")).toEqual({ line1: "文件" });
});
});

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,
};
}

11
src/video.test.ts Normal file
View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { isLikelyVideoPath } from "./video";
describe("isLikelyVideoPath", () => {
it("detects common video extensions and ignores query strings", () => {
expect(isLikelyVideoPath("/uploads/intro.MP4?token=1")).toBe(true);
expect(isLikelyVideoPath("https://cdn.example.com/demo.webm")).toBe(true);
expect(isLikelyVideoPath("/uploads/file.pdf")).toBe(false);
expect(isLikelyVideoPath(undefined)).toBe(false);
});
});

1
src/vite-env.d.ts vendored
View File

@@ -2,6 +2,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_URL: string; readonly VITE_API_URL: string;
readonly VITE_API_PREFIX?: string;
readonly VITE_WALLETCONNECT_PROJECT_ID: string; readonly VITE_WALLETCONNECT_PROJECT_ID: string;
readonly VITE_ADMIN_UI_PREFIX?: string; readonly VITE_ADMIN_UI_PREFIX?: string;
/** When `"true"`, bundle admin UI only (no public pages); use with `VITE_ADMIN_UI_PREFIX` or default secret prefix. */ /** When `"true"`, bundle admin UI only (no public pages); use with `VITE_ADMIN_UI_PREFIX` or default secret prefix. */

View File

@@ -1,23 +1,33 @@
import { defineConfig } from "vite"; import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [react()], const env = loadEnv(mode, process.cwd(), "");
build: { const apiProxyTarget = env.DEV_API_PROXY_TARGET || "http://127.0.0.1:8080";
rollupOptions: {
output: { return {
// Entry script at site root (/index-[hash].js); lazy chunks + CSS stay under /assets/. plugins: [react()],
entryFileNames: "[name]-[hash].js", build: {
chunkFileNames: "assets/[name]-[hash].js", rollupOptions: {
assetFileNames: "assets/[name]-[hash][extname]", output: {
// Entry script at site root (/index-[hash].js); lazy chunks + CSS stay under /assets/.
entryFileNames: "[name]-[hash].js",
chunkFileNames: "assets/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash][extname]",
},
}, },
}, },
}, server: {
server: { port: 5173,
port: 5173, proxy: {
proxy: { "/apnew/api": {
"/api": { target: "http://127.0.0.1:8080", changeOrigin: true }, target: apiProxyTarget,
"/uploads": { target: "http://127.0.0.1:8080", changeOrigin: true }, changeOrigin: true,
rewrite: (path) => path.replace(/^\/apnew/, ""),
},
"/api": { target: apiProxyTarget, changeOrigin: true },
"/uploads": { target: apiProxyTarget, changeOrigin: true },
},
}, },
}, };
}); });

16
vitest.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
environmentOptions: {
jsdom: {
url: "http://localhost/",
},
},
setupFiles: "./src/test/setup.ts",
css: false,
},
});