Merge pull request 'terry-staging' (#1) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 56s
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 56s
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -12,3 +12,7 @@ VITE_ADMIN_ONLY=false
|
|||||||
|
|
||||||
# Optional admin UI base path. Leave empty to use default app behavior.
|
# Optional admin UI base path. Leave empty to use default app behavior.
|
||||||
VITE_ADMIN_UI_PREFIX=
|
VITE_ADMIN_UI_PREFIX=
|
||||||
|
|
||||||
|
# Use mock Post data (Telegram-style resource stream) only when explicitly enabled.
|
||||||
|
# Default production/staging behavior should hit the real /api/posts API.
|
||||||
|
VITE_USE_MOCK_POSTS=false
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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 hooks(usePostStream + 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,间距 2px;attachments.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`,类型 chips(all/image/video/ppt/pdf/text/link/archive,沿用 `typeFilterLabel`)+ 语言 chips(all/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 优化
|
||||||
@@ -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" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:204 或 `{ ok: true }`。
|
||||||
|
|
||||||
|
用途:记录用户搜索词;失败不阻断用户体验。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.8 下载统计(可选)
|
||||||
|
|
||||||
|
文件下载目前前端可直接打开 `Attachment.url`。如果后端需要统计下载,提供:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/posts/:postId/attachments/:attachmentId/download
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:204 或 `{ 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>
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:204 或 `{ 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
|
||||||
176
.unipi/docs/specs/2026-05-25-posts-api-contract.md
Normal file
176
.unipi/docs/specs/2026-05-25-posts-api-contract.md
Normal 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~N;text-only post 时为 []
|
||||||
|
isRecommended: boolean;
|
||||||
|
publishedAt: string; // ISO 8601;用于排序 + 日期分组
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PostListResponse = {
|
||||||
|
items: Post[];
|
||||||
|
nextCursor?: string; // 不透明 cursor;undefined = 没有下一页
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键约定
|
||||||
|
|
||||||
|
- **图片当文档**(在前端显示为「文件下载卡」):`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`。
|
||||||
@@ -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~N;text-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 截图 7:2-4 格 grid,4+ 时第 4 格模糊 + `+N`
|
||||||
|
overlays/
|
||||||
|
ImageLightbox.tsx 全屏画廊(左右滑、缩放、关闭、下载)
|
||||||
|
VideoPlayer.tsx 全屏视频播放器
|
||||||
|
hooks/
|
||||||
|
usePostStream.ts cursor 分页 + IntersectionObserver;mock/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,间距 2px;4+ 时第 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 条"图片当文档"(不同 mime:jpg、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
|
||||||
|
- 桌面端多列布局
|
||||||
@@ -52,12 +52,13 @@ npm test
|
|||||||
Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template.
|
Create a local `.env` only when needed. Do not commit secrets. See `.env.example` for a template.
|
||||||
|
|
||||||
| Variable | Purpose |
|
| Variable | Purpose |
|
||||||
| --- | --- |
|
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `VITE_API_URL` | API/upload origin. Empty means same-origin and Vite dev proxy handles local `/api` and `/uploads`. Production deploy currently uses `https://api.ark-library.com`. |
|
| `VITE_API_URL` | API/upload origin. Empty means same-origin and Vite dev proxy handles local `/api` and `/uploads`. Production deploy currently uses `https://api.ark-library.com`. |
|
||||||
| `VITE_WALLETCONNECT_PROJECT_ID` | Reown / WalletConnect project id. Needed for QR/mobile wallet connection. |
|
| `VITE_WALLETCONNECT_PROJECT_ID` | Reown / WalletConnect project id. Needed for QR/mobile wallet connection. |
|
||||||
| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. |
|
| `VITE_DISABLE_ADMIN` | When set to `"true"`, public build redirects admin routes away. Production public deploy sets this to `"true"`. |
|
||||||
| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. |
|
| `VITE_ADMIN_ONLY` | When set to `"true"`, builds the admin-only app entry instead of the public app. |
|
||||||
| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. |
|
| `VITE_ADMIN_UI_PREFIX` | Optional admin UI base path. If absent in admin-only mode, code uses the secret prefix from `src/adminPaths.ts`. |
|
||||||
|
| `VITE_USE_MOCK_POSTS` | Telegram-style resource stream (`/browse`, `/category/:slug`) uses mock posts from `src/mocks/mockPosts.ts` only when set to `"true"`. Leave unset or set to `"false"` to hit the real `/api/posts` API. See `.unipi/docs/specs/2026-05-25-posts-api-contract.md`. |
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ src/
|
|||||||
App.tsx # public app + optional admin routes
|
App.tsx # public app + optional admin routes
|
||||||
AppAdminOnly.tsx # admin-only app entry
|
AppAdminOnly.tsx # admin-only app entry
|
||||||
api.ts # fetch helpers and shared API types
|
api.ts # fetch helpers and shared API types
|
||||||
i18n.tsx # zh-TW / zh-CN / en copy dictionary
|
i18n.tsx # zh-CN / en / ja / ko / vi / id / ms dictionary
|
||||||
adminPaths.ts # admin UI prefix logic
|
adminPaths.ts # admin UI prefix logic
|
||||||
adminRouteTree.tsx # admin routes
|
adminRouteTree.tsx # admin routes
|
||||||
components/ # reusable public components
|
components/ # reusable public components
|
||||||
|
|||||||
20
src/App.tsx
20
src/App.tsx
@@ -4,17 +4,18 @@ import { I18nProvider } from "./i18n";
|
|||||||
import { PublicLayout } from "./layouts/PublicLayout";
|
import { PublicLayout } from "./layouts/PublicLayout";
|
||||||
import { Home } from "./pages/Home";
|
import { Home } from "./pages/Home";
|
||||||
import { Browse } from "./pages/Browse";
|
import { Browse } from "./pages/Browse";
|
||||||
import { CategoryPage } from "./pages/CategoryPage";
|
import { CategoryPage } from "./pages/Category";
|
||||||
import { SearchPage } from "./pages/SearchPage";
|
import { SearchPage } from "./pages/Search";
|
||||||
import { FavoritesPage } from "./pages/FavoritesPage";
|
import { PostRedirect } from "./pages/PostRedirect";
|
||||||
import { ResourceDetail } from "./pages/ResourceDetail";
|
import { AboutPage } from "./pages/About";
|
||||||
import { AboutPage } from "./pages/AboutPage";
|
|
||||||
import { adminUiPrefix } from "./adminPaths";
|
import { adminUiPrefix } from "./adminPaths";
|
||||||
import { AdminRouteTree } from "./adminRouteTree";
|
import { AdminRouteTree } from "./adminRouteTree";
|
||||||
import { AdminRouterModeProvider } from "./adminRouterMode";
|
import { AdminRouterModeProvider } from "./adminRouterMode";
|
||||||
|
import { ImageLightboxProvider } from "./components/messageStream/overlays/ImageLightbox";
|
||||||
|
import { VideoPlayerProvider } from "./components/messageStream/overlays/VideoPlayer";
|
||||||
|
|
||||||
const WalletPage = lazy(() =>
|
const WalletPage = lazy(() =>
|
||||||
import("./pages/WalletPage").then((module) => ({
|
import("./pages/Wallet").then((module) => ({
|
||||||
default: module.WalletPage,
|
default: module.WalletPage,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -25,6 +26,8 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<AdminRouterModeProvider value="absolute">
|
<AdminRouterModeProvider value="absolute">
|
||||||
|
<ImageLightboxProvider>
|
||||||
|
<VideoPlayerProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<PublicLayout />}>
|
<Route element={<PublicLayout />}>
|
||||||
@@ -32,8 +35,7 @@ export default function App() {
|
|||||||
<Route path="/browse" element={<Browse />} />
|
<Route path="/browse" element={<Browse />} />
|
||||||
<Route path="/category/:slug" element={<CategoryPage />} />
|
<Route path="/category/:slug" element={<CategoryPage />} />
|
||||||
<Route path="/search" element={<SearchPage />} />
|
<Route path="/search" element={<SearchPage />} />
|
||||||
<Route path="/favorites" element={<FavoritesPage />} />
|
<Route path="/resource/:id" element={<PostRedirect />} />
|
||||||
<Route path="/resource/:id" element={<ResourceDetail />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/wallet"
|
path="/wallet"
|
||||||
element={
|
element={
|
||||||
@@ -57,6 +59,8 @@ export default function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</VideoPlayerProvider>
|
||||||
|
</ImageLightboxProvider>
|
||||||
</AdminRouterModeProvider>
|
</AdminRouterModeProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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), []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
async function loadApi(apiUrl = "") {
|
async function loadApi(apiUrl = "") {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.stubEnv("VITE_API_URL", apiUrl);
|
vi.stubEnv("VITE_API_URL", apiUrl);
|
||||||
|
vi.stubEnv("VITE_API_PREFIX", "");
|
||||||
return import("./api");
|
return import("./api");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ export async function postJSON<T>(
|
|||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Best-effort favorite counter sync (anonymous; matches localStorage favorite). */
|
export async function postNoBody(path: string): Promise<void> {
|
||||||
export function postFavoriteDelta(id: string, add: boolean) {
|
const res = await fetch(`${apiBase}${path}`, { method: "POST" });
|
||||||
return postJSON(`/api/resources/${id}/favorite`, { add }).catch(() => {});
|
if (!res.ok) throw new Error(await res.text());
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function putJSON<T>(
|
export async function putJSON<T>(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import type { Resource } from "../api";
|
import type { Resource } from "../api";
|
||||||
import { assetUrl, postJSON } from "../api";
|
import { assetUrl, postJSON, postNoBody } from "../api";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { formatDateYmd } from "../utils/format";
|
import { formatDateYmd } from "../utils/format";
|
||||||
@@ -14,11 +14,16 @@ function isPlaceholderAsset(path: string | undefined | null) {
|
|||||||
const CARD_CLASS =
|
const CARD_CLASS =
|
||||||
"group flex w-[232px] shrink-0 flex-col overflow-hidden rounded-xl border border-ark-line bg-ark-panel transition hover:border-ark-gold/55 max-[439px]:w-[232px] min-[440px]:w-[230px] sm:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]";
|
"group flex w-[232px] shrink-0 flex-col overflow-hidden rounded-xl border border-ark-line bg-ark-panel transition hover:border-ark-gold/55 max-[439px]:w-[232px] min-[440px]:w-[230px] sm:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]";
|
||||||
|
|
||||||
|
type RecommendedResource = Resource & {
|
||||||
|
downloadPostId?: string;
|
||||||
|
downloadAttachmentId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function RecommendedCard({
|
export function RecommendedCard({
|
||||||
r,
|
r,
|
||||||
visualIndex = 0,
|
visualIndex = 0,
|
||||||
}: {
|
}: {
|
||||||
r: Resource;
|
r: RecommendedResource;
|
||||||
visualIndex?: number;
|
visualIndex?: number;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -83,7 +88,13 @@ export function RecommendedCard({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
|
if (r.downloadPostId && r.downloadAttachmentId) {
|
||||||
|
await postNoBody(
|
||||||
|
`/api/posts/${r.downloadPostId}/attachments/${r.downloadAttachmentId}/download`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
await postJSON(`/api/resources/${r.id}/download`, {});
|
await postJSON(`/api/resources/${r.id}/download`, {});
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { MemoryRouter } from "react-router-dom";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import type { Resource } from "../api";
|
|
||||||
import { I18nProvider } from "../i18n";
|
|
||||||
import { ResourceCard } from "./ResourceCard";
|
|
||||||
|
|
||||||
const resource: Resource = {
|
|
||||||
id: "resource-1",
|
|
||||||
title: "Demo Resource",
|
|
||||||
description: "Short description",
|
|
||||||
type: "pdf",
|
|
||||||
language: "zh-TW",
|
|
||||||
categoryId: 1,
|
|
||||||
categorySlug: "docs",
|
|
||||||
categoryName: "文件",
|
|
||||||
coverImage: "/uploads/cover.png",
|
|
||||||
fileUrl: "/uploads/demo.pdf",
|
|
||||||
previewUrl: "/uploads/preview.pdf",
|
|
||||||
badgeLabel: "官方",
|
|
||||||
isDownloadable: true,
|
|
||||||
isRecommended: true,
|
|
||||||
updatedAt: "2026-05-16T00:00:00Z",
|
|
||||||
tags: ["ark"],
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderCard(onFavoriteToggle = vi.fn()) {
|
|
||||||
return {
|
|
||||||
user: userEvent.setup(),
|
|
||||||
onFavoriteToggle,
|
|
||||||
...render(
|
|
||||||
<MemoryRouter>
|
|
||||||
<I18nProvider>
|
|
||||||
<ResourceCard r={resource} onFavoriteToggle={onFavoriteToggle} />
|
|
||||||
</I18nProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function okResponse() {
|
|
||||||
return new Response(JSON.stringify({}), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("ResourceCard", () => {
|
|
||||||
it("renders resource summary and detail link", () => {
|
|
||||||
renderCard();
|
|
||||||
|
|
||||||
expect(screen.getByText("Demo Resource")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("文件")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("官方")).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("link", { name: /預覽/ })).toHaveAttribute(
|
|
||||||
"href",
|
|
||||||
"/resource/resource-1",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("toggles local favorite state and syncs best-effort API delta", async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue(okResponse());
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
const { user, onFavoriteToggle } = renderCard();
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /收藏/ }));
|
|
||||||
|
|
||||||
expect(JSON.parse(localStorage.getItem("ark_favorites") || "[]")).toEqual([
|
|
||||||
"resource-1",
|
|
||||||
]);
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
"/api/resources/resource-1/favorite",
|
|
||||||
expect.objectContaining({
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ add: true }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(onFavoriteToggle).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tracks downloads then opens the downloadable file", async () => {
|
|
||||||
const fetchMock = vi.fn().mockResolvedValue(okResponse());
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
const openMock = vi.spyOn(window, "open").mockImplementation(() => null);
|
|
||||||
const { user } = renderCard();
|
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /下載/ }));
|
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
|
||||||
"/api/resources/resource-1/download",
|
|
||||||
expect.objectContaining({ method: "POST" }),
|
|
||||||
);
|
|
||||||
expect(openMock).toHaveBeenCalledWith(
|
|
||||||
"/uploads/demo.pdf",
|
|
||||||
"_blank",
|
|
||||||
"noopener,noreferrer",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
import { ResourceListFooter } from "./ResourceListFooter";
|
|
||||||
|
|
||||||
const t = (key: string) =>
|
|
||||||
({
|
|
||||||
listRange: "顯示 {{from}}–{{to}},共 {{total}} 筆",
|
|
||||||
paginationPrev: "上一頁",
|
|
||||||
paginationNext: "下一頁",
|
|
||||||
pageIndicator: "{{c}} / {{p}} 頁",
|
|
||||||
})[key] ?? key;
|
|
||||||
|
|
||||||
describe("ResourceListFooter", () => {
|
|
||||||
it("renders range, page count, and disabled prev on first page", () => {
|
|
||||||
render(
|
|
||||||
<ResourceListFooter
|
|
||||||
page={1}
|
|
||||||
limit={24}
|
|
||||||
total={50}
|
|
||||||
t={t}
|
|
||||||
onPrev={vi.fn()}
|
|
||||||
onNext={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText("顯示 1–24,共 50 筆")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("1 / 3 頁")).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole("button", { name: "上一頁" })).toBeDisabled();
|
|
||||||
expect(screen.getByRole("button", { name: "下一頁" })).toBeEnabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("calls navigation handlers when buttons are enabled", () => {
|
|
||||||
const onPrev = vi.fn();
|
|
||||||
const onNext = vi.fn();
|
|
||||||
render(
|
|
||||||
<ResourceListFooter
|
|
||||||
page={2}
|
|
||||||
limit={24}
|
|
||||||
total={50}
|
|
||||||
t={t}
|
|
||||||
onPrev={onPrev}
|
|
||||||
onNext={onNext}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "上一頁" }));
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "下一頁" }));
|
|
||||||
|
|
||||||
expect(onPrev).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onNext).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
9
src/components/messageStream/DaySeparator.tsx
Normal file
9
src/components/messageStream/DaySeparator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/messageStream/FilterChips.tsx
Normal file
46
src/components/messageStream/FilterChips.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/messageStream/MessageBubble.tsx
Normal file
53
src/components/messageStream/MessageBubble.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/components/messageStream/MessageStream.tsx
Normal file
105
src/components/messageStream/MessageStream.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/messageStream/bubbles/AlbumBubble.tsx
Normal file
89
src/components/messageStream/bubbles/AlbumBubble.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/messageStream/bubbles/FileDocBubble.tsx
Normal file
72
src/components/messageStream/bubbles/FileDocBubble.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
src/components/messageStream/bubbles/ImageBubble.tsx
Normal file
27
src/components/messageStream/bubbles/ImageBubble.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/components/messageStream/bubbles/ImageWithTextBubble.tsx
Normal file
41
src/components/messageStream/bubbles/ImageWithTextBubble.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/components/messageStream/bubbles/TextBubble.tsx
Normal file
13
src/components/messageStream/bubbles/TextBubble.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/components/messageStream/bubbles/VideoBubble.tsx
Normal file
121
src/components/messageStream/bubbles/VideoBubble.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/messageStream/hooks/useGroupedByDay.test.ts
Normal file
53
src/components/messageStream/hooks/useGroupedByDay.test.ts
Normal 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
67
src/components/messageStream/hooks/useGroupedByDay.ts
Normal file
67
src/components/messageStream/hooks/useGroupedByDay.ts
Normal 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 };
|
||||||
163
src/components/messageStream/hooks/usePostStream.ts
Normal file
163
src/components/messageStream/hooks/usePostStream.ts
Normal 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 };
|
||||||
222
src/components/messageStream/overlays/ImageLightbox.tsx
Normal file
222
src/components/messageStream/overlays/ImageLightbox.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/messageStream/overlays/VideoPlayer.tsx
Normal file
118
src/components/messageStream/overlays/VideoPlayer.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/messageStream/utils/autolink.test.tsx
Normal file
42
src/components/messageStream/utils/autolink.test.tsx
Normal 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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
42
src/components/messageStream/utils/autolink.tsx
Normal file
42
src/components/messageStream/utils/autolink.tsx
Normal 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;
|
||||||
|
}
|
||||||
56
src/components/messageStream/utils/fileIcon.ts
Normal file
56
src/components/messageStream/utils/fileIcon.ts
Normal 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;
|
||||||
|
}
|
||||||
34
src/components/messageStream/utils/formatBytes.test.ts
Normal file
34
src/components/messageStream/utils/formatBytes.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
15
src/components/messageStream/utils/formatBytes.ts
Normal file
15
src/components/messageStream/utils/formatBytes.ts
Normal 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]}`;
|
||||||
|
}
|
||||||
34
src/components/messageStream/utils/formatTime.ts
Normal file
34
src/components/messageStream/utils/formatTime.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
13
src/components/messageStream/utils/postText.ts
Normal file
13
src/components/messageStream/utils/postText.ts
Normal 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() ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { isFavorite, readFavorites, toggleFavorite } from "./favorites";
|
|
||||||
|
|
||||||
describe("favorites localStorage helpers", () => {
|
|
||||||
it("returns an empty list for missing, invalid, or non-array values", () => {
|
|
||||||
expect(readFavorites()).toEqual([]);
|
|
||||||
|
|
||||||
localStorage.setItem("ark_favorites", "not-json");
|
|
||||||
expect(readFavorites()).toEqual([]);
|
|
||||||
|
|
||||||
localStorage.setItem("ark_favorites", JSON.stringify({ id: "r1" }));
|
|
||||||
expect(readFavorites()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters non-string entries and toggles favorite ids", () => {
|
|
||||||
localStorage.setItem("ark_favorites", JSON.stringify(["r1", 123, "r2"]));
|
|
||||||
expect(readFavorites()).toEqual(["r1", "r2"]);
|
|
||||||
|
|
||||||
expect(toggleFavorite("r3")).toBe(true);
|
|
||||||
expect(isFavorite("r3")).toBe(true);
|
|
||||||
expect(readFavorites()).toEqual(["r1", "r2", "r3"]);
|
|
||||||
|
|
||||||
expect(toggleFavorite("r1")).toBe(false);
|
|
||||||
expect(isFavorite("r1")).toBe(false);
|
|
||||||
expect(readFavorites()).toEqual(["r2", "r3"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
258
src/i18n.tsx
258
src/i18n.tsx
@@ -6,135 +6,11 @@ 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 資料庫",
|
|
||||||
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_ID(Reown Cloud 免費申請),否則無法使用 WalletConnect/手機掃碼。",
|
|
||||||
walletSetupNeeded: "錢包掃碼未啟用(請於伺服器設定環境變數)",
|
|
||||||
lang_zh_TW: "繁體中文",
|
|
||||||
lang_zh_CN: "简体中文",
|
|
||||||
lang_en: "English",
|
|
||||||
filterAll: "全部",
|
|
||||||
sortPublished: "發布時間",
|
|
||||||
type_ppt: "PPT",
|
|
||||||
type_video: "影片",
|
|
||||||
type_image: "圖片",
|
|
||||||
type_pdf: "PDF",
|
|
||||||
type_link: "連結",
|
|
||||||
type_text: "文字",
|
|
||||||
type_archive: "壓縮檔",
|
|
||||||
type_zip: "ZIP",
|
|
||||||
adminLoginTitle: "管理後台登入",
|
|
||||||
adminEditResource: "編輯資料",
|
|
||||||
adminVideoFileHint:
|
|
||||||
"上傳影片檔(MP4/WebM/MOV 等),類型請選「影片」;儲存後前台會自動播放(預設靜音,可點喇叭開聲音)。",
|
|
||||||
adminStatTodayNew: "今日新增",
|
|
||||||
adminStatFavorites: "收藏",
|
|
||||||
adminMetricDownloads: "下載",
|
|
||||||
adminMetricFavorites: "收藏",
|
|
||||||
adminMetricViews: "瀏覽",
|
|
||||||
edit: "編輯",
|
|
||||||
backToList: "返回列表",
|
|
||||||
sortOrderLabel: "排序權重",
|
|
||||||
previewUrlLabel: "預覽網址",
|
|
||||||
tagsCommaLabel: "標籤(逗號分隔)",
|
|
||||||
uploadFile: "上傳檔案",
|
|
||||||
loading: "載入中…",
|
|
||||||
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: "編號",
|
|
||||||
},
|
|
||||||
"zh-CN": {
|
|
||||||
brand: "ARK 数据库",
|
brand: "ARK 数据库",
|
||||||
mainNav: "网站导航",
|
mainNav: "网站导航",
|
||||||
home: "首页",
|
home: "首页",
|
||||||
@@ -143,7 +19,6 @@ const dict: Record<Lang, Dict> = {
|
|||||||
latest: "最新更新",
|
latest: "最新更新",
|
||||||
official: "官方推荐",
|
official: "官方推荐",
|
||||||
popular: "热门资料",
|
popular: "热门资料",
|
||||||
favorites: "我的收藏",
|
|
||||||
search: "搜索",
|
search: "搜索",
|
||||||
searchPlaceholder: "搜索资料...",
|
searchPlaceholder: "搜索资料...",
|
||||||
searchNow: "立即搜索资料",
|
searchNow: "立即搜索资料",
|
||||||
@@ -157,7 +32,6 @@ const dict: Record<Lang, Dict> = {
|
|||||||
popularSection: "热门资料",
|
popularSection: "热门资料",
|
||||||
preview: "预览",
|
preview: "预览",
|
||||||
download: "下载",
|
download: "下载",
|
||||||
favorite: "收藏",
|
|
||||||
share: "分享",
|
share: "分享",
|
||||||
profile: "个人中心",
|
profile: "个人中心",
|
||||||
langLabel: "语言",
|
langLabel: "语言",
|
||||||
@@ -208,12 +82,17 @@ const dict: Record<Lang, Dict> = {
|
|||||||
walletMissingProjectId:
|
walletMissingProjectId:
|
||||||
"请配置 VITE_WALLETCONNECT_PROJECT_ID(Reown Cloud),否则无法使用 WalletConnect/扫码。",
|
"请配置 VITE_WALLETCONNECT_PROJECT_ID(Reown Cloud),否则无法使用 WalletConnect/扫码。",
|
||||||
walletSetupNeeded: "钱包扫码未启用(请在服务器配置环境变量)",
|
walletSetupNeeded: "钱包扫码未启用(请在服务器配置环境变量)",
|
||||||
lang_zh_TW: "繁体中文",
|
lang_zh_CN: "中文",
|
||||||
lang_zh_CN: "简体中文",
|
|
||||||
lang_en: "English",
|
lang_en: "English",
|
||||||
|
lang_ja: "日本語",
|
||||||
|
lang_ko: "한국어",
|
||||||
|
lang_vi: "Tiếng Việt",
|
||||||
|
lang_id: "Bahasa Indonesia",
|
||||||
|
lang_ms: "Bahasa Melayu",
|
||||||
filterAll: "全部",
|
filterAll: "全部",
|
||||||
sortPublished: "发布时间",
|
sortPublished: "发布时间",
|
||||||
type_ppt: "PPT",
|
type_ppt: "PPT",
|
||||||
|
type_music: "音乐",
|
||||||
type_video: "视频",
|
type_video: "视频",
|
||||||
type_image: "图片",
|
type_image: "图片",
|
||||||
type_pdf: "PDF",
|
type_pdf: "PDF",
|
||||||
@@ -237,7 +116,6 @@ const dict: Record<Lang, Dict> = {
|
|||||||
tagsCommaLabel: "标签(逗号分隔)",
|
tagsCommaLabel: "标签(逗号分隔)",
|
||||||
uploadFile: "上传文件",
|
uploadFile: "上传文件",
|
||||||
loading: "加载中…",
|
loading: "加载中…",
|
||||||
favoritesEmpty: "还没有收藏。",
|
|
||||||
paginationPrev: "上一页",
|
paginationPrev: "上一页",
|
||||||
paginationNext: "下一页",
|
paginationNext: "下一页",
|
||||||
listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
|
listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
|
||||||
@@ -255,8 +133,9 @@ const dict: Record<Lang, Dict> = {
|
|||||||
adminSearchQuery: "查询词",
|
adminSearchQuery: "查询词",
|
||||||
adminSearchTime: "时间",
|
adminSearchTime: "时间",
|
||||||
adminSearchId: "编号",
|
adminSearchId: "编号",
|
||||||
},
|
};
|
||||||
en: {
|
|
||||||
|
const enDict: Dict = {
|
||||||
brand: "ARK Library",
|
brand: "ARK Library",
|
||||||
mainNav: "Site menu",
|
mainNav: "Site menu",
|
||||||
home: "Home",
|
home: "Home",
|
||||||
@@ -265,7 +144,6 @@ const dict: Record<Lang, Dict> = {
|
|||||||
latest: "Latest",
|
latest: "Latest",
|
||||||
official: "Official picks",
|
official: "Official picks",
|
||||||
popular: "Popular",
|
popular: "Popular",
|
||||||
favorites: "Favorites",
|
|
||||||
search: "Search",
|
search: "Search",
|
||||||
searchPlaceholder: "Search resources...",
|
searchPlaceholder: "Search resources...",
|
||||||
searchNow: "Search now",
|
searchNow: "Search now",
|
||||||
@@ -279,7 +157,6 @@ const dict: Record<Lang, Dict> = {
|
|||||||
popularSection: "Popular assets",
|
popularSection: "Popular assets",
|
||||||
preview: "Preview",
|
preview: "Preview",
|
||||||
download: "Download",
|
download: "Download",
|
||||||
favorite: "Favorite",
|
|
||||||
share: "Share",
|
share: "Share",
|
||||||
profile: "Profile",
|
profile: "Profile",
|
||||||
langLabel: "Language",
|
langLabel: "Language",
|
||||||
@@ -331,12 +208,17 @@ const dict: Record<Lang, Dict> = {
|
|||||||
walletMissingProjectId:
|
walletMissingProjectId:
|
||||||
"Set VITE_WALLETCONNECT_PROJECT_ID (free on Reown Cloud). Required for WalletConnect / QR login.",
|
"Set VITE_WALLETCONNECT_PROJECT_ID (free on Reown Cloud). Required for WalletConnect / QR login.",
|
||||||
walletSetupNeeded: "Wallet QR login disabled (set env on server)",
|
walletSetupNeeded: "Wallet QR login disabled (set env on server)",
|
||||||
lang_zh_TW: "Traditional Chinese",
|
lang_zh_CN: "Chinese",
|
||||||
lang_zh_CN: "Simplified Chinese",
|
|
||||||
lang_en: "English",
|
lang_en: "English",
|
||||||
|
lang_ja: "Japanese",
|
||||||
|
lang_ko: "Korean",
|
||||||
|
lang_vi: "Vietnamese",
|
||||||
|
lang_id: "Indonesian",
|
||||||
|
lang_ms: "Malay",
|
||||||
filterAll: "All types",
|
filterAll: "All types",
|
||||||
sortPublished: "Published date",
|
sortPublished: "Published date",
|
||||||
type_ppt: "PPT",
|
type_ppt: "PPT",
|
||||||
|
type_music: "Music",
|
||||||
type_video: "Video",
|
type_video: "Video",
|
||||||
type_image: "Image",
|
type_image: "Image",
|
||||||
type_pdf: "PDF",
|
type_pdf: "PDF",
|
||||||
@@ -360,7 +242,6 @@ const dict: Record<Lang, Dict> = {
|
|||||||
tagsCommaLabel: "Tags (comma-separated)",
|
tagsCommaLabel: "Tags (comma-separated)",
|
||||||
uploadFile: "Upload",
|
uploadFile: "Upload",
|
||||||
loading: "Loading…",
|
loading: "Loading…",
|
||||||
favoritesEmpty: "No favorites yet.",
|
|
||||||
paginationPrev: "Previous",
|
paginationPrev: "Previous",
|
||||||
paginationNext: "Next",
|
paginationNext: "Next",
|
||||||
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
||||||
@@ -378,12 +259,87 @@ const dict: Record<Lang, Dict> = {
|
|||||||
adminSearchQuery: "Query",
|
adminSearchQuery: "Query",
|
||||||
adminSearchTime: "Time",
|
adminSearchTime: "Time",
|
||||||
adminSearchId: "ID",
|
adminSearchId: "ID",
|
||||||
|
};
|
||||||
|
|
||||||
|
const languageNames: Record<Lang, Dict> = {
|
||||||
|
"zh-CN": {
|
||||||
|
lang_zh_CN: "中文",
|
||||||
|
lang_en: "English",
|
||||||
|
lang_ja: "日本語",
|
||||||
|
lang_ko: "한국어",
|
||||||
|
lang_vi: "Tiếng Việt",
|
||||||
|
lang_id: "Bahasa Indonesia",
|
||||||
|
lang_ms: "Bahasa Melayu",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
lang_zh_CN: "Chinese",
|
||||||
|
lang_en: "English",
|
||||||
|
lang_ja: "Japanese",
|
||||||
|
lang_ko: "Korean",
|
||||||
|
lang_vi: "Vietnamese",
|
||||||
|
lang_id: "Indonesian",
|
||||||
|
lang_ms: "Malay",
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
lang_zh_CN: "中国語",
|
||||||
|
lang_en: "英語",
|
||||||
|
lang_ja: "日本語",
|
||||||
|
lang_ko: "韓国語",
|
||||||
|
lang_vi: "ベトナム語",
|
||||||
|
lang_id: "インドネシア語",
|
||||||
|
lang_ms: "マレー語",
|
||||||
|
},
|
||||||
|
ko: {
|
||||||
|
lang_zh_CN: "중국어",
|
||||||
|
lang_en: "영어",
|
||||||
|
lang_ja: "일본어",
|
||||||
|
lang_ko: "한국어",
|
||||||
|
lang_vi: "베트남어",
|
||||||
|
lang_id: "인도네시아어",
|
||||||
|
lang_ms: "말레이어",
|
||||||
|
},
|
||||||
|
vi: {
|
||||||
|
lang_zh_CN: "Tiếng Trung",
|
||||||
|
lang_en: "Tiếng Anh",
|
||||||
|
lang_ja: "Tiếng Nhật",
|
||||||
|
lang_ko: "Tiếng Hàn",
|
||||||
|
lang_vi: "Tiếng Việt",
|
||||||
|
lang_id: "Tiếng Indonesia",
|
||||||
|
lang_ms: "Tiếng Mã Lai",
|
||||||
|
},
|
||||||
|
id: {
|
||||||
|
lang_zh_CN: "Bahasa Tionghoa",
|
||||||
|
lang_en: "Bahasa Inggris",
|
||||||
|
lang_ja: "Bahasa Jepang",
|
||||||
|
lang_ko: "Bahasa Korea",
|
||||||
|
lang_vi: "Bahasa Vietnam",
|
||||||
|
lang_id: "Bahasa Indonesia",
|
||||||
|
lang_ms: "Bahasa Melayu",
|
||||||
|
},
|
||||||
|
ms: {
|
||||||
|
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
26
src/i18nLanguages.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
|||||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { ArkLogoMark } from "../components/ArkLogoMark";
|
import { ArkLogoMark } from "../components/ArkLogoMark";
|
||||||
import { useI18n, type Lang } from "../i18n";
|
import { useI18n, type Lang } from "../i18n";
|
||||||
|
import { LANG_OPTIONS } from "../i18nLanguages";
|
||||||
import { adminUiPrefix } from "../adminPaths";
|
import { adminUiPrefix } from "../adminPaths";
|
||||||
|
|
||||||
type PublicNavWhich =
|
type PublicNavWhich =
|
||||||
@@ -12,7 +13,6 @@ type PublicNavWhich =
|
|||||||
| "browseLatest"
|
| "browseLatest"
|
||||||
| "browseRecommended"
|
| "browseRecommended"
|
||||||
| "browsePopular"
|
| "browsePopular"
|
||||||
| "favorites"
|
|
||||||
| "wallet"
|
| "wallet"
|
||||||
| "about";
|
| "about";
|
||||||
|
|
||||||
@@ -36,8 +36,6 @@ function navIsActive(
|
|||||||
return pathname === "/browse" && sp.get("sort") === "recommended";
|
return pathname === "/browse" && sp.get("sort") === "recommended";
|
||||||
case "browsePopular":
|
case "browsePopular":
|
||||||
return pathname === "/browse" && sp.get("sort") === "popular";
|
return pathname === "/browse" && sp.get("sort") === "popular";
|
||||||
case "favorites":
|
|
||||||
return pathname === "/favorites";
|
|
||||||
case "wallet":
|
case "wallet":
|
||||||
return pathname === "/wallet";
|
return pathname === "/wallet";
|
||||||
case "about":
|
case "about":
|
||||||
@@ -136,13 +134,6 @@ export function PublicLayout() {
|
|||||||
>
|
>
|
||||||
{t("popular")}
|
{t("popular")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
to="/favorites"
|
|
||||||
className={navClassName(na("favorites"))}
|
|
||||||
aria-current={na("favorites") ? "page" : undefined}
|
|
||||||
>
|
|
||||||
{t("favorites")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
to="/wallet"
|
to="/wallet"
|
||||||
className={navClassName(na("wallet"))}
|
className={navClassName(na("wallet"))}
|
||||||
@@ -175,9 +166,11 @@ export function PublicLayout() {
|
|||||||
onChange={(e) => setLang(e.target.value as Lang)}
|
onChange={(e) => setLang(e.target.value as Lang)}
|
||||||
aria-label={t("langLabel")}
|
aria-label={t("langLabel")}
|
||||||
>
|
>
|
||||||
<option value="zh-TW">繁體中文</option>
|
{LANG_OPTIONS.map((option) => (
|
||||||
<option value="zh-CN">简体中文</option>
|
<option key={option.code} value={option.code}>
|
||||||
<option value="en">English</option>
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -204,6 +197,21 @@ export function PublicLayout() {
|
|||||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]"
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2">
|
||||||
|
<Globe size={16} className="shrink-0 text-ark-gold/80" />
|
||||||
|
<select
|
||||||
|
className="w-full bg-transparent text-sm text-neutral-200 outline-none"
|
||||||
|
value={lang}
|
||||||
|
onChange={(e) => setLang(e.target.value as Lang)}
|
||||||
|
aria-label={t("langLabel")}
|
||||||
|
>
|
||||||
|
{LANG_OPTIONS.map((option) => (
|
||||||
|
<option key={option.code} value={option.code}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className={navClassName(na("home"))}
|
className={navClassName(na("home"))}
|
||||||
@@ -228,14 +236,6 @@ export function PublicLayout() {
|
|||||||
>
|
>
|
||||||
{t("categories")}
|
{t("categories")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
to="/favorites"
|
|
||||||
className={navClassName(na("favorites"))}
|
|
||||||
aria-current={na("favorites") ? "page" : undefined}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
{t("favorites")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
to="/browse?sort=latest"
|
to="/browse?sort=latest"
|
||||||
className={navClassName(na("browseLatest"))}
|
className={navClassName(na("browseLatest"))}
|
||||||
@@ -326,10 +326,10 @@ export function PublicLayout() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to="/favorites"
|
to="/wallet"
|
||||||
label={t("favorites")}
|
label={t("wallet")}
|
||||||
icon="heart"
|
icon="profile"
|
||||||
active={pathname === "/favorites"}
|
active={pathname === "/wallet"}
|
||||||
/>
|
/>
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to="/browse?sort=latest"
|
to="/browse?sort=latest"
|
||||||
@@ -356,7 +356,7 @@ function BottomNavIcon({
|
|||||||
}: {
|
}: {
|
||||||
to: string;
|
to: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: "home" | "document" | "heart" | "update";
|
icon: "home" | "document" | "profile" | "update";
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}) {
|
}) {
|
||||||
const src = active
|
const src = active
|
||||||
|
|||||||
342
src/mocks/mockPosts.ts
Normal file
342
src/mocks/mockPosts.ts
Normal 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",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
|
|
||||||
export function AboutPage() {
|
export function AboutPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useSearchParams } from "react-router-dom";
|
|
||||||
import { getJSON, itemsOrEmpty, type Resource } from "../api";
|
|
||||||
import { ResourceCard } from "../components/ResourceCard";
|
|
||||||
import { ResourceListFooter } from "../components/ResourceListFooter";
|
|
||||||
import { useI18n } from "../i18n";
|
|
||||||
import { typeFilterLabel } from "../resourceTypeLabels";
|
|
||||||
|
|
||||||
const types = [
|
|
||||||
"all",
|
|
||||||
"image",
|
|
||||||
"video",
|
|
||||||
"ppt",
|
|
||||||
"pdf",
|
|
||||||
"text",
|
|
||||||
"link",
|
|
||||||
"archive",
|
|
||||||
] as const;
|
|
||||||
const resourceLangCodes = ["", "zh-TW", "zh-CN", "en"] as const;
|
|
||||||
|
|
||||||
function resourceLangLabel(t: (k: string) => string, code: string) {
|
|
||||||
if (!code) return t("filterLanguageAll");
|
|
||||||
if (code === "zh-TW") return t("lang_zh_TW");
|
|
||||||
if (code === "zh-CN") return t("lang_zh_CN");
|
|
||||||
return t("lang_en");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Browse() {
|
|
||||||
const { t, lang } = useI18n();
|
|
||||||
const [sp, setSp] = useSearchParams();
|
|
||||||
const sort = sp.get("sort") || "latest";
|
|
||||||
const type = sp.get("type") || "all";
|
|
||||||
const tag = (sp.get("tag") || "").trim();
|
|
||||||
const resourceLang = sp.get("language") || "";
|
|
||||||
const page = Math.max(1, parseInt(sp.get("page") || "1", 10) || 1);
|
|
||||||
const limit = 24;
|
|
||||||
|
|
||||||
const [items, setItems] = useState<Resource[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [err, setErr] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const query = useMemo(() => {
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
p.set("lang", lang);
|
|
||||||
p.set("sort", sort);
|
|
||||||
p.set("limit", String(limit));
|
|
||||||
p.set("page", String(page));
|
|
||||||
if (type && type !== "all") p.set("type", type);
|
|
||||||
if (resourceLang) p.set("language", resourceLang);
|
|
||||||
if (tag) p.set("tag", tag);
|
|
||||||
return p.toString();
|
|
||||||
}, [lang, sort, type, resourceLang, tag, page]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setErr(null);
|
|
||||||
|
|
||||||
if (sort === "recommended") {
|
|
||||||
const p = new URLSearchParams();
|
|
||||||
p.set("lang", lang);
|
|
||||||
p.set("limit", "100");
|
|
||||||
|
|
||||||
getJSON<{ items: Resource[] }>(`/api/resources/recommended?${p}`)
|
|
||||||
.then((r) => {
|
|
||||||
const tagLower = tag.toLowerCase();
|
|
||||||
const officialItems = itemsOrEmpty(r.items)
|
|
||||||
.filter((item) => item.isRecommended)
|
|
||||||
.filter((item) => type === "all" || item.type === type)
|
|
||||||
.filter((item) => !resourceLang || item.language === resourceLang)
|
|
||||||
.filter(
|
|
||||||
(item) =>
|
|
||||||
!tagLower ||
|
|
||||||
item.tags?.some(
|
|
||||||
(itemTag) => itemTag.toLowerCase() === tagLower,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setTotal(officialItems.length);
|
|
||||||
setItems(officialItems.slice((page - 1) * limit, page * limit));
|
|
||||||
})
|
|
||||||
.catch((e) => setErr(String(e)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
getJSON<{ items: Resource[]; total?: number }>(`/api/resources?${query}`)
|
|
||||||
.then((r) => {
|
|
||||||
setItems(itemsOrEmpty(r.items));
|
|
||||||
setTotal(typeof r.total === "number" ? r.total : 0);
|
|
||||||
})
|
|
||||||
.catch((e) => setErr(String(e)));
|
|
||||||
}, [lang, limit, page, query, resourceLang, sort, tag, type]);
|
|
||||||
|
|
||||||
const setPage = (next: number) => {
|
|
||||||
const n = new URLSearchParams(sp);
|
|
||||||
if (next <= 1) n.delete("page");
|
|
||||||
else n.set("page", String(next));
|
|
||||||
setSp(n);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<h1 className="text-2xl font-bold">{t("all")}</h1>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
["latest", t("latest")],
|
|
||||||
["recommended", t("official")],
|
|
||||||
["popular", t("popular")],
|
|
||||||
["published", t("sortPublished")],
|
|
||||||
] as const
|
|
||||||
).map(([k, label]) => (
|
|
||||||
<button
|
|
||||||
key={k}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const n = new URLSearchParams(sp);
|
|
||||||
n.set("sort", k);
|
|
||||||
n.delete("page");
|
|
||||||
setSp(n);
|
|
||||||
}}
|
|
||||||
className={`rounded-full border px-3 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
|
||||||
sort === k
|
|
||||||
? "border-ark-gold text-ark-gold2"
|
|
||||||
: "border-ark-line"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{types.map((tp) => (
|
|
||||||
<button
|
|
||||||
key={tp}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const n = new URLSearchParams(sp);
|
|
||||||
n.delete("page");
|
|
||||||
if (tp === "all") n.delete("type");
|
|
||||||
else n.set("type", tp);
|
|
||||||
setSp(n);
|
|
||||||
}}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
|
||||||
type === tp || (tp === "all" && !sp.get("type"))
|
|
||||||
? "border-ark-gold text-ark-gold2"
|
|
||||||
: "border-ark-line"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{typeFilterLabel(t, tp)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
|
||||||
{t("resourceLangFilter")}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{resourceLangCodes.map((code) => (
|
|
||||||
<button
|
|
||||||
key={code || "all"}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const n = new URLSearchParams(sp);
|
|
||||||
n.delete("page");
|
|
||||||
if (!code) n.delete("language");
|
|
||||||
else n.set("language", code);
|
|
||||||
setSp(n);
|
|
||||||
}}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
|
||||||
(code === "" && !resourceLang) || resourceLang === code
|
|
||||||
? "border-ark-gold text-ark-gold2"
|
|
||||||
: "border-ark-line"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{resourceLangLabel(t, code)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tag ? (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm text-neutral-400">
|
|
||||||
{t("search")}: <span className="text-ark-gold2">{tag}</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const n = new URLSearchParams(sp);
|
|
||||||
n.delete("tag");
|
|
||||||
n.delete("page");
|
|
||||||
setSp(n);
|
|
||||||
}}
|
|
||||||
className="rounded-full border border-ark-line px-3 py-1 text-xs text-neutral-200 outline-none hover:border-ark-gold focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
|
||||||
>
|
|
||||||
{t("filterTagClear")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{err ? <div className="text-red-300">{err}</div> : null}
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{items.map((r) => (
|
|
||||||
<ResourceCard key={r.id} r={r} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ResourceListFooter
|
|
||||||
page={page}
|
|
||||||
limit={limit}
|
|
||||||
total={total}
|
|
||||||
t={t}
|
|
||||||
onPrev={() => setPage(page - 1)}
|
|
||||||
onNext={() => setPage(page + 1)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
14
src/pages/Browse/index.tsx
Normal file
14
src/pages/Browse/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/pages/Category/index.tsx
Normal file
31
src/pages/Category/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,56 @@
|
|||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { getJSON, itemsOrEmpty, type Category, type Resource } from "../api";
|
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
||||||
import { CategoryIcon } from "../components/CategoryIcon";
|
import { CategoryIcon } from "../../components/CategoryIcon";
|
||||||
import { FigmaBanner } from "../components/FigmaBanner";
|
import { FigmaBanner } from "../../components/FigmaBanner";
|
||||||
import {
|
import {
|
||||||
ComingSoonLatestUpdateRow,
|
ComingSoonLatestUpdateRow,
|
||||||
LatestUpdateRow,
|
LatestUpdateRow,
|
||||||
} from "../components/LatestUpdateRow";
|
} from "../../components/LatestUpdateRow";
|
||||||
import { RecommendedCard } from "../components/RecommendedCard";
|
import { RecommendedCard } from "../../components/RecommendedCard";
|
||||||
import { SectionHeader } from "../components/SectionHeader";
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
import { useI18n } from "../i18n";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
import { categoryCardLines } from "../utils/categoryDisplay";
|
import { categoryCardLines } from "../../utils/categoryDisplay";
|
||||||
|
import {
|
||||||
|
postToResource,
|
||||||
|
type PostBackedResource,
|
||||||
|
} from "../../utils/postResourceAdapter";
|
||||||
|
import type { Post } from "../../types/post";
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
const { t, lang } = useI18n();
|
const { t, lang } = useI18n();
|
||||||
const [cats, setCats] = useState<Category[]>([]);
|
const [cats, setCats] = useState<Category[]>([]);
|
||||||
const [rec, setRec] = useState<Resource[]>([]);
|
const [rec, setRec] = useState<PostBackedResource[]>([]);
|
||||||
const [latest, setLatest] = useState<Resource[]>([]);
|
const [latest, setLatest] = useState<PostBackedResource[]>([]);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const recRowRef = useRef<HTMLDivElement>(null);
|
const recRowRef = useRef<HTMLDivElement>(null);
|
||||||
const [canScrollRec, setCanScrollRec] = useState(false);
|
const [canScrollRec, setCanScrollRec] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = `?lang=${encodeURIComponent(lang)}`;
|
const q = `?lang=${encodeURIComponent(langQuery(lang))}`;
|
||||||
Promise.all([
|
Promise.all([
|
||||||
getJSON<Category[]>(`/api/categories${q}`),
|
getJSON<Category[]>(`/api/categories${q}`),
|
||||||
getJSON<{ items: Resource[] }>(`/api/resources/recommended${q}&limit=12`),
|
getJSON<{ items: Post[] }>(`/api/posts/recommended${q}&limit=12`),
|
||||||
getJSON<{ items: Resource[] }>(`/api/resources/latest${q}&limit=8`),
|
getJSON<{ items: Post[] }>(`/api/posts/latest${q}&limit=8`),
|
||||||
])
|
])
|
||||||
.then(([c, r, l]) => {
|
.then(([c, r, l]) => {
|
||||||
setCats(itemsOrEmpty(c));
|
setCats(itemsOrEmpty(c));
|
||||||
setRec(itemsOrEmpty(r.items));
|
setRec(
|
||||||
setLatest(itemsOrEmpty(l.items));
|
itemsOrEmpty(r.items).map((post) =>
|
||||||
|
postToResource(post, lang, itemsOrEmpty(c)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setLatest(
|
||||||
|
itemsOrEmpty(l.items).map((post) =>
|
||||||
|
postToResource(post, lang, itemsOrEmpty(c)),
|
||||||
|
),
|
||||||
|
);
|
||||||
})
|
})
|
||||||
.catch((e) => setErr(String(e)));
|
.catch((e) => setErr(String(e)));
|
||||||
}, [lang]);
|
}, [lang]);
|
||||||
|
|
||||||
const iconKeyForResource = (r: Resource) =>
|
const iconKeyForResource = (r: PostBackedResource) =>
|
||||||
cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder";
|
cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
41
src/pages/PostRedirect/index.tsx
Normal file
41
src/pages/PostRedirect/index.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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
126
src/pages/Search/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit";
|
import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit";
|
||||||
import { WagmiProvider } from "wagmi";
|
import { WagmiProvider } from "wagmi";
|
||||||
import "@rainbow-me/rainbowkit/styles.css";
|
import "@rainbow-me/rainbowkit/styles.css";
|
||||||
import { WalletLoginControls } from "../components/WalletLoginControls";
|
import { WalletLoginControls } from "../../components/WalletLoginControls";
|
||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
import { wagmiConfig } from "../wagmiConfig";
|
import { wagmiConfig } from "../../wagmiConfig";
|
||||||
|
|
||||||
export function WalletPage() {
|
export function WalletPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -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")}>
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
|
|
||||||
@@ -9,25 +9,29 @@ import {
|
|||||||
const t = (key: string) =>
|
const t = (key: string) =>
|
||||||
({
|
({
|
||||||
filterAll: "全部",
|
filterAll: "全部",
|
||||||
type_image: "圖片",
|
type_image: "图片",
|
||||||
type_video: "影片",
|
type_video: "视频",
|
||||||
lang_zh_TW: "繁中",
|
type_music: "音乐",
|
||||||
lang_zh_CN: "簡中",
|
lang_zh_CN: "中文",
|
||||||
lang_en: "英文",
|
lang_en: "English",
|
||||||
|
lang_ja: "日本語",
|
||||||
})[key] ?? key;
|
})[key] ?? key;
|
||||||
|
|
||||||
describe("resource labels", () => {
|
describe("resource labels", () => {
|
||||||
it("localizes known resource types and falls back to raw type", () => {
|
it("localizes known resource types and falls back to raw type", () => {
|
||||||
expect(typeFilterLabel(t, "all")).toBe("全部");
|
expect(typeFilterLabel(t, "all")).toBe("全部");
|
||||||
expect(resourceTypeLabel(t, "image")).toBe("圖片");
|
expect(resourceTypeLabel(t, "image")).toBe("图片");
|
||||||
expect(resourceTypeDisplay(t, "video")).toBe("影片");
|
expect(resourceTypeDisplay(t, "video")).toBe("视频");
|
||||||
|
expect(resourceTypeLabel(t, "music")).toBe("音乐");
|
||||||
expect(resourceTypeLabel(t, "unknown")).toBe("unknown");
|
expect(resourceTypeLabel(t, "unknown")).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes resource language codes", () => {
|
it("normalizes resource language codes", () => {
|
||||||
expect(resourceLanguageLabel(t, "zh-TW")).toBe("繁中");
|
expect(resourceLanguageLabel(t, "zh-TW")).toBe("中文");
|
||||||
expect(resourceLanguageLabel(t, "zh-hans")).toBe("簡中");
|
expect(resourceLanguageLabel(t, "zh-CN")).toBe("中文");
|
||||||
expect(resourceLanguageLabel(t, "EN")).toBe("英文");
|
expect(resourceLanguageLabel(t, "zh-hans")).toBe("中文");
|
||||||
expect(resourceLanguageLabel(t, "ja")).toBe("ja");
|
expect(resourceLanguageLabel(t, "EN")).toBe("English");
|
||||||
|
expect(resourceLanguageLabel(t, "ja")).toBe("日本語");
|
||||||
|
expect(resourceLanguageLabel(t, "xx")).toBe("xx");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
|
||||||
? "lang_en"
|
|
||||||
: "";
|
|
||||||
if (key) {
|
|
||||||
const label = t(key);
|
const label = t(key);
|
||||||
if (label !== key) return label;
|
return label !== key ? label : langCode.trim();
|
||||||
}
|
|
||||||
return langCode.trim();
|
|
||||||
}
|
}
|
||||||
|
|||||||
62
src/types/post.ts
Normal file
62
src/types/post.ts
Normal 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 };
|
||||||
63
src/utils/postResourceAdapter.ts
Normal file
63
src/utils/postResourceAdapter.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user