From 95a0ce8e56549c640175a9d018d0e38b9cfaca99 Mon Sep 17 00:00:00 2001 From: stacklix <5688532+stacklix@users.noreply.github.com> Date: Sat, 9 May 2026 00:04:37 +0800 Subject: [PATCH 1/9] =?UTF-8?q?refactor:=20align=20with=20golang=20v0.28.0?= =?UTF-8?q?=20=E2=80=94=20remove=20legacy=20MCP/AI=20routes,=20update=20di?= =?UTF-8?q?ff=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop deprecated server/routes/mcp.ts and routes/v1/ai.ts endpoints. Update DIFF-VS-GOLANG documentation to reflect current state. --- DIFF-VS-GOLANG.md | 205 +++------ DIFF-VS-GOLANG.zh-CN.md | 207 +++------ server/app.ts | 7 - server/db/repository.ts | 8 + server/routes/mcp.ts | 415 ------------------ server/routes/v1/ai.ts | 149 ------- server/routes/v1/attachments.ts | 32 -- server/routes/v1/index.ts | 2 - server/routes/v1/users.ts | 127 ++++-- .../integration/errors-unimplemented.test.ts | 32 ++ tests/integration/users-extras.test.ts | 79 +++- 11 files changed, 358 insertions(+), 905 deletions(-) delete mode 100644 server/routes/mcp.ts delete mode 100644 server/routes/v1/ai.ts diff --git a/DIFF-VS-GOLANG.md b/DIFF-VS-GOLANG.md index 22437ae6..2b8d1e25 100644 --- a/DIFF-VS-GOLANG.md +++ b/DIFF-VS-GOLANG.md @@ -1,29 +1,19 @@ -# Current Branch (`master`) vs `golang` Branch: Remaining Differences +# Current Branch (`master`) vs `golang` Branch: Differences -> Updated: 2026-04-29. Baseline: `master` vs `golang@9bf648ac` (v0.28.0). -> REST API contract reference: [https://usememos.com/docs/api/0-28-0](https://usememos.com/docs/api/0-28-0) and `golang:proto/gen/openapi.yaml`. +> Updated: 2026-05-06. Baseline: `master` vs `golang@9bf648ac` (v0.28.0). > -> **Excluded by design** (will not be closed in this fork): -> - Instance `STORAGE` setting backend API + dynamic `supportedStorageTypes` frontend rendering -> - SSE endpoint on Cloudflare Worker (CF streaming is not compatible with long-lived SSE) +> **Excluded by design**: +> - Instance `STORAGE` backend API + dynamic `supportedStorageTypes` frontend rendering +> - SSE endpoint on Cloudflare Worker (CF streaming incompatible with long-lived SSE) --- -## 1) Database schema differences +## 1) Database Schema -### 1.1 Table-level comparison (`migrations/0001_initial.sql` vs `store/migration/sqlite/LATEST.sql`) +### `user_identity` table -The 9 original business tables are **structurally identical** across both branches (column names, types, constraints, and defaults match): +`golang@9bf648ac` has `user_identity` in `store/migration/sqlite/LATEST.sql`. `master` has equivalent via `migrations/0002_user_identity.sql`: -`system_setting`, `user`, `user_setting`, `memo`, `memo_relation`, `attachment`, `idp`, `inbox`, `reaction`, `memo_share` - -### 1.2 Tables exclusive to `golang` (not yet in `master`) - -| Table | Purpose | -| --- | --- | -| `user_identity` | Stores linked SSO / OAuth2 identities per user; supports the new `linkedIdentities` REST endpoints | - -`user_identity` schema in `golang`: ```sql CREATE TABLE user_identity ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -38,154 +28,97 @@ CREATE TABLE user_identity ( CREATE INDEX idx_user_identity_user_id ON user_identity(user_id); ``` -### 1.3 Tables exclusive to `master` - -| Table | Purpose | -| --- | --- | -| `schema_migrations(version INTEGER PK)` | Node incremental migration bookkeeping; not present in `golang` | +### Migration mechanism -### 1.4 DDL-level differences (non-semantic) +| | `master` | `golang` | +|---|---|---| +| Evolution | Incremental `migrations/NNNN_*.sql` | `store/migration/sqlite/*` dirs + `LATEST.sql` | +| Version tracking | `schema_migrations` table | None | -| Difference | `master` | `golang` | -| --- | --- | --- | -| `CREATE TABLE` guard | `CREATE TABLE IF NOT EXISTS` | `CREATE TABLE` | -| Index guard | `CREATE INDEX IF NOT EXISTS` | `CREATE INDEX` | +### DDL guards -### 1.5 Migration mechanism - -| Item | `master` | `golang` | -| --- | --- | --- | -| Evolution model | Incremental `migrations/NNNN_*.sql` files | `store/migration/sqlite/*` version dirs + `LATEST.sql` | -| Version tracking | Writes explicit `schema_migrations` records | No equivalent table | +`master` uses `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`. `golang` uses plain `CREATE TABLE` / `CREATE INDEX`. --- -## 2) Backend API differences (`server/routes/v1` vs `golang`) - -> Reference: `golang:proto/gen/openapi.yaml` (auto-generated from proto definitions). +## 2) Backend API (`server/routes/v1`) -### 2.1 Missing endpoints in `master` +### API transport strategy -| Method | Path | Notes | -| --- | --- | --- | -| `GET` | `/api/v1/sse` | SSE endpoint exists in `master` but only for **Node.js** (`enableSSE: true`). CF Worker does not mount it (streaming incompatible). `golang` has it unconditionally. | +`master` uses **custom REST** — `web/src/connect.ts` (~1110 lines) translates gRPC-style service calls into plain JSON REST. -> **Note:** `GET /api/v1/users/{user}:getStats` **is implemented** in `master` (handled inside the wildcard `GET /users/:username` handler via suffix matching), despite not being a named route. +`golang` uses **Connect gRPC** via `@connectrpc/connect-web` (~203 lines); native binary+JSON Connect protocol. -### 2.2 Path differences (master deviates from golang contract) +### Missing endpoints in `master` -| Resource | `master` path | `golang` / OpenAPI path | Impact | -| --- | --- | --- | --- | -| Instance settings (GET) | `GET /api/v1/instance/settings/{KEY}` | `GET /api/v1/{name=instance/settings/*}` | Both resolve to the same effective path; `master` uses a simpler param extraction. | -| Instance settings (PATCH) | `PATCH /api/v1/instance/settings/{KEY}` | `PATCH /api/v1/{setting.name=instance/settings/*}` | Same as above. | +| Method | Path | Note | +|---|---|---| +| `GET` | `/api/v1/sse` | Node.js only (`enableSSE: true`); CF Worker excluded | -### 2.3 Semantic / field differences in existing routes +### Path differences -| Module | `master` behavior | `golang` behavior | -| --- | --- | --- | -| **Instance `STORAGE` setting** | Returns extra `supportedStorageTypes` (dynamic, includes `R2`) | Fixed enum `DATABASE/LOCAL/S3`; no dynamic field — **excluded by design** | -| **Instance `AI` setting** | `instance/settings/AI` persisted; full `AIService` + AI provider config; `POST /api/v1/ai:transcribe` implemented (aligned) | Full `AIService` + AI instance settings (`InstanceSetting_Key.AI`) | -| **Memo `filter` / CEL** | Subset implementation in `server/lib/memo-filter.ts` (covers common patterns used by the web client: creator, visibility, tag, pinned, time range, content.contains) | Full CEL compilation semantics | -| **API transport** | `web/src/connect.ts` implements a **custom REST client** (~1110 lines) that translates gRPC-style service calls into plain JSON REST calls | `web/src/connect.ts` uses **Connect gRPC/protocol transport** via `@connectrpc/connect-web` (~203 lines); native binary+JSON Connect protocol | +| Resource | `master` path | `golang` path | +|---|---|---| +| Instance settings (GET) | `GET /api/v1/instance/settings/{KEY}` | `GET /api/v1/{name=instance/settings/*}` | +| Instance settings (PATCH) | `PATCH /api/v1/instance/settings/{KEY}` | `PATCH /api/v1/{setting.name=instance/settings/*}` | -### 2.4 Resolved gaps +### Semantic differences -| Module | Status | -| --- | --- | -| Auth endpoints (`/signin`, `/signout`, `/refresh`, `/me`) | ✅ Fully aligned | -| User CRUD, PAT, webhook, notification, shortcut endpoints | ✅ Fully aligned | -| Memo CRUD, comments, reactions, relations, shares | ✅ Fully aligned | -| `DELETE /memos/{memo}` soft-delete / `?force=true` | ✅ Aligned — archives by default; `?force=true` hard-deletes | -| Attachment CRUD, `batchDelete`, `motionMedia` field | ✅ Fully aligned | -| `POST /api/v1/users:batchGet` | ✅ Implemented | -| Identity provider CRUD | ✅ Fully aligned | -| GENERAL settings persistence (`additionalScript`, `additionalStyle`, `customProfile`, `weekStartDayOffset`) | ✅ Fixed | -| **Memo `name` field** | ✅ Uses `"memos/{uid}"` (UUID v4 from `memo.uid` column) — aligned with golang | -| **`PATCH /memos/{memo}` updateMask** | ✅ `updateMask` is now required in the request body; server applies only the paths listed | -| **`GET /memos` `showDeleted` param** | ✅ `showDeleted=true` (or `show_deleted=true`) sets state to ARCHIVED — aligned with golang | -| **`PATCH /users/{user}/settings/{setting}` updateMask** | ✅ Already enforced — rejects empty updateMask | -| **MCP endpoint** | ✅ Implemented at `POST/GET/DELETE /mcp` (Streamable HTTP transport) in `server/routes/mcp.ts` | -| **`user_identity` table** | ✅ Added in `migrations/0002_user_identity.sql` | -| **`POST /api/v1/ai:transcribe`** | ✅ Implemented in `server/routes/v1/ai.ts`; reads AI provider from `instance/settings/AI` | -| **`GET/DELETE /api/v1/users/{user}/linkedIdentities[/{id}]`** | ✅ Implemented in `server/routes/v1/users.ts` | -| **Instance `AI` setting persistence** | ✅ `instance/settings/AI` key persisted and served via `server/lib/instance-ai-setting.ts` | +| Module | `master` | `golang` | +|---|---|---| +| **Instance `STORAGE`** | Dynamic `supportedStorageTypes` includes `R2` | Fixed enum `DATABASE/LOCAL/S3` — **excluded** | +| **Memo `filter` / CEL** | Subset in `server/lib/memo-filter.ts` | Full CEL compilation | --- -## 3) Frontend differences (`web/`) +## 3) Frontend (`web/`) -### 3.1 Pages (`web/src/pages/`) +### Pages with differences (`web/src/pages/`) -All 14 pages exist in both branches. The following pages have notable differences: +| Page | Difference | +|---|---| +| `SignIn.tsx` | `master` delegates to `SsoSignInForm` component; golang had inline SSO logic (~84 lines) | +| `MemoDetail.tsx` | `master` removes `MentionResolutionProvider`, `shareImageDialogOpen` state, `onShareImageOpen` prop (~69 lines) | +| `Setting.tsx` | `master` adds `ai` section with `AISection`; golang had simpler structure | +| `AuthCallback.tsx` | `ssoCredentials` object vs golang's `credentials.case/value` shape (~13 lines) | +| `Inboxes.tsx` | `master` removes `MemoMentionMessage` (~4 lines) | +| `SignUp.tsx` | `passwordCredentials` object vs golang's `credentials.case/value` (~5 lines) | -| Page | Nature of change in `master` vs `golang` | -| --- | --- | -| `SignIn.tsx` | `master` adds SSO sign-in form and extra UI (~87 lines); `golang` baseline is simpler | -| `MemoDetail.tsx` | `master` has sidebar/layout changes (~69 lines) | -| `Setting.tsx` | `golang` adds **AI** settings section and `LinkedIdentitySection`; `master` is missing these | -| `AuthCallback.tsx` | Minor differences (~13 lines) | -| `Inboxes.tsx` | Minor additions in `master` (~4 lines) | +### `master`-only components -### 3.2 Components present in `master` but NOT in `golang` - -| Component | Notes | -| --- | --- | -| `MemoAttachment.tsx` | Single-attachment display (audio inline; other files show icon + filename) | -| `MemoResource.tsx` | Wraps `MemoAttachment` to flat-render a memo's attachment list | -| `SsoSignInForm.tsx` | SSO sign-in form component | +| Component | Note | +|---|---| +| `MemoAttachment.tsx` | Single attachment display | +| `MemoResource.tsx` | Flat-renders memo's attachment list | +| `SsoSignInForm.tsx` | SSO sign-in form | | `MemoActionMenu/MemoShareImageDialog.tsx` | Share-as-image dialog | -| `MemoActionMenu/MemoShareImagePreview.tsx` | Image preview for sharing | -| `MemoActionMenu/memoShareImage.ts` | Share image generation logic | +| `MemoActionMenu/MemoShareImagePreview.tsx` | Image preview | +| `MemoActionMenu/memoShareImage.ts` | Share image generation | | `MemoContent/constants.ts` | Content rendering constants | -| `MemoEditor/hooks/useVoiceRecorder.ts` | Voice recorder hook (master equivalent of golang's `useAudioRecorder` + `useAudioWaveform`; all three now coexist) | -| `MemoEditor/services/` (6 files) | Service layer: cache, error, memo, upload, validation, index | -| `MemoEditor/state/` (5 files) | State management: actions, context, index, reducer, types | - -### 3.3 Components present in `golang` but NOT in `master` - -All `golang`-only components have been added to `master`: - -| Component | Status | -| --- | --- | -| `Settings/AISection.tsx` | ✅ Added — AI provider configuration panel (maps to `instance/settings/AI`) | -| `Settings/LinkedIdentitySection.tsx` | ✅ Added — lists and manages linked SSO identities per user | -| `Settings/InfoChip.tsx` | ✅ Added — reusable badge/chip used by `LinkedIdentitySection` and `SSOSection` | -| `router/guards.tsx` | ✅ Added — `LandingRoute`, `RequireAuthRoute`, `RequireGuestRoute` guard components | -| `helpers/sso-display.ts` | ✅ Added — SSO provider display utilities | - -### 3.4 Components that differ between branches - -All previously diverged components have been aligned with `golang`: - -| Component | Status | -| --- | --- | -| `Settings/SSOSection.tsx` | ✅ Aligned — imports `InfoChip`, `sso-display` utilities, uses `IdentityProviderRow` with error handling | -| `Settings/MyAccountSection.tsx` | ✅ Aligned — adds delete-account functionality and renders `LinkedIdentitySection` | -| `router/index.tsx` | ✅ Aligned — uses `RequireAuthRoute`/`RequireGuestRoute` guards; exports `routeConfig` | -| `web/src/App.tsx` | ✅ Aligned — adds `cleanupExpiredOAuthState()` on mount | - -### 3.5 Realtime refresh (SSE) - -The `/api/v1/sse` endpoint is mounted in `master` **Node.js only** (via `enableSSE: true`). CF Worker does not expose SSE. `golang` exposes it unconditionally. +| `MemoEditor/hooks/useVoiceRecorder.ts` | Voice recorder hook | +| `MemoEditor/services/` (6 files) | Service layer | +| `MemoEditor/state/` (5 files) | State management | --- -## 4) Runtime / deployment differences +## 4) Runtime / Deployment -| Area | `master` | `golang` | -| --- | --- | --- | -| Frontend static hosting | Worker `ASSETS` binding / Node local static dir (`dist/public/`) | Built-in Echo fileserver | -| Primary DB | Node → SQLite; Worker → D1 (Cloudflare) | Single runtime (SQLite / PostgreSQL / MySQL via driver) | +| | `master` | `golang` | +|---|---|---| +| Frontend hosting | Worker `ASSETS` / Node `dist/public/` | Echo fileserver | +| Primary DB | Node → SQLite; Worker → D1 (CF) | SQLite / PostgreSQL / MySQL | | Object storage | `DATABASE / LOCAL / S3 / R2` | `DATABASE / LOCAL / S3` (no R2) | -| Realtime (SSE) | Node.js only; CF Worker excluded | Unconditionally available | -| MCP | ✅ Implemented at `/mcp` (Streamable HTTP, stateless per-request) | `server/router/mcp/*` (stateful sessions) | -| Frontend API transport | Custom REST client in `connect.ts` | Connect gRPC/protocol via `@connectrpc/connect-web` | +| SSE | Node.js only | Unconditional | +| MCP | Stateless at `/mcp` | Stateful sessions | --- -## 5) CI / Quality gates +## 5) golang forward commits (9bf648ac → 40fd700f) + +New changes in `golang` not yet in `master`: -| Item | Notes | -| --- | --- | -| GitHub Actions CI | `.github/workflows/ci.yml` runs type-check, tests, and uploads coverage to Codecov on every push/PR targeting `master` | -| Branch protection | Merging to `master` requires CI to pass | +- `fix(fileserver): render SVG attachment previews` +- `fix: remove duplicate Japanese locale keys` +- `i18n: refine and normalize Japanese locale strings` +- `chore(web): improve navigation accessibility` +- `fix(frontend): restore sitemap and robots routes` \ No newline at end of file diff --git a/DIFF-VS-GOLANG.zh-CN.md b/DIFF-VS-GOLANG.zh-CN.md index 5be7aba4..760e3288 100644 --- a/DIFF-VS-GOLANG.zh-CN.md +++ b/DIFF-VS-GOLANG.zh-CN.md @@ -1,29 +1,19 @@ -# 当前分支(`master`)vs `golang` 分支:剩余差异 +# 当前分支(`master`)vs `golang` 分支:差异 -> 更新时间:2026-04-29。对照基线:`master` vs `golang@9bf648ac`(v0.28.0)。 -> REST API 契约参考:[https://usememos.com/docs/api/0-28-0](https://usememos.com/docs/api/0-28-0) 及 `golang:proto/gen/openapi.yaml`。 +> 更新时间:2026-05-06。对照基线:`master` vs `golang@9bf648ac`(v0.28.0)。 > -> **设计排除项**(本 fork 不计划对齐): -> - Instance `STORAGE` 设置后端 API + 动态 `supportedStorageTypes` 前端渲染 +> **设计排除项**: +> - Instance `STORAGE` 后端 API + 动态 `supportedStorageTypes` 前端渲染 > - Cloudflare Worker 上的 SSE 接口(CF 流式传输与长连接 SSE 不兼容) --- -## 1) 数据库表结构差异 +## 1) 数据库表结构 -### 1.1 表级对比(`migrations/0001_initial.sql` vs `store/migration/sqlite/LATEST.sql`) +### `user_identity` 表 -原有 9 张业务表**结构完全相同**(列名、类型、约束、默认值均一致): +`golang@9bf648ac` 在 `store/migration/sqlite/LATEST.sql` 中有 `user_identity`。`master` 通过 `migrations/0002_user_identity.sql` 提供等价实现: -`system_setting`、`user`、`user_setting`、`memo`、`memo_relation`、`attachment`、`idp`、`inbox`、`reaction`、`memo_share` - -### 1.2 仅 `golang` 存在的表(`master` 尚未添加) - -| 表 | 用途 | -| --- | --- | -| `user_identity` | 存储每个用户的已关联 SSO / OAuth2 身份;支持新增的 `linkedIdentities` REST 接口 | - -`golang` 中 `user_identity` 的表结构: ```sql CREATE TABLE user_identity ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -38,154 +28,97 @@ CREATE TABLE user_identity ( CREATE INDEX idx_user_identity_user_id ON user_identity(user_id); ``` -### 1.3 仅 `master` 存在的表 +### 迁移机制 -| 表 | 用途 | -| --- | --- | -| `schema_migrations(version INTEGER PK)` | Node 递增迁移版本记录,`golang` 无此表 | +| | `master` | `golang` | +|---|---|---| +| 演进方式 | 递增 `migrations/NNNN_*.sql` 文件 | `store/migration/sqlite/*` 版本目录 + `LATEST.sql` | +| 版本追踪 | `schema_migrations` 表 | 无 | -### 1.4 DDL 层差异(非结构语义) +### DDL 保护 -| 差异 | `master` | `golang` | -| --- | --- | --- | -| `CREATE TABLE` 保护 | `CREATE TABLE IF NOT EXISTS` | `CREATE TABLE` | -| 索引保护 | `CREATE INDEX IF NOT EXISTS` | `CREATE INDEX` | +`master` 使用 `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`。`golang` 使用原始 `CREATE TABLE` / `CREATE INDEX`。 -### 1.5 迁移机制差异 +--- -| 项目 | `master` | `golang` | -| --- | --- | --- | -| 演进方式 | 递增 `migrations/NNNN_*.sql` 文件 | `store/migration/sqlite/*` 版本目录 + `LATEST.sql` | -| 版本追踪 | 显式写入 `schema_migrations` 记录 | 无等价表 | +## 2) 后端 API(`server/routes/v1`) ---- +### API 传输策略 -## 2) 后端 API 差异(`server/routes/v1` 对照 `golang`) +`master` 使用**自定义 REST** — `web/src/connect.ts`(约 1110 行)将 gRPC 风格服务调用转换为普通 JSON REST。 -> 参考:`golang:proto/gen/openapi.yaml`(由 proto 定义自动生成)。 +`golang` 使用 **Connect gRPC** via `@connectrpc/connect-web`(约 203 行);原生 binary+JSON Connect 协议。 -### 2.1 `master` 中缺少的接口 +### `master` 中缺少的接口 | 方法 | 路径 | 说明 | -| --- | --- | --- | -| `GET` | `/api/v1/sse` | SSE 接口在 `master` 中**仅 Node.js** 可用(`enableSSE: true`);CF Worker 不挂载(流式传输不兼容)。`golang` 无条件提供。 | - -> **注:** `GET /api/v1/users/{user}:getStats` **已在 `master` 中实现**(通过通配路由 `GET /users/:username` 按后缀匹配分发),只是未作为独立命名路由注册。 - -### 2.2 路径差异(`master` 与 golang 契约不一致) - -| 资源 | `master` 路径 | `golang` / OpenAPI 路径 | 影响 | -| --- | --- | --- | --- | -| 实例设置(读) | `GET /api/v1/instance/settings/{KEY}` | `GET /api/v1/{name=instance/settings/*}` | 两者实际解析路径相同,`master` 使用更简洁的参数提取方式 | -| 实例设置(写) | `PATCH /api/v1/instance/settings/{KEY}` | `PATCH /api/v1/{setting.name=instance/settings/*}` | 同上 | - -### 2.3 已有接口的语义/字段差异 - -| 模块 | `master` 行为 | `golang` 行为 | -| --- | --- | --- | -| **Instance `STORAGE` 设置** | 额外返回 `supportedStorageTypes`(动态,含 `R2`) | 固定枚举 `DATABASE/LOCAL/S3`,无动态字段 — **设计排除** | -| **Instance `AI` 设置** | `instance/settings/AI` 键已持久化;完整 `AIService` + AI 提供商配置;`POST /api/v1/ai:transcribe` 已实现(已对齐) | 完整 `AIService` + AI 实例设置(`InstanceSetting_Key.AI`) | -| **Memo `filter` / CEL** | `server/lib/memo-filter.ts` 子集实现(覆盖 Web 客户端常用模式:creator、visibility、tag、pinned、时间范围、content.contains) | 完整 CEL 编译语义 | -| **API 传输层** | `web/src/connect.ts` 实现**自定义 REST 客户端**(约 1110 行),将 gRPC 风格的服务调用翻译为普通 JSON REST 请求 | `web/src/connect.ts` 通过 `@connectrpc/connect-web` 使用 **Connect gRPC/协议传输**(约 203 行);原生 binary+JSON Connect 协议 | - -### 2.4 已对齐项 - -| 模块 | 状态 | -| --- | --- | -| 认证接口(`/signin`、`/signout`、`/refresh`、`/me`) | ✅ 完全对齐 | -| 用户 CRUD、PAT、Webhook、通知、快捷方式接口 | ✅ 完全对齐 | -| Memo CRUD、评论、反应、关联、分享接口 | ✅ 完全对齐 | -| `DELETE /memos/{memo}` 软删除 / `?force=true` | ✅ 已对齐 — 默认归档;`?force=true` 硬删除 | -| Attachment CRUD、`batchDelete`、`motionMedia` 字段 | ✅ 完全对齐 | -| `POST /api/v1/users:batchGet` | ✅ 已实现 | -| 身份提供商 CRUD | ✅ 完全对齐 | -| GENERAL 设置持久化(`additionalScript`、`additionalStyle`、`customProfile`、`weekStartDayOffset`) | ✅ 已修复 | -| **Memo `name` 字段** | ✅ 已使用 `"memos/{uid}"`(UUID v4,来自 `memo.uid` 列)— 与 golang 对齐 | -| **`PATCH /memos/{memo}` updateMask** | ✅ 请求体现在必须包含 `updateMask`;服务端仅应用指定路径的字段 | -| **`GET /memos` `showDeleted` 参数** | ✅ `showDeleted=true`(或 `show_deleted=true`)将 state 设为 ARCHIVED — 与 golang 对齐 | -| **`PATCH /users/{user}/settings/{setting}` updateMask** | ✅ 已强制校验 — 空 updateMask 会返回错误 | -| **MCP 接口** | ✅ 已在 `server/routes/mcp.ts` 实现,挂载于 `POST/GET/DELETE /mcp`(Streamable HTTP 传输) | -| **`user_identity` 表** | ✅ 已通过 `migrations/0002_user_identity.sql` 添加 | -| **`POST /api/v1/ai:transcribe`** | ✅ 已在 `server/routes/v1/ai.ts` 实现;读取 `instance/settings/AI` 中的 AI 提供商配置 | -| **`GET/DELETE /api/v1/users/{user}/linkedIdentities[/{id}]`** | ✅ 已在 `server/routes/v1/users.ts` 实现 | -| **Instance `AI` 设置持久化** | ✅ `instance/settings/AI` 键已持久化并通过 `server/lib/instance-ai-setting.ts` 提供服务 | +|---|---|---| +| `GET` | `/api/v1/sse` | 仅 Node.js(`enableSSE: true`);CF Worker 不支持 | ---- +### 路径差异 + +| 资源 | `master` 路径 | `golang` 路径 | +|---|---|---| +| 实例设置(读) | `GET /api/v1/instance/settings/{KEY}` | `GET /api/v1/{name=instance/settings/*}` | +| 实例设置(写) | `PATCH /api/v1/instance/settings/{KEY}` | `PATCH /api/v1/{setting.name=instance/settings/*}` | -## 3) 前端差异(`web/`) +### 语义差异 -### 3.1 页面(`web/src/pages/`) +| 模块 | `master` | `golang` | +|---|---|---| +| **Instance `STORAGE`** | 动态 `supportedStorageTypes` 包含 `R2` | 固定枚举 `DATABASE/LOCAL/S3` — **设计排除** | +| **Memo `filter` / CEL** | `server/lib/memo-filter.ts` 子集实现 | 完整 CEL 编译语义 | + +--- -两个分支均有全部 14 个页面。以下页面在两分支之间有显著差异: +## 3) 前端(`web/`) -| 页面 | `master` vs `golang` 差异性质 | -| --- | --- | -| `SignIn.tsx` | `master` 新增 SSO 登录表单及额外 UI(约 87 行);`golang` 基线更简洁 | -| `MemoDetail.tsx` | `master` 有侧边栏/布局调整(约 69 行) | -| `Setting.tsx` | `golang` 新增 **AI** 设置区块和 `LinkedIdentitySection`;`master` 缺少这些 | -| `AuthCallback.tsx` | 小幅差异(约 13 行) | -| `Inboxes.tsx` | `master` 小幅新增(约 4 行) | +### 有差异的页面(`web/src/pages/`) -### 3.2 仅 `master` 存在的组件(master 独有扩展) +| 页面 | 差异 | +|---|---| +| `SignIn.tsx` | `master` 委托给 `SsoSignInForm` 组件;golang 原有内联 SSO 逻辑(约 84 行) | +| `MemoDetail.tsx` | `master` 移除 `MentionResolutionProvider`、`shareImageDialogOpen` 状态、`onShareImageOpen` 属性(约 69 行) | +| `Setting.tsx` | `master` 新增 `ai` 区块及 `AISection`;golang 原有更简单的设置结构 | +| `AuthCallback.tsx` | `ssoCredentials` 对象 vs golang 的 `credentials.case/value` 结构(约 13 行) | +| `Inboxes.tsx` | `master` 移除 `MemoMentionMessage`(约 4 行) | +| `SignUp.tsx` | `passwordCredentials` 对象 vs golang 的 `credentials.case/value`(约 5 行) | + +### `master` 独有组件 | 组件 | 说明 | -| --- | --- | -| `MemoAttachment.tsx` | 单条附件展示(音频内联播放;其他文件显示图标+文件名) | -| `MemoResource.tsx` | 组合 `MemoAttachment`,将 Memo 附件列表平铺渲染 | -| `SsoSignInForm.tsx` | SSO 登录表单组件 | +|---|---| +| `MemoAttachment.tsx` | 单条附件展示 | +| `MemoResource.tsx` | 平铺渲染 Memo 附件列表 | +| `SsoSignInForm.tsx` | SSO 登录表单 | | `MemoActionMenu/MemoShareImageDialog.tsx` | 分享为图片弹窗 | -| `MemoActionMenu/MemoShareImagePreview.tsx` | 分享图片预览 | +| `MemoActionMenu/MemoShareImagePreview.tsx` | 图片预览 | | `MemoActionMenu/memoShareImage.ts` | 分享图片生成逻辑 | | `MemoContent/constants.ts` | 内容渲染常量 | -| `MemoEditor/hooks/useVoiceRecorder.ts` | 语音录制 Hook(与 golang 的 `useAudioRecorder` + `useAudioWaveform` 并存) | -| `MemoEditor/services/`(6 个文件) | 服务层:cache、error、memo、upload、validation、index | -| `MemoEditor/state/`(5 个文件) | 状态管理:actions、context、index、reducer、types | - -### 3.3 仅 `golang` 存在的组件(`master` 缺少) - -所有 `golang` 独有组件均已添加至 `master`: - -| 组件 | 状态 | -| --- | --- | -| `Settings/AISection.tsx` | ✅ 已添加 — AI 提供商配置面板(对应 `instance/settings/AI`) | -| `Settings/LinkedIdentitySection.tsx` | ✅ 已添加 — 列出并管理用户已关联的 SSO 身份 | -| `Settings/InfoChip.tsx` | ✅ 已添加 — 可复用 badge/chip 组件 | -| `router/guards.tsx` | ✅ 已添加 — `LandingRoute`、`RequireAuthRoute`、`RequireGuestRoute` 路由守卫组件 | -| `helpers/sso-display.ts` | ✅ 已添加 — SSO 提供商展示工具函数 | - -### 3.4 两分支之间有差异的组件 - -所有此前存在差异的组件均已与 `golang` 对齐: - -| 组件 | 状态 | -| --- | --- | -| `Settings/SSOSection.tsx` | ✅ 已对齐 — 引入 `InfoChip`、`sso-display` 工具函数,使用 `IdentityProviderRow` 并增加错误处理 | -| `Settings/MyAccountSection.tsx` | ✅ 已对齐 — 新增删除账号功能并渲染 `LinkedIdentitySection` | -| `router/index.tsx` | ✅ 已对齐 — 使用 `RequireAuthRoute`/`RequireGuestRoute` 守卫;导出 `routeConfig` | -| `web/src/App.tsx` | ✅ 已对齐 — 在挂载时调用 `cleanupExpiredOAuthState()` | - -### 3.5 实时刷新(SSE) - -`/api/v1/sse` 接口在 `master` 中**仅 Node.js** 可用(`enableSSE: true`);CF Worker 不挂载。`golang` 无条件提供。 +| `MemoEditor/hooks/useVoiceRecorder.ts` | 语音录制 Hook | +| `MemoEditor/services/`(6 个文件) | 服务层 | +| `MemoEditor/state/`(5 个文件) | 状态管理 | --- -## 4) 运行时 / 部署差异 +## 4) 运行时 / 部署 -| 领域 | `master` | `golang` | -| --- | --- | --- | -| 前端静态资源托管 | Worker `ASSETS` 绑定 / Node 本地静态目录(`dist/public/`) | echo 内置文件服务器 | -| 主数据库 | Node → SQLite;Worker → D1(Cloudflare) | 单一运行时(SQLite / PostgreSQL / MySQL) | +| | `master` | `golang` | +|---|---|---| +| 前端静态托管 | Worker `ASSETS` / Node `dist/public/` | Echo 文件服务器 | +| 主数据库 | Node → SQLite;Worker → D1(CF) | SQLite / PostgreSQL / MySQL | | 对象存储 | `DATABASE / LOCAL / S3 / R2` | `DATABASE / LOCAL / S3`(无 R2) | -| 实时推送(SSE) | 仅 Node.js;CF Worker 排除 | 无条件可用 | -| MCP 接口 | ✅ 已实现,挂载于 `/mcp`(无状态逐请求模式) | `server/router/mcp/*`(有状态会话模式) | -| 前端 API 传输层 | `connect.ts` 自定义 REST 客户端 | 通过 `@connectrpc/connect-web` 的 Connect gRPC/协议传输 | +| SSE | 仅 Node.js | 无条件可用 | +| MCP | `/mcp` 无状态模式 | 有状态会话模式 | --- -## 5) CI / 质量门禁 +## 5) golang 基线后新提交(9bf648ac → 40fd700f) + +`golang` 中尚未进入 `master` 的新变更: -| 项目 | 说明 | -| --- | --- | -| GitHub Actions CI | `.github/workflows/ci.yml` 在每次推送及 PR 到 `master` 时执行类型检查、测试,并将覆盖率上报至 Codecov | -| 分支保护 | 合并到 `master` 须 CI 通过 | +- `fix(fileserver): render SVG attachment previews` +- `fix: remove duplicate Japanese locale keys` +- `i18n: refine and normalize Japanese locale strings` +- `chore(web): improve navigation accessibility` +- `fix(frontend): restore sitemap and robots routes` \ No newline at end of file diff --git a/server/app.ts b/server/app.ts index 3716651e..c333b502 100644 --- a/server/app.ts +++ b/server/app.ts @@ -11,7 +11,6 @@ import { verifyRefreshToken } from "./services/jwt-refresh.js"; import { parseInstanceStorageSetting } from "./lib/instance-storage-setting.js"; import { resolveAttachmentStorage } from "./services/attachment-storage-resolver.js"; import { parseCookieHeader, REFRESH_COOKIE_NAME } from "./lib/cookies.js"; -import { createMcpHandler } from "./routes/mcp.js"; /** Shared HTTP app. Mounts `GET /healthz` and `/api/v1` for Node and Worker. */ export function createApp(deps: AppDeps) { @@ -151,11 +150,5 @@ export function createApp(deps: AppDeps) { }); app.route("/api/v1", createV1App(deps)); - // MCP (Model Context Protocol) endpoint — works on Node.js and Cloudflare Worker. - const mcpHandler = createMcpHandler(deps); - app.all("/mcp", async (c) => { - return mcpHandler(c.req.raw); - }); - return app; } diff --git a/server/db/repository.ts b/server/db/repository.ts index eb0347e7..b3b47ff9 100644 --- a/server/db/repository.ts +++ b/server/db/repository.ts @@ -1715,6 +1715,14 @@ export function createRepository(sql: SqlAdapter) { return rows[0] ?? null; }, + async getUserIdentityByProviderExternUid(provider: string, externUid: string): Promise { + const rows = await sql.queryAll( + "SELECT id, user_id, provider, extern_uid, created_ts, updated_ts FROM user_identity WHERE provider = ? AND extern_uid = ?", + [provider, externUid], + ); + return rows[0] ?? null; + }, + async upsertUserIdentity(userId: number, provider: string, externUid: string): Promise { await sql.execute( `INSERT INTO user_identity (user_id, provider, extern_uid) diff --git a/server/routes/mcp.ts b/server/routes/mcp.ts deleted file mode 100644 index 90a1459c..00000000 --- a/server/routes/mcp.ts +++ /dev/null @@ -1,415 +0,0 @@ -/** - * MCP (Model Context Protocol) server for Memos. - * - * Exposes memo operations as MCP tools so any MCP-compatible AI client - * (Claude Desktop, Cursor, Zed, etc.) can interact with the Memos instance. - * - * Endpoint: ANY /mcp (Streamable HTTP transport, MCP spec 2025-03-26) - * - * Authentication: Bearer or Bearer - * Public reads (list/get public memos, list tags) work without auth. - */ - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { z } from "zod"; -import type { AppDeps } from "../types/deps.js"; -import { createRepository } from "../db/repository.js"; -import { verifyAccessToken } from "../services/jwt-access.js"; -import { memoToJson, attachmentToJson } from "../lib/serializers.js"; -import { normalizeMemoStateFromClient, normalizeMemoVisibilityFromClient } from "../lib/memo-enums.js"; -import { parseMemoListFilter, memoRowMatchesFilter, memoListFilterNeedsMemory, MEMO_FILTER_MAX_SCAN } from "../lib/memo-filter.js"; -import type { DbMemoRow } from "../db/repository.js"; - -/** Resolve auth from an HTTP request (Bearer JWT or PAT). */ -async function resolveAuth( - req: Request, - repo: ReturnType, - jwtSecret: string | null, -): Promise<{ username: string; role: "ADMIN" | "USER" } | null> { - const header = req.headers.get("authorization"); - const bearer = header?.match(/^\s*Bearer\s+(.+)$/i)?.[1]?.trim(); - if (!bearer) return null; - if (jwtSecret) { - const access = await verifyAccessToken(bearer, jwtSecret); - if (access) { - let username: string | null = null; - if (access.userId != null) { - const u = await repo.getUserByInternalId(access.userId); - username = u?.username ?? null; - } - if (!username && access.username) username = access.username; - if (username) return { username, role: access.role }; - } - } - if (bearer.startsWith("memos_pat_")) { - const user = await repo.findUserByPat(bearer); - if (user) return { username: user.username, role: user.role === "ADMIN" ? "ADMIN" : "USER" }; - } - return null; -} - -/** Returns true if the Origin header is acceptable (same host or no origin). */ -function isAllowedOrigin(req: Request, instanceUrl: string): boolean { - const origin = req.headers.get("Origin"); - if (!origin) return true; // CLI / desktop clients don't send Origin - try { - const originHost = new URL(origin).host; - const reqHost = req.headers.get("host") ?? ""; - if (originHost === reqHost) return true; - if (instanceUrl) { - const instanceHost = new URL(instanceUrl).host; - if (originHost === instanceHost) return true; - } - } catch { - // malformed origin → deny - } - return false; -} - -function memoIdFromName(name: string): string | null { - const p = name.startsWith("memos/") ? name.slice("memos/".length) : name; - return p.length > 0 ? p : null; -} - -function canViewMemo(m: DbMemoRow, auth: { username: string } | null): boolean { - if (m.visibility === "PUBLIC") return true; - if (!auth) return false; - if (m.visibility === "PROTECTED") return true; - return m.creator_username === auth.username; -} - -/** Build the shared McpServer with all tools registered. */ -function buildMcpServer( - repo: ReturnType, - authRef: { value: { username: string; role: "ADMIN" | "USER" } | null }, -): McpServer { - const server = new McpServer({ name: "Memos", version: "1.0.0" }); - - // ── Memo tools ───────────────────────────────────────────────────────── - - server.tool( - "list_memos", - "List memos. Authenticated users see their own and visible memos; unauthenticated callers see public memos only.", - { - page_size: z.number().int().min(1).max(200).optional().describe("Max results to return (default 50)"), - page: z.number().int().min(1).optional().describe("1-based page number (default 1)"), - state: z.enum(["NORMAL", "ARCHIVED"]).optional().describe("Memo state filter (default NORMAL)"), - order_by_pinned: z.boolean().optional().describe("When true, pinned memos appear first"), - filter: z.string().optional().describe("CEL-style filter (e.g. 'creator == \"users/alice\"')"), - }, - async ({ page_size = 50, page = 1, state = "NORMAL", filter = "" }) => { - const auth = authRef.value; - const pageSize = Math.min(200, Math.max(1, page_size)); - const offset = (Math.max(1, page) - 1) * pageSize; - const parsed = parseMemoListFilter(filter); - const needsMemory = memoListFilterNeedsMemory(parsed); - - let rows: DbMemoRow[]; - const baseArgs = { - state, - ...(auth ? { viewerUsername: auth.username } : { visibility: "PUBLIC" }), - }; - - if (needsMemory) { - const all = await repo.listMemosTopLevel({ - ...baseArgs, - limit: MEMO_FILTER_MAX_SCAN, - offset: 0, - }); - const filtered = all.filter((m) => memoRowMatchesFilter(m, parsed)); - rows = filtered.slice(offset, offset + pageSize); - } else { - rows = await repo.listMemosTopLevel({ - ...baseArgs, - limit: pageSize, - offset, - }); - } - - const memos = await Promise.all( - rows.map(async (m) => { - const atts = await repo.listAttachments({ memoUid: m.id, limit: 100, offset: 0 }); - return memoToJson(m, { attachments: atts.map(attachmentToJson) }); - }), - ); - return { content: [{ type: "text" as const, text: JSON.stringify(memos, null, 2) }] }; - }, - ); - - server.tool( - "get_memo", - "Get a single memo by resource name.", - { name: z.string().describe("Memo resource name, e.g. memos/abc123") }, - async ({ name }) => { - const id = memoIdFromName(name); - if (!id) return { content: [{ type: "text" as const, text: "invalid memo name" }], isError: true }; - const auth = authRef.value; - const row = await repo.getMemoById(id); - if (!row || row.parent_memo_id) { - return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - } - if (!canViewMemo(row, auth)) { - return { content: [{ type: "text" as const, text: "permission denied" }], isError: true }; - } - const atts = await repo.listAttachments({ memoUid: row.id, limit: 100, offset: 0 }); - return { - content: [{ type: "text" as const, text: JSON.stringify(memoToJson(row, { attachments: atts.map(attachmentToJson) }), null, 2) }], - }; - }, - ); - - server.tool( - "search_memos", - "Full-text search of memo content.", - { - query: z.string().min(1).describe("Search query string"), - page_size: z.number().int().min(1).max(200).optional(), - }, - async ({ query, page_size = 50 }) => { - const auth = authRef.value; - const all = await repo.listMemosTopLevel({ - state: "NORMAL", - ...(auth ? { viewerUsername: auth.username } : { visibility: "PUBLIC" }), - limit: MEMO_FILTER_MAX_SCAN, - offset: 0, - }); - const matched = all - .filter((m) => m.content.toLowerCase().includes(query.toLowerCase())) - .slice(0, page_size); - const memos = await Promise.all( - matched.map(async (m) => { - const atts = await repo.listAttachments({ memoUid: m.id, limit: 100, offset: 0 }); - return memoToJson(m, { attachments: atts.map(attachmentToJson) }); - }), - ); - return { content: [{ type: "text" as const, text: JSON.stringify(memos, null, 2) }] }; - }, - ); - - server.tool( - "create_memo", - "Create a new memo. Requires authentication.", - { - content: z.string().min(1).describe("Memo content (Markdown)"), - visibility: z.enum(["PUBLIC", "PROTECTED", "PRIVATE"]).optional().describe("Visibility (default PRIVATE)"), - }, - async ({ content, visibility = "PRIVATE" }) => { - const auth = authRef.value; - if (!auth) return { content: [{ type: "text" as const, text: "authentication required" }], isError: true }; - const id = crypto.randomUUID(); - const row = await repo.createMemo({ - id, - creator: auth.username, - content, - visibility: normalizeMemoVisibilityFromClient(visibility), - state: "NORMAL", - pinned: false, - }); - return { content: [{ type: "text" as const, text: JSON.stringify(memoToJson(row), null, 2) }] }; - }, - ); - - server.tool( - "update_memo", - "Update a memo you own (or any memo if admin). Requires authentication.", - { - name: z.string().describe("Memo resource name, e.g. memos/abc123"), - content: z.string().optional().describe("New content"), - visibility: z.enum(["PUBLIC", "PROTECTED", "PRIVATE"]).optional(), - pinned: z.boolean().optional(), - state: z.enum(["NORMAL", "ARCHIVED"]).optional(), - }, - async ({ name, content, visibility, pinned, state }) => { - const auth = authRef.value; - if (!auth) return { content: [{ type: "text" as const, text: "authentication required" }], isError: true }; - const id = memoIdFromName(name); - if (!id) return { content: [{ type: "text" as const, text: "invalid memo name" }], isError: true }; - const row = await repo.getMemoById(id); - if (!row) return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - if (row.creator_username !== auth.username && auth.role !== "ADMIN") { - return { content: [{ type: "text" as const, text: "permission denied" }], isError: true }; - } - await repo.updateMemo(id, { - content, - visibility: visibility !== undefined ? normalizeMemoVisibilityFromClient(visibility) : undefined, - pinned, - state: state !== undefined ? normalizeMemoStateFromClient(state) : undefined, - }); - const updated = await repo.getMemoById(id); - if (!updated) return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - return { content: [{ type: "text" as const, text: JSON.stringify(memoToJson(updated), null, 2) }] }; - }, - ); - - server.tool( - "delete_memo", - "Delete (archive) a memo you own. Pass force=true to hard-delete. Requires authentication.", - { - name: z.string().describe("Memo resource name"), - force: z.boolean().optional().describe("When true, hard-delete instead of archiving"), - }, - async ({ name, force = false }) => { - const auth = authRef.value; - if (!auth) return { content: [{ type: "text" as const, text: "authentication required" }], isError: true }; - const id = memoIdFromName(name); - if (!id) return { content: [{ type: "text" as const, text: "invalid memo name" }], isError: true }; - const row = await repo.getMemoById(id); - if (!row) return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - if (row.creator_username !== auth.username && auth.role !== "ADMIN") { - return { content: [{ type: "text" as const, text: "permission denied" }], isError: true }; - } - if (force) { - await repo.hardDeleteMemo(id); - } else { - await repo.archiveMemo(id); - } - return { content: [{ type: "text" as const, text: "{}" }] }; - }, - ); - - server.tool( - "list_memo_comments", - "List comments on a memo.", - { name: z.string().describe("Memo resource name") }, - async ({ name }) => { - const auth = authRef.value; - const id = memoIdFromName(name); - if (!id) return { content: [{ type: "text" as const, text: "invalid memo name" }], isError: true }; - const parent = await repo.getMemoById(id); - if (!parent) return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - if (!canViewMemo(parent, auth)) { - return { content: [{ type: "text" as const, text: "permission denied" }], isError: true }; - } - const comments = await repo.listCommentsForMemo(id); - return { - content: [{ type: "text" as const, text: JSON.stringify(comments.map((m) => memoToJson(m)), null, 2) }], - }; - }, - ); - - server.tool( - "create_memo_comment", - "Add a comment to a memo. Requires authentication.", - { - name: z.string().describe("Parent memo resource name"), - content: z.string().min(1).describe("Comment content"), - }, - async ({ name, content }) => { - const auth = authRef.value; - if (!auth) return { content: [{ type: "text" as const, text: "authentication required" }], isError: true }; - const parentId = memoIdFromName(name); - if (!parentId) return { content: [{ type: "text" as const, text: "invalid memo name" }], isError: true }; - const parent = await repo.getMemoById(parentId); - if (!parent) return { content: [{ type: "text" as const, text: "memo not found" }], isError: true }; - if (!canViewMemo(parent, auth)) { - return { content: [{ type: "text" as const, text: "permission denied" }], isError: true }; - } - const commentId = crypto.randomUUID(); - const row = await repo.createMemo({ - id: commentId, - creator: auth.username, - content, - visibility: "PRIVATE", - state: "NORMAL", - pinned: false, - parentId, - }); - return { content: [{ type: "text" as const, text: JSON.stringify(memoToJson(row), null, 2) }] }; - }, - ); - - // ── Tag tools ─────────────────────────────────────────────────────────── - - server.tool( - "list_tags", - "List all tags with their memo counts. Results are sorted by count descending, then alphabetically.", - {}, - async () => { - const auth = authRef.value; - const memos = await repo.listMemosTopLevel({ - state: "NORMAL", - ...(auth ? { viewerUsername: auth.username } : { visibility: "PUBLIC" }), - limit: MEMO_FILTER_MAX_SCAN, - offset: 0, - }); - const counts: Record = {}; - for (const m of memos) { - for (const tag of m.payload_tags) { - counts[tag] = (counts[tag] ?? 0) + 1; - } - } - const tags = Object.entries(counts) - .map(([tag, count]) => ({ tag, count })) - .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); - return { content: [{ type: "text" as const, text: JSON.stringify(tags, null, 2) }] }; - }, - ); - - return server; -} - -/** - * Create a Hono-compatible request handler for the MCP endpoint. - * Returns a function `(req: Request) => Promise`. - */ -export function createMcpHandler(deps: AppDeps): (req: Request) => Promise { - const repo = createRepository(deps.sql); - - return async (req: Request): Promise => { - // CORS / origin validation (DNS rebinding protection) - if (!isAllowedOrigin(req, deps.instanceUrl)) { - return new Response(JSON.stringify({ error: "invalid origin" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }); - } - - // CORS preflight - const origin = req.headers.get("Origin"); - if (req.method === "OPTIONS") { - const headers = new Headers(); - if (origin) { - headers.set("Vary", "Origin"); - headers.set("Access-Control-Allow-Origin", origin); - headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID"); - headers.set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); - } - return new Response(null, { status: 204, headers }); - } - - // Resolve auth - const jwtSecret = deps.demo ? "usememos" : (await repo.getSecretKey()); - const auth = await resolveAuth(req, repo, jwtSecret); - - if (auth && !auth.username) { - return new Response(JSON.stringify({ error: "invalid or expired token" }), { - status: 401, - headers: { "Content-Type": "application/json" }, - }); - } - - // Build a per-request MCP server + transport (stateless mode) - const authRef: { value: { username: string; role: "ADMIN" | "USER" } | null } = { value: auth }; - const mcpServer = buildMcpServer(repo, authRef); - const transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: undefined, // stateless - enableJsonResponse: true, - }); - - await mcpServer.connect(transport); - const response = await transport.handleRequest(req); - - // Attach CORS headers to response - if (origin) { - const headers = new Headers(response.headers); - headers.set("Vary", "Origin"); - headers.set("Access-Control-Allow-Origin", origin); - return new Response(response.body, { - status: response.status, - headers, - }); - } - return response; - }; -} diff --git a/server/routes/v1/ai.ts b/server/routes/v1/ai.ts deleted file mode 100644 index 980f4c08..00000000 --- a/server/routes/v1/ai.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { Hono } from "hono"; -import type { ApiVariables } from "../../types/api-variables.js"; -import type { AppDeps } from "../../types/deps.js"; -import { createRepository } from "../../db/repository.js"; -import { GrpcCode, jsonError } from "../../lib/grpc-status.js"; -import { parseAISettingFromRaw } from "../../lib/instance-ai-setting.js"; - -const MAX_AUDIO_BYTES = 25 * 1024 * 1024; // 25 MiB - -const SUPPORTED_AUDIO_TYPES = new Set([ - "audio/aac", - "audio/aiff", - "audio/flac", - "audio/mpeg", - "audio/mp3", - "audio/mp4", - "audio/mpga", - "audio/ogg", - "audio/wav", - "audio/x-wav", - "audio/x-flac", - "audio/x-m4a", - "audio/webm", - "video/mp4", - "video/mpeg", - "video/webm", -]); - -function isSupportedAudioType(contentType: string): boolean { - const base = contentType.split(";")[0]?.trim().toLowerCase() ?? ""; - return SUPPORTED_AUDIO_TYPES.has(base); -} - -export function createAIRoutes(deps: AppDeps) { - const r = new Hono<{ Variables: ApiVariables }>(); - const repo = createRepository(deps.sql); - - r.post("/:action", async (c) => { - const action = c.req.param("action"); - if (action !== "transcribe") { - return jsonError(c, GrpcCode.NOT_FOUND, "unknown AI action"); - } - - const auth = c.get("auth"); - if (!auth) return jsonError(c, GrpcCode.UNAUTHENTICATED, "permission denied"); - - type Body = { - providerId?: string; - config?: { prompt?: string; language?: string }; - audio?: { content?: string; filename?: string; contentType?: string }; - }; - let body: Body; - try { - body = (await c.req.json()) as Body; - } catch { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid json"); - } - - const providerId = typeof body.providerId === "string" ? body.providerId.trim() : ""; - if (!providerId) { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "providerId is required"); - } - - const audioContent = typeof body.audio?.content === "string" ? body.audio.content : ""; - if (!audioContent) { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "audio.content is required"); - } - - // Decode base64 audio - let audioBytes: Buffer; - try { - audioBytes = Buffer.from(audioContent, "base64"); - } catch { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "audio.content must be valid base64"); - } - - if (audioBytes.length > MAX_AUDIO_BYTES) { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "audio file is too large; maximum size is 25 MiB"); - } - - let contentType = typeof body.audio?.contentType === "string" ? body.audio.contentType.trim() : ""; - if (!contentType) { - // Detect from magic bytes - if (audioBytes[0] === 0x49 && audioBytes[1] === 0x44 && audioBytes[2] === 0x33) { - contentType = "audio/mpeg"; - } else if (audioBytes[0] === 0x52 && audioBytes[1] === 0x49 && audioBytes[2] === 0x46 && audioBytes[3] === 0x46) { - contentType = "audio/wav"; - } else if (audioBytes[0] === 0x4f && audioBytes[1] === 0x67 && audioBytes[2] === 0x67 && audioBytes[3] === 0x53) { - contentType = "audio/ogg"; - } else { - contentType = "audio/webm"; - } - } - - if (!isSupportedAudioType(contentType)) { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, `audio content type "${contentType}" is not supported`); - } - - const filename = typeof body.audio?.filename === "string" ? body.audio.filename.trim() : "audio.webm"; - const prompt = typeof body.config?.prompt === "string" ? body.config.prompt.trim() : ""; - const language = typeof body.config?.language === "string" ? body.config.language.trim() : ""; - - // Look up the AI provider config - const aiSetting = parseAISettingFromRaw(await repo.getInstanceSettingRaw("AI")); - const provider = aiSetting.providers.find((p) => p.id === providerId); - if (!provider) { - return jsonError(c, GrpcCode.NOT_FOUND, "AI provider not found"); - } - if (!provider.apiKey) { - return jsonError(c, GrpcCode.FAILED_PRECONDITION, "AI provider has no API key configured"); - } - - // Call OpenAI Whisper (or compatible) API - const endpoint = provider.endpoint?.trim() || "https://api.openai.com/v1"; - const transcribeUrl = `${endpoint.replace(/\/$/, "")}/audio/transcriptions`; - - const formData = new FormData(); - const blob = new Blob([audioBytes], { type: contentType }); - formData.append("file", blob, filename); - formData.append("model", "whisper-1"); - if (prompt) formData.append("prompt", prompt); - if (language) formData.append("language", language); - - let fetchRes: Response; - try { - fetchRes = await fetch(transcribeUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${provider.apiKey}`, - }, - body: formData, - }); - } catch (err) { - console.error("[ai:transcribe] fetch error:", err); - return jsonError(c, GrpcCode.UNAVAILABLE, "failed to reach AI provider"); - } - - if (!fetchRes.ok) { - const errText = await fetchRes.text().catch(() => ""); - console.error(`[ai:transcribe] provider error ${fetchRes.status}: ${errText}`); - return jsonError(c, GrpcCode.INTERNAL, `AI provider returned error: ${fetchRes.status}`); - } - - const result = (await fetchRes.json()) as { text?: string }; - return c.json({ text: result.text ?? "" }); - }); - - return r; -} diff --git a/server/routes/v1/attachments.ts b/server/routes/v1/attachments.ts index ebafe3f8..9ddc3f67 100644 --- a/server/routes/v1/attachments.ts +++ b/server/routes/v1/attachments.ts @@ -209,38 +209,6 @@ export function createAttachmentRoutes(deps: AppDeps) { return c.json(attachmentToJson(row)); }); - r.post("/:action", async (c) => { - if (c.req.param("action") !== ":batchDelete") { - return jsonError(c, GrpcCode.UNIMPLEMENTED, "unknown action"); - } - const auth = c.get("auth"); - if (!auth) return jsonError(c, GrpcCode.UNAUTHENTICATED, "permission denied"); - type Body = { names?: string[] }; - let body: Body; - try { - body = (await c.req.json()) as Body; - } catch { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid json"); - } - const names = Array.isArray(body.names) ? body.names : []; - if (names.length > 100) { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "too many attachment names; max 100"); - } - for (const name of names) { - const uid = attachmentIdFromName(name); - if (!uid) continue; - const existing = await repo.getAttachmentByUid(uid); - if (!existing) continue; - if (existing.creator_username !== auth.username && auth.role !== "ADMIN") { - return jsonError(c, GrpcCode.PERMISSION_DENIED, "permission denied"); - } - const storage = await getStorage(); - await storage.delete(existing.reference); - await repo.deleteAttachment(uid); - } - return c.json({}); - }); - r.get("/:attachment", async (c) => { const uid = attachmentIdFromName(c.req.param("attachment")); if (!uid) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid attachment id"); diff --git a/server/routes/v1/index.ts b/server/routes/v1/index.ts index dae3a6f0..dc03e5d0 100644 --- a/server/routes/v1/index.ts +++ b/server/routes/v1/index.ts @@ -14,7 +14,6 @@ import { createMemoRoutes, createShareByTokenRoute } from "./memos.js"; import { createAttachmentRoutes } from "./attachments.js"; import { createIdentityProviderRoutes } from "./idp.js"; import { createSseRoutes } from "./sse.js"; -import { createAIRoutes } from "./ai.js"; import { userStatsFieldsFromMemoRows } from "../../lib/user-stats-from-memos.js"; export function createV1App(deps: AppDeps) { @@ -113,7 +112,6 @@ export function createV1App(deps: AppDeps) { v1.route("/attachments", createAttachmentRoutes(deps)); v1.route("/identity-providers", createIdentityProviderRoutes(deps)); v1.route("/shares", createShareByTokenRoute(deps)); - v1.route("/ai", createAIRoutes(deps)); // Node.js only — CF Worker streaming is not supported for long-lived SSE connections. if (deps.enableSSE) { v1.route("/sse", createSseRoutes()); diff --git a/server/routes/v1/users.ts b/server/routes/v1/users.ts index f972a6c3..7c58a531 100644 --- a/server/routes/v1/users.ts +++ b/server/routes/v1/users.ts @@ -11,11 +11,24 @@ import { import { GrpcCode, jsonError } from "../../lib/grpc-status.js"; import { b64urlToUtf8, utf8ToB64url } from "../../lib/b64url.js"; import { hashPassword } from "../../services/password.js"; +import { + exchangeOAuth2Token, + fetchOAuth2UserInfo, + parseOAuth2Config, +} from "../../services/oauth2-idp.js"; import { authPrincipalFromUserRow, userToJson } from "../../lib/serializers.js"; import { userStatsFieldsFromMemoRows } from "../../lib/user-stats-from-memos.js"; import { validateUserAvatarUrl } from "../../lib/user-avatar-data-uri.js"; import { isValidMemosUsername } from "../../lib/user-username.js"; +function extractIdentityProviderUid(name: string): string | null { + const prefix = "identity-providers/"; + if (!name.startsWith(prefix)) return null; + const uid = name.slice(prefix.length).trim(); + if (!uid || !/^[a-z0-9][a-z0-9-]{0,31}$/i.test(uid)) return null; + return uid; +} + /** Proto `User.Role`: ROLE_UNSPECIFIED=0, ADMIN=2, USER=3 — JSON often uses these numbers. */ const createUserRoleField = z .union([ @@ -245,32 +258,6 @@ export function createUserRoutes(deps: AppDeps) { return c.json(userToJson(created, authPrincipalFromUserRow(created))); }); - r.post("/:action", async (c) => { - if (c.req.param("action") !== ":batchGet") { - return jsonError(c, GrpcCode.UNIMPLEMENTED, "unknown action"); - } - const viewer = c.get("auth"); - type Body = { usernames?: string[] }; - let body: Body; - try { - body = (await c.req.json()) as Body; - } catch { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid json"); - } - const rawUsernames = Array.isArray(body.usernames) ? body.usernames : []; - if (rawUsernames.length > 100) { - return jsonError(c, GrpcCode.INVALID_ARGUMENT, "too many usernames; max 100"); - } - const usernames = rawUsernames.map((u) => - typeof u === "string" && u.startsWith("users/") ? u.slice("users/".length) : u, - ); - const users = await Promise.all(usernames.map((u) => repo.getUser(u))); - const active = users.filter( - (u): u is NonNullable => u !== null && u !== undefined && u.state === "NORMAL", - ); - return c.json({ users: active.map((u) => userToJson(u, viewer)) }); - }); - const forUser = new Hono<{ Variables: ApiVariables }>(); forUser.use(async (c, next) => { @@ -1026,6 +1013,94 @@ export function createUserRoutes(deps: AppDeps) { }); }); + forUser.post("/linkedIdentities", async (c) => { + const auth = c.get("auth"); + if (!auth) return jsonError(c, GrpcCode.UNAUTHENTICATED, "permission denied"); + const username = c.req.param("username")!; + if (username.includes(":")) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid user resource"); + if (auth.username !== username) { + return jsonError(c, GrpcCode.PERMISSION_DENIED, "permission denied"); + } + type Body = { + idpName?: string; + code?: string; + redirectUri?: string; + codeVerifier?: string; + }; + const body = (await c.req.json()) as Body; + const providerUid = extractIdentityProviderUid(body.idpName ?? ""); + if (!providerUid) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "invalid identity provider name"); + if (!body.code) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "code required"); + if (!body.redirectUri) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "redirect uri required"); + + const userId = await repo.getUserInternalId(username); + if (userId === null) return jsonError(c, GrpcCode.NOT_FOUND, "user not found"); + const provider = await repo.getIdentityProviderByUid(providerUid); + if (!provider) return jsonError(c, GrpcCode.INVALID_ARGUMENT, "identity provider not found"); + if (provider.type !== "OAUTH2") return jsonError(c, GrpcCode.INVALID_ARGUMENT, "unsupported identity provider type"); + + let providerConfig: unknown = {}; + try { + providerConfig = JSON.parse(provider.config); + } catch { + return jsonError(c, GrpcCode.INTERNAL, "invalid identity provider config"); + } + const oauth2Config = parseOAuth2Config(providerConfig); + if (!oauth2Config) return jsonError(c, GrpcCode.INTERNAL, "invalid identity provider config"); + + let oauthAccessToken = ""; + try { + oauthAccessToken = await exchangeOAuth2Token({ + config: oauth2Config, + redirectUri: body.redirectUri, + code: body.code, + codeVerifier: body.codeVerifier, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "failed to exchange token"; + return jsonError(c, GrpcCode.INTERNAL, message); + } + + let userInfo: { identifier: string }; + try { + userInfo = await fetchOAuth2UserInfo({ + config: oauth2Config, + accessToken: oauthAccessToken, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "failed to get user info"; + return jsonError(c, GrpcCode.INTERNAL, message); + } + + if (provider.identifier_filter) { + let regex: RegExp; + try { + regex = new RegExp(provider.identifier_filter); + } catch { + return jsonError(c, GrpcCode.INTERNAL, "invalid identity provider identifier filter"); + } + if (!regex.test(userInfo.identifier)) { + return jsonError(c, GrpcCode.PERMISSION_DENIED, "identifier is not allowed"); + } + } + + const existingForUser = await repo.getUserIdentity(userId, providerUid); + if (existingForUser) { + return jsonError(c, GrpcCode.ALREADY_EXISTS, "identity provider already linked"); + } + const existingForIdentity = await repo.getUserIdentityByProviderExternUid(providerUid, userInfo.identifier); + if (existingForIdentity && existingForIdentity.user_id !== userId) { + return jsonError(c, GrpcCode.ALREADY_EXISTS, "identity already linked to another user"); + } + + const row = await repo.upsertUserIdentity(userId, providerUid, userInfo.identifier); + return c.json({ + name: `users/${username}/linkedIdentities/${encodeURIComponent(row.provider)}`, + idpName: `identity-providers/${encodeURIComponent(row.provider)}`, + externUid: row.extern_uid, + }); + }); + forUser.get("/linkedIdentities/:provider", async (c) => { const auth = c.get("auth"); if (!auth) return jsonError(c, GrpcCode.UNAUTHENTICATED, "permission denied"); diff --git a/tests/integration/errors-unimplemented.test.ts b/tests/integration/errors-unimplemented.test.ts index 8529197e..3763c6a1 100644 --- a/tests/integration/errors-unimplemented.test.ts +++ b/tests/integration/errors-unimplemented.test.ts @@ -80,4 +80,36 @@ describe("integration: errors and unimplemented", () => { }); expect(patch.status).toBe(200); }); + + it("does not expose master-only non-contract endpoints", async () => { + const app = createTestApp(); + const { accessToken } = await seedAdmin(app, { username: "contract-admin", password: "secret123" }); + + const ai = await apiRequest(app, "/api/v1/ai/transcribe", { + method: "POST", + bearer: accessToken, + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + expect(ai.status).toBe(404); + + const users = await apiRequest(app, "/api/v1/users/:batchGet", { + method: "POST", + bearer: accessToken, + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + expect(users.status).toBe(404); + + const attachments = await apiRequest(app, "/api/v1/attachments/:batchDelete", { + method: "POST", + bearer: accessToken, + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + expect(attachments.status).toBe(404); + + const mcp = await apiRequest(app, "/mcp", { method: "POST" }); + expect(mcp.status).toBe(404); + }); }); diff --git a/tests/integration/users-extras.test.ts b/tests/integration/users-extras.test.ts index 89d5edeb..b67c80b3 100644 --- a/tests/integration/users-extras.test.ts +++ b/tests/integration/users-extras.test.ts @@ -1,10 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { apiJson } from "../helpers/http.js"; import { createTestApp } from "../helpers/test-app.js"; import { postFirstUser, postMemo, signIn } from "../helpers/seed.js"; import { GrpcCode } from "../../server/lib/grpc-status.js"; describe("integration: users extras (shortcuts, PAT, webhooks, notifications)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("shortcuts CRUD round-trip", async () => { const app = createTestApp(); await postFirstUser(app, { username: "sc", password: "secret123", role: "USER" }); @@ -109,6 +113,79 @@ describe("integration: users extras (shortcuts, PAT, webhooks, notifications)", expect(Array.isArray((res.body as { notifications: unknown[] }).notifications)).toBe(true); }); + it("links an OAuth identity to the current user", async () => { + const app = createTestApp(); + await postFirstUser(app, { username: "idpadmin", password: "secret123", role: "ADMIN" }); + const { accessToken } = await signIn(app, "idpadmin", "secret123"); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + const url = String(input); + if (url === "https://idp.example/token") { + expect(init?.method).toBe("POST"); + return new Response(JSON.stringify({ access_token: "oauth-token" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (url === "https://idp.example/userinfo") { + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer oauth-token"); + return new Response(JSON.stringify({ sub: "extern-1", name: "Extern User", email: "extern@example.com" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + + const createProvider = await apiJson(app, "/api/v1/identity-providers", { + method: "POST", + bearer: accessToken, + json: { + identityProviderId: "oauth-test", + identityProvider: { + title: "OAuth Test", + type: "OAUTH2", + config: { + oauth2Config: { + clientId: "client-id", + clientSecret: "client-secret", + authUrl: "https://idp.example/auth", + tokenUrl: "https://idp.example/token", + userInfoUrl: "https://idp.example/userinfo", + scopes: ["openid"], + fieldMapping: { + identifier: "sub", + displayName: "name", + email: "email", + avatarUrl: "", + }, + }, + }, + }, + }, + }); + expect(createProvider.status).toBe(200); + + const linked = await apiJson<{ name: string; idpName: string; externUid: string }>(app, "/api/v1/users/idpadmin/linkedIdentities", { + method: "POST", + bearer: accessToken, + json: { + idpName: "identity-providers/oauth-test", + code: "oauth-code", + redirectUri: "http://localhost/auth/callback", + codeVerifier: "verifier", + }, + }); + + expect(linked.status).toBe(200); + expect(linked.body).toMatchObject({ + name: "users/idpadmin/linkedIdentities/oauth-test", + idpName: "identity-providers/oauth-test", + externUid: "extern-1", + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it("returns INVALID_ARGUMENT for malformed pageToken on user settings/webhooks/shortcuts", async () => { const app = createTestApp(); await postFirstUser(app, { username: "pg", password: "secret123", role: "USER" }); From 0dfad661e2ef5c2594a5744acae9337ed89f0246 Mon Sep 17 00:00:00 2001 From: stacklix <5688532+stacklix@users.noreply.github.com> Date: Sat, 9 May 2026 00:04:51 +0800 Subject: [PATCH 2/9] =?UTF-8?q?refactor:=20align=20frontend=20with=20golan?= =?UTF-8?q?g=20v0.28.0=20=E2=80=94=20refactor=20MemoEditor,=20audio=20reco?= =?UTF-8?q?rding,=20add=20share=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor voice recording to audio recording (VoiceRecorderPanel → AudioRecorderPanel) - Reorganize MemoEditor services (cache, error, memo, upload, validation, transcription) - Add MemoShareImageDialog and related components for memo sharing - Add MemoMentionMessage for inbox mentions - Update tsconfig.build.json for test configuration --- web/package-lock.json | 1227 ++++++++++++++++- web/package.json | 13 +- web/src/App.tsx | 1 + .../components/Inbox/MemoCommentMessage.tsx | 130 +- .../components/Inbox/MemoMentionMessage.tsx | 164 +++ .../MemoActionMenu/MemoShareImageDialog.tsx | 137 ++ .../MemoActionMenu/MemoShareImagePreview.tsx | 87 ++ web/src/components/MemoActionMenu/hooks.ts | 5 +- .../MemoActionMenu/memoShareImage.ts | 117 ++ .../memoShareImagePreviewModel.ts | 58 + web/src/components/MemoContent/constants.ts | 69 +- web/src/components/MemoContent/index.tsx | 12 +- .../MemoDetailSidebar/MemoDetailSidebar.tsx | 35 +- .../MemoDetailSidebarDrawer.tsx | 5 +- .../MemoEditor/Editor/useSuggestions.ts | 11 +- .../MemoEditor/Toolbar/InsertMenu.tsx | 76 +- .../components/AudioRecorderPanel.tsx | 100 ++ .../MemoEditor/components/EditorContent.tsx | 2 + .../MemoEditor/components/EditorMetadata.tsx | 1 + .../MemoEditor/components/EditorToolbar.tsx | 4 +- .../components/VoiceRecorderPanel.tsx | 135 -- .../MemoEditor/components/VoiceWaveform.tsx | 34 + .../components/MemoEditor/components/index.ts | 2 +- web/src/components/MemoEditor/hooks/index.ts | 3 +- .../MemoEditor/hooks/useAudioRecorder.ts | 32 +- .../MemoEditor/hooks/useAutoSave.ts | 55 +- .../MemoEditor/hooks/useFileUpload.ts | 72 +- .../MemoEditor/hooks/useMemoInit.ts | 14 +- .../MemoEditor/hooks/useVoiceRecorder.ts | 192 --- web/src/components/MemoEditor/index.tsx | 213 ++- .../MemoEditor/services/cacheService.ts | 46 +- .../MemoEditor/services/errorService.ts | 4 + .../components/MemoEditor/services/index.ts | 1 + .../MemoEditor/services/memoService.ts | 61 +- .../services/transcriptionService.ts | 27 + .../MemoEditor/services/uploadService.ts | 26 +- .../MemoEditor/services/validationService.ts | 6 +- .../components/MemoEditor/state/actions.ts | 32 +- .../components/MemoEditor/state/reducer.ts | 47 +- web/src/components/MemoEditor/state/types.ts | 32 +- .../components/MemoEditor/types/attachment.ts | 147 +- .../components/MemoEditor/types/components.ts | 19 +- .../Attachment/AttachmentListEditor.tsx | 402 ++++-- .../components/MemoPreview/MemoPreview.tsx | 1 + web/src/components/MemoView/MemoView.tsx | 37 +- .../components/MemoView/MemoViewContext.tsx | 7 +- .../components/MemoCommentListView.tsx | 2 +- .../MemoView/components/MemoHeader.tsx | 11 +- web/src/components/MemoView/types.ts | 2 + .../Settings/LinkedIdentitySection.tsx | 2 +- web/src/components/Settings/SettingTable.tsx | 7 +- .../components/Settings/StorageSection.tsx | 55 +- web/src/components/SsoSignInForm.tsx | 17 +- web/src/connect.ts | 188 ++- web/src/contexts/ViewContext.tsx | 32 +- web/src/hooks/useMemoQueries.ts | 4 +- web/src/lib/proto-adapters.ts | 7 +- web/src/locales/en.json | 245 ++-- web/src/locales/zh-Hans.json | 193 ++- web/src/locales/zh-Hant.json | 137 +- web/src/pages/AuthCallback.tsx | 48 +- web/src/pages/Inboxes.tsx | 6 +- web/src/pages/MemoDetail.tsx | 69 +- web/src/pages/Setting.tsx | 18 +- web/src/router/guards.tsx | 6 +- web/src/router/index.tsx | 24 +- web/src/router/routes.ts | 9 +- web/src/utils/auth-redirect.ts | 57 +- web/src/utils/oauth.ts | 16 +- web/src/utils/redirect-safety.ts | 72 + .../remark-split-mixed-task-lists.ts | 57 + web/tests/auth-redirect.test.ts | 115 ++ web/tests/guards.test.tsx | 202 +++ web/tests/memo-content-list.test.tsx | 45 + web/tests/memo-content-security.test.tsx | 87 ++ web/tests/memo-editor-cache.test.ts | 57 + web/tests/memo-share-image.test.ts | 134 ++ web/tests/oauth.test.ts | 44 + web/tests/redirect-safety.test.ts | 76 + web/tests/router-config.test.tsx | 75 + web/tests/setup.ts | 42 + web/vitest.config.mts | 28 + 82 files changed, 4861 insertions(+), 1229 deletions(-) create mode 100644 web/src/components/Inbox/MemoMentionMessage.tsx create mode 100644 web/src/components/MemoActionMenu/MemoShareImageDialog.tsx create mode 100644 web/src/components/MemoActionMenu/MemoShareImagePreview.tsx create mode 100644 web/src/components/MemoActionMenu/memoShareImage.ts create mode 100644 web/src/components/MemoActionMenu/memoShareImagePreviewModel.ts create mode 100644 web/src/components/MemoEditor/components/AudioRecorderPanel.tsx delete mode 100644 web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx create mode 100644 web/src/components/MemoEditor/components/VoiceWaveform.tsx delete mode 100644 web/src/components/MemoEditor/hooks/useVoiceRecorder.ts create mode 100644 web/src/components/MemoEditor/services/transcriptionService.ts create mode 100644 web/src/utils/redirect-safety.ts create mode 100644 web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts create mode 100644 web/tests/auth-redirect.test.ts create mode 100644 web/tests/guards.test.tsx create mode 100644 web/tests/memo-content-list.test.tsx create mode 100644 web/tests/memo-content-security.test.tsx create mode 100644 web/tests/memo-editor-cache.test.ts create mode 100644 web/tests/memo-share-image.test.ts create mode 100644 web/tests/oauth.test.ts create mode 100644 web/tests/redirect-safety.test.ts create mode 100644 web/tests/router-config.test.tsx create mode 100644 web/tests/setup.ts create mode 100644 web/vitest.config.mts diff --git a/web/package-lock.json b/web/package-lock.json index 9d5a89e8..c0ab4750 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -30,6 +30,7 @@ "dayjs": "^1.11.20", "fuse.js": "^7.1.0", "highlight.js": "^11.11.1", + "html-to-image": "^1.11.13", "i18next": "^25.8.18", "katex": "^0.16.38", "leaflet": "^1.9.4", @@ -65,6 +66,9 @@ "devDependencies": { "@biomejs/biome": "^2.4.7", "@bufbuild/protobuf": "^2.11.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/katex": "^0.16.8", @@ -80,16 +84,25 @@ "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.7.0", "baseline-browser-mapping": "^2.10.8", + "jsdom": "^29.0.2", "long": "^5.3.2", "terser": "^5.46.1", "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.4" }, "engines": { "node": ">=24" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@antfu/install-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", @@ -103,6 +116,78 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -560,6 +645,40 @@ "integrity": "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==", "license": "MIT" }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@bramus/specificity/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/@bramus/specificity/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/@bufbuild/protobuf": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz", @@ -627,6 +746,146 @@ "@bufbuild/protobuf": "^2.7.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -1215,6 +1474,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -2745,6 +3022,13 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -2956,6 +3240,64 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -3055,6 +3397,98 @@ "react": "^18 || ^19" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3100,6 +3534,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", @@ -3362,6 +3807,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3561,10 +4013,130 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/@xobotyi/scrollbar-width": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz", - "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==", + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xobotyi/scrollbar-width": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz", + "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==", "license": "MIT" }, "node_modules/acorn": { @@ -3579,6 +4151,29 @@ "node": ">=0.4.0" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3591,6 +4186,26 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -3629,6 +4244,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -3710,6 +4335,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3912,6 +4547,13 @@ "node": ">=0.10.0" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4426,6 +5068,20 @@ "lodash-es": "^4.17.21" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", @@ -4449,6 +5105,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -4508,6 +5171,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", @@ -4567,6 +5237,13 @@ "stackframe": "^1.3.4" } }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -4641,6 +5318,26 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5022,6 +5719,19 @@ "react-is": "^16.7.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -5031,6 +5741,12 @@ "void-elements": "3.1.0" } }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -5116,6 +5832,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -5217,6 +5943,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -5238,6 +5971,104 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", + "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5637,6 +6468,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -6621,6 +7462,16 @@ "node": ">=16" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -6691,6 +7542,17 @@ "dev": true, "license": "MIT" }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -6866,6 +7728,28 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -6876,6 +7760,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -7158,6 +8052,20 @@ "react-dom": "*" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rehype-katex": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", @@ -7303,6 +8211,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -7422,6 +8340,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -7468,6 +8399,13 @@ "node": ">=6.9" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -7526,6 +8464,13 @@ "stackframe": "^1.3.4" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -7562,6 +8507,13 @@ "stacktrace-gps": "^3.0.4" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -7576,6 +8528,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -7612,6 +8577,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -7682,6 +8654,13 @@ "node": ">=10" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", @@ -7708,12 +8687,68 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/toggle-selection": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", "license": "MIT" }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -7785,6 +8820,16 @@ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -8111,6 +9156,96 @@ } } }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -8169,6 +9304,19 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -8179,6 +9327,75 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 31862aa0..a0fa1e04 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,10 @@ "release": "vite build --mode release --outDir=../dist/public --emptyOutDir", "lint": "tsc --noEmit --skipLibCheck && biome check src", "lint:fix": "biome check --write src", - "format": "biome format --write src" + "format": "biome format --write src", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@connectrpc/connect": "^2.1.1", @@ -37,6 +40,7 @@ "dayjs": "^1.11.20", "fuse.js": "^7.1.0", "highlight.js": "^11.11.1", + "html-to-image": "^1.11.13", "i18next": "^25.8.18", "katex": "^0.16.38", "leaflet": "^1.9.4", @@ -72,6 +76,9 @@ "devDependencies": { "@biomejs/biome": "^2.4.7", "@bufbuild/protobuf": "^2.11.0", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/d3": "^7.4.3", "@types/hast": "^3.0.4", "@types/katex": "^0.16.8", @@ -87,10 +94,12 @@ "@types/uuid": "^10.0.0", "@vitejs/plugin-react": "^4.7.0", "baseline-browser-mapping": "^2.10.8", + "jsdom": "^29.0.2", "long": "^5.3.2", "terser": "^5.46.1", "tw-animate-css": "^1.4.0", "typescript": "^6.0.2", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^4.1.4" } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 5cded7f2..e034c322 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,6 +6,7 @@ import useNavigateTo from "./hooks/useNavigateTo"; import { useUserLocale } from "./hooks/useUserLocale"; import { useUserTheme } from "./hooks/useUserTheme"; import { cleanupExpiredOAuthState } from "./utils/oauth"; + const App = () => { const navigateTo = useNavigateTo(); const { profile: instanceProfile, profileLoaded, generalSetting: instanceGeneralSetting } = useInstance(); diff --git a/web/src/components/Inbox/MemoCommentMessage.tsx b/web/src/components/Inbox/MemoCommentMessage.tsx index 48c67ea4..c4f23f78 100644 --- a/web/src/components/Inbox/MemoCommentMessage.tsx +++ b/web/src/components/Inbox/MemoCommentMessage.tsx @@ -1,16 +1,11 @@ import { create } from "@bufbuild/protobuf"; import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt"; import { CheckIcon, MessageCircleIcon, TrashIcon, XIcon } from "lucide-react"; -import { useState } from "react"; import toast from "react-hot-toast"; import UserAvatar from "@/components/UserAvatar"; -import { memoServiceClient, userServiceClient } from "@/connect"; -import useAsyncEffect from "@/hooks/useAsyncEffect"; +import { userServiceClient } from "@/connect"; import useNavigateTo from "@/hooks/useNavigateTo"; -import { useUser } from "@/hooks/useUserQueries"; -import { handleError } from "@/lib/error"; import { cn } from "@/lib/utils"; -import { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -21,53 +16,8 @@ interface Props { function MemoCommentMessage({ notification }: Props) { const t = useTranslate(); const navigateTo = useNavigateTo(); - const [relatedMemo, setRelatedMemo] = useState(undefined); - const [commentMemo, setCommentMemo] = useState(undefined); - const [senderName, setSenderName] = useState(undefined); - const [initialized, setInitialized] = useState(false); - const [hasError, setHasError] = useState(false); - - const { data: sender } = useUser(senderName || "", { enabled: !!senderName }); - - useAsyncEffect(async () => { - if (notification.payload?.case !== "memoComment") { - setHasError(true); - return; - } - - try { - const memoCommentPayload = notification.payload.value; - const memo = await memoServiceClient.getMemo({ - name: memoCommentPayload.relatedMemo, - }); - setRelatedMemo(memo); - - const comment = await memoServiceClient.getMemo({ - name: memoCommentPayload.memo, - }); - setCommentMemo(comment); - - setSenderName(notification.sender); - setInitialized(true); - } catch (error) { - handleError(error, () => {}, { - context: "Failed to fetch memo comment notification", - onError: () => setHasError(true), - }); - return; - } - }, [notification.payload, notification.sender]); - - const handleNavigateToMemo = async () => { - if (!relatedMemo) { - return; - } - - navigateTo(`/${relatedMemo.name}`); - if (notification.status === UserNotification_Status.UNREAD) { - handleArchiveMessage(true); - } - }; + const commentPayload = notification.payload?.case === "memoComment" ? notification.payload.value : undefined; + const sender = notification.senderUser; const handleArchiveMessage = async (silence = false) => { await userServiceClient.updateUserNotification({ @@ -89,22 +39,7 @@ function MemoCommentMessage({ notification }: Props) { toast.success(t("message.deleted-successfully")); }; - if (!initialized && !hasError) { - return ( -
-
-
-
-
-
-
-
-
-
- ); - } - - if (hasError) { + if (!commentPayload) { return (
@@ -128,6 +63,13 @@ function MemoCommentMessage({ notification }: Props) { const isUnread = notification.status === UserNotification_Status.UNREAD; + const handleNavigateToMemo = async () => { + navigateTo(`/${commentPayload.relatedMemo}`); + if (isUnread) { + await handleArchiveMessage(true); + } + }; + return (
- {/* Unread indicator bar */} {isUnread &&
}
- {/* Avatar & Icon */}
- {/* Content */}
- {/* Header */}
{sender?.displayName || sender?.username} @@ -188,35 +126,29 @@ function MemoCommentMessage({ notification }: Props) {
- {/* Original Memo Snippet */} - {relatedMemo && ( -
-

- Original: - {relatedMemo.content || Empty memo} -

-
- )} +
+

+ Original: + {commentPayload.relatedMemoSnippet || Empty memo} +

+
- {/* Comment Preview */} - {commentMemo && ( -
-
-
- -
-
-

Comment

-

- {commentMemo.content || Empty comment} -

-
+
+
+
+ +
+
+

Comment

+

+ {commentPayload.memoSnippet || Empty comment} +

- )} +
diff --git a/web/src/components/Inbox/MemoMentionMessage.tsx b/web/src/components/Inbox/MemoMentionMessage.tsx new file mode 100644 index 00000000..25119fbe --- /dev/null +++ b/web/src/components/Inbox/MemoMentionMessage.tsx @@ -0,0 +1,164 @@ +import { create } from "@bufbuild/protobuf"; +import { FieldMaskSchema, timestampDate } from "@bufbuild/protobuf/wkt"; +import { AtSignIcon, CheckIcon, MessageSquareIcon, TrashIcon, XIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import UserAvatar from "@/components/UserAvatar"; +import { userServiceClient } from "@/connect"; +import useNavigateTo from "@/hooks/useNavigateTo"; +import { cn } from "@/lib/utils"; +import { UserNotification, UserNotification_Status } from "@/types/proto/api/v1/user_service_pb"; +import { useTranslate } from "@/utils/i18n"; + +interface Props { + notification: UserNotification; +} + +function MemoMentionMessage({ notification }: Props) { + const t = useTranslate(); + const navigateTo = useNavigateTo(); + const mentionPayload = notification.payload?.case === "memoMention" ? notification.payload.value : undefined; + const sender = notification.senderUser; + + const handleArchiveMessage = async (silence = false) => { + await userServiceClient.updateUserNotification({ + notification: { + name: notification.name, + status: UserNotification_Status.ARCHIVED, + }, + updateMask: create(FieldMaskSchema, { paths: ["status"] }), + }); + if (!silence) { + toast.success(t("message.archived-successfully")); + } + }; + + const handleDeleteMessage = async () => { + await userServiceClient.deleteUserNotification({ + name: notification.name, + }); + toast.success(t("message.deleted-successfully")); + }; + + if (!mentionPayload) { + return ( +
+
+
+
+ +
+ {t("inbox.failed-to-load")} +
+ +
+
+ ); + } + + const isUnread = notification.status === UserNotification_Status.UNREAD; + const isCommentMention = Boolean(mentionPayload.relatedMemo); + const targetName = mentionPayload.relatedMemo || mentionPayload.memo; + + const handleNavigate = async () => { + navigateTo(`/${targetName}`); + if (isUnread) { + await handleArchiveMessage(true); + } + }; + + return ( +
+ {isUnread &&
} + +
+
+ +
+ +
+
+ +
+
+
+ {sender?.displayName || sender?.username} + mentioned you {isCommentMention ? "in a comment" : "in a memo"} + + {notification.createTime && + timestampDate(notification.createTime)?.toLocaleDateString([], { month: "short", day: "numeric" })}{" "} + at{" "} + {notification.createTime && + timestampDate(notification.createTime)?.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} + +
+
+ {isUnread ? ( + + ) : ( + + )} +
+
+ + {mentionPayload.relatedMemo && ( +
+

+ Memo: + {mentionPayload.relatedMemoSnippet || Empty memo} +

+
+ )} + +
+
+
+ +
+
+

+ {isCommentMention ? "Comment" : "Memo"} +

+

+ {mentionPayload.memoSnippet || Empty memo} +

+
+
+
+
+
+
+ ); +} + +export default MemoMentionMessage; diff --git a/web/src/components/MemoActionMenu/MemoShareImageDialog.tsx b/web/src/components/MemoActionMenu/MemoShareImageDialog.tsx new file mode 100644 index 00000000..6308e50f --- /dev/null +++ b/web/src/components/MemoActionMenu/MemoShareImageDialog.tsx @@ -0,0 +1,137 @@ +import { DownloadIcon, ImageIcon, Loader2Icon, Share2Icon } from "lucide-react"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { toast } from "react-hot-toast"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useTranslate } from "@/utils/i18n"; +import { useMemoViewContext } from "../MemoView/MemoViewContext"; +import MemoShareImagePreview from "./MemoShareImagePreview"; +import { + buildMemoShareImageFileName, + createMemoShareImageBlob, + getMemoShareDialogWidth, + getMemoSharePreviewWidth, + getMemoShareRenderWidth, +} from "./memoShareImage"; + +interface MemoShareImageDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const MemoShareImageDialog = ({ open, onOpenChange }: MemoShareImageDialogProps) => { + const t = useTranslate(); + const { memo, cardWidth } = useMemoViewContext(); + const previewRef = useRef(null); + const [isRendering, setIsRendering] = useState(false); + + const previewWidth = useMemo(() => getMemoSharePreviewWidth(cardWidth), [cardWidth]); + const dialogWidth = useMemo(() => getMemoShareDialogWidth(previewWidth), [previewWidth]); + const previewRenderWidth = useMemo(() => getMemoShareRenderWidth(previewWidth, dialogWidth), [dialogWidth, previewWidth]); + + const createShareBlob = useCallback(async () => { + const preview = previewRef.current; + if (!preview) { + throw new Error("Preview is not ready"); + } + + return createMemoShareImageBlob(preview); + }, []); + + const handleDownload = useCallback(async () => { + setIsRendering(true); + try { + const blob = await createShareBlob(); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = buildMemoShareImageFileName(memo.name); + anchor.click(); + URL.revokeObjectURL(url); + toast.success(t("memo.share.image-downloaded")); + } catch { + toast.error(t("memo.share.image-download-failed")); + } finally { + setIsRendering(false); + } + }, [createShareBlob, memo.name, t]); + + const handleNativeShare = useCallback(async () => { + if (typeof navigator.share !== "function") { + return; + } + + setIsRendering(true); + try { + const blob = await createShareBlob(); + const file = new File([blob], buildMemoShareImageFileName(memo.name), { type: "image/png" }); + if (typeof navigator.canShare === "function" && !navigator.canShare({ files: [file] })) { + toast.error(t("memo.share.image-share-failed")); + return; + } + + await navigator.share({ + files: [file], + title: memo.content.slice(0, 60), + }); + } catch (error) { + if (!(error instanceof DOMException && error.name === "AbortError")) { + toast.error(t("memo.share.image-share-failed")); + } + } finally { + setIsRendering(false); + } + }, [createShareBlob, memo.content, memo.name, t]); + + const supportsNativeShare = + typeof navigator !== "undefined" && typeof navigator.share === "function" && typeof navigator.canShare === "function"; + + return ( + + +
+ + + + {t("memo.share.image-title")} + + {t("memo.share.image-description", { width: previewRenderWidth })} + + +
+ +
+ + + {supportsNativeShare && ( + + )} + + +
+
+
+ ); +}; + +export default MemoShareImageDialog; diff --git a/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx b/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx new file mode 100644 index 00000000..b29acc1e --- /dev/null +++ b/web/src/components/MemoActionMenu/MemoShareImagePreview.tsx @@ -0,0 +1,87 @@ +import { forwardRef, useMemo } from "react"; +import MemoContent from "@/components/MemoContent"; +import UserAvatar from "@/components/UserAvatar"; +import i18n from "@/i18n"; +import { cn } from "@/lib/utils"; +import { useTranslate } from "@/utils/i18n"; +import { useMemoViewContext } from "../MemoView/MemoViewContext"; +import { buildMemoShareImagePreviewModel } from "./memoShareImagePreviewModel"; + +const MemoShareImagePreview = forwardRef(({ width }, ref) => { + const t = useTranslate(); + const { memo, creator, blurred, showBlurredContent } = useMemoViewContext(); + const fallbackDisplayName = t("common.memo"); + const locale = i18n.language; + + const preview = useMemo( + () => + buildMemoShareImagePreviewModel({ + memo, + creator, + fallbackDisplayName, + locale, + }), + [creator, fallbackDisplayName, locale, memo], + ); + + return ( +
+
+
+
+ +
+
{preview.displayName}
+ {preview.formattedDisplayTime &&
{preview.formattedDisplayTime}
} +
+
+
+ +
+
+ +
+
+ + {preview.visualItems.length > 0 && ( +
+ {preview.visualItems.slice(0, 4).map((item, index) => ( +
+ {item.filename} + {index === 3 && preview.visualItems.length > 4 && ( +
+ +{preview.visualItems.length - 4} +
+ )} +
+ ))} +
+ )} + + {preview.footerBadges.length > 0 && ( +
+ {preview.footerBadges.map((badge) => ( + + {badge.count} {t("common.attachments").toLowerCase()} + + ))} +
+ )} +
+
+ ); +}); + +MemoShareImagePreview.displayName = "MemoShareImagePreview"; + +export default MemoShareImagePreview; diff --git a/web/src/components/MemoActionMenu/hooks.ts b/web/src/components/MemoActionMenu/hooks.ts index ddac3895..c5cbbf28 100644 --- a/web/src/components/MemoActionMenu/hooks.ts +++ b/web/src/components/MemoActionMenu/hooks.ts @@ -8,6 +8,7 @@ import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries"; import useNavigateTo from "@/hooks/useNavigateTo"; import { userKeys } from "@/hooks/useUserQueries"; import { handleError } from "@/lib/error"; +import { ROUTES } from "@/router/routes"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -74,7 +75,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use } if (isInMemoDetailPage) { - navigateTo(memo.state === State.ARCHIVED ? "/" : "/archived"); + navigateTo(memo.state === State.ARCHIVED ? ROUTES.HOME : ROUTES.ARCHIVED); } memoUpdatedCallback(); }, [memo.name, memo.state, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, updateMemo]); @@ -109,7 +110,7 @@ export const useMemoActionHandlers = ({ memo, onEdit, setDeleteDialogOpen }: Use queryClient.invalidateQueries({ queryKey: memoKeys.comments(memo.parent) }); } if (isInMemoDetailPage) { - navigateTo("/"); + navigateTo(ROUTES.HOME); } memoUpdatedCallback(); }, [memo.name, memo.parent, t, isInMemoDetailPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]); diff --git a/web/src/components/MemoActionMenu/memoShareImage.ts b/web/src/components/MemoActionMenu/memoShareImage.ts new file mode 100644 index 00000000..ab0516c7 --- /dev/null +++ b/web/src/components/MemoActionMenu/memoShareImage.ts @@ -0,0 +1,117 @@ +import { toBlob } from "html-to-image"; + +const WINDOW_HORIZONTAL_MARGIN = 32; +const PREVIEW_HORIZONTAL_PADDING_IN_DIALOG = 40; +const PREVIEW_WIDTH_BOOST_IN_DIALOG = 48; + +export const MEMO_SHARE_IMAGE_CONFIG = { + dialogExtraWidth: 80, + maxWidth: 520, + minWidth: 260, + previewScale: 0.9, + viewportMargin: 48, +} as const; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const isExportableImageUrl = (value?: string) => { + if (!value) { + return false; + } + + if (value.startsWith("/") || value.startsWith("data:") || value.startsWith("blob:")) { + return true; + } + + try { + return new URL(value, window.location.origin).origin === window.location.origin; + } catch { + return false; + } +}; + +const waitForPreviewAssets = async (node: HTMLElement) => { + try { + await document.fonts?.ready; + } catch { + // Ignore font loading failures and continue with the best available render. + } + + const images = Array.from(node.querySelectorAll("img")); + await Promise.all( + images.map( + (image) => + new Promise((resolve) => { + if (image.complete) { + resolve(); + return; + } + + image.addEventListener("load", () => resolve(), { once: true }); + image.addEventListener("error", () => resolve(), { once: true }); + }), + ), + ); +}; + +export const buildMemoShareImageFileName = (memoName: string) => { + const suffix = memoName.split("/").pop() || "memo"; + return `memo-${suffix}.png`; +}; + +export const getMemoSharePreviewWidth = (cardWidth: number) => { + const viewportWidth = + typeof window === "undefined" ? MEMO_SHARE_IMAGE_CONFIG.maxWidth : window.innerWidth - MEMO_SHARE_IMAGE_CONFIG.viewportMargin; + const baseWidth = cardWidth || viewportWidth; + + return clamp( + Math.round(baseWidth * MEMO_SHARE_IMAGE_CONFIG.previewScale), + MEMO_SHARE_IMAGE_CONFIG.minWidth, + MEMO_SHARE_IMAGE_CONFIG.maxWidth, + ); +}; + +export const getMemoShareDialogWidth = (previewWidth: number) => { + const viewportWidth = + typeof window === "undefined" ? previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth : window.innerWidth - WINDOW_HORIZONTAL_MARGIN; + return Math.min(previewWidth + MEMO_SHARE_IMAGE_CONFIG.dialogExtraWidth, viewportWidth); +}; + +export const getMemoShareRenderWidth = (previewWidth: number, dialogWidth: number) => { + const maxRenderWidth = Math.max(MEMO_SHARE_IMAGE_CONFIG.minWidth, dialogWidth - PREVIEW_HORIZONTAL_PADDING_IN_DIALOG); + return clamp(previewWidth + PREVIEW_WIDTH_BOOST_IN_DIALOG, MEMO_SHARE_IMAGE_CONFIG.minWidth, maxRenderWidth); +}; + +export const getMemoSharePreviewAvatarUrl = (avatarUrl?: string) => (isExportableImageUrl(avatarUrl) ? avatarUrl : undefined); + +export const createMemoShareImageBlob = async (node: HTMLElement) => { + await waitForPreviewAssets(node); + + const rect = node.getBoundingClientRect(); + const width = Math.ceil(rect.width || node.offsetWidth || node.clientWidth); + const height = Math.ceil(rect.height || node.offsetHeight || node.clientHeight); + + const blob = await toBlob(node, { + cacheBust: true, + height, + pixelRatio: Math.max(2, Math.min(window.devicePixelRatio || 1, 3)), + width, + filter: (currentNode) => { + if (!(currentNode instanceof HTMLElement)) { + return true; + } + + if (currentNode instanceof HTMLImageElement) { + return isExportableImageUrl(currentNode.currentSrc || currentNode.src); + } + + return !(currentNode instanceof HTMLVideoElement); + }, + }); + + if (!blob) { + throw new Error("Failed to render image"); + } + + return blob; +}; diff --git a/web/src/components/MemoActionMenu/memoShareImagePreviewModel.ts b/web/src/components/MemoActionMenu/memoShareImagePreviewModel.ts new file mode 100644 index 00000000..5d5b2c98 --- /dev/null +++ b/web/src/components/MemoActionMenu/memoShareImagePreviewModel.ts @@ -0,0 +1,58 @@ +import { timestampDate } from "@bufbuild/protobuf/wkt"; +import { separateAttachments } from "@/components/MemoMetadata/Attachment/attachmentHelpers"; +import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; +import type { User } from "@/types/proto/api/v1/user_service_pb"; +import { type AttachmentVisualItem, buildAttachmentVisualItems, countLogicalAttachmentItems } from "@/utils/media-item"; +import { getMemoSharePreviewAvatarUrl } from "./memoShareImage"; + +interface BuildMemoShareImagePreviewModelOptions { + memo: Memo; + creator?: User; + fallbackDisplayName: string; + locale: string; +} + +export interface MemoShareImageAttachmentSummaryBadge { + type: "attachment-summary"; + count: number; +} + +export type MemoShareImageFooterBadge = MemoShareImageAttachmentSummaryBadge; + +export interface MemoShareImagePreviewModel { + displayName: string; + avatarUrl?: string; + formattedDisplayTime?: string; + visualItems: AttachmentVisualItem[]; + footerBadges: MemoShareImageFooterBadge[]; +} + +export const buildMemoShareImagePreviewModel = ({ + memo, + creator, + fallbackDisplayName, + locale, +}: BuildMemoShareImagePreviewModelOptions): MemoShareImagePreviewModel => { + const displayName = creator?.displayName || creator?.username || fallbackDisplayName; + const avatarUrl = getMemoSharePreviewAvatarUrl(creator?.avatarUrl); + const displayTime = memo.createTime ? timestampDate(memo.createTime) : undefined; + const formattedDisplayTime = displayTime?.toLocaleString(locale, { + dateStyle: "medium", + timeStyle: "short", + }); + + const attachmentGroups = separateAttachments(memo.attachments); + const visualItems = buildAttachmentVisualItems(attachmentGroups.visual); + const attachmentCount = countLogicalAttachmentItems(memo.attachments); + const nonVisualAttachmentCount = Math.max(attachmentCount - visualItems.length, 0); + const footerBadges: MemoShareImageFooterBadge[] = + nonVisualAttachmentCount > 0 ? [{ type: "attachment-summary", count: attachmentCount }] : []; + + return { + displayName, + avatarUrl, + formattedDisplayTime, + visualItems, + footerBadges, + }; +}; diff --git a/web/src/components/MemoContent/constants.ts b/web/src/components/MemoContent/constants.ts index 6a82e5c0..33a0be5e 100644 --- a/web/src/components/MemoContent/constants.ts +++ b/web/src/components/MemoContent/constants.ts @@ -29,15 +29,19 @@ const TRUSTED_IFRAME_SRC_PATTERNS = [ /^https:\/\/(?:www\.)?draw\.io\/(?:[^?#]+)?(?:\?.*)?$/i, ]; +const KATEX_INLINE_CLASS_NAMES = ["language-math", "math-inline"] as const; +const KATEX_BLOCK_CLASS_NAMES = ["language-math", "math-display"] as const; +const SPAN_CLASS_NAMES = ["mention", "tag"] as const; +const INPUT_ATTRIBUTES = [...(defaultSchema.attributes?.input || []), ["checked", true]] as const; + export const isTrustedIframeSrc = (src: string): boolean => TRUSTED_IFRAME_SRC_PATTERNS.some((pattern) => pattern.test(src)); /** * Sanitization schema for markdown HTML content. * Extends the default schema to allow: - * - KaTeX math rendering elements (MathML tags) - * - KaTeX-specific attributes (className, style, aria-*, data-*) - * - Safe HTML elements for rich content - * - iframe embeds for trusted video providers (YouTube, Vimeo, etc.) + * - KaTeX marker classes used before trusted KaTeX rendering runs + * - Mention/tag metadata generated by trusted remark plugins + * - iframe embeds only from trusted video providers * * This prevents XSS attacks while preserving math rendering functionality. */ @@ -45,50 +49,25 @@ export const SANITIZE_SCHEMA = { ...defaultSchema, attributes: { ...defaultSchema.attributes, - div: [...(defaultSchema.attributes?.div || []), "className"], img: [...(defaultSchema.attributes?.img || []), "height", "width"], - span: [...(defaultSchema.attributes?.span || []), "className", "style", ["aria*"], ["data*"]], - // iframe attributes for video embeds - iframe: ["src", "width", "height", "frameborder", "allowfullscreen", "allow", "title", "referrerpolicy", "loading"], - // MathML attributes for KaTeX rendering - annotation: ["encoding"], - math: ["xmlns"], - mi: [], - mn: [], - mo: [], - mrow: [], - mspace: [], - mstyle: [], - msup: [], - msub: [], - msubsup: [], - mfrac: [], - mtext: [], - semantics: [], + input: INPUT_ATTRIBUTES, + code: [...(defaultSchema.attributes?.code || []), ["className", ...KATEX_INLINE_CLASS_NAMES, ...KATEX_BLOCK_CLASS_NAMES]], + span: [...(defaultSchema.attributes?.span || []), ["className", ...SPAN_CLASS_NAMES], ["aria*"], ["data*"]], + iframe: [ + ["src", ...TRUSTED_IFRAME_SRC_PATTERNS], + "width", + "height", + "frameborder", + "allowfullscreen", + "allow", + "title", + "referrerpolicy", + "loading", + ], }, - tagNames: [ - ...(defaultSchema.tagNames || []), - // iframe for video embeds - "iframe", - // MathML elements for KaTeX math rendering - "math", - "annotation", - "semantics", - "mi", - "mn", - "mo", - "mrow", - "mspace", - "mstyle", - "msup", - "msub", - "msubsup", - "mfrac", - "mtext", - ], + tagNames: [...(defaultSchema.tagNames || []), "iframe"], protocols: { ...defaultSchema.protocols, - // Allow HTTPS iframe embeds only for security - iframe: { src: ["https"] }, + src: ["https"], }, }; diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx index 484911e7..232d434b 100644 --- a/web/src/components/MemoContent/index.tsx +++ b/web/src/components/MemoContent/index.tsx @@ -14,6 +14,7 @@ import { rehypeHeadingId } from "@/utils/rehype-plugins/rehype-heading-id"; import { remarkDisableSetext } from "@/utils/remark-plugins/remark-disable-setext"; import { extractMentionUsernames, remarkMention } from "@/utils/remark-plugins/remark-mention"; import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type"; +import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists"; import { remarkTag } from "@/utils/remark-plugins/remark-tag"; import { CodeBlock } from "./CodeBlock"; import { isMentionNode, isTagNode, isTaskListItemNode } from "./ConditionalComponent"; @@ -79,7 +80,16 @@ const MemoContent = (props: MemoContentProps) => { onDoubleClick={onDoubleClick} > void; } interface PropertyBadge { @@ -39,7 +40,10 @@ const PROPERTY_BADGE_CLASSES = const TAG_BADGE_CLASSES = "inline-flex items-center gap-1 px-1 rounded-md border border-border/60 bg-muted/60 text-sm text-muted-foreground hover:bg-muted hover:text-foreground/80 transition-colors cursor-pointer"; -const MemoDetailSidebar = ({ memo, className }: Props) => { +const SHARE_ACTION_ROW_CLASSES = + "h-auto min-h-0 w-full justify-between rounded-none px-2 py-1.5 text-xs font-normal leading-tight text-muted-foreground transition-colors hover:bg-muted/40 hover:text-muted-foreground focus-visible:ring-offset-0 gap-1.5"; + +const MemoDetailSidebar = ({ memo, className, onShareImageOpen }: Props) => { const t = useTranslate(); const currentUser = useCurrentUser(); const [sharePanelOpen, setSharePanelOpen] = useState(false); @@ -64,12 +68,29 @@ const MemoDetailSidebar = ({ memo, className }: Props) => { )} - {canManageShares && ( + {(canManageShares || onShareImageOpen) && ( - +
+ {onShareImageOpen && ( + + )} + {onShareImageOpen && canManageShares &&
} + {canManageShares && ( + + )} +
)} diff --git a/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx b/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx index 9c058a9d..9a36774f 100644 --- a/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx +++ b/web/src/components/MemoDetailSidebar/MemoDetailSidebarDrawer.tsx @@ -8,9 +8,10 @@ import MemoDetailSidebar from "./MemoDetailSidebar"; interface Props { memo: Memo; + onShareImageOpen?: () => void; } -const MemoDetailSidebarDrawer = ({ memo }: Props) => { +const MemoDetailSidebarDrawer = ({ memo, onShareImageOpen }: Props) => { const location = useLocation(); const [open, setOpen] = useState(false); @@ -26,7 +27,7 @@ const MemoDetailSidebarDrawer = ({ memo }: Props) => { - + ); diff --git a/web/src/components/MemoEditor/Editor/useSuggestions.ts b/web/src/components/MemoEditor/Editor/useSuggestions.ts index aff69a14..2548fa43 100644 --- a/web/src/components/MemoEditor/Editor/useSuggestions.ts +++ b/web/src/components/MemoEditor/Editor/useSuggestions.ts @@ -22,6 +22,7 @@ export interface UseSuggestionsReturn { suggestions: T[]; selectedIndex: number; isVisible: boolean; + searchQuery: string; handleItemSelect: (item: T) => void; } @@ -52,12 +53,17 @@ export function useSuggestions({ const hide = () => setPosition(null); const suggestionsRef = useRef([]); + const searchQueryRef = useRef(""); suggestionsRef.current = (() => { const [word] = getCurrentWord(); if (!word.startsWith(triggerChar)) return []; - const searchQuery = word.slice(triggerChar.length).toLowerCase(); - return filterItems(items, searchQuery); + searchQueryRef.current = word.slice(triggerChar.length).toLowerCase(); + return filterItems(items, searchQueryRef.current); })(); + if (suggestionsRef.current.length === 0) { + const [word] = getCurrentWord(); + searchQueryRef.current = word.startsWith(triggerChar) ? word.slice(triggerChar.length).toLowerCase() : ""; + } const isVisibleRef = useRef(false); isVisibleRef.current = !!(position && suggestionsRef.current.length > 0); @@ -153,6 +159,7 @@ export function useSuggestions({ suggestions: suggestionsRef.current, selectedIndex, isVisible: isVisibleRef.current, + searchQuery: searchQueryRef.current, handleItemSelect: handleAutocomplete, }; } diff --git a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx index cca9578e..baac1084 100644 --- a/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx +++ b/web/src/components/MemoEditor/Toolbar/InsertMenu.tsx @@ -2,6 +2,7 @@ import { LatLng } from "leaflet"; import { uniqBy } from "lodash-es"; import { FileIcon, + ImageIcon, LinkIcon, LoaderIcon, type LucideIcon, @@ -16,11 +17,11 @@ import { useDebounce } from "react-use"; import { LinkMemoDialog, LocationDialog } from "@/components/MemoMetadata"; import { useReverseGeocoding } from "@/components/map"; import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, @@ -131,41 +132,49 @@ const InsertMenu = (props: InsertMenuProps) => { setMoreSubmenuOpen(false); }, [onToggleFocusMode]); + const handleMediaUploadClick = useCallback(() => { + handleUploadClick("image/*,video/*"); + }, [handleUploadClick]); + + const handleFileUploadClick = useCallback(() => { + handleUploadClick(); + }, [handleUploadClick]); + const menuItems = useMemo( () => [ { - key: "upload", - label: t("common.upload"), + key: "upload-media", + label: t("attachment-library.tabs.media"), + icon: ImageIcon, + onClick: handleMediaUploadClick, + }, + { + key: "record-audio", + label: t("editor.audio-recorder.trigger"), + icon: MicIcon, + onClick: () => props.onAudioRecorderClick?.(), + }, + { + key: "upload-file", + label: t("common.file"), icon: FileIcon, - onClick: handleUploadClick, + onClick: handleFileUploadClick, }, { key: "link", - label: t("tooltip.link-memo"), + label: t("editor.insert-menu.link-memo"), icon: LinkIcon, onClick: handleOpenLinkDialog, }, { key: "location", - label: t("tooltip.select-location"), + label: t("editor.insert-menu.add-location"), icon: MapPinIcon, onClick: handleLocationClick, }, - { - key: "voice-note", - label: t("editor.voice-recorder.trigger"), - icon: MicIcon, - onClick: () => props.onVoiceRecorderClick?.(), - }, - ] satisfies Array<{ - key: string; - label: string; - icon: LucideIcon; - onClick: () => void; - notImplemented?: boolean; - }>, - [handleLocationClick, handleOpenLinkDialog, handleUploadClick, props, t], + ] satisfies Array<{ key: string; label: string; icon: LucideIcon; onClick: () => void }>, + [handleFileUploadClick, handleLocationClick, handleMediaUploadClick, handleOpenLinkDialog, props, t], ); return ( @@ -177,23 +186,20 @@ const InsertMenu = (props: InsertMenuProps) => { - {menuItems.map((item) => ( - - - {item.label} - {item.notImplemented ? ( - - {t("editor.attachments-not-implemented")} - - ) : null} + {menuItems.slice(0, 3).map((item) => ( + + + {item.label} + + ))} + + {menuItems.slice(3).map((item) => ( + + + {item.label} ))} + {/* View submenu with Focus Mode */} diff --git a/web/src/components/MemoEditor/components/AudioRecorderPanel.tsx b/web/src/components/MemoEditor/components/AudioRecorderPanel.tsx new file mode 100644 index 00000000..f153f670 --- /dev/null +++ b/web/src/components/MemoEditor/components/AudioRecorderPanel.tsx @@ -0,0 +1,100 @@ +import { AudioWaveformIcon, LoaderCircleIcon, SquareIcon, XIcon } from "lucide-react"; +import type { FC } from "react"; +import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentHelpers"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { useTranslate } from "@/utils/i18n"; +import { useAudioWaveform } from "../hooks/useAudioWaveform"; +import type { AudioRecorderPanelProps } from "../types/components"; +import { VoiceWaveform } from "./VoiceWaveform"; + +export const AudioRecorderPanel: FC = ({ + audioRecorder, + mediaStream, + onStop, + onCancel, + onTranscribe, + canTranscribe = false, + isTranscribing = false, +}) => { + const t = useTranslate(); + const { status, elapsedSeconds } = audioRecorder; + + const isRequestingPermission = status === "requesting_permission"; + const isRecording = status === "recording"; + const isTranscribeDisabled = isRequestingPermission || isTranscribing; + const waveformLevels = useAudioWaveform(mediaStream, isRecording && mediaStream !== null); + const srStatusText = isTranscribing + ? t("editor.audio-recorder.transcribing") + : isRequestingPermission + ? t("editor.audio-recorder.requesting-permission") + : t("editor.audio-recorder.recording"); + + return ( +
+
+ {isRequestingPermission || isTranscribing ? ( + + ) : null} + {srStatusText} + + + {isTranscribing ? t("editor.audio-recorder.transcribing") : formatAudioTime(elapsedSeconds)} + +
+ +
+ + {canTranscribe && ( + + + + + + + +

{t("editor.audio-recorder.transcribe")}

+
+
+ )} + +
+
+ ); +}; diff --git a/web/src/components/MemoEditor/components/EditorContent.tsx b/web/src/components/MemoEditor/components/EditorContent.tsx index 5cc14f78..dab0f32d 100644 --- a/web/src/components/MemoEditor/components/EditorContent.tsx +++ b/web/src/components/MemoEditor/components/EditorContent.tsx @@ -13,6 +13,7 @@ export const EditorContent = forwardRef(({ const localFiles: LocalFile[] = Array.from(files).map((file) => ({ file, previewUrl: createBlobUrl(file), + origin: "upload", })); localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile))); }); @@ -49,6 +50,7 @@ export const EditorContent = forwardRef(({ const localFiles: LocalFile[] = files.map((file) => ({ file, previewUrl: createBlobUrl(file), + origin: "upload", })); localFiles.forEach((localFile) => dispatch(actions.addLocalFile(localFile))); event.preventDefault(); diff --git a/web/src/components/MemoEditor/components/EditorMetadata.tsx b/web/src/components/MemoEditor/components/EditorMetadata.tsx index df01ddf9..ee984e13 100644 --- a/web/src/components/MemoEditor/components/EditorMetadata.tsx +++ b/web/src/components/MemoEditor/components/EditorMetadata.tsx @@ -12,6 +12,7 @@ export const EditorMetadata: FC = ({ memoName }) => { attachments={state.metadata.attachments} localFiles={state.localFiles} onAttachmentsChange={(attachments) => dispatch(actions.setMetadata({ attachments }))} + onLocalFilesChange={(localFiles) => dispatch(actions.setLocalFiles(localFiles))} onRemoveLocalFile={(previewUrl) => dispatch(actions.removeLocalFile(previewUrl))} /> diff --git a/web/src/components/MemoEditor/components/EditorToolbar.tsx b/web/src/components/MemoEditor/components/EditorToolbar.tsx index 6f9ad1b1..5205e0b5 100644 --- a/web/src/components/MemoEditor/components/EditorToolbar.tsx +++ b/web/src/components/MemoEditor/components/EditorToolbar.tsx @@ -7,7 +7,7 @@ import InsertMenu from "../Toolbar/InsertMenu"; import VisibilitySelector from "../Toolbar/VisibilitySelector"; import type { EditorToolbarProps } from "../types"; -export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onVoiceRecorderClick }) => { +export const EditorToolbar: FC = ({ onSave, onCancel, memoName, onAudioRecorderClick }) => { const t = useTranslate(); const { state, actions, dispatch } = useEditorContext(); const { valid } = validationService.canSave(state); @@ -35,7 +35,7 @@ export const EditorToolbar: FC = ({ onSave, onCancel, memoNa onLocationChange={handleLocationChange} onToggleFocusMode={handleToggleFocusMode} memoName={memoName} - onVoiceRecorderClick={onVoiceRecorderClick} + onAudioRecorderClick={onAudioRecorderClick} />
diff --git a/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx b/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx deleted file mode 100644 index 121ddbe6..00000000 --- a/web/src/components/MemoEditor/components/VoiceRecorderPanel.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { AudioLinesIcon, LoaderCircleIcon, MicIcon, RotateCcwIcon, SquareIcon, Trash2Icon } from "lucide-react"; -import type { FC } from "react"; -import { AudioAttachmentItem } from "@/components/MemoMetadata/Attachment"; -import { formatAudioTime } from "@/components/MemoMetadata/Attachment/attachmentHelpers"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { useTranslate } from "@/utils/i18n"; -import type { VoiceRecorderPanelProps } from "../types/components"; - -export const VoiceRecorderPanel: FC = ({ - voiceRecorder, - onStart, - onStop, - onKeep, - onDiscard, - onRecordAgain, - onClose, -}) => { - const t = useTranslate(); - const { status, elapsedSeconds, error, recording } = voiceRecorder; - - const isRecording = status === "recording"; - const isRequestingPermission = status === "requesting_permission"; - const isUnsupported = status === "unsupported"; - const hasRecording = status === "recorded" && recording; - - return ( -
-
-
-
- {isRequestingPermission ? ( - - ) : hasRecording ? ( - - ) : ( - - )} -
- -
-
- {isRecording - ? t("editor.voice-recorder.recording") - : isRequestingPermission - ? t("editor.voice-recorder.requesting-permission") - : hasRecording - ? t("editor.voice-recorder.ready") - : isUnsupported - ? t("editor.voice-recorder.unsupported") - : error - ? t("editor.voice-recorder.error") - : t("editor.voice-recorder.title")} -
- -
- {isRecording - ? t("editor.voice-recorder.recording-description", { duration: formatAudioTime(elapsedSeconds) }) - : isRequestingPermission - ? t("editor.voice-recorder.requesting-permission-description") - : hasRecording - ? t("editor.voice-recorder.ready-description") - : isUnsupported - ? t("editor.voice-recorder.unsupported-description") - : error - ? error - : t("editor.voice-recorder.idle-description")} -
-
-
- - {isRecording && ( -
- - {formatAudioTime(elapsedSeconds)} -
- )} -
- - {hasRecording && ( -
- -
- )} - -
- {hasRecording ? ( - <> - - - - - ) : isRecording ? ( - - ) : ( - <> - - {!isUnsupported && ( - - )} - - )} -
-
- ); -}; diff --git a/web/src/components/MemoEditor/components/VoiceWaveform.tsx b/web/src/components/MemoEditor/components/VoiceWaveform.tsx new file mode 100644 index 00000000..988c84f1 --- /dev/null +++ b/web/src/components/MemoEditor/components/VoiceWaveform.tsx @@ -0,0 +1,34 @@ +import type { FC } from "react"; +import { cn } from "@/lib/utils"; + +/** Max half-height of each bar (px); bars are centered vertically. */ +const MAX_BAR_PX = 11; +const MIN_BAR_PX = 2; + +type VoiceWaveformProps = { + levels: number[]; + className?: string; +}; + +/** + * Tight-packed vertical bars (rounded caps): fixed bar width + minimal gap — no `flex-1` columns + * so bars stay visually dense like compact voice-memo waveforms. + */ +export const VoiceWaveform: FC = ({ levels, className }) => { + return ( +
+ {levels.map((level, i) => { + const h = Math.max(MIN_BAR_PX, level * MAX_BAR_PX); + const centerDistance = Math.abs(i - (levels.length - 1) / 2) / (levels.length / 2); + const opacity = 0.35 + (1 - centerDistance) * 0.35; + return ( + + ); + })} +
+ ); +}; diff --git a/web/src/components/MemoEditor/components/index.ts b/web/src/components/MemoEditor/components/index.ts index eed02ff1..73faf0f1 100644 --- a/web/src/components/MemoEditor/components/index.ts +++ b/web/src/components/MemoEditor/components/index.ts @@ -1,8 +1,8 @@ // UI components for MemoEditor +export * from "./AudioRecorderPanel"; export * from "./EditorContent"; export * from "./EditorMetadata"; export * from "./EditorToolbar"; export { FocusModeExitButton, FocusModeOverlay } from "./FocusModeOverlay"; export { TimestampPopover } from "./TimestampPopover"; -export * from "./VoiceRecorderPanel"; diff --git a/web/src/components/MemoEditor/hooks/index.ts b/web/src/components/MemoEditor/hooks/index.ts index a1ca003e..5854dfd1 100644 --- a/web/src/components/MemoEditor/hooks/index.ts +++ b/web/src/components/MemoEditor/hooks/index.ts @@ -1,4 +1,6 @@ // Custom hooks for MemoEditor (internal use only) +export { useAudioRecorder } from "./useAudioRecorder"; +export { useAudioWaveform } from "./useAudioWaveform"; export { useAutoSave } from "./useAutoSave"; export { useBlobUrls } from "./useBlobUrls"; export { useDragAndDrop } from "./useDragAndDrop"; @@ -8,4 +10,3 @@ export { useKeyboard } from "./useKeyboard"; export { useLinkMemo } from "./useLinkMemo"; export { useLocation } from "./useLocation"; export { useMemoInit } from "./useMemoInit"; -export { useVoiceRecorder } from "./useVoiceRecorder"; diff --git a/web/src/components/MemoEditor/hooks/useAudioRecorder.ts b/web/src/components/MemoEditor/hooks/useAudioRecorder.ts index 7df3b79c..63722e59 100644 --- a/web/src/components/MemoEditor/hooks/useAudioRecorder.ts +++ b/web/src/components/MemoEditor/hooks/useAudioRecorder.ts @@ -3,6 +3,7 @@ import type { LocalFile } from "../types/attachment"; import { useBlobUrls } from "./useBlobUrls"; const FALLBACK_AUDIO_MIME_TYPE = "audio/webm"; +export type AudioRecordingCompleteMode = "attach" | "transcribe"; interface AudioRecorderActions { setAudioRecorderSupport: (value: boolean) => void; @@ -10,7 +11,8 @@ interface AudioRecorderActions { setAudioRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "error" | "unsupported") => void; setAudioRecorderElapsed: (value: number) => void; setAudioRecorderError: (value?: string) => void; - onRecordingComplete: (localFile: LocalFile) => void; + onRecordingComplete: (localFile: LocalFile, mode: AudioRecordingCompleteMode) => void; + onRecordingEmpty?: (mode: AudioRecordingCompleteMode) => void; } const AUDIO_MIME_TYPE_CANDIDATES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"] as const; @@ -55,6 +57,7 @@ export const useAudioRecorder = (actions: AudioRecorderActions) => { const startedAtRef = useRef(null); const elapsedTimerRef = useRef(null); const recorderMimeTypeRef = useRef(FALLBACK_AUDIO_MIME_TYPE); + const completionModeRef = useRef("attach"); const startRequestIdRef = useRef(0); const { createBlobUrl } = useBlobUrls(); @@ -153,10 +156,13 @@ export const useAudioRecorder = (actions: AudioRecorderActions) => { const durationSeconds = startedAtRef.current ? Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1000)) : 0; const blob = new Blob(chunksRef.current, { type: recorderMimeTypeRef.current }); + const completionMode = completionModeRef.current; + completionModeRef.current = "attach"; if (blob.size === 0) { actions.setAudioRecorderElapsed(0); actions.setAudioRecorderError(undefined); actions.setAudioRecorderStatus("idle"); + actions.onRecordingEmpty?.(completionMode); resetRecorderRefs(); return; } @@ -164,14 +170,17 @@ export const useAudioRecorder = (actions: AudioRecorderActions) => { const file = createRecordedFile(blob, recorderMimeTypeRef.current); const previewUrl = createBlobUrl(file); - actions.onRecordingComplete({ - file, - previewUrl, - origin: "audio_recording", - audioMeta: { - durationSeconds, + actions.onRecordingComplete( + { + file, + previewUrl, + origin: "audio_recording", + audioMeta: { + durationSeconds, + }, }, - }); + completionMode, + ); actions.setAudioRecorderElapsed(0); actions.setAudioRecorderError(undefined); actions.setAudioRecorderStatus("idle"); @@ -203,17 +212,20 @@ export const useAudioRecorder = (actions: AudioRecorderActions) => { } }; - const stopRecording = () => { + const stopRecording = (mode: AudioRecordingCompleteMode = "attach") => { if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") { - return; + return false; } + completionModeRef.current = mode; cleanupTimer(); mediaRecorderRef.current.stop(); + return true; }; const resetRecording = () => { startRequestIdRef.current += 1; + completionModeRef.current = "attach"; resetRecorderRefs(); actions.setAudioRecorderElapsed(0); actions.setAudioRecorderError(undefined); diff --git a/web/src/components/MemoEditor/hooks/useAutoSave.ts b/web/src/components/MemoEditor/hooks/useAutoSave.ts index 556938f1..18eb903e 100644 --- a/web/src/components/MemoEditor/hooks/useAutoSave.ts +++ b/web/src/components/MemoEditor/hooks/useAutoSave.ts @@ -1,9 +1,58 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { cacheService } from "../services"; -export const useAutoSave = (content: string, username: string, cacheKey: string | undefined) => { +export const useAutoSave = (content: string, username: string, cacheKey: string | undefined, enabled = true) => { + const latestContentRef = useRef(content); + const discardedContentRef = useRef(undefined); + + useEffect(() => { + latestContentRef.current = content; + if (discardedContentRef.current !== undefined && discardedContentRef.current !== content) { + discardedContentRef.current = undefined; + } + }, [content]); + useEffect(() => { + if (!enabled) return; + const key = cacheService.key(username, cacheKey); cacheService.save(key, content); - }, [content, username, cacheKey]); + }, [content, username, cacheKey, enabled]); + + useEffect(() => { + if (!enabled) return; + + const key = cacheService.key(username, cacheKey); + const flushDraft = () => { + if (discardedContentRef.current === latestContentRef.current) { + return; + } + + cacheService.saveNow(key, latestContentRef.current); + }; + const handleVisibilityChange = () => { + if (document.visibilityState === "hidden") { + flushDraft(); + } + }; + + window.addEventListener("pagehide", flushDraft); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + // Flush on unmount (e.g. editor closes) to ensure the draft is persisted + // before the component is torn down — distinct from the visibility flush above. + flushDraft(); + window.removeEventListener("pagehide", flushDraft); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [username, cacheKey, enabled]); + + const discardDraft = useCallback(() => { + const key = cacheService.key(username, cacheKey); + discardedContentRef.current = latestContentRef.current; + cacheService.clear(key); + }, [username, cacheKey]); + + return { discardDraft }; }; diff --git a/web/src/components/MemoEditor/hooks/useFileUpload.ts b/web/src/components/MemoEditor/hooks/useFileUpload.ts index 66bda31c..ae69db2b 100644 --- a/web/src/components/MemoEditor/hooks/useFileUpload.ts +++ b/web/src/components/MemoEditor/hooks/useFileUpload.ts @@ -1,4 +1,6 @@ +import { create } from "@bufbuild/protobuf"; import { useRef } from "react"; +import { type MotionMedia, MotionMediaFamily, MotionMediaRole, MotionMediaSchema } from "@/types/proto/api/v1/attachment_service_pb"; import type { LocalFile } from "../types/attachment"; export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void) => { @@ -11,18 +13,26 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void return; } selectingFlagRef.current = true; - const localFiles: LocalFile[] = files.map((file) => ({ - file, - previewUrl: URL.createObjectURL(file), - })); + const localFiles: LocalFile[] = pairAppleLivePhotoFiles( + files.map((file) => ({ + file, + previewUrl: URL.createObjectURL(file), + origin: "upload", + })), + ); onFilesSelected(localFiles); selectingFlagRef.current = false; // Optionally clear input value to allow re-selecting the same file if (fileInputRef.current) fileInputRef.current.value = ""; }; - const handleUploadClick = () => { - fileInputRef.current?.click(); + const handleUploadClick = (accept = "*") => { + if (!fileInputRef.current) { + return; + } + + fileInputRef.current.accept = accept; + fileInputRef.current.click(); }; return { @@ -32,3 +42,53 @@ export const useFileUpload = (onFilesSelected: (localFiles: LocalFile[]) => void handleUploadClick, }; }; + +const pairAppleLivePhotoFiles = (localFiles: LocalFile[]): LocalFile[] => { + const stemMap = new Map(); + for (const localFile of localFiles) { + const stem = normalizeFilenameStem(localFile.file.name); + const group = stemMap.get(stem) ?? []; + group.push(localFile); + stemMap.set(stem, group); + } + + const groupIds = new Map(); + return localFiles.map((localFile) => { + const stem = normalizeFilenameStem(localFile.file.name); + const group = stemMap.get(stem) ?? []; + const images = group.filter((item) => item.file.type.startsWith("image/")); + const videos = group.filter((item) => item.file.type.startsWith("video/")); + if (images.length !== 1 || videos.length !== 1) { + return localFile; + } + + const image = images[0]; + const video = videos[0]; + const groupId = groupIds.get(stem) ?? `${stem}-${crypto.randomUUID()}`; + groupIds.set(stem, groupId); + if (localFile.previewUrl === image.previewUrl) { + return { ...localFile, motionMedia: buildLocalMotionMedia(groupId, MotionMediaRole.STILL) }; + } + if (localFile.previewUrl === video.previewUrl) { + return { ...localFile, motionMedia: buildLocalMotionMedia(groupId, MotionMediaRole.VIDEO) }; + } + return localFile; + }); +}; + +const buildLocalMotionMedia = (groupId: string, role: MotionMediaRole): MotionMedia => + create(MotionMediaSchema, { + family: MotionMediaFamily.APPLE_LIVE_PHOTO, + role, + groupId, + presentationTimestampUs: 0n, + hasEmbeddedVideo: false, + }); + +const normalizeFilenameStem = (filename: string): string => { + const parts = filename.split("."); + if (parts.length <= 1) { + return filename.toLowerCase(); + } + return parts.slice(0, -1).join(".").toLowerCase(); +}; diff --git a/web/src/components/MemoEditor/hooks/useMemoInit.ts b/web/src/components/MemoEditor/hooks/useMemoInit.ts index 704a6467..853ce6fc 100644 --- a/web/src/components/MemoEditor/hooks/useMemoInit.ts +++ b/web/src/components/MemoEditor/hooks/useMemoInit.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; import type { Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb"; import type { EditorRefActions } from "../Editor"; import { cacheService, memoService } from "../services"; @@ -16,15 +16,19 @@ interface UseMemoInitOptions { export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, defaultVisibility }: UseMemoInitOptions) => { const { actions, dispatch } = useEditorContext(); const initializedRef = useRef(false); + const [isInitialized, setIsInitialized] = useState(false); useEffect(() => { if (initializedRef.current) return; initializedRef.current = true; + const key = cacheService.key(username, cacheKey); if (memo) { - dispatch(actions.initMemo(memoService.fromMemo(memo))); + const initialState = memoService.fromMemo(memo); + cacheService.clear(key); + dispatch(actions.initMemo(initialState)); } else { - const cachedContent = cacheService.load(cacheService.key(username, cacheKey)); + const cachedContent = cacheService.load(key); if (cachedContent) { dispatch(actions.updateContent(cachedContent)); } @@ -36,5 +40,9 @@ export const useMemoInit = ({ editorRef, memo, cacheKey, username, autoFocus, de if (autoFocus) { setTimeout(() => editorRef.current?.focus(), 100); } + + setIsInitialized(true); }, [memo, cacheKey, username, autoFocus, defaultVisibility, actions, dispatch, editorRef]); + + return { isInitialized }; }; diff --git a/web/src/components/MemoEditor/hooks/useVoiceRecorder.ts b/web/src/components/MemoEditor/hooks/useVoiceRecorder.ts deleted file mode 100644 index eb21d4a6..00000000 --- a/web/src/components/MemoEditor/hooks/useVoiceRecorder.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { useEffect, useRef } from "react"; -import type { LocalFile } from "../types/attachment"; -import { useBlobUrls } from "./useBlobUrls"; - -const FALLBACK_AUDIO_MIME_TYPE = "audio/webm"; - -interface VoiceRecorderActions { - setVoiceRecorderSupport: (value: boolean) => void; - setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => void; - setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") => void; - setVoiceRecorderElapsed: (value: number) => void; - setVoiceRecorderError: (value?: string) => void; - setVoiceRecording: (value?: { localFile: LocalFile; durationSeconds: number; mimeType: string }) => void; -} - -const AUDIO_MIME_TYPE_CANDIDATES = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"] as const; - -function getSupportedAudioMimeType(): string | undefined { - if (typeof window === "undefined" || typeof MediaRecorder === "undefined") { - return undefined; - } - - for (const candidate of AUDIO_MIME_TYPE_CANDIDATES) { - if (MediaRecorder.isTypeSupported(candidate)) { - return candidate; - } - } - - return undefined; -} - -function getFileExtension(mimeType: string): string { - if (mimeType.includes("ogg")) return "ogg"; - if (mimeType.includes("mp4")) return "m4a"; - return "webm"; -} - -function createRecordedFile(blob: Blob, mimeType: string): File { - const extension = getFileExtension(mimeType); - const now = new Date(); - const datePart = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, "0"), String(now.getDate()).padStart(2, "0")].join(""); - const timePart = [String(now.getHours()).padStart(2, "0"), String(now.getMinutes()).padStart(2, "0")].join(""); - return new File([blob], `voice-note-${datePart}-${timePart}.${extension}`, { type: mimeType }); -} - -export const useVoiceRecorder = (actions: VoiceRecorderActions) => { - const mediaRecorderRef = useRef(null); - const mediaStreamRef = useRef(null); - const chunksRef = useRef([]); - const startedAtRef = useRef(null); - const elapsedTimerRef = useRef(null); - const recorderMimeTypeRef = useRef(FALLBACK_AUDIO_MIME_TYPE); - const { createBlobUrl } = useBlobUrls(); - - const cleanupTimer = () => { - if (elapsedTimerRef.current !== null) { - window.clearInterval(elapsedTimerRef.current); - elapsedTimerRef.current = null; - } - }; - - const cleanupStream = () => { - mediaStreamRef.current?.getTracks().forEach((track) => track.stop()); - mediaStreamRef.current = null; - }; - - const resetRecorderRefs = () => { - cleanupTimer(); - cleanupStream(); - mediaRecorderRef.current = null; - chunksRef.current = []; - startedAtRef.current = null; - }; - - useEffect(() => { - const isSupported = - typeof window !== "undefined" && - typeof navigator !== "undefined" && - typeof navigator.mediaDevices?.getUserMedia === "function" && - typeof MediaRecorder !== "undefined"; - - actions.setVoiceRecorderSupport(isSupported); - if (!isSupported) { - actions.setVoiceRecorderStatus("unsupported"); - actions.setVoiceRecorderError("Voice recording is not supported in this browser."); - return; - } - - actions.setVoiceRecorderStatus("idle"); - actions.setVoiceRecorderError(undefined); - - return () => { - resetRecorderRefs(); - }; - }, [actions]); - - const startRecording = async () => { - if ( - typeof navigator === "undefined" || - typeof navigator.mediaDevices?.getUserMedia !== "function" || - typeof MediaRecorder === "undefined" - ) { - actions.setVoiceRecorderSupport(false); - actions.setVoiceRecorderStatus("unsupported"); - actions.setVoiceRecorderError("Voice recording is not supported in this browser."); - return; - } - - actions.setVoiceRecorderError(undefined); - actions.setVoiceRecorderStatus("requesting_permission"); - actions.setVoiceRecorderElapsed(0); - actions.setVoiceRecording(undefined); - - try { - const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - const mimeType = getSupportedAudioMimeType() ?? FALLBACK_AUDIO_MIME_TYPE; - const mediaRecorder = new MediaRecorder(stream, getSupportedAudioMimeType() ? { mimeType } : undefined); - - recorderMimeTypeRef.current = mimeType; - mediaStreamRef.current = stream; - mediaRecorderRef.current = mediaRecorder; - chunksRef.current = []; - - mediaRecorder.addEventListener("dataavailable", (event) => { - if (event.data.size > 0) { - chunksRef.current.push(event.data); - } - }); - - mediaRecorder.addEventListener("stop", () => { - const durationSeconds = startedAtRef.current ? Math.max(0, Math.round((Date.now() - startedAtRef.current) / 1000)) : 0; - const blob = new Blob(chunksRef.current, { type: recorderMimeTypeRef.current }); - const file = createRecordedFile(blob, recorderMimeTypeRef.current); - const previewUrl = createBlobUrl(file); - - actions.setVoiceRecording({ - localFile: { - file, - previewUrl, - }, - durationSeconds, - mimeType: recorderMimeTypeRef.current, - }); - actions.setVoiceRecorderElapsed(durationSeconds); - actions.setVoiceRecorderStatus("recorded"); - resetRecorderRefs(); - }); - - mediaRecorder.start(); - startedAtRef.current = Date.now(); - actions.setVoiceRecorderPermission("granted"); - actions.setVoiceRecorderStatus("recording"); - - elapsedTimerRef.current = window.setInterval(() => { - if (startedAtRef.current) { - actions.setVoiceRecorderElapsed(Math.max(0, Math.floor((Date.now() - startedAtRef.current) / 1000))); - } - }, 250); - } catch (error) { - const permissionDenied = - error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "PermissionDeniedError"); - - actions.setVoiceRecorderPermission(permissionDenied ? "denied" : "unknown"); - actions.setVoiceRecorderStatus("error"); - actions.setVoiceRecorderError(permissionDenied ? "Microphone permission was denied." : "Failed to start voice recording."); - resetRecorderRefs(); - } - }; - - const stopRecording = () => { - if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") { - return; - } - - cleanupTimer(); - mediaRecorderRef.current.stop(); - }; - - const resetRecording = () => { - resetRecorderRefs(); - actions.setVoiceRecorderElapsed(0); - actions.setVoiceRecorderError(undefined); - actions.setVoiceRecording(undefined); - actions.setVoiceRecorderStatus("idle"); - }; - - return { - startRecording, - stopRecording, - resetRecording, - }; -}; diff --git a/web/src/components/MemoEditor/index.tsx b/web/src/components/MemoEditor/index.tsx index 38798c31..248e85a4 100644 --- a/web/src/components/MemoEditor/index.tsx +++ b/web/src/components/MemoEditor/index.tsx @@ -1,29 +1,37 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "react-hot-toast"; import { useAuth } from "@/contexts/AuthContext"; +import { useInstance } from "@/contexts/InstanceContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { memoKeys } from "@/hooks/useMemoQueries"; import { userKeys } from "@/hooks/useUserQueries"; import { handleError } from "@/lib/error"; import { cn } from "@/lib/utils"; +import { InstanceSetting_AIProviderType, InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb"; import { useTranslate } from "@/utils/i18n"; import { convertVisibilityFromString } from "@/utils/memo"; import { + AudioRecorderPanel, EditorContent, EditorMetadata, EditorToolbar, FocusModeExitButton, FocusModeOverlay, TimestampPopover, - VoiceRecorderPanel, } from "./components"; import { FOCUS_MODE_STYLES } from "./constants"; import type { EditorRefActions } from "./Editor"; -import { useAutoSave, useFocusMode, useKeyboard, useMemoInit, useVoiceRecorder } from "./hooks"; -import { cacheService, errorService, memoService, validationService } from "./services"; +import { useAudioRecorder, useAutoSave, useFocusMode, useKeyboard, useMemoInit } from "./hooks"; +import { errorService, memoService, transcriptionService, validationService } from "./services"; import { EditorProvider, useEditorContext } from "./state"; import type { MemoEditorProps } from "./types"; +import type { LocalFile } from "./types/attachment"; + +const TRANSCRIPTION_PROVIDER_TYPES: InstanceSetting_AIProviderType[] = [ + InstanceSetting_AIProviderType.OPENAI, + InstanceSetting_AIProviderType.GEMINI, +]; const MemoEditor = (props: MemoEditorProps) => ( @@ -47,87 +55,158 @@ const MemoEditorImpl: React.FC = ({ const editorRef = useRef(null); const { state, actions, dispatch } = useEditorContext(); const { userGeneralSetting } = useAuth(); - const [isVoiceRecorderOpen, setIsVoiceRecorderOpen] = useState(false); + const { aiSetting, fetchSetting } = useInstance(); + const [isAudioRecorderOpen, setIsAudioRecorderOpen] = useState(false); + const [isTranscribingAudio, setIsTranscribingAudio] = useState(false); const memoName = memo?.name; + const transcriptionProvider = useMemo( + () => aiSetting.providers.find((provider) => provider.apiKeySet && TRANSCRIPTION_PROVIDER_TYPES.includes(provider.type)), + [aiSetting.providers], + ); // Get default visibility from user settings const defaultVisibility = userGeneralSetting?.memoVisibility ? convertVisibilityFromString(userGeneralSetting.memoVisibility) : undefined; - useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility }); + const { isInitialized } = useMemoInit({ editorRef, memo, cacheKey, username: currentUser?.name ?? "", autoFocus, defaultVisibility }); + const isDraftCacheEnabled = !memo; // Auto-save content to localStorage - useAutoSave(state.content, currentUser?.name ?? "", cacheKey); + const { discardDraft } = useAutoSave(state.content, currentUser?.name ?? "", cacheKey, isInitialized && isDraftCacheEnabled); // Focus mode management with body scroll lock useFocusMode(state.ui.isFocusMode); - const voiceRecorderActions = useMemo( + useEffect(() => { + if (!currentUser) { + return; + } + + void fetchSetting(InstanceSetting_Key.AI).catch(() => undefined); + }, [currentUser, fetchSetting]); + + const insertTranscribedText = useCallback((text: string) => { + const editor = editorRef.current; + if (!editor) { + return; + } + + const content = editor.getContent(); + const cursor = editor.getCursorPosition(); + const beforeCursor = content.slice(0, cursor); + const afterCursor = content.slice(cursor); + const prefix = beforeCursor.length === 0 || beforeCursor.endsWith("\n\n") ? "" : beforeCursor.endsWith("\n") ? "\n" : "\n\n"; + const suffix = afterCursor.length === 0 || afterCursor.startsWith("\n\n") ? "" : afterCursor.startsWith("\n") ? "\n" : "\n\n"; + + editor.insertText(text, prefix, suffix); + editor.scrollToCursor(); + }, []); + + const handleTranscribeRecordedAudio = useCallback( + async (localFile: LocalFile) => { + if (!transcriptionProvider) { + dispatch(actions.addLocalFile(localFile)); + setIsTranscribingAudio(false); + setIsAudioRecorderOpen(false); + return; + } + + try { + const text = (await transcriptionService.transcribeFile(localFile.file, transcriptionProvider)).trim(); + if (!text) { + dispatch(actions.addLocalFile(localFile)); + toast.error(t("editor.audio-recorder.transcribe-empty")); + return; + } + + insertTranscribedText(text); + toast.success(t("editor.audio-recorder.transcribe-success")); + } catch (error) { + console.error(error); + toast.error(errorService.getErrorMessage(error) || t("editor.audio-recorder.transcribe-error")); + dispatch(actions.addLocalFile(localFile)); + } finally { + setIsTranscribingAudio(false); + setIsAudioRecorderOpen(false); + } + }, + [actions, dispatch, insertTranscribedText, t, transcriptionProvider], + ); + + const audioRecorderActions = useMemo( () => ({ - setVoiceRecorderSupport: (value: boolean) => dispatch(actions.setVoiceRecorderSupport(value)), - setVoiceRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setVoiceRecorderPermission(value)), - setVoiceRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported") => - dispatch(actions.setVoiceRecorderStatus(value)), - setVoiceRecorderElapsed: (value: number) => dispatch(actions.setVoiceRecorderElapsed(value)), - setVoiceRecorderError: (value?: string) => dispatch(actions.setVoiceRecorderError(value)), - setVoiceRecording: (value?: typeof state.voiceRecorder.recording) => dispatch(actions.setVoiceRecording(value)), + setAudioRecorderSupport: (value: boolean) => dispatch(actions.setAudioRecorderSupport(value)), + setAudioRecorderPermission: (value: "unknown" | "granted" | "denied") => dispatch(actions.setAudioRecorderPermission(value)), + setAudioRecorderStatus: (value: "idle" | "requesting_permission" | "recording" | "error" | "unsupported") => + dispatch(actions.setAudioRecorderStatus(value)), + setAudioRecorderElapsed: (value: number) => dispatch(actions.setAudioRecorderElapsed(value)), + setAudioRecorderError: (value?: string) => dispatch(actions.setAudioRecorderError(value)), + onRecordingComplete: (localFile: LocalFile, mode: "attach" | "transcribe") => { + if (mode === "transcribe") { + void handleTranscribeRecordedAudio(localFile); + return; + } + + dispatch(actions.addLocalFile(localFile)); + setIsAudioRecorderOpen(false); + }, + onRecordingEmpty: (mode: "attach" | "transcribe") => { + if (mode === "transcribe") { + setIsTranscribingAudio(false); + toast.error(t("editor.audio-recorder.transcribe-empty")); + } + setIsAudioRecorderOpen(false); + }, }), - [actions, dispatch], + [actions, dispatch, handleTranscribeRecordedAudio, t], ); - const voiceRecorder = useVoiceRecorder(voiceRecorderActions); + const audioRecorder = useAudioRecorder(audioRecorderActions); + + useEffect(() => { + if (!isAudioRecorderOpen) { + return; + } + + if (state.audioRecorder.status === "error" || state.audioRecorder.status === "unsupported") { + toast.error(state.audioRecorder.error || t("editor.audio-recorder.error-description")); + setIsAudioRecorderOpen(false); + } + }, [isAudioRecorderOpen, state.audioRecorder.error, state.audioRecorder.status, t]); const handleToggleFocusMode = () => { dispatch(actions.toggleFocusMode()); }; - const handleStartVoiceRecording = async () => { - setIsVoiceRecorderOpen(true); - await voiceRecorder.startRecording(); + const handleStartAudioRecording = async () => { + setIsAudioRecorderOpen(true); + await audioRecorder.startRecording(); }; - const handleVoiceRecorderClick = () => { - setIsVoiceRecorderOpen(true); - - if ( - state.voiceRecorder.status === "recording" || - state.voiceRecorder.status === "requesting_permission" || - state.voiceRecorder.status === "recorded" - ) { + const handleAudioRecorderClick = () => { + if (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") { return; } - void handleStartVoiceRecording(); + void handleStartAudioRecording(); }; - const handleKeepVoiceRecording = () => { - const recording = state.voiceRecorder.recording; - if (!recording) { - return; - } - - dispatch(actions.addLocalFile(recording.localFile)); - voiceRecorder.resetRecording(); - setIsVoiceRecorderOpen(false); - }; - - const handleDiscardVoiceRecording = () => { - voiceRecorder.resetRecording(); - setIsVoiceRecorderOpen(false); + const handleCancelAudioRecording = () => { + setIsTranscribingAudio(false); + audioRecorder.resetRecording(); + setIsAudioRecorderOpen(false); }; - const handleCloseVoiceRecorder = () => { - if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") { + const handleTranscribeAudioRecording = () => { + if (!transcriptionProvider || isTranscribingAudio) { return; } - voiceRecorder.resetRecording(); - setIsVoiceRecorderOpen(false); - }; - - const handleRecordAgain = async () => { - voiceRecorder.resetRecording(); - await handleStartVoiceRecording(); + setIsTranscribingAudio(true); + const didStop = audioRecorder.stopRecording("transcribe"); + if (!didStop) { + setIsTranscribingAudio(false); + } }; useKeyboard(editorRef, handleSave); @@ -151,8 +230,9 @@ const MemoEditorImpl: React.FC = ({ return; } - // Clear localStorage cache on successful save - cacheService.clear(cacheService.key(currentUser?.name ?? "", cacheKey)); + // Clear localStorage cache on successful save and prevent the unmount + // flush from writing the just-saved content back as a stale draft. + discardDraft(); // Invalidate React Query cache to refresh memo lists across the app const invalidationPromises = [ @@ -220,22 +300,23 @@ const MemoEditorImpl: React.FC = ({ {/* Editor content grows to fill available space in focus mode */} - {isVoiceRecorderOpen && ( - void handleStartVoiceRecording()} - onStop={voiceRecorder.stopRecording} - onKeep={handleKeepVoiceRecording} - onDiscard={handleDiscardVoiceRecording} - onRecordAgain={() => void handleRecordAgain()} - onClose={handleCloseVoiceRecorder} - /> - )} + {isAudioRecorderOpen && + (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission" || isTranscribingAudio) && ( + + )} {/* Metadata and toolbar grouped together at bottom */}
- +
diff --git a/web/src/components/MemoEditor/services/cacheService.ts b/web/src/components/MemoEditor/services/cacheService.ts index 5c93e3b4..07826f24 100644 --- a/web/src/components/MemoEditor/services/cacheService.ts +++ b/web/src/components/MemoEditor/services/cacheService.ts @@ -1,6 +1,33 @@ export const CACHE_DEBOUNCE_DELAY = 500; const pendingSaves = new Map>(); +const STRUCTURED_CACHE_ENTRY_KIND = "memos.editor-cache"; +const STRUCTURED_CACHE_ENTRY_VERSION = 1; + +function deserializeContent(raw: string): string { + try { + const parsed = JSON.parse(raw) as { kind?: unknown; version?: unknown; content?: unknown }; + if ( + parsed.kind === STRUCTURED_CACHE_ENTRY_KIND && + parsed.version === STRUCTURED_CACHE_ENTRY_VERSION && + typeof parsed.content === "string" + ) { + return parsed.content; + } + } catch { + // Drafts have historically been stored as raw markdown strings. + } + + return raw; +} + +function writeEntry(key: string, content: string): void { + if (content.trim()) { + localStorage.setItem(key, content); + } else { + localStorage.removeItem(key); + } +} export const cacheService = { key: (username: string, cacheKey?: string): string => { @@ -16,18 +43,25 @@ export const cacheService = { const timeoutId = window.setTimeout(() => { pendingSaves.delete(key); - if (content.trim()) { - localStorage.setItem(key, content); - } else { - localStorage.removeItem(key); - } + writeEntry(key, content); }, CACHE_DEBOUNCE_DELAY); pendingSaves.set(key, timeoutId); }, + saveNow: (key: string, content: string) => { + const pendingSave = pendingSaves.get(key); + if (pendingSave) { + window.clearTimeout(pendingSave); + pendingSaves.delete(key); + } + + writeEntry(key, content); + }, + load(key: string): string { - return localStorage.getItem(key) || ""; + const raw = localStorage.getItem(key); + return raw ? deserializeContent(raw) : ""; }, clear(key: string): void { diff --git a/web/src/components/MemoEditor/services/errorService.ts b/web/src/components/MemoEditor/services/errorService.ts index 76ce3bbe..7913fd88 100644 --- a/web/src/components/MemoEditor/services/errorService.ts +++ b/web/src/components/MemoEditor/services/errorService.ts @@ -1,5 +1,9 @@ export const errorService = { getErrorMessage(error: unknown): string { + if (error && typeof error === "object" && "rawMessage" in error) { + return (error as { rawMessage?: string }).rawMessage || "An error occurred"; + } + // Handle ConnectError or errors with details property if (error && typeof error === "object" && "details" in error) { return (error as { details?: string }).details || "An error occurred"; diff --git a/web/src/components/MemoEditor/services/index.ts b/web/src/components/MemoEditor/services/index.ts index 7b9fb3f4..5bd92a58 100644 --- a/web/src/components/MemoEditor/services/index.ts +++ b/web/src/components/MemoEditor/services/index.ts @@ -1,5 +1,6 @@ export * from "./cacheService"; export * from "./errorService"; export * from "./memoService"; +export * from "./transcriptionService"; export * from "./uploadService"; export * from "./validationService"; diff --git a/web/src/components/MemoEditor/services/memoService.ts b/web/src/components/MemoEditor/services/memoService.ts index d6df24bb..20eee213 100644 --- a/web/src/components/MemoEditor/services/memoService.ts +++ b/web/src/components/MemoEditor/services/memoService.ts @@ -2,20 +2,25 @@ import { create } from "@bufbuild/protobuf"; import { FieldMaskSchema, timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt"; import { isEqual } from "lodash-es"; import { memoServiceClient } from "@/connect"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; -import { LocationSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; +import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb"; import type { EditorState } from "../state"; import { uploadService } from "./uploadService"; -function memoLocationFingerprint(l: Memo["location"]): string | null { - if (!l) return null; - return JSON.stringify([l.placeholder, l.latitude, l.longitude]); +/** + * Converts attachments to reference format for API requests. + * The backend only needs the attachment name to link it to a memo. + */ +function toAttachmentReferences(attachments: Attachment[]): Attachment[] { + return attachments.map((a) => create(AttachmentSchema, { name: a.name })); } function buildUpdateMask( prevMemo: Memo, state: EditorState, - _allAttachments: typeof state.metadata.attachments, + allAttachments: typeof state.metadata.attachments, ): { mask: Set; patch: Partial } { const mask = new Set(); const patch: Partial = { @@ -31,22 +36,21 @@ function buildUpdateMask( mask.add("visibility"); patch.visibility = state.metadata.visibility; } + if (!isEqual(allAttachments, prevMemo.attachments)) { + mask.add("attachments"); + patch.attachments = toAttachmentReferences(allAttachments); + } if (!isEqual(state.metadata.relations, prevMemo.relations)) { mask.add("relations"); patch.relations = state.metadata.relations; } - if (memoLocationFingerprint(state.metadata.location) !== memoLocationFingerprint(prevMemo.location)) { + if (!isEqual(state.metadata.location, prevMemo.location)) { mask.add("location"); - if (state.metadata.location) { - patch.location = create(LocationSchema, { - placeholder: state.metadata.location.placeholder, - latitude: state.metadata.location.latitude, - longitude: state.metadata.location.longitude, - }); - } + patch.location = state.metadata.location; } - if (["content", "relations"].some((key) => mask.has(key))) { + // Auto-update timestamp if content changed + if (["content", "attachments", "relations", "location"].some((key) => mask.has(key))) { mask.add("update_time"); } @@ -78,8 +82,8 @@ export const memoService = { }, ): Promise<{ memoName: string; hasChanges: boolean }> { // 1. Upload local files first - const uploadedAttachments = await uploadService.uploadFiles(state.localFiles); - const allAttachments = [...state.metadata.attachments, ...uploadedAttachments]; + const newAttachments = await uploadService.uploadFiles(state.localFiles); + const allAttachments = [...state.metadata.attachments, ...newAttachments]; // 2. Update existing memo if (options.memoName) { @@ -94,10 +98,6 @@ export const memoService = { memo: create(MemoSchema, patch as Record), updateMask: create(FieldMaskSchema, { paths: Array.from(mask) }), }); - await memoServiceClient.setMemoAttachments({ - name: memo.name, - attachments: allAttachments, - }); return { memoName: memo.name, hasChanges: true }; } @@ -105,18 +105,11 @@ export const memoService = { const memoData = create(MemoSchema, { content: state.content, visibility: state.metadata.visibility, + attachments: toAttachmentReferences(allAttachments), relations: state.metadata.relations, + location: state.metadata.location, createTime: state.timestamps.createTime ? timestampFromDate(state.timestamps.createTime) : undefined, updateTime: state.timestamps.updateTime ? timestampFromDate(state.timestamps.updateTime) : undefined, - ...(state.metadata.location - ? { - location: create(LocationSchema, { - placeholder: state.metadata.location.placeholder, - latitude: state.metadata.location.latitude, - longitude: state.metadata.location.longitude, - }), - } - : {}), }); const memo = options.parentMemoName @@ -126,13 +119,6 @@ export const memoService = { }) : await memoServiceClient.createMemo({ memo: memoData }); - if (allAttachments.length > 0) { - await memoServiceClient.setMemoAttachments({ - name: memo.name, - attachments: allAttachments, - }); - } - return { memoName: memo.name, hasChanges: true }; }, @@ -156,13 +142,12 @@ export const memoService = { updateTime: memo.updateTime ? timestampDate(memo.updateTime) : undefined, }, localFiles: [], - voiceRecorder: { + audioRecorder: { isSupported: true, permission: "unknown", status: "idle", elapsedSeconds: 0, error: undefined, - recording: undefined, }, }; }, diff --git a/web/src/components/MemoEditor/services/transcriptionService.ts b/web/src/components/MemoEditor/services/transcriptionService.ts new file mode 100644 index 00000000..74c078bf --- /dev/null +++ b/web/src/components/MemoEditor/services/transcriptionService.ts @@ -0,0 +1,27 @@ +import { aiServiceClient } from "@/connect"; +import type { InstanceSetting_AIProviderConfig } from "@/types/proto/api/v1/instance_service_pb"; + +function bytesToBase64(bytes: Uint8Array): string { + let bin = ""; + for (const byte of bytes) { + bin += String.fromCharCode(byte); + } + return btoa(bin); +} + +export const transcriptionService = { + async transcribeFile(file: File, provider: InstanceSetting_AIProviderConfig): Promise { + const content = new Uint8Array(await file.arrayBuffer()); + const response = await aiServiceClient.transcribe({ + providerId: provider.id, + config: {}, + audio: { + content: bytesToBase64(content), + filename: file.name, + contentType: file.type, + }, + }); + + return response.text; + }, +}; diff --git a/web/src/components/MemoEditor/services/uploadService.ts b/web/src/components/MemoEditor/services/uploadService.ts index 7cf04c2e..404eae58 100644 --- a/web/src/components/MemoEditor/services/uploadService.ts +++ b/web/src/components/MemoEditor/services/uploadService.ts @@ -1,24 +1,30 @@ import { create } from "@bufbuild/protobuf"; import { attachmentServiceClient } from "@/connect"; -import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import { AttachmentSchema, MotionMediaSchema } from "@/types/proto/api/v1/attachment_service_pb"; import type { LocalFile } from "../types/attachment"; export const uploadService = { async uploadFiles(localFiles: LocalFile[]): Promise { - const uploaded: Attachment[] = []; - for (const local of localFiles) { - const file = local.file; - const content = new Uint8Array(await file.arrayBuffer()); - const created = await attachmentServiceClient.createAttachment({ + if (localFiles.length === 0) return []; + + const attachments: Attachment[] = []; + + for (const localFile of localFiles) { + const { file, motionMedia } = localFile; + const buffer = new Uint8Array(await file.arrayBuffer()); + const attachment = await attachmentServiceClient.createAttachment({ attachment: create(AttachmentSchema, { filename: file.name, - content, - type: file.type || "application/octet-stream", + size: BigInt(file.size), + type: file.type, + content: buffer, + motionMedia: motionMedia ? create(MotionMediaSchema, motionMedia) : undefined, }), }); - uploaded.push(created); + attachments.push(attachment); } - return uploaded; + + return attachments; }, }; diff --git a/web/src/components/MemoEditor/services/validationService.ts b/web/src/components/MemoEditor/services/validationService.ts index ac32c150..8c2509dc 100644 --- a/web/src/components/MemoEditor/services/validationService.ts +++ b/web/src/components/MemoEditor/services/validationService.ts @@ -22,9 +22,9 @@ export const validationService = { return { valid: false, reason: "Wait for upload to complete" }; } - // Cannot save while voice recorder is active - if (state.voiceRecorder.status === "recording" || state.voiceRecorder.status === "requesting_permission") { - return { valid: false, reason: "Finish voice recording before saving" }; + // Cannot save while audio recorder is active + if (state.audioRecorder.status === "recording" || state.audioRecorder.status === "requesting_permission") { + return { valid: false, reason: "Finish audio recording before saving" }; } // Cannot save while already saving diff --git a/web/src/components/MemoEditor/state/actions.ts b/web/src/components/MemoEditor/state/actions.ts index 3b0b1be9..392a96bb 100644 --- a/web/src/components/MemoEditor/state/actions.ts +++ b/web/src/components/MemoEditor/state/actions.ts @@ -1,7 +1,7 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { MemoRelation } from "@/types/proto/api/v1/memo_service_pb"; import type { LocalFile } from "../types/attachment"; -import type { EditorAction, EditorState, LoadingKey, VoiceRecorderPermission, VoiceRecorderStatus, VoiceRecordingPreview } from "./types"; +import type { AudioRecorderPermission, AudioRecorderStatus, EditorAction, EditorState, LoadingKey } from "./types"; export const editorActions = { initMemo: (payload: { content: string; metadata: EditorState["metadata"]; timestamps: EditorState["timestamps"] }): EditorAction => ({ @@ -49,6 +49,11 @@ export const editorActions = { payload: previewUrl, }), + setLocalFiles: (files: LocalFile[]): EditorAction => ({ + type: "SET_LOCAL_FILES", + payload: files, + }), + clearLocalFiles: (): EditorAction => ({ type: "CLEAR_LOCAL_FILES", }), @@ -72,33 +77,28 @@ export const editorActions = { payload: timestamps, }), - setVoiceRecorderSupport: (value: boolean): EditorAction => ({ - type: "SET_VOICE_RECORDER_SUPPORT", - payload: value, - }), - - setVoiceRecorderPermission: (value: VoiceRecorderPermission): EditorAction => ({ - type: "SET_VOICE_RECORDER_PERMISSION", + setAudioRecorderSupport: (value: boolean): EditorAction => ({ + type: "SET_AUDIO_RECORDER_SUPPORT", payload: value, }), - setVoiceRecorderStatus: (value: VoiceRecorderStatus): EditorAction => ({ - type: "SET_VOICE_RECORDER_STATUS", + setAudioRecorderPermission: (value: AudioRecorderPermission): EditorAction => ({ + type: "SET_AUDIO_RECORDER_PERMISSION", payload: value, }), - setVoiceRecorderElapsed: (value: number): EditorAction => ({ - type: "SET_VOICE_RECORDER_ELAPSED", + setAudioRecorderStatus: (value: AudioRecorderStatus): EditorAction => ({ + type: "SET_AUDIO_RECORDER_STATUS", payload: value, }), - setVoiceRecorderError: (value?: string): EditorAction => ({ - type: "SET_VOICE_RECORDER_ERROR", + setAudioRecorderElapsed: (value: number): EditorAction => ({ + type: "SET_AUDIO_RECORDER_ELAPSED", payload: value, }), - setVoiceRecording: (value?: VoiceRecordingPreview): EditorAction => ({ - type: "SET_VOICE_RECORDING", + setAudioRecorderError: (value?: string): EditorAction => ({ + type: "SET_AUDIO_RECORDER_ERROR", payload: value, }), diff --git a/web/src/components/MemoEditor/state/reducer.ts b/web/src/components/MemoEditor/state/reducer.ts index 2ad6ee49..82028231 100644 --- a/web/src/components/MemoEditor/state/reducer.ts +++ b/web/src/components/MemoEditor/state/reducer.ts @@ -74,6 +74,12 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS localFiles: state.localFiles.filter((f) => f.previewUrl !== action.payload), }; + case "SET_LOCAL_FILES": + return { + ...state, + localFiles: action.payload, + }; + case "CLEAR_LOCAL_FILES": return { ...state, @@ -119,61 +125,52 @@ export function editorReducer(state: EditorState, action: EditorAction): EditorS }, }; - case "SET_VOICE_RECORDER_SUPPORT": + case "SET_AUDIO_RECORDER_SUPPORT": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, isSupported: action.payload, - status: action.payload ? state.voiceRecorder.status : "unsupported", + status: action.payload ? state.audioRecorder.status : "unsupported", }, }; - case "SET_VOICE_RECORDER_PERMISSION": + case "SET_AUDIO_RECORDER_PERMISSION": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, permission: action.payload, }, }; - case "SET_VOICE_RECORDER_STATUS": + case "SET_AUDIO_RECORDER_STATUS": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, status: action.payload, }, }; - case "SET_VOICE_RECORDER_ELAPSED": + case "SET_AUDIO_RECORDER_ELAPSED": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, elapsedSeconds: action.payload, }, }; - case "SET_VOICE_RECORDER_ERROR": + case "SET_AUDIO_RECORDER_ERROR": return { ...state, - voiceRecorder: { - ...state.voiceRecorder, + audioRecorder: { + ...state.audioRecorder, error: action.payload, }, }; - case "SET_VOICE_RECORDING": - return { - ...state, - voiceRecorder: { - ...state.voiceRecorder, - recording: action.payload, - }, - }; - case "RESET": return { ...initialState, diff --git a/web/src/components/MemoEditor/state/types.ts b/web/src/components/MemoEditor/state/types.ts index bae16313..f06c3336 100644 --- a/web/src/components/MemoEditor/state/types.ts +++ b/web/src/components/MemoEditor/state/types.ts @@ -4,14 +4,8 @@ import { Visibility } from "@/types/proto/api/v1/memo_service_pb"; import type { LocalFile } from "../types/attachment"; export type LoadingKey = "saving" | "uploading" | "loading"; -export type VoiceRecorderPermission = "unknown" | "granted" | "denied"; -export type VoiceRecorderStatus = "idle" | "requesting_permission" | "recording" | "recorded" | "error" | "unsupported"; - -export interface VoiceRecordingPreview { - localFile: LocalFile; - durationSeconds: number; - mimeType: string; -} +export type AudioRecorderPermission = "unknown" | "granted" | "denied"; +export type AudioRecorderStatus = "idle" | "requesting_permission" | "recording" | "error" | "unsupported"; export interface EditorState { content: string; @@ -35,13 +29,12 @@ export interface EditorState { updateTime?: Date; }; localFiles: LocalFile[]; - voiceRecorder: { + audioRecorder: { isSupported: boolean; - permission: VoiceRecorderPermission; - status: VoiceRecorderStatus; + permission: AudioRecorderPermission; + status: AudioRecorderStatus; elapsedSeconds: number; error?: string; - recording?: VoiceRecordingPreview; }; } @@ -55,17 +48,17 @@ export type EditorAction = | { type: "REMOVE_RELATION"; payload: string } | { type: "ADD_LOCAL_FILE"; payload: LocalFile } | { type: "REMOVE_LOCAL_FILE"; payload: string } + | { type: "SET_LOCAL_FILES"; payload: LocalFile[] } | { type: "CLEAR_LOCAL_FILES" } | { type: "TOGGLE_FOCUS_MODE" } | { type: "SET_LOADING"; payload: { key: LoadingKey; value: boolean } } | { type: "SET_COMPOSING"; payload: boolean } | { type: "SET_TIMESTAMPS"; payload: Partial } - | { type: "SET_VOICE_RECORDER_SUPPORT"; payload: boolean } - | { type: "SET_VOICE_RECORDER_PERMISSION"; payload: VoiceRecorderPermission } - | { type: "SET_VOICE_RECORDER_STATUS"; payload: VoiceRecorderStatus } - | { type: "SET_VOICE_RECORDER_ELAPSED"; payload: number } - | { type: "SET_VOICE_RECORDER_ERROR"; payload?: string } - | { type: "SET_VOICE_RECORDING"; payload?: VoiceRecordingPreview } + | { type: "SET_AUDIO_RECORDER_SUPPORT"; payload: boolean } + | { type: "SET_AUDIO_RECORDER_PERMISSION"; payload: AudioRecorderPermission } + | { type: "SET_AUDIO_RECORDER_STATUS"; payload: AudioRecorderStatus } + | { type: "SET_AUDIO_RECORDER_ELAPSED"; payload: number } + | { type: "SET_AUDIO_RECORDER_ERROR"; payload?: string } | { type: "RESET" }; export const initialState: EditorState = { @@ -90,12 +83,11 @@ export const initialState: EditorState = { updateTime: undefined, }, localFiles: [], - voiceRecorder: { + audioRecorder: { isSupported: true, permission: "unknown", status: "idle", elapsedSeconds: 0, error: undefined, - recording: undefined, }, }; diff --git a/web/src/components/MemoEditor/types/attachment.ts b/web/src/components/MemoEditor/types/attachment.ts index 4d5b914a..2cb0d675 100644 --- a/web/src/components/MemoEditor/types/attachment.ts +++ b/web/src/components/MemoEditor/types/attachment.ts @@ -1,11 +1,13 @@ -import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; +import type { Attachment, MotionMedia } from "@/types/proto/api/v1/attachment_service_pb"; +import { MotionMediaFamily, MotionMediaRole } from "@/types/proto/api/v1/attachment_service_pb"; import { getAttachmentThumbnailUrl, getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; +import { buildAttachmentVisualItems } from "@/utils/media-item"; -export type FileCategory = "image" | "video" | "audio" | "document"; +export type FileCategory = "image" | "video" | "motion" | "audio" | "document"; -// Unified view model for rendering attachments and local files export interface AttachmentItem { readonly id: string; + readonly memberIds: string[]; readonly filename: string; readonly category: FileCategory; readonly mimeType: string; @@ -13,31 +15,57 @@ export interface AttachmentItem { readonly sourceUrl: string; readonly size?: number; readonly isLocal: boolean; + readonly isVoiceNote: boolean; + readonly audioMeta?: LocalFile["audioMeta"]; } -// For MemoEditor: local files being uploaded export interface LocalFile { readonly file: File; readonly previewUrl: string; readonly origin?: "audio_recording" | "upload"; readonly audioMeta?: { - durationSeconds: number; + readonly durationSeconds: number; }; + readonly motionMedia?: MotionMedia; } -function categorizeFile(mimeType: string): FileCategory { +const AUDIO_RECORDING_FILENAME_RE = /^(?:voice-(?:recording|note)|audio-recording)-(\d{8})-(\d{4,6})/i; + +export const isAudioRecordingFilename = (filename: string): boolean => AUDIO_RECORDING_FILENAME_RE.test(filename); + +export const getAudioRecordingTimeLabel = (filename: string): string | undefined => { + const match = filename.match(AUDIO_RECORDING_FILENAME_RE); + const timePart = match?.[2]; + if (!timePart) { + return undefined; + } + + if (timePart.length === 4) { + return `${timePart.slice(0, 2)}:${timePart.slice(2, 4)}`; + } + + if (timePart.length === 6) { + return `${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}`; + } + + return undefined; +}; + +function categorizeFile(mimeType: string, motionMedia?: MotionMedia): FileCategory { + if (motionMedia) return "motion"; if (mimeType.startsWith("image/")) return "image"; if (mimeType.startsWith("video/")) return "video"; if (mimeType.startsWith("audio/")) return "audio"; return "document"; } -export function attachmentToItem(attachment: Attachment): AttachmentItem { +function attachmentGroupToItem(attachment: Attachment): AttachmentItem { const attachmentType = getAttachmentType(attachment); const sourceUrl = getAttachmentUrl(attachment); return { id: attachment.name, + memberIds: [attachment.name], filename: attachment.filename, category: categorizeFile(attachment.type), mimeType: attachment.type, @@ -45,24 +73,109 @@ export function attachmentToItem(attachment: Attachment): AttachmentItem { sourceUrl, size: Number(attachment.size), isLocal: false, + isVoiceNote: categorizeFile(attachment.type) === "audio" && isAudioRecordingFilename(attachment.filename), + audioMeta: undefined, + }; +} + +function visualItemToAttachmentItem(item: ReturnType[number]): AttachmentItem { + return { + id: item.id, + memberIds: item.attachmentNames, + filename: item.filename, + category: item.kind === "motion" ? "motion" : item.kind, + mimeType: item.mimeType, + thumbnailUrl: item.posterUrl, + sourceUrl: item.sourceUrl, + size: item.attachments.reduce((total, attachment) => total + Number(attachment.size), 0), + isLocal: false, + isVoiceNote: false, + audioMeta: undefined, }; } -export function fileToItem(file: File, blobUrl: string): AttachmentItem { +function fileToItem(file: LocalFile): AttachmentItem { return { - id: blobUrl, - filename: file.name, - category: categorizeFile(file.type), - mimeType: file.type, - thumbnailUrl: blobUrl, - sourceUrl: blobUrl, - size: file.size, + id: file.motionMedia?.groupId || file.previewUrl, + memberIds: [file.previewUrl], + filename: file.file.name, + category: categorizeFile(file.file.type, file.motionMedia), + mimeType: file.file.type, + thumbnailUrl: file.previewUrl, + sourceUrl: file.previewUrl, + size: file.file.size, isLocal: true, + isVoiceNote: + categorizeFile(file.file.type, file.motionMedia) === "audio" && + (file.origin === "audio_recording" || isAudioRecordingFilename(file.file.name)), + audioMeta: file.audioMeta, }; } +function toLocalMotionItems(localFiles: LocalFile[]): AttachmentItem[] { + const grouped = new Map(); + const singles: AttachmentItem[] = []; + + for (const localFile of localFiles) { + const groupId = localFile.motionMedia?.groupId; + if (!groupId) { + singles.push(fileToItem(localFile)); + continue; + } + + const group = grouped.get(groupId) ?? []; + group.push(localFile); + grouped.set(groupId, group); + } + + const groupedItems = Array.from(grouped.entries()).flatMap(([groupId, files]) => { + const still = files.find( + (file) => file.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && file.motionMedia.role === MotionMediaRole.STILL, + ); + const video = files.find( + (file) => file.motionMedia?.family === MotionMediaFamily.APPLE_LIVE_PHOTO && file.motionMedia.role === MotionMediaRole.VIDEO, + ); + if (still && video && files.length === 2) { + return [ + { + id: groupId, + memberIds: [still.previewUrl, video.previewUrl], + filename: still.file.name, + category: "motion" as const, + mimeType: still.file.type, + thumbnailUrl: still.previewUrl, + sourceUrl: video.previewUrl, + size: still.file.size + video.file.size, + isLocal: true, + isVoiceNote: false, + audioMeta: undefined, + }, + ]; + } + + return files.map(fileToItem); + }); + + return [...groupedItems, ...singles]; +} + export function toAttachmentItems(attachments: Attachment[], localFiles: LocalFile[] = []): AttachmentItem[] { - return [...attachments.map(attachmentToItem), ...localFiles.map(({ file, previewUrl }) => fileToItem(file, previewUrl))]; + const visualAttachments = attachments.filter((attachment) => { + const attachmentType = getAttachmentType(attachment); + return attachmentType === "image/*" || attachmentType === "video/*" || attachment.motionMedia !== undefined; + }); + const attachmentVisualIds = new Set(); + const attachmentVisualItems = buildAttachmentVisualItems(visualAttachments).map((item) => { + item.attachmentNames.forEach((name) => attachmentVisualIds.add(name)); + return visualItemToAttachmentItem(item); + }); + + const nonVisualAttachmentItems = attachments + .filter((attachment) => !attachmentVisualIds.has(attachment.name)) + .map(attachmentGroupToItem) + .filter((item) => item.category === "audio" || item.category === "document"); + + return [...attachmentVisualItems, ...nonVisualAttachmentItems, ...toLocalMotionItems(localFiles)]; } export function filterByCategory(items: AttachmentItem[], categories: FileCategory[]): AttachmentItem[] { @@ -75,7 +188,7 @@ export function separateMediaAndDocs(items: AttachmentItem[]): { media: Attachme const docs: AttachmentItem[] = []; for (const item of items) { - if (item.category === "image" || item.category === "video") { + if (item.category === "image" || item.category === "video" || item.category === "motion") { media.push(item); } else { docs.push(item); diff --git a/web/src/components/MemoEditor/types/components.ts b/web/src/components/MemoEditor/types/components.ts index 6069768b..367a082d 100644 --- a/web/src/components/MemoEditor/types/components.ts +++ b/web/src/components/MemoEditor/types/components.ts @@ -23,21 +23,22 @@ export interface EditorToolbarProps { onSave: () => void; onCancel?: () => void; memoName?: string; - onVoiceRecorderClick: () => void; + onAudioRecorderClick: () => void; } export interface EditorMetadataProps { memoName?: string; } -export interface VoiceRecorderPanelProps { - voiceRecorder: EditorState["voiceRecorder"]; - onStart: () => void; +export interface AudioRecorderPanelProps { + audioRecorder: EditorState["audioRecorder"]; + /** Active mic stream while recording; used for live waveform visualization. */ + mediaStream: MediaStream | null; onStop: () => void; - onKeep: () => void; - onDiscard: () => void; - onRecordAgain: () => void; - onClose: () => void; + onCancel: () => void; + onTranscribe?: () => void; + canTranscribe?: boolean; + isTranscribing?: boolean; } export interface FocusModeOverlayProps { @@ -57,7 +58,7 @@ export interface InsertMenuProps { onLocationChange: (location?: Location) => void; onToggleFocusMode?: () => void; memoName?: string; - onVoiceRecorderClick?: () => void; + onAudioRecorderClick?: () => void; } export interface TagSuggestionsProps { diff --git a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx index bdbb8a20..79c38a5e 100644 --- a/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx +++ b/web/src/components/MemoMetadata/Attachment/AttachmentListEditor.tsx @@ -1,165 +1,359 @@ -import { ChevronDownIcon, ChevronUpIcon, FileIcon, PaperclipIcon, XIcon } from "lucide-react"; -import type { FC } from "react"; +import { ChevronDownIcon, ChevronUpIcon, FileAudioIcon, FileIcon, PaperclipIcon, PauseIcon, PlayIcon, XIcon } from "lucide-react"; +import { type FC, type MouseEvent, useMemo, useRef, useState } from "react"; import type { AttachmentItem, LocalFile } from "@/components/MemoEditor/types/attachment"; -import { toAttachmentItems } from "@/components/MemoEditor/types/attachment"; +import { getAudioRecordingTimeLabel, toAttachmentItems } from "@/components/MemoEditor/types/attachment"; import MetadataSection from "@/components/MemoMetadata/MetadataSection"; +import PreviewImageDialog from "@/components/PreviewImageDialog"; import { cn } from "@/lib/utils"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import { formatFileSize, getFileTypeLabel } from "@/utils/format"; +import { useTranslate } from "@/utils/i18n"; +import type { PreviewMediaItem } from "@/utils/media-item"; +import { formatAudioTime } from "./attachmentHelpers"; interface AttachmentListEditorProps { attachments: Attachment[]; localFiles?: LocalFile[]; onAttachmentsChange?: (attachments: Attachment[]) => void; + onLocalFilesChange?: (localFiles: LocalFile[]) => void; onRemoveLocalFile?: (previewUrl: string) => void; } +const AttachmentItemActions: FC<{ + onRemove?: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + canMoveUp?: boolean; + canMoveDown?: boolean; +}> = ({ onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { + const stopPropagation = (event: MouseEvent) => { + event.stopPropagation(); + }; + + return ( +
+ {onMoveUp && ( + + )} + + {onMoveDown && ( + + )} + + {onRemove && ( + + )} +
+ ); +}; + const AttachmentItemCard: FC<{ item: AttachmentItem; + onPreview?: () => void; onRemove?: () => void; onMoveUp?: () => void; onMoveDown?: () => void; canMoveUp?: boolean; canMoveDown?: boolean; -}> = ({ item, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { - const { category, filename, thumbnailUrl, mimeType, size } = item; - const fileTypeLabel = getFileTypeLabel(mimeType); - const fileSizeLabel = size ? formatFileSize(size) : undefined; - const displayName = category === "audio" && /^voice-(recording|note)-/i.test(filename) ? "Voice note" : filename; +}> = ({ item, onPreview, onRemove, onMoveUp, onMoveDown, canMoveUp = true, canMoveDown = true }) => { + const t = useTranslate(); + const { category, filename, thumbnailUrl, mimeType, size, sourceUrl, isVoiceNote, audioMeta } = item; + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const fileTypeLabel = item.category === "motion" ? "Live Photo" : getFileTypeLabel(mimeType); + const isPreviewable = category === "image" || category === "video" || category === "motion"; + const recordingTimeLabel = isVoiceNote ? getAudioRecordingTimeLabel(filename) : undefined; + const titleLabel = + isVoiceNote && recordingTimeLabel + ? t("editor.audio-recorder.attachment-label-with-time", { time: recordingTimeLabel }) + : isVoiceNote + ? t("editor.audio-recorder.attachment-label") + : filename; + const detailParts = [ + audioMeta?.durationSeconds ? formatAudioTime(audioMeta.durationSeconds) : undefined, + fileTypeLabel, + size ? formatFileSize(size) : undefined, + ].filter(Boolean); + + const handleAudioToggle = async (event: MouseEvent) => { + event.stopPropagation(); + + const audio = audioRef.current; + if (!audio) { + return; + } + + if (audio.paused) { + try { + await audio.play(); + } catch { + setIsPlaying(false); + } + return; + } + + audio.pause(); + }; return (
-
- {category === "image" && thumbnailUrl ? ( - +
+ {(category === "image" || category === "motion") && thumbnailUrl ? ( + + ) : isVoiceNote ? ( + <> + +
- {displayName} + {titleLabel}
- {fileTypeLabel} - {fileSizeLabel && ( - <> - - {fileSizeLabel} - - )} + {detailParts.map((part, index) => ( + + {index > 0 && } + {part} + + ))}
-
- {onMoveUp && ( - - )} - - {onMoveDown && ( - - )} - - {onRemove && ( - - )} -
+
); }; -const AttachmentListEditor: FC = ({ attachments, localFiles = [], onAttachmentsChange, onRemoveLocalFile }) => { - if (attachments.length === 0 && localFiles.length === 0) { - return null; - } - +const AttachmentListEditor: FC = ({ + attachments, + localFiles = [], + onAttachmentsChange, + onLocalFilesChange, + onRemoveLocalFile, +}) => { + const [previewState, setPreviewState] = useState<{ open: boolean; initialIndex: number }>({ open: false, initialIndex: 0 }); const items = toAttachmentItems(attachments, localFiles); + const attachmentItems = items.filter((item) => !item.isLocal); + const localItems = items.filter((item) => item.isLocal); + const previewItems = useMemo( + () => + items.reduce((acc, item) => { + if (item.category === "image") { + acc.push({ id: item.id, kind: "image", sourceUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename }); + return acc; + } + + if (item.category === "video") { + acc.push({ id: item.id, kind: "video", sourceUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename }); + return acc; + } + + if (item.category === "motion") { + acc.push({ id: item.id, kind: "motion", motionUrl: item.sourceUrl, posterUrl: item.thumbnailUrl, filename: item.filename }); + return acc; + } - const handleMoveUp = (index: number) => { - if (index === 0 || !onAttachmentsChange) return; + return acc; + }, []), + [items], + ); + + const handleMoveAttachments = (itemId: string, direction: -1 | 1) => { + if (!onAttachmentsChange) return; + + const itemIndex = attachmentItems.findIndex((item) => item.id === itemId); + const targetIndex = itemIndex + direction; + if (itemIndex < 0 || targetIndex < 0 || targetIndex >= attachmentItems.length) { + return; + } + + const reorderedItems = [...attachmentItems]; + [reorderedItems[itemIndex], reorderedItems[targetIndex]] = [reorderedItems[targetIndex], reorderedItems[itemIndex]]; - const newAttachments = [...attachments]; - [newAttachments[index - 1], newAttachments[index]] = [newAttachments[index], newAttachments[index - 1]]; - onAttachmentsChange(newAttachments); + const attachmentMap = new Map(attachments.map((attachment) => [attachment.name, attachment])); + onAttachmentsChange( + reorderedItems.flatMap((item) => item.memberIds.map((memberId) => attachmentMap.get(memberId)).filter(Boolean) as Attachment[]), + ); }; - const handleMoveDown = (index: number) => { - if (index === attachments.length - 1 || !onAttachmentsChange) return; + const handleMoveLocalFiles = (itemId: string, direction: -1 | 1) => { + if (!onLocalFilesChange) return; - const newAttachments = [...attachments]; - [newAttachments[index], newAttachments[index + 1]] = [newAttachments[index + 1], newAttachments[index]]; - onAttachmentsChange(newAttachments); + const itemIndex = localItems.findIndex((item) => item.id === itemId); + const targetIndex = itemIndex + direction; + if (itemIndex < 0 || targetIndex < 0 || targetIndex >= localItems.length) { + return; + } + + const reorderedItems = [...localItems]; + [reorderedItems[itemIndex], reorderedItems[targetIndex]] = [reorderedItems[targetIndex], reorderedItems[itemIndex]]; + + const localFileMap = new Map(localFiles.map((localFile) => [localFile.previewUrl, localFile])); + onLocalFilesChange( + reorderedItems.flatMap((item) => item.memberIds.map((memberId) => localFileMap.get(memberId)).filter(Boolean) as LocalFile[]), + ); }; - const handleRemoveAttachment = (name: string) => { + const handleRemoveItem = (item: AttachmentItem) => { + if (item.isLocal) { + const nextLocalFiles = localFiles.filter((file) => !item.memberIds.includes(file.previewUrl)); + onLocalFilesChange?.(nextLocalFiles); + if (!onLocalFilesChange) { + item.memberIds.forEach((previewUrl) => onRemoveLocalFile?.(previewUrl)); + } + return; + } + if (onAttachmentsChange) { - onAttachmentsChange(attachments.filter((attachment) => attachment.name !== name)); + onAttachmentsChange(attachments.filter((attachment) => !item.memberIds.includes(attachment.name))); } }; - const handleRemoveItem = (item: (typeof items)[0]) => { - if (item.isLocal) { - onRemoveLocalFile?.(item.id); - } else { - handleRemoveAttachment(item.id); + const handlePreviewItem = (item: AttachmentItem) => { + const previewIndex = previewItems.findIndex((previewItem) => previewItem.id === item.id); + if (previewIndex < 0) { + return; } + + setPreviewState({ open: true, initialIndex: previewIndex }); }; + if (items.length === 0) { + return null; + } + return ( - - {items.map((item) => { - const isLocalFile = item.isLocal; - const attachmentIndex = isLocalFile ? -1 : attachments.findIndex((a) => a.name === item.id); - - return ( - handleRemoveItem(item)} - onMoveUp={!isLocalFile ? () => handleMoveUp(attachmentIndex) : undefined} - onMoveDown={!isLocalFile ? () => handleMoveDown(attachmentIndex) : undefined} - canMoveUp={!isLocalFile && attachmentIndex > 0} - canMoveDown={!isLocalFile && attachmentIndex < attachments.length - 1} - /> - ); - })} - + <> + + {items.map((item) => { + const itemList = item.isLocal ? localItems : attachmentItems; + const itemIndex = itemList.findIndex((entry) => entry.id === item.id); + + return ( + handlePreviewItem(item) + : undefined + } + onRemove={() => handleRemoveItem(item)} + onMoveUp={item.isLocal ? () => handleMoveLocalFiles(item.id, -1) : () => handleMoveAttachments(item.id, -1)} + onMoveDown={item.isLocal ? () => handleMoveLocalFiles(item.id, 1) : () => handleMoveAttachments(item.id, 1)} + canMoveUp={itemIndex > 0} + canMoveDown={itemIndex >= 0 && itemIndex < itemList.length - 1} + /> + ); + })} + + + setPreviewState((state) => ({ ...state, open }))} + items={previewItems} + initialIndex={previewState.initialIndex} + /> + ); }; diff --git a/web/src/components/MemoPreview/MemoPreview.tsx b/web/src/components/MemoPreview/MemoPreview.tsx index 7149a5a0..c1c03422 100644 --- a/web/src/components/MemoPreview/MemoPreview.tsx +++ b/web/src/components/MemoPreview/MemoPreview.tsx @@ -26,6 +26,7 @@ const STUB_CONTEXT: MemoViewContextValue = { creator: undefined, currentUser: undefined, parentPage: "/", + cardWidth: 0, isArchived: false, readonly: true, showBlurredContent: false, diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index e5c43997..7f45321e 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; import { useInstance } from "@/contexts/InstanceContext"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -7,6 +7,7 @@ import { findTagMetadata } from "@/lib/tag"; import { cn } from "@/lib/utils"; import { State } from "@/types/proto/api/v1/common_pb"; import { isSuperUser } from "@/utils/user"; +import MemoShareImageDialog from "../MemoActionMenu/MemoShareImageDialog"; import MemoEditor from "../MemoEditor"; import PreviewImageDialog from "../PreviewImageDialog"; import { MemoBody, MemoCommentListView, MemoHeader } from "./components"; @@ -19,6 +20,7 @@ const MemoView: React.FC = (props: MemoViewProps) => { const { memo: memoData, className, parentPage: parentPageProp, compact, showCreator, showVisibility, showPinned } = props; const cardRef = useRef(null); const [showEditor, setShowEditor] = useState(false); + const [cardWidth, setCardWidth] = useState(0); const currentUser = useCurrentUser(); const { tagsSetting } = useInstance(); @@ -41,12 +43,40 @@ const MemoView: React.FC = (props: MemoViewProps) => { const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/"); const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0; + useEffect(() => { + const card = cardRef.current; + if (!card) { + return; + } + + const updateWidth = (nextWidth?: number) => { + const width = Math.round(nextWidth ?? card.getBoundingClientRect().width); + setCardWidth((prev) => (prev === width ? prev : width)); + }; + + updateWidth(); + + if (typeof ResizeObserver === "undefined") { + const handleResize = () => updateWidth(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + } + + const resizeObserver = new ResizeObserver((entries) => { + updateWidth(entries[0]?.contentRect.width); + }); + + resizeObserver.observe(card); + return () => resizeObserver.disconnect(); + }, []); + const contextValue = useMemo( () => ({ memo: memoData, creator, currentUser, parentPage, + cardWidth, isArchived, readonly, showBlurredContent, @@ -60,6 +90,7 @@ const MemoView: React.FC = (props: MemoViewProps) => { creator, currentUser, parentPage, + cardWidth, isArchived, readonly, showBlurredContent, @@ -100,6 +131,10 @@ const MemoView: React.FC = (props: MemoViewProps) => { items={previewState.items} initialIndex={previewState.index} /> + + {props.onShareImageDialogOpenChange && ( + + )} ); diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx index 79413313..a2604b83 100644 --- a/web/src/components/MemoView/MemoViewContext.tsx +++ b/web/src/components/MemoView/MemoViewContext.tsx @@ -1,6 +1,7 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; import { createContext, useContext } from "react"; import { useLocation } from "react-router-dom"; +import { useView } from "@/contexts/ViewContext"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { MemoRelation_Type } from "@/types/proto/api/v1/memo_service_pb"; import type { User } from "@/types/proto/api/v1/user_service_pb"; @@ -12,6 +13,7 @@ export interface MemoViewContextValue { creator: User | undefined; currentUser: User | undefined; parentPage: string; + cardWidth: number; isArchived: boolean; readonly: boolean; showBlurredContent: boolean; @@ -36,12 +38,14 @@ export const computeCommentAmount = (memo: Memo): number => export const useMemoViewDerived = () => { const { memo, isArchived, readonly } = useMemoViewContext(); + const { timeBasis } = useView(); const location = useLocation(); const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`) || location.pathname.startsWith("/memos/shares/"); const commentAmount = computeCommentAmount(memo); - const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined; + const displayTimestamp = timeBasis === "update_time" ? memo.updateTime : memo.createTime; + const displayTime = displayTimestamp ? timestampDate(displayTimestamp) : undefined; const relativeTimeFormat: "datetime" | "auto" = displayTime && Date.now() - displayTime.getTime() > RELATIVE_TIME_THRESHOLD_MS ? "datetime" : "auto"; @@ -50,6 +54,7 @@ export const useMemoViewDerived = () => { readonly, isInMemoDetailPage, commentAmount, + displayTime, relativeTimeFormat, }; }; diff --git a/web/src/components/MemoView/components/MemoCommentListView.tsx b/web/src/components/MemoView/components/MemoCommentListView.tsx index c416449b..9654bb0e 100644 --- a/web/src/components/MemoView/components/MemoCommentListView.tsx +++ b/web/src/components/MemoView/components/MemoCommentListView.tsx @@ -10,7 +10,7 @@ const MemoCommentListView: React.FC = () => { const { memo } = useMemoViewContext(); const { isInMemoDetailPage, commentAmount } = useMemoViewDerived(); - const { data } = useMemoComments(memo.name, { enabled: !isInMemoDetailPage && commentAmount > 0 }); + const { data } = useMemoComments(memo.name, { enabled: !isInMemoDetailPage && commentAmount > 0, pageSize: 3 }); const comments = data?.memos ?? []; const displayedComments = comments.slice(0, 3); const { data: commentCreators } = useUsersByNames(displayedComments.map((comment) => comment.creator)); diff --git a/web/src/components/MemoView/components/MemoHeader.tsx b/web/src/components/MemoView/components/MemoHeader.tsx index c91e308d..7fd2e8d0 100644 --- a/web/src/components/MemoView/components/MemoHeader.tsx +++ b/web/src/components/MemoView/components/MemoHeader.tsx @@ -1,4 +1,3 @@ -import { timestampDate } from "@bufbuild/protobuf/wkt"; import { BookmarkIcon } from "lucide-react"; import { useCallback, useState } from "react"; import { Link } from "react-router-dom"; @@ -23,7 +22,7 @@ const MemoHeader: React.FC = ({ showCreator, showVisibility, sh const [reactionSelectorOpen, setReactionSelectorOpen] = useState(false); const { memo, creator, currentUser, parentPage, isArchived, readonly, openEditor } = useMemoViewContext(); - const { relativeTimeFormat } = useMemoViewDerived(); + const { displayTime: memoDisplayTime, relativeTimeFormat } = useMemoViewDerived(); const navigateTo = useNavigateTo(); const handleGotoMemoDetailPage = useCallback(() => { @@ -33,13 +32,9 @@ const MemoHeader: React.FC = ({ showCreator, showVisibility, sh const { unpinMemo } = useMemoActions(memo); const displayTime = isArchived ? ( - (memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language) + memoDisplayTime?.toLocaleString(i18n.language) ) : ( - + ); return ( diff --git a/web/src/components/MemoView/types.ts b/web/src/components/MemoView/types.ts index 0025e12f..b99ca73c 100644 --- a/web/src/components/MemoView/types.ts +++ b/web/src/components/MemoView/types.ts @@ -8,6 +8,8 @@ export interface MemoViewProps { showPinned?: boolean; className?: string; parentPage?: string; + shareImageDialogOpen?: boolean; + onShareImageDialogOpenChange?: (open: boolean) => void; } export interface MemoHeaderProps { diff --git a/web/src/components/Settings/LinkedIdentitySection.tsx b/web/src/components/Settings/LinkedIdentitySection.tsx index ca7a0448..fcf4293a 100644 --- a/web/src/components/Settings/LinkedIdentitySection.tsx +++ b/web/src/components/Settings/LinkedIdentitySection.tsx @@ -101,7 +101,7 @@ const LinkedIdentitySection = () => { try { const returnUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`; - const { state, codeChallenge } = await storeOAuthState(identityProvider.name, returnUrl); + const { state, codeChallenge } = await storeOAuthState(identityProvider.name, "link", returnUrl, currentUser.name); let authUrl = `${oauth2Config.authUrl}?client_id=${ oauth2Config.clientId diff --git a/web/src/components/Settings/SettingTable.tsx b/web/src/components/Settings/SettingTable.tsx index 6145d123..e6df3093 100644 --- a/web/src/components/Settings/SettingTable.tsx +++ b/web/src/components/Settings/SettingTable.tsx @@ -26,9 +26,10 @@ const SettingTable = >({ getRowKey, variant = "default", }: SettingTableProps) => { - const cellBase = variant === "info-flow" - ? "px-3 py-3 text-sm text-muted-foreground align-top" - : "whitespace-nowrap px-3 py-2 text-sm text-muted-foreground"; + const cellBase = + variant === "info-flow" + ? "px-3 py-3 text-sm text-muted-foreground align-top" + : "whitespace-nowrap px-3 py-2 text-sm text-muted-foreground"; return (
diff --git a/web/src/components/Settings/StorageSection.tsx b/web/src/components/Settings/StorageSection.tsx index 48c09fdd..f9f99641 100644 --- a/web/src/components/Settings/StorageSection.tsx +++ b/web/src/components/Settings/StorageSection.tsx @@ -12,9 +12,10 @@ import { handleError } from "@/lib/error"; import { InstanceSetting_Key, InstanceSetting_StorageSetting, + InstanceSetting_StorageSetting_S3Config, InstanceSetting_StorageSetting_S3ConfigSchema, - InstanceSetting_StorageSettingSchema, InstanceSetting_StorageSetting_StorageType, + InstanceSetting_StorageSettingSchema, InstanceSettingSchema, } from "@/types/proto/api/v1/instance_service_pb"; import { useTranslate } from "@/utils/i18n"; @@ -50,11 +51,24 @@ const STORAGE_OPTIONS: Array<{ }, ]; +const createS3Config = ( + current: InstanceSetting_StorageSetting_S3Config | undefined, + patch: Partial, +) => + create(InstanceSetting_StorageSetting_S3ConfigSchema, { + accessKeyId: current?.accessKeyId ?? "", + accessKeySecret: current?.accessKeySecret ?? "", + endpoint: current?.endpoint ?? "", + region: current?.region ?? "", + bucket: current?.bucket ?? "", + usePathStyle: current?.usePathStyle ?? false, + ...patch, + }); + const StorageSection = () => { const t = useTranslate(); const { storageSetting: originalSetting, storageSupportedTypes, updateSetting, fetchSetting } = useInstance(); - const [localSetting, setLocalSetting] = - useState(originalSetting); + const [localSetting, setLocalSetting] = useState(originalSetting); useEffect(() => { setLocalSetting(originalSetting); @@ -126,10 +140,7 @@ const StorageSection = () => { > {orderedStorageOptions.map((option) => (
- +
))} @@ -168,10 +179,7 @@ const StorageSection = () => { value={localSetting.s3Config?.accessKeyId ?? ""} onChange={(e) => updatePartial({ - s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, { - ...(localSetting.s3Config ?? {}), - accessKeyId: e.target.value, - }), + s3Config: createS3Config(localSetting.s3Config, { accessKeyId: e.target.value }), }) } /> @@ -184,10 +192,7 @@ const StorageSection = () => { value={localSetting.s3Config?.accessKeySecret ?? ""} onChange={(e) => updatePartial({ - s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, { - ...(localSetting.s3Config ?? {}), - accessKeySecret: e.target.value, - }), + s3Config: createS3Config(localSetting.s3Config, { accessKeySecret: e.target.value }), }) } /> @@ -199,10 +204,7 @@ const StorageSection = () => { value={localSetting.s3Config?.endpoint ?? ""} onChange={(e) => updatePartial({ - s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, { - ...(localSetting.s3Config ?? {}), - endpoint: e.target.value, - }), + s3Config: createS3Config(localSetting.s3Config, { endpoint: e.target.value }), }) } /> @@ -214,10 +216,7 @@ const StorageSection = () => { value={localSetting.s3Config?.region ?? ""} onChange={(e) => updatePartial({ - s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, { - ...(localSetting.s3Config ?? {}), - region: e.target.value, - }), + s3Config: createS3Config(localSetting.s3Config, { region: e.target.value }), }) } /> @@ -229,10 +228,7 @@ const StorageSection = () => { value={localSetting.s3Config?.bucket ?? ""} onChange={(e) => updatePartial({ - s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, { - ...(localSetting.s3Config ?? {}), - bucket: e.target.value, - }), + s3Config: createS3Config(localSetting.s3Config, { bucket: e.target.value }), }) } /> @@ -243,10 +239,7 @@ const StorageSection = () => { checked={localSetting.s3Config?.usePathStyle ?? false} onCheckedChange={(checked) => updatePartial({ - s3Config: create(InstanceSetting_StorageSetting_S3ConfigSchema, { - ...(localSetting.s3Config ?? {}), - usePathStyle: checked, - }), + s3Config: createS3Config(localSetting.s3Config, { usePathStyle: checked }), }) } /> diff --git a/web/src/components/SsoSignInForm.tsx b/web/src/components/SsoSignInForm.tsx index 5f4ed2b1..eb570803 100644 --- a/web/src/components/SsoSignInForm.tsx +++ b/web/src/components/SsoSignInForm.tsx @@ -3,8 +3,8 @@ import { Button } from "@/components/ui/button"; import { identityProviderServiceClient } from "@/connect"; import { absolutifyLink } from "@/helpers/utils"; import { IdentityProvider } from "@/types/proto/api/v1/idp_service_pb"; -import { storeOAuthState } from "@/utils/oauth"; import { useTranslate } from "@/utils/i18n"; +import { storeOAuthState } from "@/utils/oauth"; interface Props { redirectPath?: string; @@ -31,10 +31,7 @@ const SsoSignInForm = ({ redirectPath }: Props) => { const availableProviders = useMemo( () => providers.filter( - (p) => - p.config?.config?.case === "oauth2Config" && - p.config.config.value?.authUrl && - p.config.config.value?.clientId, + (p) => p.config?.config?.case === "oauth2Config" && p.config.config.value?.authUrl && p.config.config.value?.clientId, ), [providers], ); @@ -45,7 +42,7 @@ const SsoSignInForm = ({ redirectPath }: Props) => { setLoading(true); try { const redirectUri = absolutifyLink("/auth/callback"); - const { state, codeChallenge } = await storeOAuthState(provider.name, redirectPath); + const { state, codeChallenge } = await storeOAuthState(provider.name, "signin", redirectPath); const url = new URL(oauth2.authUrl); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", oauth2.clientId); @@ -69,13 +66,7 @@ const SsoSignInForm = ({ redirectPath }: Props) => { return (
{availableProviders.map((provider) => ( - ))} diff --git a/web/src/connect.ts b/web/src/connect.ts index 97233286..cf72ac51 100644 --- a/web/src/connect.ts +++ b/web/src/connect.ts @@ -3,12 +3,16 @@ * service client names so hooks and components stay unchanged. */ import { create } from "@bufbuild/protobuf"; -import { Code, ConnectError } from "@connectrpc/connect"; import type { FieldMask } from "@bufbuild/protobuf/wkt"; import { timestampDate, timestampFromDate } from "@bufbuild/protobuf/wkt"; +import { Code, ConnectError } from "@connectrpc/connect"; import { getAccessToken, hasStoredToken, isTokenExpired, REQUEST_TOKEN_EXPIRY_BUFFER_MS, setAccessToken } from "./auth-state"; import { memoFromJson, userFromJson, userStatsFromJson } from "./lib/proto-adapters"; -import { redirectOnAuthFailure } from "./utils/auth-redirect"; +import type { Attachment } from "./types/proto/api/v1/attachment_service_pb"; +import { AttachmentSchema } from "./types/proto/api/v1/attachment_service_pb"; +import { State } from "./types/proto/api/v1/common_pb"; +import type { IdentityProvider } from "./types/proto/api/v1/idp_service_pb"; +import { IdentityProvider_Type, IdentityProviderSchema } from "./types/proto/api/v1/idp_service_pb"; import type { InstanceProfile, InstanceSetting, @@ -18,38 +22,45 @@ import type { } from "./types/proto/api/v1/instance_service_pb"; import { InstanceProfileSchema, - InstanceSettingSchema, + InstanceSetting_AIProviderConfigSchema, InstanceSetting_AIProviderType, InstanceSetting_AISettingSchema, - InstanceSetting_AIProviderConfigSchema, InstanceSetting_GeneralSettingSchema, InstanceSetting_MemoRelatedSettingSchema, InstanceSetting_NotificationSettingSchema, InstanceSetting_StorageSetting_StorageType, InstanceSetting_StorageSettingSchema, InstanceSetting_TagsSettingSchema, + InstanceSettingSchema, } from "./types/proto/api/v1/instance_service_pb"; -import { State } from "./types/proto/api/v1/common_pb"; -import type { Memo, MemoShare, MemoRelation, Reaction } from "./types/proto/api/v1/memo_service_pb"; -import { MemoShareSchema, MemoRelationSchema, ReactionSchema } from "./types/proto/api/v1/memo_service_pb"; -import type { Attachment } from "./types/proto/api/v1/attachment_service_pb"; -import { AttachmentSchema } from "./types/proto/api/v1/attachment_service_pb"; +import type { Memo, MemoRelation, MemoShare, Reaction } from "./types/proto/api/v1/memo_service_pb"; +import { MemoRelationSchema, MemoShareSchema, ReactionSchema } from "./types/proto/api/v1/memo_service_pb"; import type { Shortcut } from "./types/proto/api/v1/shortcut_service_pb"; import { ShortcutSchema } from "./types/proto/api/v1/shortcut_service_pb"; -import type { IdentityProvider } from "./types/proto/api/v1/idp_service_pb"; -import { - IdentityProviderSchema, - IdentityProvider_Type, -} from "./types/proto/api/v1/idp_service_pb"; -import type { CreatePersonalAccessTokenResponse, LinkedIdentity, PersonalAccessToken, User, UserSetting } from "./types/proto/api/v1/user_service_pb"; +import type { + CreatePersonalAccessTokenResponse, + LinkedIdentity, + PersonalAccessToken, + User, + UserNotification, + UserSetting, + UserWebhook, +} from "./types/proto/api/v1/user_service_pb"; import { CreatePersonalAccessTokenResponseSchema, LinkedIdentitySchema, PersonalAccessTokenSchema, - UserSettingSchema, + UserNotification_MemoCommentPayloadSchema, + UserNotification_MemoMentionPayloadSchema, + UserNotification_Status, + UserNotification_Type, + UserNotificationSchema, UserSetting_GeneralSettingSchema, UserSetting_WebhooksSettingSchema, + UserSettingSchema, + UserWebhookSchema, } from "./types/proto/api/v1/user_service_pb"; +import { redirectOnAuthFailure } from "./utils/auth-redirect"; const API = "/api/v1"; const RETRY_HEADER = "X-Retry"; @@ -122,8 +133,7 @@ const tokenRefreshManager = (() => { }; })(); -const fetchWithCredentials: typeof globalThis.fetch = (input, init) => - globalThis.fetch(input, { ...init, credentials: "include" }); +const fetchWithCredentials: typeof globalThis.fetch = (input, init) => globalThis.fetch(input, { ...init, credentials: "include" }); async function doRefreshAccessToken(): Promise { const res = await fetchWithCredentials(`${window.location.origin}${API}/auth/refresh`, { @@ -392,6 +402,63 @@ function attachmentFromJson(j: Record): Attachment { }); } +function notificationStatusFromJson(raw: unknown): UserNotification_Status { + if (raw === UserNotification_Status.UNREAD || raw === 1 || raw === "UNREAD") return UserNotification_Status.UNREAD; + if (raw === UserNotification_Status.ARCHIVED || raw === 2 || raw === "ARCHIVED") return UserNotification_Status.ARCHIVED; + return UserNotification_Status.STATUS_UNSPECIFIED; +} + +function notificationTypeFromJson(raw: unknown): UserNotification_Type { + if (raw === UserNotification_Type.MEMO_COMMENT || raw === 1 || raw === "MEMO_COMMENT") return UserNotification_Type.MEMO_COMMENT; + if (raw === UserNotification_Type.MEMO_MENTION || raw === 2 || raw === "MEMO_MENTION") return UserNotification_Type.MEMO_MENTION; + return UserNotification_Type.TYPE_UNSPECIFIED; +} + +function notificationFromJson(j: Record): UserNotification { + const payload = j.payload && typeof j.payload === "object" ? (j.payload as Record) : {}; + const memoComment = (payload.memoComment ?? j.memoComment) as Record | undefined; + const memoMention = (payload.memoMention ?? j.memoMention) as Record | undefined; + return create(UserNotificationSchema, { + name: String(j.name ?? ""), + sender: String(j.sender ?? ""), + senderUser: j.senderUser && typeof j.senderUser === "object" ? userFromJson(j.senderUser as Record) : undefined, + status: notificationStatusFromJson(j.status), + createTime: j.createTime ? timestampFromDate(new Date(String(j.createTime))) : undefined, + type: notificationTypeFromJson(j.type), + payload: memoComment + ? { + case: "memoComment", + value: create(UserNotification_MemoCommentPayloadSchema, { + memo: String(memoComment.memo ?? ""), + relatedMemo: String(memoComment.relatedMemo ?? ""), + memoSnippet: String(memoComment.memoSnippet ?? ""), + relatedMemoSnippet: String(memoComment.relatedMemoSnippet ?? ""), + }), + } + : memoMention + ? { + case: "memoMention", + value: create(UserNotification_MemoMentionPayloadSchema, { + memo: String(memoMention.memo ?? ""), + relatedMemo: String(memoMention.relatedMemo ?? ""), + memoSnippet: String(memoMention.memoSnippet ?? ""), + relatedMemoSnippet: String(memoMention.relatedMemoSnippet ?? ""), + }), + } + : undefined, + }); +} + +function webhookFromJson(j: Record): UserWebhook { + return create(UserWebhookSchema, { + name: String(j.name ?? ""), + url: String(j.url ?? ""), + displayName: String(j.displayName ?? ""), + createTime: j.createTime ? timestampFromDate(new Date(String(j.createTime))) : undefined, + updateTime: j.updateTime ? timestampFromDate(new Date(String(j.updateTime))) : undefined, + }); +} + function bytesToBase64(bytes: Uint8Array): string { let bin = ""; for (let i = 0; i < bytes.length; i++) { @@ -407,7 +474,7 @@ function listMemosQuery(req: Record): string { let stateStr = "NORMAL"; if (req.state != null && req.state !== "") { const st = req.state as number | string; - stateStr = typeof st === "number" ? (State[st] as string | undefined) ?? "NORMAL" : String(st); + stateStr = typeof st === "number" ? ((State[st] as string | undefined) ?? "NORMAL") : String(st); } else if (req.showDeleted) stateStr = "ARCHIVED"; p.set("state", stateStr); if (req.filter != null && String(req.filter).length > 0) { @@ -492,7 +559,7 @@ export const authServiceClient = { async signIn(req: { passwordCredentials?: { username?: string; password?: string }; ssoCredentials?: unknown; - }): Promise<{ user?: User; accessToken?: string; accessTokenExpiresAt?: string }> { + }): Promise<{ user?: User; accessToken?: string; accessTokenExpiresAt?: ReturnType }> { const res = await fetchWithCredentials(`${window.location.origin}${API}/auth/signin`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -513,7 +580,7 @@ export const authServiceClient = { return { user: j.user ? userFromJson(j.user) : undefined, accessToken: j.accessToken, - accessTokenExpiresAt: j.accessTokenExpiresAt, + accessTokenExpiresAt: j.accessTokenExpiresAt ? timestampFromDate(new Date(j.accessTokenExpiresAt)) : undefined, }; }, async signOut(_req: object): Promise { @@ -693,16 +760,9 @@ export const userServiceClient = { if (req.pageSize != null) q.set("pageSize", String(req.pageSize)); if (req.pageToken) q.set("pageToken", req.pageToken); const qs = q.toString(); - const j = await apiJson<{ webhooks: { name: string; url: string; createTime?: string }[] }>( - `/users/${encodeURIComponent(u)}/webhooks${qs ? `?${qs}` : ""}`, - ); + const j = await apiJson<{ webhooks: Record[] }>(`/users/${encodeURIComponent(u)}/webhooks${qs ? `?${qs}` : ""}`); return { - webhooks: j.webhooks.map((w) => ({ - name: w.name, - url: w.url, - displayName: "", - createTime: w.createTime, - })), + webhooks: j.webhooks.map((w) => webhookFromJson(w)), }; }, async createUserWebhook(req: { parent: string; webhook?: { url?: string; displayName?: string } }) { @@ -734,9 +794,12 @@ export const userServiceClient = { async listUserNotifications(req: { parent: string }) { const u = userSeg(req.parent); const j = await apiJson<{ notifications: Record[] }>(`/users/${encodeURIComponent(u)}/notifications`); - return { notifications: j.notifications }; + return { notifications: j.notifications.map((n) => notificationFromJson(n)) }; }, - async updateUserNotification(req: { notification: { name?: string; status?: string; payload?: unknown } }) { + async updateUserNotification(req: { + notification: { name?: string; status?: UserNotification_Status; payload?: unknown }; + updateMask?: FieldMask; + }) { const name = req.notification.name ?? ""; const m = name.match(/^users\/([^/]+)\/notifications\/([^/]+)$/); if (!m) throw new ConnectError("invalid notification name", Code.InvalidArgument); @@ -765,9 +828,7 @@ export const userServiceClient = { }, async listLinkedIdentities(req: { parent: string }): Promise<{ linkedIdentities: LinkedIdentity[] }> { const u = userSeg(req.parent); - const j = await apiJson<{ linkedIdentities?: Record[] }>( - `/users/${encodeURIComponent(u)}/linkedIdentities`, - ); + const j = await apiJson<{ linkedIdentities?: Record[] }>(`/users/${encodeURIComponent(u)}/linkedIdentities`); const identities = (j.linkedIdentities ?? []).map((li) => create(LinkedIdentitySchema, { name: String(li.name ?? ""), @@ -777,12 +838,33 @@ export const userServiceClient = { ); return { linkedIdentities: identities }; }, + async createLinkedIdentity(req: { + parent: string; + idpName: string; + code: string; + redirectUri: string; + codeVerifier?: string; + }): Promise { + const u = userSeg(req.parent); + const j = await apiJson>(`/users/${encodeURIComponent(u)}/linkedIdentities`, { + method: "POST", + body: JSON.stringify({ + idpName: req.idpName, + code: req.code, + redirectUri: req.redirectUri, + codeVerifier: req.codeVerifier ?? "", + }), + }); + return create(LinkedIdentitySchema, { + name: String(j.name ?? ""), + idpName: String(j.idpName ?? ""), + externUid: String(j.externUid ?? ""), + }); + }, async getLinkedIdentity(req: { name: string }): Promise { const m = req.name.match(/^users\/([^/]+)\/linkedIdentities\/(.+)$/); if (!m) throw new ConnectError("invalid linked identity name", Code.InvalidArgument); - const j = await apiJson>( - `/users/${encodeURIComponent(m[1])}/linkedIdentities/${encodeURIComponent(m[2])}`, - ); + const j = await apiJson>(`/users/${encodeURIComponent(m[1])}/linkedIdentities/${encodeURIComponent(m[2])}`); return create(LinkedIdentitySchema, { name: String(j.name ?? ""), idpName: String(j.idpName ?? ""), @@ -792,10 +874,7 @@ export const userServiceClient = { async deleteLinkedIdentity(req: { name: string }): Promise { const m = req.name.match(/^users\/([^/]+)\/linkedIdentities\/(.+)$/); if (!m) throw new ConnectError("invalid linked identity name", Code.InvalidArgument); - await apiJson( - `/users/${encodeURIComponent(m[1])}/linkedIdentities/${encodeURIComponent(m[2])}`, - { method: "DELETE" }, - ); + await apiJson(`/users/${encodeURIComponent(m[1])}/linkedIdentities/${encodeURIComponent(m[2])}`, { method: "DELETE" }); return {}; }, }; @@ -819,7 +898,7 @@ export const shortcutServiceClient = { totalSize: j.totalSize ?? j.shortcuts.length, }; }, - async createShortcut(req: { parent?: string; shortcut?: { title?: string; filter?: string } }) { + async createShortcut(req: { parent?: string; shortcut?: { name?: string; title?: string; filter?: string } }) { if (!req.parent) throw new ConnectError("parent required", Code.InvalidArgument); const u = userSeg(req.parent); const j = await apiJson>(`/users/${encodeURIComponent(u)}/shortcuts`, { @@ -963,10 +1042,14 @@ export const memoServiceClient = { await apiJson(`/memos/${encodeURIComponent(id)}`, { method: "DELETE" }); return {}; }, - async listMemoComments(req: { name: string }) { + async listMemoComments(req: { name: string; pageSize?: number; pageToken?: string }) { const id = memoIdFromName(req.name); + const q = new URLSearchParams(); + if (req.pageSize != null) q.set("pageSize", String(req.pageSize)); + if (req.pageToken) q.set("pageToken", req.pageToken); + const qs = q.toString(); const j = await apiJson<{ memos: Record[]; nextPageToken?: string; totalSize?: number }>( - `/memos/${encodeURIComponent(id)}/comments`, + `/memos/${encodeURIComponent(id)}/comments${qs ? `?${qs}` : ""}`, ); return { memos: j.memos.map((m) => memoFromJson(m)), @@ -1035,7 +1118,7 @@ export const memoServiceClient = { totalSize: j.totalSize ?? j.reactions.length, }; }, - async upsertMemoReaction(req: { name: string; reaction: { reactionType?: string } }): Promise { + async upsertMemoReaction(req: { name: string; reaction: { contentId?: string; reactionType?: string } }): Promise { const id = memoIdFromName(req.name); const j = await apiJson>(`/memos/${encodeURIComponent(id)}/reactions`, { method: "POST", @@ -1151,18 +1234,14 @@ export const identityProviderServiceClient = { config: { config: { case: "oauth2Config", - value: (row.config as { oauth2Config?: Record } | undefined) - ?.oauth2Config ?? {}, + value: (row.config as { oauth2Config?: Record } | undefined)?.oauth2Config ?? {}, }, }, } as Record), ); return { identityProviders }; }, - async createIdentityProvider(req: { - identityProvider?: IdentityProvider; - identityProviderId?: string; - }): Promise { + async createIdentityProvider(req: { identityProvider?: IdentityProvider; identityProviderId?: string }): Promise { const body = { identityProvider: req.identityProvider, identityProviderId: req.identityProviderId ?? "", @@ -1173,10 +1252,7 @@ export const identityProviderServiceClient = { }); return create(IdentityProviderSchema, j); }, - async updateIdentityProvider(req: { - identityProvider?: IdentityProvider; - updateMask?: FieldMask; - }): Promise { + async updateIdentityProvider(req: { identityProvider?: IdentityProvider; updateMask?: FieldMask }): Promise { if (!req.identityProvider?.name) { throw new ConnectError("identityProvider.name is required", Code.InvalidArgument); } diff --git a/web/src/contexts/ViewContext.tsx b/web/src/contexts/ViewContext.tsx index 3057d9ef..70bb3d1f 100644 --- a/web/src/contexts/ViewContext.tsx +++ b/web/src/contexts/ViewContext.tsx @@ -1,8 +1,18 @@ import { createContext, type ReactNode, useContext, useState } from "react"; +export type MemoTimeBasis = "create_time" | "update_time"; + +interface ViewState { + orderByTimeAsc: boolean; + timeBasis?: MemoTimeBasis; + sortTimeField?: MemoTimeBasis; +} + interface ViewContextValue { orderByTimeAsc: boolean; + timeBasis: MemoTimeBasis; toggleSortOrder: () => void; + setTimeBasis: (field: MemoTimeBasis) => void; } const ViewContext = createContext(null); @@ -10,13 +20,16 @@ const ViewContext = createContext(null); const LOCAL_STORAGE_KEY = "memos-view-setting"; export function ViewProvider({ children }: { children: ReactNode }) { - const getInitialState = () => { + const getInitialState = (): ViewState => { try { const cached = localStorage.getItem(LOCAL_STORAGE_KEY); if (cached) { - const data = JSON.parse(cached); + const data = JSON.parse(cached) as Partial; + const cachedTimeBasis = data.timeBasis ?? data.sortTimeField; + const timeBasis = cachedTimeBasis === "create_time" || cachedTimeBasis === "update_time" ? cachedTimeBasis : undefined; return { orderByTimeAsc: Boolean(data.orderByTimeAsc ?? false), + timeBasis, }; } } catch (error) { @@ -26,8 +39,9 @@ export function ViewProvider({ children }: { children: ReactNode }) { }; const [viewState, setViewState] = useState(getInitialState); + const timeBasis = viewState.timeBasis ?? "create_time"; - const persistToStorage = (newState: typeof viewState) => { + const persistToStorage = (newState: ViewState) => { try { localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newState)); } catch (error) { @@ -43,11 +57,21 @@ export function ViewProvider({ children }: { children: ReactNode }) { }); }; + const setTimeBasis = (field: MemoTimeBasis) => { + setViewState((prev) => { + const newState = { ...prev, timeBasis: field }; + persistToStorage(newState); + return newState; + }); + }; + return ( {children} diff --git a/web/src/hooks/useMemoQueries.ts b/web/src/hooks/useMemoQueries.ts index 458c5c6b..67334308 100644 --- a/web/src/hooks/useMemoQueries.ts +++ b/web/src/hooks/useMemoQueries.ts @@ -145,11 +145,11 @@ export function useDeleteMemo() { }); } -export function useMemoComments(name: string, options?: { enabled?: boolean }) { +export function useMemoComments(name: string, options?: { enabled?: boolean; pageSize?: number; pageToken?: string }) { return useQuery({ queryKey: memoKeys.comments(name), queryFn: async () => { - const response = await memoServiceClient.listMemoComments({ name }); + const response = await memoServiceClient.listMemoComments({ name, pageSize: options?.pageSize, pageToken: options?.pageToken }); return response; }, enabled: options?.enabled ?? true, diff --git a/web/src/lib/proto-adapters.ts b/web/src/lib/proto-adapters.ts index 189d3ead..d470ecf8 100644 --- a/web/src/lib/proto-adapters.ts +++ b/web/src/lib/proto-adapters.ts @@ -1,12 +1,11 @@ import { create } from "@bufbuild/protobuf"; import { timestampFromDate } from "@bufbuild/protobuf/wkt"; -import { State } from "@/types/proto/api/v1/common_pb"; import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb"; +import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import { LocationSchema, MemoSchema, Visibility } from "@/types/proto/api/v1/memo_service_pb"; -import type { User } from "@/types/proto/api/v1/user_service_pb"; -import type { UserStats } from "@/types/proto/api/v1/user_service_pb"; -import { UserSchema, UserStatsSchema, User_Role } from "@/types/proto/api/v1/user_service_pb"; +import type { User, UserStats } from "@/types/proto/api/v1/user_service_pb"; +import { User_Role, UserSchema, UserStatsSchema } from "@/types/proto/api/v1/user_service_pb"; /** REST JSON uses enum names as strings; protobuf-es expects numeric enums. */ function stateFromApiJson(s: unknown): State { diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 852dbe90..54facf36 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -3,6 +3,7 @@ "blogs": "Blogs", "description": "A privacy-first, lightweight note-taking service. Easily capture and share your great thoughts.", "documents": "Documents", + "media": "Media", "github-repository": "GitHub Repo", "official-website": "Official Website" }, @@ -15,6 +16,43 @@ "sign-in-tip": "Already have an account?", "sign-up-tip": "Don't have an account yet?" }, + "demo": { + "banner-description": "Changes are temporary and may be reset.", + "banner-title": "Memos demo", + "deploy-link": "Deploy your Memos" + }, + "attachment-library": { + "actions": { + "open": "Open", + "retry": "Retry" + }, + "empty": { + "audio": "No audio attachments yet.", + "documents": "No document attachments yet.", + "media": "No media attachments yet." + }, + "errors": { + "load": "Failed to load attachments." + }, + "labels": { + "live": "Live", + "live-photo": "Live Photo", + "memo": "Memo", + "not-linked": "Not linked", + "unknown-date": "Unknown date", + "unused": "Unused" + }, + "tabs": { + "audio": "Audio", + "documents": "Documents", + "media": "Media" + }, + "unused": { + "confirm-description": "This removes every uploaded file that is not linked to a memo.", + "description": "These uploads were never attached to a memo. Review or remove them here.", + "title": "Unlinked uploads" + } + }, "common": { "about": "About", "add": "Add", @@ -51,6 +89,7 @@ "delete": "Delete", "description": "Description", "edit": "Edit", + "empty-placeholder": "Empty", "email": "Email", "expand": "Expand", "explore": "Explore", @@ -107,6 +146,7 @@ "today": "Today", "tree-mode": "Tree mode", "type": "Type", + "unlink": "Unlink", "unpin": "Unpin", "update": "Update", "upload": "Upload", @@ -114,39 +154,51 @@ "username": "Username", "version": "Version", "visibility": "Visibility", - "yourself": "Yourself", - "unlink": "Unlink" + "yourself": "Yourself" }, "editor": { "add-your-comment-here": "Add your comment here...", "any-thoughts": "Any thoughts...", "exit-focus-mode": "Exit Focus Mode", "focus-mode": "Focus Mode", + "insert-menu": { + "add-location": "Add location", + "link-memo": "Link memo", + "upload-file": "Upload file" + }, "no-changes-detected": "No changes detected", "save": "Save", "saving": "Saving...", "slash-commands": "Type `/` for commands", - "attachments-not-implemented": "Not implemented yet", - "attachments-not-implemented-detail": "File and voice attachments require server upload support, which is not available in this build.", - "voice-recorder": { + "audio-recorder": { + "attachment-label": "Audio recording", + "attachment-label-with-time": "Audio recording {{time}}", + "configure-ai-provider": "Configure an AI provider first", "discard": "Discard", "error": "Microphone unavailable", "error-description": "Try again after checking microphone access for this site.", - "idle-description": "Start recording to add a voice note as an audio attachment.", + "idle-description": "Start recording to add an audio recording as an attachment.", "keep": "Keep recording", + "pause-recording": "Pause audio recording", + "play-recording": "Play audio recording", "ready": "Recording ready", "ready-description": "Preview the clip, then keep it as an audio attachment or discard it.", "record-again": "Record again", - "recording": "Recording voice note", + "recording": "Recording audio", "recording-description": "Capture a quick audio attachment. Current length: {{duration}}", "requesting": "Requesting access...", "requesting-permission": "Requesting microphone access", "requesting-permission-description": "Allow microphone access in your browser to start recording.", "start": "Start recording", "stop": "Stop recording", - "title": "Voice recorder", - "trigger": "Voice note", - "unsupported": "Voice recording unsupported", + "title": "Audio recorder", + "transcribe": "Transcribe", + "transcribe-empty": "No speech detected", + "transcribe-error": "Failed to transcribe audio", + "transcribe-success": "Transcription added", + "transcribing": "Transcribing...", + "trigger": "Record audio", + "unsupported": "Audio recording unsupported", "unsupported-description": "This browser cannot record audio from the memo composer." } }, @@ -195,9 +247,13 @@ "load-more": "Load more", "no-archived-memos": "No archived memos.", "no-memos": "No memos.", + "newest-first": "Newest first", + "oldest-first": "Oldest first", + "order": "Order", "order-by": "Order By", "outline": "Outline", "search-placeholder": "Search memos...", + "shown-time": "Shown time", "share": { "active-links": "Active share links", "copied": "Copied!", @@ -210,10 +266,19 @@ "expiry-7-days": "7 days", "expiry-label": "Expires", "expiry-never": "Never", + "image-description": "Preview rendered at your current device width ({{width}}px).", + "image-download": "Download PNG", + "image-download-failed": "Failed to export image", + "image-downloaded": "Image saved", + "image-footer": "Shared from Memos", + "image-share": "Share image", + "image-share-failed": "Failed to share image", + "image-title": "Share as image", "expires-on": "Expires {{date}}", "invalid-link": "This link is invalid or has expired.", "never-expires": "Never expires", "no-links": "No share links yet. Create one below.", + "open-image": "Share as image", "open-panel": "Manage share links", "revoke": "Revoke", "revoke-failed": "Failed to revoke link", @@ -327,6 +392,10 @@ }, "account": { "change-password": "Change password", + "danger-area": "Danger area", + "danger-area-description": "Irreversible account actions live here. Review them carefully before continuing.", + "delete-account": "Delete account", + "delete-account-description": "Permanently remove this account and all associated access from this instance. This action cannot be undone.", "email-note": "Optional", "export-memos": "Export Memos", "nickname-note": "Displayed in the banner", @@ -336,11 +405,34 @@ "reset-api": "Reset API", "title": "Account Information", "update-information": "Update Information", - "username-note": "Used to sign in", - "danger-area": "Danger area", - "danger-area-description": "Irreversible account actions live here. Review them carefully before continuing.", - "delete-account": "Delete account", - "delete-account-description": "Permanently remove this account and all associated access from this instance. This action cannot be undone." + "username-note": "Used to sign in" + }, + "ai": { + "add-provider": "Add provider", + "api-key": "API key", + "api-key-required": "API key is required.", + "byok-description": "Connect OpenAI or Gemini with an API key from your own account. Memos calls the provider directly from this server.", + "byok-key-note": "Use a key from your provider account; Memos does not bundle shared AI credentials.", + "byok-label": "BYOK", + "byok-model-note": "Memos selects supported models for features like audio transcription.", + "byok-storage-note": "Keys stay on this instance and are masked when settings are loaded.", + "byok-title": "Use your own AI account", + "configured": "Configured", + "current-key": "Current key: {{key}}", + "default-endpoint": "Default endpoint", + "delete-provider": "Delete AI provider `{{title}}`?", + "description": "Provider keys are supplied by the instance owner and used by server-side AI features.", + "dialog-description": "Add a key from your own provider account. Memos uses built-in models for each provider; leave the API key blank while editing to keep the stored key.", + "edit-provider": "Edit provider", + "endpoint": "Endpoint", + "endpoint-hint": "Leave empty to use the official provider endpoint.", + "keep-api-key": "Leave blank to keep the existing key", + "label": "AI", + "no-providers": "No AI providers configured.", + "provider-title": "Provider name", + "provider-title-required": "Provider name is required.", + "provider-type": "Provider type", + "providers": "Providers" }, "instance": { "disallow-change-nickname": "Disallow changing nickname", @@ -353,8 +445,10 @@ "week-start-day": "Week start day" }, "member": { + "active": "Active", "admin": "Admin", "archive-member": "Archive member", + "archived": "Archived", "archive-success": "{{username}} archived successfully", "archive-warning": "Are you sure you want to archive {{username}}?", "archive-warning-description": "Archiving disables the account. You can restore or delete it later.", @@ -365,7 +459,9 @@ "delete-warning-description": "THIS ACTION IS IRREVERSIBLE", "label": "Member", "list-title": "Member list", + "member-column": "Member", "restore-success": "{{username}} restored successfully", + "summary-column": "Summary", "user": "User", "no-members-found": "No members found" }, @@ -393,52 +489,69 @@ "delete-success": "Shortcut `{{title}}` deleted successfully" }, "sso": { + "account": "Account", + "accounts-description": "Review each identity provider, see the current link state, and connect or disconnect external identities from this account.", + "accounts-title": "SSO Accounts", "authorization-endpoint": "Authorization endpoint", + "avatar-url": "Avatar URL", + "basic-settings": "Basic settings", + "basic-settings-description": "Set the provider identity, display name, and optional identifier rules before filling in the OAuth details.", "client-id": "Client ID", "client-secret": "Client secret", + "client-secret-optional-description": "Leave blank to keep the existing client secret unchanged.", + "configuration": "Configuration", + "configuration-summary-description": "Show the essentials that help identify and audit a provider without exposing the full configuration inline.", "confirm-delete": "Are you sure you want to delete `{{name}}` SSO configuration? THIS ACTION IS IRREVERSIBLE", "create-sso": "Create SSO", + "create-sso-description": "Create a new identity provider for administrator-managed single sign-on.", "custom": "Custom", "delete-sso": "Confirm delete", "disabled-password-login-warning": "Password-login is disabled, be extra careful when removing identity providers", - "display-name": "Display Name", - "identifier": "Identifier", - "identifier-filter": "Identifier Filter", - "label": "SSO", - "no-sso-found": "No SSO found.", - "redirect-url": "Redirect URL", - "scopes": "Scopes", - "single-sign-on": "Configuring Single Sign-On (SSO) for Authentication", - "sso-created": "SSO {{name}} created", - "sso-list": "SSO List", - "sso-updated": "SSO {{name}} updated", - "template": "Template", - "token-endpoint": "Token endpoint", - "update-sso": "Update SSO", - "user-endpoint": "User endpoint", - "account": "Account", - "accounts-description": "Review each identity provider, see the current link state, and connect or disconnect external identities from this account.", - "accounts-title": "SSO Accounts", - "configuration": "Configuration", - "configuration-summary-description": "Show the essentials that help identify and audit a provider without exposing the full configuration inline.", "endpoints": "Endpoints", + "display-name": "Display Name", "extern-uid": "External ID", "extern-uid-description": "This is the provider-side identity currently linked to your account.", "filter-disabled": "Disabled", + "identifier": "Identifier", + "identifier-filter": "Identifier Filter", + "identifier-filter-description": "Optional regex used to allow or restrict which external identifiers may sign in.", + "field-mapping": "Claims mapping", + "field-mapping-description": "Map the upstream profile fields used to identify the user and prefill profile data.", + "field-mapping-identifier-description": "Used as the stable external identifier when signing in or linking an account.", "linked": "Linked", + "label": "SSO", "mapping": "Mapping", "mapping-avatar-short": "avatar", "mapping-display-name-short": "name", "mapping-email-short": "email", "mapping-identifier-short": "id", "mapping-none": "Not configured", + "no-sso-found": "No SSO found.", "not-linked": "Not linked", "not-linked-description": "No external identity is linked yet. You can connect this provider to sign in with it later.", + "oauth-configuration": "OAuth configuration", + "oauth-configuration-description": "Fill in the OAuth client credentials and the provider endpoints used during sign-in.", "provider": "Provider", + "provider-id": "Provider ID", + "provider-id-description": "Lowercase letters, numbers, and hyphens only. This value becomes part of the provider resource name.", "provider-uid": "UID", + "redirect-url": "Redirect URL", + "redirect-url-description": "Register this callback URL with your identity provider so the authorization code flow can complete.", "scope-count_one": "{{count}} scope", "scope-count_other": "{{count}} scopes", - "unlink-success": "Unlinked {{name}}." + "scopes": "Scopes", + "scopes-description": "Separate scopes with spaces. Most providers only need a small set such as profile or email access.", + "single-sign-on": "Configuring Single Sign-On (SSO) for Authentication", + "sso-created": "SSO {{name}} created", + "sso-list": "SSO List", + "sso-updated": "SSO {{name}} updated", + "template": "Template", + "template-description": "Start from a provider preset, then adjust the credentials and endpoints for your tenant.", + "unlink-success": "Unlinked {{name}}.", + "token-endpoint": "Token endpoint", + "update-sso": "Update SSO", + "update-sso-description": "Review the provider configuration, then save the fields that should change.", + "user-endpoint": "User endpoint" }, "storage": { "accesskey": "Access key", @@ -453,7 +566,6 @@ "filepath-template": "Filepath template", "label": "Storage", "local-storage-path": "Local storage path", - "not-implemented-hint": "Attachment storage is not implemented in this server build. These options are shown for reference only; saving has no effect.", "path": "Storage Path", "path-description": "You can use the same dynamic variables from local storage, like {filename}", "path-placeholder": "custom/path", @@ -537,33 +649,6 @@ "tag-pattern-hint": "Tag name or regex pattern (e.g. project/.* matches all project/ tags)", "invalid-regex": "Invalid or unsafe regex pattern.", "using-default-color": "Using default color." - }, - "ai": { - "add-provider": "Add provider", - "api-key": "API key", - "api-key-required": "API key is required.", - "byok-description": "Connect OpenAI or Gemini with an API key from your own account. Memos calls the provider directly from this server.", - "byok-key-note": "Use a key from your provider account; Memos does not bundle shared AI credentials.", - "byok-label": "BYOK", - "byok-model-note": "Memos selects supported models for features like audio transcription.", - "byok-storage-note": "Keys stay on this instance and are masked when settings are loaded.", - "byok-title": "Use your own AI account", - "configured": "Configured", - "current-key": "Current key: {{key}}", - "default-endpoint": "Default endpoint", - "delete-provider": "Delete AI provider `{{title}}`?", - "description": "Provider keys are supplied by the instance owner and used by server-side AI features.", - "dialog-description": "Add a key from your own provider account. Memos uses built-in models for each provider; leave the API key blank while editing to keep the stored key.", - "edit-provider": "Edit provider", - "endpoint": "Endpoint", - "endpoint-hint": "Leave empty to use the official provider endpoint.", - "keep-api-key": "Leave blank to keep the existing key", - "label": "AI", - "no-providers": "No AI providers configured.", - "provider-title": "Provider name", - "provider-title-required": "Provider name is required.", - "provider-type": "Provider type", - "providers": "Providers" } }, "tag": { @@ -589,37 +674,5 @@ "select-visibility": "Visibility", "tags": "Tags", "upload-attachment": "Upload Attachment(s)" - }, - "attachment-library": { - "actions": { - "open": "Open", - "retry": "Retry" - }, - "empty": { - "audio": "No audio attachments yet.", - "documents": "No document attachments yet.", - "media": "No media attachments yet." - }, - "errors": { - "load": "Failed to load attachments." - }, - "labels": { - "live": "Live", - "live-photo": "Live Photo", - "memo": "Memo", - "not-linked": "Not linked", - "unknown-date": "Unknown date", - "unused": "Unused" - }, - "tabs": { - "audio": "Audio", - "documents": "Documents", - "media": "Media" - }, - "unused": { - "confirm-description": "This removes every uploaded file that is not linked to a memo.", - "description": "These uploads were never attached to a memo. Review or remove them here.", - "title": "Unlinked uploads" - } } } diff --git a/web/src/locales/zh-Hans.json b/web/src/locales/zh-Hans.json index 4cac1180..b27def4f 100644 --- a/web/src/locales/zh-Hans.json +++ b/web/src/locales/zh-Hans.json @@ -15,6 +15,11 @@ "sign-in-tip": "已有账户?", "sign-up-tip": "还没有账户?" }, + "demo": { + "banner-description": "这里的改动是临时的,可能会被重置。", + "banner-title": "Memos 演示站", + "deploy-link": "部署你的 Memos" + }, "common": { "about": "关于", "add": "增加", @@ -51,6 +56,7 @@ "delete": "删除", "description": "说明", "edit": "编辑", + "empty-placeholder": "空", "email": "邮箱", "expand": "展开", "explore": "发现", @@ -107,6 +113,7 @@ "today": "今天", "tree-mode": "树模式", "type": "类型", + "unlink": "解绑", "unpin": "取消置顶", "update": "更新", "upload": "上传", @@ -125,8 +132,42 @@ "save": "保存", "saving": "保存中...", "slash-commands": "按下 `/` 输入命令", - "attachments-not-implemented": "暂未实现", - "attachments-not-implemented-detail": "附件上传(文件与语音)需服务端支持,当前版本未提供。" + "insert-menu": { + "add-location": "添加位置", + "link-memo": "链接备忘录", + "upload-file": "上传文件" + }, + "audio-recorder": { + "attachment-label": "录音", + "attachment-label-with-time": "录音 {{time}}", + "configure-ai-provider": "请先配置 AI provider", + "discard": "丢弃", + "error": "麦克风不可用", + "error-description": "检查此站点的麦克风访问权限后重试。", + "idle-description": "开始录音以将录音添加为附件。", + "keep": "继续录音", + "pause-recording": "暂停录音", + "play-recording": "播放录音", + "ready": "录音准备就绪", + "ready-description": "预览剪辑,然后将其保留为音频附件或将其丢弃。", + "record-again": "再次录制", + "recording": "录音", + "recording-description": "捕获快速音频附件。当前长度:{{duration}}", + "requesting": "请求访问...", + "requesting-permission": "请求麦克风访问权限", + "requesting-permission-description": "允许浏览器中的麦克风访问以开始录音。", + "start": "开始录音", + "stop": "停止录音", + "title": "录音机", + "transcribe": "转为文字", + "transcribe-empty": "未检测到语音", + "transcribe-error": "音频转写失败", + "transcribe-success": "转写内容已加入", + "transcribing": "转写中...", + "trigger": "录制音频", + "unsupported": "不支持录音", + "unsupported-description": "此浏览器无法录制备忘录编辑器中的音频。" + } }, "inbox": { "failed-to-load": "加载失败", @@ -161,14 +202,19 @@ "filters": { "has-code": "有代码", "has-link": "有链接", - "has-task-list": "有待办" + "has-task-list": "有待办", + "label": "过滤器" }, "links": "链接", "load-more": "加载更多", "no-archived-memos": "没有已归档备忘录。", "no-memos": "无备忘录", + "newest-first": "最新优先", + "oldest-first": "最早优先", + "order": "顺序", "order-by": "排序", "search-placeholder": "搜索备忘录", + "shown-time": "显示时间", "show-less": "显示较少", "show-more": "查看更多", "to-do": "待办", @@ -178,6 +224,41 @@ "private": "私有", "protected": "工作区", "public": "公开" + }, + "outline": "概要", + "share": { + "active-links": "活跃的分享链接", + "copied": "已复制!", + "copy": "复制链接", + "create-failed": "无法创建分享链接", + "create-link": "创建新链接", + "creating": "创建中…", + "expiry-1-day": "1天", + "expiry-30-days": "30天", + "expiry-7-days": "7天", + "expiry-label": "到期时间", + "expiry-never": "永不", + "image-description": "预览会按你当前设备上的展示宽度({{width}}px)渲染。", + "image-download": "下载 PNG", + "image-download-failed": "导出图片失败", + "image-downloaded": "图片已保存", + "image-footer": "由 Memos 生成分享图", + "image-share": "分享图片", + "image-share-failed": "分享图片失败", + "image-title": "分享成图片", + "expires-on": "{{date}} 到期", + "invalid-link": "该链接无效或已过期。", + "never-expires": "永不过期", + "no-links": "还没有分享链接。在下面创建一个。", + "open-image": "分享成图片", + "open-panel": "管理分享链接", + "revoke": "撤销", + "revoke-failed": "撤销链接失败", + "revoked": "分享链接已撤销", + "section-label": "分享", + "share": "分享", + "shared-by": "由 {{creator}} 分享", + "title": "分享这份备忘录" } }, "message": { @@ -250,8 +331,10 @@ }, "setting": { "member": { + "active": "启用中", "admin": "管理员", "archive-member": "归档成员", + "archived": "已归档", "archive-success": "{{username}} 归档成功", "archive-warning": "您确定要归档 {{username}} 吗?", "archive-warning-description": "归档会禁用用户。您可以稍后恢复或删除它。", @@ -263,11 +346,41 @@ "restore-success": "{{username}} 恢复成功", "user": "普通用户", "label": "成员", - "list-title": "成员列表" + "list-title": "成员列表", + "member-column": "成员", + "summary-column": "摘要", + "no-members-found": "没有找到会员" }, "my-account": { "label": "我的账号" }, + "ai": { + "add-provider": "添加 provider", + "api-key": "API key", + "api-key-required": "API key 不能为空。", + "byok-description": "使用你自己的 OpenAI 或 Gemini 账号 API key。Memos 会从本实例服务器直接调用 provider。", + "byok-key-note": "使用你在 provider 账号中创建的 key;Memos 不提供共享 AI 凭据。", + "byok-label": "BYOK", + "byok-model-note": "Memos 会为音频转写等功能选择内置支持的模型。", + "byok-storage-note": "Key 保存在本实例中,加载设置时只返回脱敏提示。", + "byok-title": "使用你自己的 AI 账号", + "configured": "已配置", + "current-key": "当前 key:{{key}}", + "default-endpoint": "默认端点", + "delete-provider": "删除 AI provider `{{title}}`?", + "description": "Provider key 由实例所有者提供,并用于服务端 AI 功能。", + "dialog-description": "添加你自己的 provider 账号 key。Memos 会为每个 provider 使用内置模型;编辑时留空 API key 可保留已保存的 key。", + "edit-provider": "编辑 provider", + "endpoint": "端点", + "endpoint-hint": "留空则使用官方 provider 端点。", + "keep-api-key": "留空以保留已保存的 key", + "label": "AI", + "no-providers": "尚未配置 AI provider。", + "provider-title": "Provider 名称", + "provider-title-required": "Provider 名称不能为空。", + "provider-type": "Provider 类型", + "providers": "Providers" + }, "preference": { "default-memo-sort-option": "备忘录显示时间", "default-memo-visibility": "默认备忘录可见性", @@ -279,29 +392,70 @@ "delete-success": "捷径 `{{title}}` 删除成功" }, "sso": { + "account": "账户", + "accounts-description": "查看每个身份提供程序的当前绑定状态,并为当前账户连接或解绑外部身份。", + "accounts-title": "SSO 账户", "authorization-endpoint": "授权端点(Authorization Endpoint)", + "avatar-url": "头像链接(Avatar URL)", + "basic-settings": "基础信息", + "basic-settings-description": "先设置 provider 的标识、展示名称和可选的标识符规则,再补充 OAuth 配置。", "client-id": "客户端ID(Client ID)", "client-secret": "客户端密钥(Client Secret)", + "client-secret-optional-description": "留空则保留现有的客户端密钥,不会覆盖。", + "configuration": "配置摘要", + "configuration-summary-description": "这里只展示便于识别和审查 provider 的关键信息,完整配置仍然通过编辑入口查看。", "confirm-delete": "您确定要删除“{{name}}”单点登录配置吗?(此操作不可逆)", "create-sso": "创建单点登录", + "create-sso-description": "为管理员管理的单点登录创建新的身份提供程序。", "custom": "自定义", "delete-sso": "确认删除", "disabled-password-login-warning": "密码登录已被禁用,删除身份提供程序时要格外小心", + "endpoints": "端点", "display-name": "显示名称", + "extern-uid": "外部 ID", + "extern-uid-description": "这是当前绑定到您账户上的身份提供程序侧标识。", + "filter-disabled": "未启用", + "field-mapping": "字段映射", + "field-mapping-description": "映射上游用户信息字段,用于识别用户并预填展示资料。", + "field-mapping-identifier-description": "这是登录或绑定账户时使用的稳定外部标识字段。", "identifier": "标识符(Identifier)", "identifier-filter": "标识符过滤器(Identifier Filter)", + "identifier-filter-description": "可选正则表达式,用来限制或允许哪些外部标识符可以登录。", + "linked": "已绑定", "no-sso-found": "没有 SSO 配置", + "no-scopes": "无 Scopes", + "not-linked": "未绑定", + "oauth-configuration": "OAuth 配置", + "oauth-configuration-description": "填写 OAuth 客户端凭据,以及登录流程中使用的 provider 端点。", + "provider": "提供程序", + "provider-id": "Provider ID", + "provider-id-description": "仅支持小写字母、数字和连字符。该值会成为 provider 资源名的一部分。", + "provider-uid": "UID", "redirect-url": "重定向链接", + "redirect-url-description": "将这个回调地址注册到身份提供程序中,授权码流程才能正确返回。", "scopes": "范围", + "scopes-description": "使用空格分隔多个 scope。大多数 provider 只需要 profile、email 这类基础 scope。", "single-sign-on": "配置单点登录(SSO)进行身份验证", "sso-created": "单点登录 {{name}} 已创建", "sso-list": "单点登录列表", "sso-updated": "单点登录 {{name}} 已更新", "template": "模板", + "template-description": "先选择一个 provider 预设,再按你的租户信息调整凭据和端点。", + "mapping": "映射", + "mapping-avatar-short": "avatar", + "mapping-display-name-short": "name", + "mapping-email-short": "email", + "mapping-identifier-short": "id", + "mapping-none": "未配置", + "unlink-success": "已解绑 {{name}}。", + "label": "单点登录", + "not-linked-description": "当前还没有绑定外部身份。绑定后即可使用这个提供程序登录。", + "scope-count_one": "{{count}} 个 scope", + "scope-count_other": "{{count}} 个 scopes", "token-endpoint": "令牌端点(Token Endpoint)", "update-sso": "更新单点登录", - "user-endpoint": "用户端点(User Endpoint)", - "label": "单点登录" + "update-sso-description": "检查当前 provider 配置,只保存你需要变更的字段。", + "user-endpoint": "用户端点(User Endpoint)" }, "storage": { "accesskey": "访问密钥(Access key)", @@ -336,8 +490,7 @@ "url-suffix": "链接后缀", "url-suffix-placeholder": "自定义链接后缀,可选", "warning-text": "您确定要删除存储服务“{{name}}”吗?(此操作不可逆)", - "label": "存储", - "not-implemented-hint": "当前服务端未实现附件存储,以下选项仅供对照,保存不会生效。" + "label": "存储" }, "system": { "additional-script": "自定义脚本", @@ -386,10 +539,15 @@ }, "description": "该账号下全部的访问令牌", "title": "访问令牌", - "token": "令牌" + "token": "令牌", + "no-tokens-found": "未找到访问令牌" }, "account": { "change-password": "修改密码", + "danger-area": "危险操作区", + "danger-area-description": "不可逆的账号操作统一放在这里,执行前请再次确认影响。", + "delete-account": "删除账号", + "delete-account-description": "永久删除当前账号,并移除它在这个实例中的全部访问权限。此操作无法撤销。", "email-note": "可选", "export-memos": "导出备忘录", "nickname-note": "显示在横幅中", @@ -418,7 +576,8 @@ "enable-memo-location": "启用备忘录定位", "reactions": "表态", "title": "备忘录相关设置", - "label": "备忘录" + "label": "备忘录", + "reactions-required": "反应列表不能为空" }, "webhook": { "create-dialog": { @@ -439,6 +598,20 @@ "title": "Webhook", "url": "链接", "label": "Webhook" + }, + "tags": { + "label": "标签", + "title": "标签元数据", + "description": "将可选的显示颜色分配给实例范围内的标签,或模糊匹配的备忘录内容。标签名称被视为锚定的正则表达式模式。", + "background-color": "背景颜色", + "blur-content": "模糊内容", + "no-tags-configured": "未配置标签元数据。", + "tag-name": "标签名称", + "tag-name-placeholder": "例如工作或project/.*", + "tag-already-exists": "标签已经存在。", + "tag-pattern-hint": "标签名称或正则表达式模式(例如 project/.* 匹配所有 project/ 标签)", + "invalid-regex": "无效或不安全的正则表达式模式。", + "using-default-color": "使用默认颜色。" } }, "tag": { diff --git a/web/src/locales/zh-Hant.json b/web/src/locales/zh-Hant.json index bebaf6a8..ee892c23 100644 --- a/web/src/locales/zh-Hant.json +++ b/web/src/locales/zh-Hant.json @@ -15,6 +15,10 @@ "sign-in-tip": "已經有帳戶了嗎?", "sign-up-tip": "還沒有帳戶嗎?" }, + "demo": { + "banner-description": "這裡的改動是暫時的,可能會被重置。", + "banner-title": "Memos 演示站" + }, "common": { "about": "關於", "add": "新增", @@ -125,8 +129,42 @@ "save": "儲存", "saving": "儲存中...", "slash-commands": "輸入 `/` 以使用指令", - "attachments-not-implemented": "暫未實作", - "attachments-not-implemented-detail": "附件上傳(檔案與語音)需伺服端支援,此版本尚未提供。" + "insert-menu": { + "add-location": "新增位置", + "link-memo": "連結備忘錄", + "upload-file": "上傳文件" + }, + "audio-recorder": { + "attachment-label": "錄音", + "attachment-label-with-time": "錄音 {{time}}", + "configure-ai-provider": "請先配置 AI provider", + "discard": "丟棄", + "error": "麥克風不可用", + "error-description": "檢查此網站的麥克風存取權限後重試。", + "idle-description": "開始錄音以將錄音新增為附件。", + "keep": "繼續錄音", + "pause-recording": "暫停錄音", + "play-recording": "播放錄音", + "ready": "錄音準備就緒", + "ready-description": "預覽剪輯,然後將其保留為音訊附件或將其丟棄。", + "record-again": "再次錄製", + "recording": "錄音", + "recording-description": "擷取快速音訊附件。目前長度:{{duration}}", + "requesting": "請求訪問...", + "requesting-permission": "請求麥克風存取權限", + "requesting-permission-description": "允許瀏覽器中的麥克風存取以開始錄音。", + "start": "開始錄音", + "stop": "停止錄音", + "title": "錄音機", + "transcribe": "轉為文字", + "transcribe-empty": "未偵測到語音", + "transcribe-error": "音訊轉寫失敗", + "transcribe-success": "轉寫內容已加入", + "transcribing": "轉寫中...", + "trigger": "錄製音訊", + "unsupported": "不支援錄音", + "unsupported-description": "此瀏覽器無法錄製備忘錄編輯器中的音訊。" + } }, "inbox": { "failed-to-load": "載入失敗", @@ -161,14 +199,19 @@ "filters": { "has-code": "有程式碼", "has-link": "有連結", - "has-task-list": "有待辦事項" + "has-task-list": "有待辦事項", + "label": "過濾器" }, "links": "連結", "load-more": "載入更多", "no-archived-memos": "無已封存的備忘錄", "no-memos": "無備忘錄", + "newest-first": "最新優先", + "oldest-first": "最早優先", + "order": "順序", "order-by": "排序", "search-placeholder": "搜尋備忘錄", + "shown-time": "顯示時間", "show-less": "顯示較少", "show-more": "查看更多", "to-do": "待辦事項", @@ -178,6 +221,41 @@ "private": "私人", "protected": "成員", "public": "公開" + }, + "outline": "概要", + "share": { + "active-links": "有效的分享連結", + "copied": "已複製!", + "copy": "複製連結", + "create-failed": "無法建立分享連結", + "create-link": "建立新連結", + "creating": "建立中…", + "expiry-1-day": "1天", + "expiry-30-days": "30天", + "expiry-7-days": "7天", + "expiry-label": "到期時間", + "expiry-never": "永不", + "image-description": "預覽會依照你目前裝置上的顯示寬度({{width}}px)渲染。", + "image-download": "下載 PNG", + "image-download-failed": "匯出圖片失敗", + "image-downloaded": "圖片已儲存", + "image-footer": "由 Memos 產生分享圖", + "image-share": "分享圖片", + "image-share-failed": "分享圖片失敗", + "image-title": "分享成圖片", + "expires-on": "{{date}} 到期", + "invalid-link": "該連結無效或已過期。", + "never-expires": "永不過期", + "no-links": "還沒有分享連結,請在下方建立。", + "open-image": "分享成圖片", + "open-panel": "管理分享連結", + "revoke": "撤銷", + "revoke-failed": "撤銷連結失敗", + "revoked": "分享連結已撤銷", + "section-label": "分享", + "share": "分享", + "shared-by": "由 {{creator}} 分享", + "title": "分享這份備忘錄" } }, "message": { @@ -263,11 +341,39 @@ "restore-success": "{{username}} 恢復成功", "user": "使用者", "label": "使用者", - "list-title": "使用者列表" + "list-title": "使用者列表", + "no-members-found": "沒有找到會員" }, "my-account": { "label": "我的帳號" }, + "ai": { + "add-provider": "新增 provider", + "api-key": "API key", + "api-key-required": "API key 不可為空。", + "byok-description": "使用你自己的 OpenAI 或 Gemini 帳號 API key。Memos 會從本實例伺服器直接呼叫 provider。", + "byok-key-note": "使用你在 provider 帳號中建立的 key;Memos 不提供共享 AI 憑證。", + "byok-label": "BYOK", + "byok-model-note": "Memos 會為音訊轉寫等功能選擇內建支援的模型。", + "byok-storage-note": "Key 保存在本實例中,載入設定時只返回脫敏提示。", + "byok-title": "使用你自己的 AI 帳號", + "configured": "已設定", + "current-key": "目前 key:{{key}}", + "default-endpoint": "預設端點", + "delete-provider": "刪除 AI provider `{{title}}`?", + "description": "Provider key 由實例擁有者提供,並用於伺服器端 AI 功能。", + "dialog-description": "新增你自己的 provider 帳號 key。Memos 會為每個 provider 使用內建模型;編輯時留空 API key 可保留已保存的 key。", + "edit-provider": "編輯 provider", + "endpoint": "端點", + "endpoint-hint": "留空則使用官方 provider 端點。", + "keep-api-key": "留空以保留已保存的 key", + "label": "AI", + "no-providers": "尚未設定 AI provider。", + "provider-title": "Provider 名稱", + "provider-title-required": "Provider 名稱不可為空。", + "provider-type": "Provider 類型", + "providers": "Providers" + }, "preference": { "default-memo-sort-option": "備忘錄顯示時間", "default-memo-visibility": "備忘錄預設瀏覽權限", @@ -336,8 +442,7 @@ "url-suffix": "網址後綴", "url-suffix-placeholder": "自訂網址後綴(選填)", "warning-text": "您確定要刪除存儲服務 `{{name}}` 嗎?此操作無法恢復。", - "label": "儲存空間", - "not-implemented-hint": "目前伺服端未實作附件儲存,以下選項僅供對照,儲存不會生效。" + "label": "儲存空間" }, "system": { "additional-script": "自訂腳本", @@ -386,7 +491,8 @@ }, "description": "此處列出您帳號的所有存取令牌。", "title": "存取令牌", - "token": "令牌" + "token": "令牌", + "no-tokens-found": "未找到訪問令牌" }, "account": { "change-password": "變更密碼", @@ -418,7 +524,8 @@ "enable-memo-location": "啟用備忘錄定位", "reactions": "表情回應", "title": "備忘錄相關設定", - "label": "備忘錄" + "label": "備忘錄", + "reactions-required": "反應列表不能為空" }, "webhook": { "create-dialog": { @@ -439,6 +546,20 @@ "title": "Webhook", "url": "網址", "label": "Webhook" + }, + "tags": { + "label": "標籤", + "title": "標籤元數據", + "description": "將可選的顯示顏色指派給實例範圍內的標籤,或模糊符合的備忘錄內容。��籤名稱被視為錨定的正規表示式模式。", + "background-color": "背景顏色", + "blur-content": "模糊內容", + "no-tags-configured": "未配置標籤元資料。", + "tag-name": "標籤名稱", + "tag-name-placeholder": "例如工作或project/.*", + "tag-already-exists": "標籤已經存在。", + "tag-pattern-hint": "標籤名稱或正規表示式模式(例如 project/.* 符合所有 project/ 標籤)", + "invalid-regex": "無效或不安全的正規表示式模式。", + "using-default-color": "使用預設顏色。" } }, "tag": { diff --git a/web/src/pages/AuthCallback.tsx b/web/src/pages/AuthCallback.tsx index 5647a9b8..77bba2b7 100644 --- a/web/src/pages/AuthCallback.tsx +++ b/web/src/pages/AuthCallback.tsx @@ -2,11 +2,13 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; import { useEffect, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { setAccessToken } from "@/auth-state"; -import { authServiceClient } from "@/connect"; +import { authServiceClient, userServiceClient } from "@/connect"; import { useAuth } from "@/contexts/AuthContext"; import { absolutifyLink } from "@/helpers/utils"; import useNavigateTo from "@/hooks/useNavigateTo"; import { handleError } from "@/lib/error"; +import { ROUTES } from "@/router/routes"; +import { getSafeRedirectPath } from "@/utils/auth-redirect"; import { validateOAuthState } from "@/utils/oauth"; interface State { @@ -16,7 +18,7 @@ interface State { const AuthCallback = () => { const navigateTo = useNavigateTo(); - const { initialize } = useAuth(); + const { currentUser, initialize, isInitialized } = useAuth(); const [searchParams] = useSearchParams(); const handledRef = useRef(false); const [state, setState] = useState({ @@ -28,7 +30,9 @@ const AuthCallback = () => { if (handledRef.current) { return; } - handledRef.current = true; + if (!isInitialized) { + return; + } // Check for OAuth error response first (e.g., user denied access) const error = searchParams.get("error"); const errorDescription = searchParams.get("error_description"); @@ -72,30 +76,46 @@ const AuthCallback = () => { return; } - const { identityProviderName, returnUrl, codeVerifier } = validatedState; + const { identityProviderName, flowMode, returnUrl, linkingUserName, codeVerifier } = validatedState; const redirectUri = absolutifyLink("/auth/callback"); + handledRef.current = true; (async () => { try { - const response = await authServiceClient.signIn({ - ssoCredentials: { + if (flowMode === "link") { + if (!currentUser?.name) { + throw new Error("Failed to link account. Please sign in to Memos again and retry."); + } + if (linkingUserName && currentUser.name !== linkingUserName) { + throw new Error("The signed-in user changed before the OAuth callback completed. Please retry linking from account settings."); + } + await userServiceClient.createLinkedIdentity({ + parent: currentUser.name, idpName: identityProviderName, code, redirectUri, codeVerifier: codeVerifier || "", - }, - }); - // Store access token from login response - if (response.accessToken) { - setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined); + }); + } else { + const response = await authServiceClient.signIn({ + ssoCredentials: { + idpName: identityProviderName, + code, + redirectUri, + codeVerifier: codeVerifier || "", + }, + }); + // Store access token from login response + if (response.accessToken) { + setAccessToken(response.accessToken, response.accessTokenExpiresAt ? timestampDate(response.accessTokenExpiresAt) : undefined); + } } setState({ loading: false, errorMessage: "", }); await initialize(); - // Redirect to return URL if specified, otherwise home - navigateTo(returnUrl || "/"); + navigateTo(getSafeRedirectPath(returnUrl) ?? ROUTES.HOME); } catch (error: unknown) { handleError(error, () => {}, { fallbackMessage: "Failed to authenticate.", @@ -109,7 +129,7 @@ const AuthCallback = () => { }); } })(); - }, [searchParams, navigateTo]); + }, [currentUser?.name, initialize, isInitialized, navigateTo, searchParams]); if (state.loading) return null; diff --git a/web/src/pages/Inboxes.tsx b/web/src/pages/Inboxes.tsx index 1e0c0a97..3a3db14e 100644 --- a/web/src/pages/Inboxes.tsx +++ b/web/src/pages/Inboxes.tsx @@ -4,6 +4,7 @@ import { ArchiveIcon, BellIcon, InboxIcon } from "lucide-react"; import { useState } from "react"; import Empty from "@/components/Empty"; import MemoCommentMessage from "@/components/Inbox/MemoCommentMessage"; +import MemoMentionMessage from "@/components/Inbox/MemoMentionMessage"; import MobileHeader from "@/components/MobileHeader"; import useMediaQuery from "@/hooks/useMediaQuery"; import { useNotifications } from "@/hooks/useUserQueries"; @@ -19,7 +20,7 @@ const Inboxes = () => { // Fetch notifications with React Query const { data: fetchedNotifications = [] } = useNotifications(); - const allNotifications = sortBy(fetchedNotifications, (notification: UserNotification) => { + const allNotifications: UserNotification[] = sortBy(fetchedNotifications, (notification: UserNotification) => { return -((notification.createTime ? timestampDate(notification.createTime) : undefined)?.getTime() || 0); }); @@ -108,6 +109,9 @@ const Inboxes = () => { if (notification.type === UserNotification_Type.MEMO_COMMENT) { return ; } + if (notification.type === UserNotification_Type.MEMO_MENTION) { + return ; + } return null; })} diff --git a/web/src/pages/MemoDetail.tsx b/web/src/pages/MemoDetail.tsx index 9f1776be..05266198 100644 --- a/web/src/pages/MemoDetail.tsx +++ b/web/src/pages/MemoDetail.tsx @@ -1,8 +1,9 @@ import { Code, ConnectError } from "@connectrpc/connect"; import { ArrowUpLeftFromCircleIcon } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Link, Navigate, useLocation, useParams } from "react-router-dom"; import MemoCommentSection from "@/components/MemoCommentSection"; +import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext"; import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar"; import MemoView from "@/components/MemoView"; import MobileHeader from "@/components/MobileHeader"; @@ -16,6 +17,7 @@ import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; const MemoDetail = () => { const md = useMediaQuery("md"); + const [shareImageDialogOpen, setShareImageDialogOpen] = useState(false); const params = useParams(); const location = useLocation(); const { state: locationState, hash } = location; @@ -72,46 +74,51 @@ const MemoDetail = () => { const displayMemo = isShareMode ? { ...memo, attachments: withShareAttachmentLinks(memo.attachments as Attachment[], shareToken!) } : memo; + const mentionResolutionContents = [displayMemo.content, ...comments.map((comment) => comment.content)]; return (
{!md && ( - + setShareImageDialogOpen(true)} /> )} -
-
- {parentMemo && ( -
- - - {parentMemo.content} - + +
+
+ {parentMemo && ( +
+ + + {parentMemo.content} + +
+ )} + + +
+ {md && ( +
+ setShareImageDialogOpen(true)} />
)} - -
- {md && ( -
- -
- )} -
+
); }; diff --git a/web/src/pages/Setting.tsx b/web/src/pages/Setting.tsx index bb52a577..141d5348 100644 --- a/web/src/pages/Setting.tsx +++ b/web/src/pages/Setting.tsx @@ -21,8 +21,8 @@ import MemoRelatedSettings from "@/components/Settings/MemoRelatedSettings"; import MyAccountSection from "@/components/Settings/MyAccountSection"; import PreferencesSection from "@/components/Settings/PreferencesSection"; import SectionMenuItem from "@/components/Settings/SectionMenuItem"; -import StorageSection from "@/components/Settings/StorageSection"; import SSOSection from "@/components/Settings/SSOSection"; +import StorageSection from "@/components/Settings/StorageSection"; import TagsSection from "@/components/Settings/TagsSection"; import WebhookSection from "@/components/Settings/WebhookSection"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -33,17 +33,7 @@ import { InstanceSetting_Key } from "@/types/proto/api/v1/instance_service_pb"; import { User_Role } from "@/types/proto/api/v1/user_service_pb"; import { useTranslate } from "@/utils/i18n"; -type SettingSection = - | "my-account" - | "preference" - | "webhook" - | "member" - | "system" - | "memo" - | "storage" - | "tags" - | "sso" - | "ai"; +type SettingSection = "my-account" | "preference" | "webhook" | "member" | "system" | "memo" | "storage" | "tags" | "sso" | "ai"; const BASIC_SECTIONS: SettingSection[] = ["my-account", "preference", "webhook"]; const ADMIN_SECTIONS: SettingSection[] = ["member", "system", "memo", "tags", "storage", "sso", "ai"]; @@ -92,9 +82,7 @@ const Setting = () => { const hash = location.hash.slice(1) as SettingSection; const saved = localStorage.getItem(LAST_SETTING_SECTION_STORAGE_KEY) as SettingSection | null; const savedSection = saved && settingsSectionList.includes(saved) ? saved : null; - const nextSection = settingsSectionList.includes(hash) - ? hash - : (savedSection ?? "my-account"); + const nextSection = settingsSectionList.includes(hash) ? hash : (savedSection ?? "my-account"); setSelectedSection(nextSection); localStorage.setItem(LAST_SETTING_SECTION_STORAGE_KEY, nextSection); if (!location.hash && nextSection) { diff --git a/web/src/router/guards.tsx b/web/src/router/guards.tsx index dacfbe77..71466170 100644 --- a/web/src/router/guards.tsx +++ b/web/src/router/guards.tsx @@ -11,7 +11,7 @@ import { ROUTES } from "./routes"; export const LandingRoute = () => { const currentUser = useCurrentUser(); const location = useLocation(); - const target = currentUser ? ROUTES.ROOT : ROUTES.EXPLORE; + const target = currentUser ? ROUTES.HOME : ROUTES.EXPLORE; return ( { /** * Guard for guest-only routes (sign-in and sign-up). Already-authenticated users - * are redirected to the requested `redirect` target (when safe) or to `/`. + * are redirected to the requested `redirect` target (when safe) or to `/home`. * * The OAuth callback route (`/auth/callback`) intentionally opts out of this guard: * an authenticated session in another tab must not prevent the callback from @@ -56,7 +56,7 @@ export const RequireGuestRoute = () => { if (currentUser) { const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); - return ; + return ; } return ; diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 64d5f7b6..774af03b 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -4,7 +4,9 @@ import App from "@/App"; import { ChunkLoadErrorFallback } from "@/components/ErrorBoundary"; import MainLayout from "@/layouts/MainLayout"; import RootLayout from "@/layouts/RootLayout"; -import { RequireAuthRoute, RequireGuestRoute } from "./guards"; +import Home from "@/pages/Home"; +import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "./guards"; +import { ROUTES } from "./routes"; // Wrap lazy imports to auto-reload on chunk load failure (e.g., after redeployment). function lazyWithReload(factory: () => Promise<{ default: T }>) { @@ -23,24 +25,21 @@ function lazyWithReload(factory: () => Promise<{ ); } -const Home = lazyWithReload(() => import("@/pages/Home")); const AdminSignIn = lazyWithReload(() => import("@/pages/AdminSignIn")); const Archived = lazyWithReload(() => import("@/pages/Archived")); -const Attachments = lazyWithReload(() => import("@/pages/Attachments")); +const AuthCallback = lazyWithReload(() => import("@/pages/AuthCallback")); const Explore = lazyWithReload(() => import("@/pages/Explore")); const Inboxes = lazyWithReload(() => import("@/pages/Inboxes")); const MemoDetail = lazyWithReload(() => import("@/pages/MemoDetail")); const NotFound = lazyWithReload(() => import("@/pages/NotFound")); const PermissionDenied = lazyWithReload(() => import("@/pages/PermissionDenied")); +const Attachments = lazyWithReload(() => import("@/pages/Attachments")); const Setting = lazyWithReload(() => import("@/pages/Setting")); const SignIn = lazyWithReload(() => import("@/pages/SignIn")); -const AuthCallback = lazyWithReload(() => import("@/pages/AuthCallback")); const SignUp = lazyWithReload(() => import("@/pages/SignUp")); const UserProfile = lazyWithReload(() => import("@/pages/UserProfile")); -import { ROUTES } from "./routes"; - -// Backward compatibility alias +// Backward compatibility alias. export const Routes = ROUTES; export { ROUTES }; @@ -72,19 +71,22 @@ export const routeConfig: RouteObject[] = [ }, ], }, + { index: true, element: }, { - path: Routes.ROOT, + path: Routes.ENTRY, element: , children: [ { element: , children: [ - { path: "", element: }, { path: Routes.EXPLORE, element: }, { path: "u/:username", element: }, { element: , - children: [{ path: Routes.ARCHIVED, element: }], + children: [ + { path: Routes.HOME, element: }, + { path: Routes.ARCHIVED, element: }, + ], }, ], }, @@ -93,8 +95,8 @@ export const routeConfig: RouteObject[] = [ { element: , children: [ - { path: Routes.INBOX, element: }, { path: Routes.ATTACHMENTS, element: }, + { path: Routes.INBOX, element: }, { path: Routes.SETTING, element: }, ], }, diff --git a/web/src/router/routes.ts b/web/src/router/routes.ts index fbfad60e..afa4c6c3 100644 --- a/web/src/router/routes.ts +++ b/web/src/router/routes.ts @@ -1,7 +1,12 @@ export const ROUTES = { - ROOT: "/", - INBOX: "/inbox", + // Entry-only route. Hosts the landing redirect, never a business page. + ENTRY: "/", + // The authenticated user's primary workspace page. + HOME: "/home", + // Backward compatibility alias for older code paths that meant "workspace root". + ROOT: "/home", ATTACHMENTS: "/attachments", + INBOX: "/inbox", ARCHIVED: "/archived", SETTING: "/setting", EXPLORE: "/explore", diff --git a/web/src/utils/auth-redirect.ts b/web/src/utils/auth-redirect.ts index 7e1e00cb..bb7ad064 100644 --- a/web/src/utils/auth-redirect.ts +++ b/web/src/utils/auth-redirect.ts @@ -1,49 +1,18 @@ import { clearAccessToken } from "@/auth-state"; import { ROUTES } from "@/router/routes"; - -const PUBLIC_ROUTES = [ - ROUTES.AUTH, // Authentication pages - ROUTES.EXPLORE, // Explore page - ROUTES.SHARED_MEMO + "/", // Shared memo pages (share-link viewer) - "/u/", // User profile pages (dynamic) - "/memos/", // Individual memo detail pages (dynamic) -] as const; - -export const AUTH_REDIRECT_PARAM = "redirect"; -export const AUTH_REASON_PARAM = "reason"; -export const AUTH_REASON_PROTECTED_MEMO = "protected-memo"; - -function isPublicRoute(path: string): boolean { - return PUBLIC_ROUTES.some((route) => path.startsWith(route)); -} - -export function getSafeRedirectPath(path: string | null | undefined): string | undefined { - if (!path) { - return undefined; - } - - if (!path.startsWith("/") || path.startsWith("//")) { - return undefined; - } - - return path; -} - -export function buildAuthRoute(options?: { redirect?: string | null; reason?: string | null }): string { - const searchParams = new URLSearchParams(); - const redirectPath = getSafeRedirectPath(options?.redirect); - - if (redirectPath) { - searchParams.set(AUTH_REDIRECT_PARAM, redirectPath); - } - - if (options?.reason) { - searchParams.set(AUTH_REASON_PARAM, options.reason); - } - - const search = searchParams.toString(); - return search ? `${ROUTES.AUTH}?${search}` : ROUTES.AUTH; -} +import { buildAuthRoute, isPublicRoute } from "./redirect-safety"; + +// Re-export the pure helpers so existing call sites (`@/utils/auth-redirect`) +// keep working without every caller switching to the new module. The side-effectful +// `redirectOnAuthFailure` lives here; pure logic lives in `./redirect-safety`. +export { + AUTH_REASON_PARAM, + AUTH_REASON_PROTECTED_MEMO, + AUTH_REDIRECT_PARAM, + buildAuthRoute, + getSafeRedirectPath, + isPublicRoute, +} from "./redirect-safety"; export function redirectOnAuthFailure( forceRedirect = false, diff --git a/web/src/utils/oauth.ts b/web/src/utils/oauth.ts index d3bb8f4e..96de3e72 100644 --- a/web/src/utils/oauth.ts +++ b/web/src/utils/oauth.ts @@ -1,11 +1,15 @@ const STATE_STORAGE_KEY = "oauth_state"; const STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes +export type OAuthFlowMode = "signin" | "link"; + interface OAuthState { state: string; identityProviderName: string; + flowMode: OAuthFlowMode; timestamp: number; returnUrl?: string; + linkingUserName?: string; codeVerifier?: string; // PKCE code_verifier } @@ -44,7 +48,9 @@ function base64UrlEncode(buffer: Uint8Array): string { // PKCE is optional - if crypto APIs are unavailable (HTTP context), falls back to standard OAuth export async function storeOAuthState( identityProviderName: string, + flowMode: OAuthFlowMode, returnUrl?: string, + linkingUserName?: string, ): Promise<{ state: string; codeChallenge?: string }> { const state = generateSecureState(); @@ -74,8 +80,10 @@ export async function storeOAuthState( const stateData: OAuthState = { state, identityProviderName, + flowMode, timestamp: Date.now(), returnUrl, + linkingUserName, codeVerifier, // Store for later retrieval in callback (undefined if PKCE not available) }; @@ -90,8 +98,10 @@ export async function storeOAuthState( } // Validate and retrieve OAuth state from storage (CSRF protection) -// Returns identityProviderName, returnUrl, and codeVerifier for PKCE -export function validateOAuthState(stateParam: string): { identityProviderName: string; returnUrl?: string; codeVerifier?: string } | null { +// Returns identityProviderName, flowMode, returnUrl, linkingUserName, and codeVerifier for PKCE +export function validateOAuthState( + stateParam: string, +): { identityProviderName: string; flowMode: OAuthFlowMode; returnUrl?: string; linkingUserName?: string; codeVerifier?: string } | null { try { const storedData = sessionStorage.getItem(STATE_STORAGE_KEY); if (!storedData) { @@ -119,7 +129,9 @@ export function validateOAuthState(stateParam: string): { identityProviderName: sessionStorage.removeItem(STATE_STORAGE_KEY); return { identityProviderName: stateData.identityProviderName, + flowMode: stateData.flowMode || "signin", returnUrl: stateData.returnUrl, + linkingUserName: stateData.linkingUserName, codeVerifier: stateData.codeVerifier, // Return PKCE code_verifier }; } catch (error) { diff --git a/web/src/utils/redirect-safety.ts b/web/src/utils/redirect-safety.ts new file mode 100644 index 00000000..bfc4741d --- /dev/null +++ b/web/src/utils/redirect-safety.ts @@ -0,0 +1,72 @@ +import { ROUTES } from "@/router/routes"; + +/** Query parameter used to preserve the intended destination across the auth flow. */ +export const AUTH_REDIRECT_PARAM = "redirect"; + +/** Query parameter used to surface why the user was sent to the auth page. */ +export const AUTH_REASON_PARAM = "reason"; + +/** Reason code signalling that the user hit a memo that requires authentication. */ +export const AUTH_REASON_PROTECTED_MEMO = "protected-memo"; + +/** + * Validates a post-authentication redirect target. + * + * Returns the path when it is a safe same-origin internal destination, otherwise `undefined`. + * Rejected targets include: non-string / empty, protocol-relative URLs (`//host`), absolute URLs, + * and any auth-family route (`/auth`, `/auth/callback`, …) which must not be a landing target + * after sign-in. + */ +export function getSafeRedirectPath(path: string | null | undefined): string | undefined { + if (!path) { + return undefined; + } + + if (!path.startsWith("/") || path.startsWith("//")) { + return undefined; + } + + // Never let a redirect target point back into the auth flow — it would either + // bounce the user in a guest/auth guard loop or hijack the OAuth callback. + if (path === ROUTES.AUTH || path.startsWith(`${ROUTES.AUTH}/`) || path.startsWith(`${ROUTES.AUTH}?`)) { + return undefined; + } + + return path; +} + +/** + * Builds a URL pointing at the auth entry page, optionally embedding a validated + * `redirect` target and a machine-readable `reason` code. + */ +export function buildAuthRoute(options?: { redirect?: string | null; reason?: string | null }): string { + const searchParams = new URLSearchParams(); + const redirectPath = getSafeRedirectPath(options?.redirect); + + if (redirectPath) { + searchParams.set(AUTH_REDIRECT_PARAM, redirectPath); + } + + if (options?.reason) { + searchParams.set(AUTH_REASON_PARAM, options.reason); + } + + const search = searchParams.toString(); + return search ? `${ROUTES.AUTH}?${search}` : ROUTES.AUTH; +} + +const PUBLIC_ROUTE_PREFIXES = [ + ROUTES.AUTH, // Authentication pages + ROUTES.EXPLORE, // Explore page + `${ROUTES.SHARED_MEMO}/`, // Shared memo pages (share-link viewer) + "/u/", // User profile pages (dynamic) + "/memos/", // Individual memo detail pages (dynamic) +] as const; + +/** + * Reports whether a given pathname corresponds to a page that unauthenticated + * visitors are allowed to view without being bounced to the auth page. + */ +export function isPublicRoute(path: string): boolean { + return PUBLIC_ROUTE_PREFIXES.some((route) => path.startsWith(route)); +} diff --git a/web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts b/web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts new file mode 100644 index 00000000..5957ebc8 --- /dev/null +++ b/web/src/utils/remark-plugins/remark-split-mixed-task-lists.ts @@ -0,0 +1,57 @@ +import type { List, ListItem, Root } from "mdast"; +import type { Parent } from "unist"; + +const isTaskListItem = (item: ListItem): boolean => typeof item.checked === "boolean"; + +const splitMixedList = (list: List): List[] => { + const hasTaskItem = list.children.some(isTaskListItem); + const hasRegularItem = list.children.some((item) => !isTaskListItem(item)); + + if (!hasTaskItem || !hasRegularItem) { + return [list]; + } + + const groups: Array<{ isTaskGroup: boolean; items: ListItem[] }> = []; + for (const item of list.children) { + const isTaskGroup = isTaskListItem(item); + const previousGroup = groups.at(-1); + + if (previousGroup && previousGroup.isTaskGroup === isTaskGroup) { + previousGroup.items.push(item); + } else { + groups.push({ isTaskGroup, items: [item] }); + } + } + + return groups.map(({ isTaskGroup, items }) => ({ + ...list, + children: isTaskGroup ? items : items.map((item) => ({ ...item, spread: false })), + spread: isTaskGroup ? list.spread : false, + })); +}; + +const splitMixedTaskListsInParent = (parent: Parent): void => { + for (let index = 0; index < parent.children.length; index++) { + const child = parent.children[index]; + + if ("children" in child && Array.isArray(child.children)) { + splitMixedTaskListsInParent(child as Parent); + } + + if (child.type !== "list") { + continue; + } + + const splitLists = splitMixedList(child as List); + if (splitLists.length > 1) { + parent.children.splice(index, 1, ...splitLists); + index += splitLists.length - 1; + } + } +}; + +export const remarkSplitMixedTaskLists = () => { + return (tree: Root) => { + splitMixedTaskListsInParent(tree); + }; +}; diff --git a/web/tests/auth-redirect.test.ts b/web/tests/auth-redirect.test.ts new file mode 100644 index 00000000..f0150e08 --- /dev/null +++ b/web/tests/auth-redirect.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/auth-state", () => ({ + clearAccessToken: vi.fn(), +})); + +import { clearAccessToken } from "@/auth-state"; +import { redirectOnAuthFailure } from "@/utils/auth-redirect"; + +const mockedClearAccessToken = vi.mocked(clearAccessToken); + +type NavigationStub = { replace: ReturnType; href: string }; + +function installLocation(href: string): NavigationStub { + const url = new URL(href); + const replace = vi.fn((next: string) => { + // Mirror real navigation: update the mutable href on subsequent inspection. + location.href = new URL(next, url).toString(); + }); + const location: NavigationStub = { replace, href: url.toString() }; + + Object.defineProperty(window, "location", { + configurable: true, + value: { + get href() { + return location.href; + }, + set href(value: string) { + location.href = value; + }, + pathname: url.pathname, + search: url.search, + hash: url.hash, + origin: url.origin, + replace, + }, + }); + + return location; +} + +describe("redirectOnAuthFailure", () => { + let originalLocation: Location; + + beforeEach(() => { + originalLocation = window.location; + }); + + afterEach(() => { + Object.defineProperty(window, "location", { + configurable: true, + value: originalLocation, + }); + }); + + it("does nothing when the user is already on an /auth page", () => { + const nav = installLocation("http://localhost/auth?foo=bar"); + + redirectOnAuthFailure(); + + expect(nav.replace).not.toHaveBeenCalled(); + expect(mockedClearAccessToken).not.toHaveBeenCalled(); + }); + + it("does nothing on a public route by default", () => { + const nav = installLocation("http://localhost/explore"); + + redirectOnAuthFailure(); + + expect(nav.replace).not.toHaveBeenCalled(); + expect(mockedClearAccessToken).not.toHaveBeenCalled(); + }); + + it("clears the token and redirects to /auth on a protected route", () => { + const nav = installLocation("http://localhost/home?tab=pins#latest"); + + redirectOnAuthFailure(); + + expect(mockedClearAccessToken).toHaveBeenCalledTimes(1); + expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fhome%3Ftab%3Dpins%23latest"); + }); + + it("honours forceRedirect even on a public route", () => { + const nav = installLocation("http://localhost/explore"); + + redirectOnAuthFailure(true); + + expect(mockedClearAccessToken).toHaveBeenCalledTimes(1); + expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fexplore"); + }); + + it("embeds the reason parameter when provided", () => { + const nav = installLocation("http://localhost/home"); + + redirectOnAuthFailure(false, { reason: "protected-memo" }); + + expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fhome&reason=protected-memo"); + }); + + it("prefers an explicitly provided redirect target over the current location", () => { + const nav = installLocation("http://localhost/home"); + + redirectOnAuthFailure(false, { redirect: "/setting" }); + + expect(nav.replace).toHaveBeenCalledWith("/auth?redirect=%2Fsetting"); + }); + + it("drops an unsafe redirect target silently", () => { + const nav = installLocation("http://localhost/home"); + + redirectOnAuthFailure(false, { redirect: "//evil.example/phish" }); + + expect(nav.replace).toHaveBeenCalledWith("/auth"); + }); +}); diff --git a/web/tests/guards.test.tsx b/web/tests/guards.test.tsx new file mode 100644 index 00000000..6f9c7897 --- /dev/null +++ b/web/tests/guards.test.tsx @@ -0,0 +1,202 @@ +import type { ReactNode } from "react"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/hooks/useCurrentUser", () => ({ + __esModule: true, + default: vi.fn(), +})); + +import useCurrentUser from "@/hooks/useCurrentUser"; +import { LandingRoute, RequireAuthRoute, RequireGuestRoute } from "@/router/guards"; + +const mockedUseCurrentUser = vi.mocked(useCurrentUser); + +// Minimal User-like stand-in — guards only check truthiness on the value. +const fakeUser = { name: "users/steven" } as unknown as ReturnType; + +const LocationProbe = () => { + const location = useLocation(); + return
{`${location.pathname}${location.search}${location.hash}`}
; +}; + +const renderAt = (initialEntry: string, children: ReactNode) => + render({children}); + +describe("LandingRoute", () => { + it("sends an authenticated visitor from the entry to /home", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/", + + } /> + } /> + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/home"); + }); + + it("sends an unauthenticated visitor from the entry to /explore", () => { + mockedUseCurrentUser.mockReturnValue(undefined); + + renderAt( + "/", + + } /> + } /> + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/explore"); + }); + + it("preserves the query string and hash when redirecting an authenticated visitor", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/?filter=tag:work&sort=desc#top", + + } /> + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/home?filter=tag:work&sort=desc#top"); + }); + + it("preserves the query string and hash when redirecting an unauthenticated visitor", () => { + // Covers the regression in issue #5846: bookmarks pointing at `/?filter=...` + // must not drop their params on the trip through the landing redirect. + mockedUseCurrentUser.mockReturnValue(undefined); + + renderAt( + "/?filter=tag:work#latest", + + } /> + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/explore?filter=tag:work#latest"); + }); +}); + +describe("RequireAuthRoute", () => { + it("renders the protected content for authenticated users", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/home", + + }> + secret} /> + + , + ); + + expect(screen.getByTestId("protected")).toHaveTextContent("secret"); + }); + + it("redirects unauthenticated users to /auth with the preserved location", () => { + mockedUseCurrentUser.mockReturnValue(undefined); + + renderAt( + "/home?tab=pins#latest", + + }> + secret} /> + + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/auth?redirect=%2Fhome%3Ftab%3Dpins%23latest"); + }); +}); + +describe("RequireGuestRoute", () => { + it("renders the auth page when no user is present", () => { + mockedUseCurrentUser.mockReturnValue(undefined); + + renderAt( + "/auth", + + }> + sign in} /> + + , + ); + + expect(screen.getByTestId("sign-in")).toHaveTextContent("sign in"); + }); + + it("redirects already-authenticated users to /home by default", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/auth", + + }> + sign in} /> + + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/home"); + }); + + it("honours a safe redirect target from the query string", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/auth?redirect=%2Fsetting", + + }> + sign in} /> + + } /> + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/setting"); + }); + + it("ignores an auth-family redirect target and falls back to /home", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/auth?redirect=%2Fauth%2Fcallback", + + }> + sign in} /> + + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/home"); + }); + + it("ignores an external redirect target and falls back to /home", () => { + mockedUseCurrentUser.mockReturnValue(fakeUser); + + renderAt( + "/auth?redirect=%2F%2Fevil.example%2Fphish", + + }> + sign in} /> + + } /> + , + ); + + expect(screen.getByTestId("location").textContent).toBe("/home"); + }); +}); diff --git a/web/tests/memo-content-list.test.tsx b/web/tests/memo-content-list.test.tsx new file mode 100644 index 00000000..cfc6ecb4 --- /dev/null +++ b/web/tests/memo-content-list.test.tsx @@ -0,0 +1,45 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { List, ListItem } from "@/components/MemoContent/markdown"; +import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "@/components/MemoContent/constants"; +import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-mixed-task-lists"; +import { describe, expect, it } from "vitest"; + +const renderListContent = (content: string): string => + renderToStaticMarkup( + {children}, + li: ({ children, ...props }) => {children}, + }} + > + {content} + , + ); + +describe("memo content lists", () => { + it("keeps bullets on regular items in mixed task and bullet lists", () => { + const html = renderListContent("- [ ] pickup package\n- [ ] library returns\n\n- milk\n- eggs\n- bread"); + const listOpenTags = html.match(/
    milk'); + expect(html).not.toContain('
  • \n

    milk

    '); + expect(html).toContain(TASK_LIST_ITEM_CLASS); + }); + + it("keeps compact styling for pure task lists", () => { + const html = renderListContent("- [ ] pickup package\n- [ ] library returns"); + + expect(html).toMatch(/
      ; + +const TrustedIframe = (props: IframeProps) => { + if (typeof props.src !== "string" || !isTrustedIframeSrc(props.src)) { + return null; + } + return '); + const untrusted = renderMemoContent(''); + + expect(trusted).toMatch(/