[
  {
    "path": "DEPLOYMENT.md",
    "content": "# 🚀 TMarks 部署指南\r\n\r\n## 📹 视频教程\r\n\r\n**完整部署教程视频**: [点击观看](https://bushutmarks.pages.dev/course/tmarks)\r\n\r\n跟随视频教程，3 分钟完成部署。\r\n\r\n---\r\n\r\n## 开源用户一页部署指南\r\n\r\n**前置条件**\r\n- 有 Cloudflare 账号\r\n- 有 GitHub 账号\r\n\r\n---\r\n\r\n### 1. 连接仓库并配置构建\r\n\r\n1. 在 GitHub 上 Fork 本仓库\r\n2. 打开 Cloudflare Dashboard → **Workers & Pages** → **Pages** → **创建项目**\r\n3. 选择「连接到 Git」，选中你的 Fork\r\n4. 构建配置：\r\n   - 根目录：`tmarks`\r\n   - 构建命令：`pnpm install && pnpm build:deploy`\r\n   - 构建输出目录：`.deploy`\r\n5. 保存并触发一次部署（第一次失败没关系，后面会修好）\r\n\r\n### 2. 创建 Cloudflare 资源\r\n\r\n1. **D1 数据库（必需）**\r\n   - Workers & Pages → **D1 SQL Database** → Create database\r\n   - 名称：`tmarks-prod-db`\r\n\r\n2. **R2 存储桶（可选，快照用）**\r\n   - R2 对象存储 → 创建存储桶\r\n   - 名称：`tmarks-snapshots`\r\n   - 不创建则快照功能不可用，但其他功能正常\r\n\r\n> **注意**：KV 命名空间已不再需要，代码中已移除 KV 依赖\r\n\r\n### 3. 在 Pages 项目中绑定资源\r\n\r\n进入 Pages 项目 → **设置 → 函数**：\r\n\r\n- **D1 绑定（必需）**：\r\n  - 新建 D1 绑定，变量名：`DB` → 选择 `tmarks-prod-db`\r\n\r\n- **R2 绑定（可选）**：\r\n  - 新建 R2 绑定，变量名：`SNAPSHOTS_BUCKET` → 选择 `tmarks-snapshots`\r\n\r\n> **重要**：如果之前配置过 KV 绑定（变量名 `TMARKS_KV`），请删除该绑定，代码中已不再使用 KV。\r\n> \r\n> 没有 R2 时，可以跳过 R2 绑定，应用仍然可以启动（快照功能不可用）。\r\n\r\n### 4. 配置环境变量\r\n\r\n进入 Pages 项目 → **设置 → 环境变量（生产环境）**，复制以下配置：\r\n\r\n```\r\nALLOW_REGISTRATION = \"true\"\r\nENVIRONMENT = \"production\"\r\nJWT_ACCESS_TOKEN_EXPIRES_IN = \"365d\"\r\nJWT_REFRESH_TOKEN_EXPIRES_IN = \"365d\"\r\nR2_MAX_TOTAL_BYTES = \"7516192768\"\r\nJWT_SECRET = \"your-long-random-jwt-secret-at-least-48-characters\"\r\nENCRYPTION_KEY = \"your-long-random-encryption-key-at-least-48-characters\"\r\n```\r\n\r\n> **重要**：`JWT_SECRET` 和 `ENCRYPTION_KEY` 必须替换为你自己生成的随机字符串（建议 ≥ 48 位）\r\n\r\n### 5. 初始化数据库\r\n\r\n1. 打开 **Workers & Pages → D1 SQL Database**\r\n2. 进入 `tmarks-prod-db` → **Console**\r\n3. 打开仓库中的以下 SQL 文件：\r\n   - `tmarks/migrations/0001_d1_console.sql`\r\n   - `tmarks/migrations/0002_d1_console_ai_settings.sql`\r\n   - `tmarks/migrations/0100_d1_console.sql`\r\n   - `tmarks/migrations/0101_d1_console.sql`\r\n4. 复制全部 SQL，粘贴到控制台，点击 **Execute** 执行\r\n\r\n### 6. 重新部署\r\n\r\n1. 回到 Pages 项目 → 部署\r\n2. 对之前失败的部署点击「重试」，或推送任意提交重新触发\r\n3. 构建成功后，就可以访问你的 TMarks 站点了 🎉\r\n\r\n> 之后更新：只要往 GitHub 推代码，Cloudflare 会自动重新构建和部署，之前配置的数据库 / R2 / 环境变量都不会丢。\r\n\r\n---\r\n\r\n## 常见问题\r\n\r\n### 部署失败：KV namespace not found\r\n\r\n**原因**：Cloudflare Pages Dashboard 中配置了 KV 绑定，但代码中已不再使用 KV。\r\n\r\n**解决方案**：\r\n1. 进入 Pages 项目 → 设置 → 函数\r\n2. 找到 KV namespace bindings\r\n3. 删除名为 `TMARKS_KV` 的绑定\r\n4. 保存并重新部署\r\n\r\n### 部署失败：D1 database not found\r\n\r\n**原因**：D1 数据库绑定配置不正确或数据库不存在。\r\n\r\n**解决方案**：\r\n1. 确认已创建 D1 数据库 `tmarks-prod-db`\r\n2. 进入 Pages 项目 → 设置 → 函数\r\n3. 检查 D1 绑定：变量名必须是 `DB`，选择正确的数据库\r\n4. 保存并重新部署\r\n\r\n### 如何更新到最新版本\r\n\r\n1. 在 GitHub 上同步你的 Fork（Sync fork 按钮）\r\n2. Cloudflare Pages 会自动检测到更新并重新部署\r\n3. 如果有数据库迁移，需要在 D1 Console 中执行新的 SQL 文件\r\n\r\n---\r\n\r\n## 本地开发\r\n\r\n```bash\r\n# 1. 克隆项目\r\ngit clone https://github.com/ai-tmarks/tmarks.git\r\ncd tmarks\r\n\r\n# 2. 安装依赖\r\ncd tmarks\r\npnpm install\r\n\r\n# 3. 创建数据库并迁移\r\nwrangler d1 create tmarks-prod-db --local\r\npnpm db:migrate:local\r\n\r\n# 4. 启动开发服务器\r\npnpm dev\r\n\r\n# 访问 http://localhost:5173\r\n```\r\n\r\n---\r\n\r\n## 技术支持\r\n\r\n- [问题反馈](https://github.com/ai-tmarks/tmarks/issues)\r\n- [功能建议](https://github.com/ai-tmarks/tmarks/discussions)\r\n- [视频教程](https://bushutmarks.pages.dev/course/tmarks)\r\n"
  },
  {
    "path": "LICENSE",
    "content": "Creative Commons Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0)\r\n\r\nCopyright (c) 2024 TMarks Team\r\n\r\nYou are free to:\r\n\r\n  Share — copy and redistribute the material in any medium or format\r\n  Adapt — remix, transform, and build upon the material\r\n\r\nUnder the following terms:\r\n\r\n  Attribution — You must give appropriate credit, provide a link to the\r\n  license, and indicate if changes were made. You may do so in any reasonable\r\n  manner, but not in any way that suggests the licensor endorses you or your use.\r\n\r\n  NonCommercial — You may not use the material for commercial purposes.\r\n\r\nNo additional restrictions — You may not apply legal terms or technological\r\nmeasures that legally restrict others from doing anything the license permits.\r\n\r\nFull license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r\nSOFTWARE.\r\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\r\n\r\n# 🔖 TMarks\r\n\r\n**AI 驱动的智能书签管理系统**\r\n\r\n[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-blue.svg)](https://www.typescriptlang.org/)\r\n[![React](https://img.shields.io/badge/React-18.3%20%7C%2019-61dafb.svg)](https://reactjs.org/)\r\n[![Vite](https://img.shields.io/badge/Vite-6.0%20%7C%207-646cff.svg)](https://vitejs.dev/)\r\n[![Cloudflare](https://img.shields.io/badge/Cloudflare-Workers-f38020.svg)](https://workers.cloudflare.com/)\r\n[![许可证](https://img.shields.io/badge/许可证-MIT-green.svg)](LICENSE)\r\n\r\n简体中文\r\n\r\n[在线演示](https://tmarks.669696.xyz) | [视频教程](https://bushutmarks.pages.dev/course/tmarks) | [问题反馈](https://github.com/ai-tmarks/tmarks/issues) | [功能建议](https://github.com/ai-tmarks/tmarks/discussions)\r\n\r\n</div>\r\n\r\n---\r\n\r\n## ✨ 项目简介\r\n\r\nTMarks 是一个现代化的智能书签管理系统，结合 AI 技术自动生成标签，让书签管理变得简单高效。\r\n\r\n### 核心特性\r\n\r\n- 📚 **智能书签管理** - AI自动标签、多维筛选、批量操作、拖拽排序\r\n- 🗂️ **标签页组管理** - 一键收纳标签页、智能分组、快速恢复\r\n- 🌐 **公开分享** - 创建个性化书签展示页、KV缓存加速\r\n- 🔌 **浏览器扩展** - 快速保存、AI推荐、离线支持、自动同步\r\n- 🔐 **安全可靠** - JWT认证、API Key管理、数据加密\r\n\r\n### 技术栈\r\n\r\n- **前端**: React 18/19 + TypeScript + Vite + TailwindCSS 4\r\n- **后端**: Cloudflare Workers + Pages Functions\r\n- **数据库**: Cloudflare D1 (SQLite)\r\n- **快照存储**: Cloudflare R2（可选，用于存储网页快照 HTML 与图片，支持全局 7GB 配额限制）\r\n- **AI集成**: 支持 OpenAI、Anthropic、DeepSeek、智谱等 8+ 提供商\r\n\r\n---\r\n\r\n## 🔌 浏览器扩展\r\n\r\n登录 TMarks 后，进入 **个人设置** 页面下载并安装浏览器扩展。\r\n\r\n### 扩展功能\r\n\r\n- **快速保存书签** - 一键保存当前网页，AI 自动推荐标签\r\n- **标签页收纳** - 一键收纳所有标签页，改天再看\r\n\r\n### 支持浏览器\r\n\r\nChrome / Edge / Opera / Brave / 360 / QQ / 搜狗\r\n\r\n---\r\n\r\n\r\n## 🚀 部署\r\n\r\n📖 **详细部署文档**: [DEPLOYMENT.md](DEPLOYMENT.md)\r\n\r\n📹 **视频教程**: [点击观看](https://bushutmarks.pages.dev/course/tmarks)（3 分钟完成部署）\r\n\r\n---\r\n\r\n## 📄 许可证\r\n\r\n本项目采用 [MIT License](LICENSE) 开源协议。\r\n"
  },
  {
    "path": "tmarks/.gitignore",
    "content": "# Logs\r\nlogs\r\n*.log\r\nnpm-debug.log*\r\nyarn-debug.log*\r\nyarn-error.log*\r\npnpm-debug.log*\r\nlerna-debug.log*\r\n\r\nnode_modules\r\ndist\r\ndist-ssr\r\n*.local\r\n\r\n# Editor directories and files\r\n.vscode/*\r\n!.vscode/extensions.json\r\n.idea\r\n.DS_Store\r\n*.suo\r\n*.ntvs*\r\n*.njsproj\r\n*.sln\r\n*.sw?\r\n\r\n# Environment\r\n.env\r\n.env.local\r\n.env.*.local\r\n.dev.vars\r\n\r\n# Wrangler\r\n.wrangler\r\n.mf\r\n\r\n# Testing\r\ncoverage\r\n\r\n# Build\r\nbuild\r\n.deploy\r\n\r\n# Database migration history (local only)\r\n.migration-history.json\r\n.migration-history-prod.json\r\n"
  },
  {
    "path": "tmarks/.prettierignore",
    "content": "# Dependencies\nnode_modules\n\n# Build outputs\ndist\ndist-ssr\nbuild\n.wrangler\n\n# Environment\n.env\n.env.local\n.dev.vars\n\n# Logs\n*.log\n\n# OS\n.DS_Store\n\n# IDE\n.vscode\n.idea\n"
  },
  {
    "path": "tmarks/.prettierrc",
    "content": "{\n  \"semi\": false,\n  \"singleQuote\": true,\n  \"tabWidth\": 2,\n  \"trailingComma\": \"es5\",\n  \"printWidth\": 100,\n  \"arrowParens\": \"always\"\n}\n"
  },
  {
    "path": "tmarks/API-DATABASE-AUDIT.md",
    "content": "# 数据库表与 API 接口一致性审计报告\r\n\r\n生成时间: 2026-04-15\r\n\r\n## 📊 总体统计\r\n\r\n- **总 API 端点数**: 73\r\n- **总数据库表数**: 21\r\n- **已使用的表数**: 17 (81.0%)\r\n- **未使用的表数**: 4 (19.0%)\r\n- **中间件/工具使用**: 3 (rate_limits, api_key_rate_limits, bookmark_images)\r\n\r\n---\r\n\r\n## ✅ 核心功能表使用情况\r\n\r\n### 1. 书签相关 (Bookmarks)\r\n\r\n| 表名 | 使用端点数 | 状态 |\r\n|------|-----------|------|\r\n| `bookmarks` | 37 | ✅ 核心表 |\r\n| `bookmark_tags` | 24 | ✅ 活跃 |\r\n| `bookmark_snapshots` | 16 | ✅ 活跃 |\r\n| `bookmark_click_events` | 3 | ✅ 正常 |\r\n| `bookmark_images` | 0 (工具函数使用) | ✅ 正常 |\r\n\r\n**分析**: \r\n- 书签核心功能完整，包括标签、快照、点击统计\r\n- `bookmark_images` 通过 `lib/image-upload.ts` 和 `lib/storage-quota.ts` 使用，用于封面图管理\r\n\r\n### 2. 标签组相关 (Tab Groups)\r\n\r\n| 表名 | 使用端点数 | 状态 |\r\n|------|-----------|------|\r\n| `tab_groups` | 22 | ✅ 核心表 |\r\n| `tab_group_items` | 17 | ✅ 活跃 |\r\n| `shares` | 4 | ✅ 正常 |\r\n\r\n**分析**: 标签组功能完整，包括分享功能\r\n\r\n### 3. 标签相关 (Tags)\r\n\r\n| 表名 | 使用端点数 | 状态 |\r\n|------|-----------|------|\r\n| `tags` | 21 | ✅ 核心表 |\r\n\r\n**分析**: 标签功能活跃，与书签紧密集成\r\n\r\n### 4. 用户与认证\r\n\r\n| 表名 | 使用端点数 | 状态 |\r\n|------|-----------|------|\r\n| `users` | 8 | ✅ 核心表 |\r\n| `auth_tokens` | 3 | ✅ 正常 |\r\n| `user_preferences` | 5 | ✅ 正常 |\r\n\r\n**分析**: 用户认证和偏好设置功能正常\r\n\r\n### 5. API 密钥管理\r\n\r\n| 表名 | 使用端点数 | 状态 |\r\n|------|-----------|------|\r\n| `api_keys` | 2 | ✅ 正常 |\r\n| `api_key_logs` | 1 | ✅ 正常 |\r\n| `api_key_rate_limits` | 0 (中间件使用) | ✅ 正常 |\r\n\r\n**分析**: \r\n- API 密钥基础功能正常\r\n- `api_key_rate_limits` 在中间件 `rate-limiter.ts` 中使用，用于 API Key 速率限制\r\n\r\n### 6. 审计与统计\r\n\r\n| 表名 | 使用端点数 | 状态 |\r\n|------|-----------|------|\r\n| `audit_logs` | 6 | ✅ 正常 |\r\n| `statistics` | 0 | ⚠️ 未使用 |\r\n\r\n**分析**: \r\n- 审计日志功能正常\r\n- `statistics` 表未使用，统计可能通过实时查询实现\r\n\r\n---\r\n\r\n## ⚠️ 未使用的数据库表\r\n\r\n以下表在数据库中定义，但未被任何 API 端点直接使用：\r\n\r\n### 1. `bookmark_images` ✅ 工具函数使用\r\n- **定义位置**: `0001_d1_console.sql`\r\n- **用途**: 存储书签封面图片（去重存储）\r\n- **实际使用**: \r\n  - `lib/image-upload.ts` - 图片上传和管理\r\n  - `lib/storage-quota.ts` - 存储配额计算\r\n- **状态**: ✅ 正常使用，通过工具函数间接调用\r\n\r\n### 2. `statistics`\r\n- **定义位置**: `0001_d1_console.sql`\r\n- **用途**: 存储每日统计数据\r\n- **建议**: \r\n  - 检查是否有计划使用此表\r\n  - 当前统计通过实时查询实现，考虑是否需要持久化\r\n\r\n### 3. `registration_limits`\r\n- **定义位置**: `0001_d1_console.sql`\r\n- **用途**: 限制每日注册数量\r\n- **建议**: \r\n  - 检查注册限流逻辑是否在其他地方实现\r\n  - 如果功能已实现，添加对应的 API 使用\r\n\r\n### 4. `ai_settings`\r\n- **定义位置**: `0002_d1_console_ai_settings.sql`\r\n- **用途**: AI 功能配置\r\n- **建议**: \r\n  - 这是新功能表，可能尚未实现 API\r\n  - 需要添加 AI 设置相关的 API 端点\r\n\r\n### 5. `api_key_rate_limits` ✅ 中间件使用\r\n- **定义位置**: `0103_api_key_rate_limits.sql`\r\n- **用途**: API 密钥速率限制\r\n- **实际使用**: `lib/api-key/rate-limiter.ts` - API Key 速率限制中间件\r\n- **状态**: ✅ 正常使用，在中间件层面使用\r\n\r\n### 6. `rate_limits` ✅ 中间件使用\r\n- **定义位置**: `0104_rate_limits.sql`\r\n- **用途**: 通用速率限制\r\n- **实际使用**: `lib/rate-limit.ts` - 通用速率限制工具\r\n- **状态**: ✅ 正常使用，在中间件层面使用\r\n\r\n### 7. `schema_migrations`\r\n- **定义位置**: `0001_d1_console.sql`\r\n- **用途**: 数据库迁移版本管理\r\n- **状态**: ✅ 系统表，正常未使用\r\n\r\n---\r\n\r\n## 📍 API 端点分类\r\n\r\n### /api/tab/* (扩展 API - 需要 API Key)\r\n- 共 34 个端点\r\n- 主要功能：书签、标签组、标签、搜索、统计\r\n- 认证方式：API Key (X-API-Key header)\r\n\r\n### /api/v1/* (Web API - 需要 JWT)\r\n- 共 35 个端点\r\n- 主要功能：认证、书签、标签组、标签、设置、统计\r\n- 认证方式：JWT Token\r\n\r\n### /api/public/* (公开 API)\r\n- 1 个端点：公开分享页面\r\n- 无需认证\r\n\r\n### /api/share/* (分享 API)\r\n- 1 个端点：标签组分享\r\n- 通过 token 访问\r\n\r\n### /api/snapshot-images/* (快照图片)\r\n- 1 个端点：获取快照图片\r\n- 需要签名验证\r\n\r\n---\r\n\r\n## 🔍 潜在问题与建议\r\n\r\n### 1. 真正未使用的表\r\n\r\n**需要确认的表**:\r\n- `ai_settings`: 需要实现 AI 设置 API（新功能）\r\n- `statistics`: 评估是否需要持久化统计数据\r\n- `registration_limits`: 确认注册限流策略\r\n\r\n### 2. API 一致性\r\n\r\n**建议**:\r\n- `/api/tab/*` 和 `/api/v1/*` 存在功能重复\r\n- 考虑统一 API 设计，减少维护成本\r\n- 明确两套 API 的使用场景和差异\r\n\r\n### 3. 缺失的功能\r\n\r\n**可能需要添加的 API**:\r\n- AI 设置管理 (`/api/v1/settings/ai`)\r\n- 注册限流管理 (管理员功能)\r\n- 速率限制查询 API（如果需要对外暴露）\r\n\r\n### 4. 数据完整性\r\n\r\n**建议检查**:\r\n- 所有外键约束是否正确设置\r\n- 级联删除是否符合业务逻辑\r\n- 索引是否覆盖常用查询\r\n\r\n---\r\n\r\n## 📋 详细端点列表\r\n\r\n### 书签管理 (Bookmarks)\r\n\r\n#### /api/tab/bookmarks\r\n- `GET` - 获取书签列表\r\n- `POST` - 创建书签\r\n- 使用表: `bookmarks`, `tags`, `bookmark_tags`, `bookmark_snapshots`\r\n\r\n#### /api/tab/bookmarks/:id\r\n- `GET` - 获取书签详情\r\n- `PATCH` - 更新书签\r\n- `DELETE` - 软删除书签\r\n- 使用表: `bookmarks`, `tags`, `bookmark_tags`, `bookmark_snapshots`\r\n\r\n#### /api/tab/bookmarks/:id/click\r\n- `POST` - 记录书签点击\r\n- 使用表: `bookmarks`, `bookmark_click_events`\r\n\r\n#### /api/tab/bookmarks/:id/snapshots\r\n- `GET` - 获取快照列表\r\n- `POST` - 创建快照\r\n- 使用表: `bookmarks`, `bookmark_snapshots`, `user_preferences`\r\n\r\n#### /api/tab/bookmarks/batch\r\n- `POST` - 批量创建书签\r\n- 使用表: `bookmarks`, `tags`, `bookmark_tags`, `audit_logs`\r\n\r\n#### /api/tab/bookmarks/trash\r\n- `GET` - 获取回收站书签\r\n- 使用表: `bookmarks`, `tags`, `bookmark_tags`\r\n\r\n#### /api/tab/bookmarks/trash/empty\r\n- `DELETE` - 清空回收站\r\n- 使用表: `bookmarks`, `bookmark_tags`, `bookmark_snapshots`\r\n\r\n### 标签组管理 (Tab Groups)\r\n\r\n#### /api/tab/tab-groups\r\n- `GET` - 获取标签组列表\r\n- `POST` - 创建标签组\r\n- 使用表: `tab_groups`, `tab_group_items`\r\n\r\n#### /api/tab/tab-groups/:id\r\n- `GET` - 获取标签组详情\r\n- `PATCH` - 更新标签组\r\n- `DELETE` - 删除标签组\r\n- 使用表: `tab_groups`, `tab_group_items`\r\n\r\n#### /api/tab/tab-groups/:id/share\r\n- `POST` - 创建分享\r\n- `DELETE` - 删除分享\r\n- 使用表: `tab_groups`, `shares`\r\n\r\n### 标签管理 (Tags)\r\n\r\n#### /api/tab/tags\r\n- `GET` - 获取标签列表\r\n- `POST` - 创建标签\r\n- 使用表: `tags`, `bookmark_tags`\r\n\r\n#### /api/tab/tags/:id\r\n- `GET` - 获取标签详情\r\n- `PATCH` - 更新标签\r\n- `DELETE` - 删除标签\r\n- 使用表: `tags`, `bookmark_tags`\r\n\r\n### 认证 (Authentication)\r\n\r\n#### /api/v1/auth/register\r\n- `POST` - 用户注册\r\n- 使用表: `users`, `user_preferences`, `audit_logs`\r\n\r\n#### /api/v1/auth/login\r\n- `POST` - 用户登录\r\n- 使用表: `users`, `auth_tokens`, `audit_logs`\r\n\r\n#### /api/v1/auth/logout\r\n- `POST` - 用户登出\r\n- 使用表: `auth_tokens`, `audit_logs`\r\n\r\n#### /api/v1/auth/refresh\r\n- `POST` - 刷新 Token\r\n- 使用表: `users`, `auth_tokens`, `audit_logs`\r\n\r\n### 设置 (Settings)\r\n\r\n#### /api/v1/preferences\r\n- `GET` - 获取用户偏好\r\n- `PUT` - 更新用户偏好\r\n- 使用表: `user_preferences`\r\n\r\n#### /api/v1/settings/api-keys\r\n- `GET` - 获取 API 密钥列表\r\n- `POST` - 创建 API 密钥\r\n- 使用表: `api_keys`\r\n\r\n#### /api/v1/settings/api-keys/:id\r\n- `GET` - 获取 API 密钥详情\r\n- `PATCH` - 更新 API 密钥\r\n- `DELETE` - 删除 API 密钥\r\n- 使用表: `api_keys`, `api_key_logs`\r\n\r\n#### /api/v1/settings/share\r\n- `GET` - 获取公开分享设置\r\n- `PUT` - 更新公开分享设置\r\n- 使用表: `users`\r\n\r\n### 统计 (Statistics)\r\n\r\n#### /api/tab/statistics\r\n- `GET` - 获取统计数据\r\n- 使用表: `tab_groups`, `tab_group_items`, `shares`\r\n\r\n#### /api/v1/statistics\r\n- `GET` - 获取统计数据\r\n- 使用表: `tab_groups`, `tab_group_items`\r\n\r\n#### /api/v1/bookmarks/statistics\r\n- `GET` - 获取书签统计\r\n- 使用表: `bookmarks`, `tags`, `bookmark_tags`, `bookmark_click_events`\r\n\r\n---\r\n\r\n## 🎯 行动建议\r\n\r\n### 立即执行\r\n1. ✅ 修复导入路径问题（已完成）\r\n2. ✅ 确认速率限制功能的实现位置（已确认在中间件中）\r\n3. ✅ 确认 bookmark_images 功能（已确认在工具函数中）\r\n\r\n### 短期计划\r\n1. 实现 AI 设置相关 API\r\n2. 评估 `statistics` 表的使用需求\r\n3. 确认 `registration_limits` 的实现方式\r\n\r\n### 长期规划\r\n1. 统一 `/api/tab/*` 和 `/api/v1/*` 的设计\r\n2. 完善 API 文档\r\n3. 添加更多的统计和分析功能\r\n\r\n---\r\n\r\n## 📝 备注\r\n\r\n- 本报告基于代码静态分析生成\r\n- 未包含中间件和工具函数中的数据库操作\r\n- 建议定期更新此报告以保持同步\r\n"
  },
  {
    "path": "tmarks/eslint.config.js",
    "content": "import js from '@eslint/js'\r\nimport globals from 'globals'\r\nimport reactHooks from 'eslint-plugin-react-hooks'\r\nimport reactRefresh from 'eslint-plugin-react-refresh'\r\nimport tseslint from 'typescript-eslint'\r\n\r\nexport default tseslint.config(\r\n  {\r\n    ignores: [\r\n      'node_modules',\r\n      'dist',\r\n      'dist-ssr',\r\n      'build',\r\n      '.wrangler',\r\n      '.deploy',\r\n      '**/*.config.js',\r\n      '**/*.config.ts',\r\n      'vite.config.ts',\r\n    ],\r\n  },\r\n  {\r\n    extends: [js.configs.recommended, ...tseslint.configs.recommended],\r\n    files: ['**/*.{ts,tsx}'],\r\n    languageOptions: {\r\n      ecmaVersion: 2020,\r\n      globals: globals.browser,\r\n    },\r\n    plugins: {\r\n      'react-hooks': reactHooks,\r\n      'react-refresh': reactRefresh,\r\n    },\r\n    rules: {\r\n      ...reactHooks.configs.recommended.rules,\r\n      'react-refresh/only-export-components': [\r\n        'warn',\r\n        { allowConstantExport: true },\r\n      ],\r\n    },\r\n  },\r\n)\r\n"
  },
  {
    "path": "tmarks/functions/api/_middleware.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport { corsHeaders, securityHeaders, requestLogger } from '../middleware/security'\r\n\r\nexport const onRequest: PagesFunction[] = [\r\n  requestLogger,\r\n  corsHeaders,\r\n  securityHeaders,\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/index.ts",
    "content": "/**\r\n *  API -  API \r\n * : /api\r\n * \r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../lib/types'\r\n\nexport const onRequestGet: PagesFunction<Env> = async () => {\r\n  return Response.json({\r\n    name: 'TMarks API',\r\n    version: 'v1',\r\n    description: 'TMarks Bookmark Management API',\r\n    documentation: '/api/docs',\r\n    endpoints: {\r\n      bookmarks: {\r\n        list: 'GET /api/bookmarks',\r\n        create: 'POST /api/bookmarks',\r\n        get: 'GET /api/bookmarks/:id',\r\n        update: 'PATCH /api/bookmarks/:id',\r\n        delete: 'DELETE /api/bookmarks/:id',\r\n      },\r\n      tags: {\r\n        list: 'GET /api/tags',\r\n        create: 'POST /api/tags',\r\n        get: 'GET /api/tags/:id',\r\n        update: 'PATCH /api/tags/:id',\r\n        delete: 'DELETE /api/tags/:id',\r\n      },\r\n      user: {\r\n        me: 'GET /api/me',\r\n      },\r\n      search: {\r\n        global: 'GET /api/search?q=keyword',\r\n      },\r\n    },\r\n    authentication: {\r\n      type: 'API Key',\r\n      header: 'X-API-Key',\r\n      format: 'tmk_live_xxxxxxxxxxxxxxxxxxxx',\r\n      how_to_get: 'Create an API Key in TMarks Settings > API Keys',\r\n    },\r\n    rate_limits: {\r\n      per_minute: 60,\r\n      per_hour: 1000,\r\n      per_day: 10000,\r\n    },\r\n    support: {\r\n      docs: '/help',\r\n    },\r\n  })\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/api/public/[slug].ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, BookmarkRow, PublicProfile } from '../../lib/types'\r\nimport { notFound, success, internalError } from '../../lib/response'\r\nimport { normalizeBookmark } from '../../lib/bookmark-utils'\r\nimport { CacheService } from '../../lib/cache'\r\nimport { generateCacheKey } from '../../lib/cache/strategies'\r\n\r\ninterface PublicSharePayload {\r\n  profile: {\r\n    username: string\r\n    title: string | null\r\n    description: string | null\r\n    slug: string\r\n  }\r\n  bookmarks: Array<ReturnType<typeof normalizeBookmark> & { tags: Array<{ id: string; name: string; color: string | null }> }>\r\n  tags: Array<{ id: string; name: string; color: string | null; bookmark_count: number }>\r\n  generated_at: string\r\n}\r\n\r\ninterface PublicSharePaginatedPayload {\r\n  profile: {\r\n    username: string\r\n    title: string | null\r\n    description: string | null\r\n    slug: string\r\n  }\r\n  bookmarks: Array<ReturnType<typeof normalizeBookmark> & { tags: Array<{ id: string; name: string; color: string | null }> }>\r\n  tags: Array<{ id: string; name: string; color: string | null; bookmark_count: number }>\r\n  meta: {\r\n    page_size: number\r\n    count: number\r\n    next_cursor: string | null\r\n    has_more: boolean\r\n  }\r\n}\r\n\r\n\r\n\r\nexport const onRequestGet: PagesFunction<Env> = async (context) => {\r\n  const slug = (context.params.slug as string | undefined)?.toLowerCase()\r\n\r\n  if (!slug) {\r\n    return notFound('Share link not found')\r\n  }\r\n\r\n  const url = new URL(context.request.url)\r\n  const pageSize = Math.min(parseInt(url.searchParams.get('page_size') || '30', 10) || 30, 100)\r\n  const pageCursor = url.searchParams.get('page_cursor') || ''\r\n\r\n  // ，（）\r\n  const usePagination = url.searchParams.has('page_size') || url.searchParams.has('page_cursor')\r\n\r\n  // \r\n  const cache = new CacheService(context.env)\r\n  const cacheKey = usePagination\r\n    ? generateCacheKey('publicShare', slug, { page_cursor: pageCursor || 'first', page_size: pageSize })\r\n    : generateCacheKey('publicShare', slug)\r\n\r\n  try {\r\n    // \r\n    const user = await context.env.DB.prepare(\r\n      `SELECT id as user_id, username, public_share_enabled, public_slug, public_page_title, public_page_description\r\n       FROM users\r\n       WHERE LOWER(public_slug) = ? AND public_share_enabled = 1`\r\n    )\r\n      .bind(slug)\r\n      .first<PublicProfile>()\r\n\r\n    if (!user || !user.public_share_enabled || !user.public_slug) {\r\n      return notFound('Share link not found')\r\n    }\r\n\r\n    // \r\n    const cached = await cache.get<PublicSharePayload | PublicSharePaginatedPayload>('publicShare', cacheKey)\r\n    if (cached) {\r\n      return success({\r\n        ...cached,\r\n        _cached: true, // \r\n      })\r\n    }\r\n\r\n    // \r\n    let bookmarkQuery = `\r\n      SELECT *\r\n      FROM bookmarks\r\n      WHERE user_id = ?\r\n        AND is_public = 1\r\n        AND deleted_at IS NULL\r\n    `\r\n    const bookmarkParams: (string | number)[] = [user.user_id]\r\n\r\n    // \r\n    if (usePagination && pageCursor) {\r\n      bookmarkQuery += ' AND id < ?'\r\n      bookmarkParams.push(pageCursor)\r\n    }\r\n\r\n    bookmarkQuery += ' ORDER BY is_pinned DESC, created_at DESC'\r\n\r\n    // ， LIMIT\r\n    if (usePagination) {\r\n      bookmarkQuery += ' LIMIT ?'\r\n      bookmarkParams.push(pageSize + 1) // \r\n    }\r\n\r\n    const { results: bookmarkRows } = await context.env.DB.prepare(bookmarkQuery)\r\n      .bind(...bookmarkParams)\r\n      .all<BookmarkRow>()\r\n\r\n    // （）\r\n    const hasMore = usePagination && bookmarkRows.length > pageSize\r\n    const bookmarksToProcess = usePagination && hasMore\r\n      ? bookmarkRows.slice(0, pageSize)\r\n      : bookmarkRows\r\n    const nextCursor = usePagination && hasMore && bookmarksToProcess.length > 0\r\n      ? String(bookmarksToProcess[bookmarksToProcess.length - 1].id)\r\n      : null\r\n\r\n    const bookmarkIds = bookmarksToProcess.map((row) => row.id)\r\n\r\n    let allTags: Array<{ bookmark_id: string; id: string; name: string; color: string | null }> = []\r\n\r\n    if (bookmarkIds.length > 0) {\r\n      const placeholders = bookmarkIds.map(() => '?').join(',')\r\n      const { results: tagRows } = await context.env.DB.prepare(\r\n        `SELECT bt.bookmark_id, t.id, t.name, t.color\r\n         FROM bookmark_tags bt\r\n         INNER JOIN tags t ON t.id = bt.tag_id\r\n         WHERE bt.bookmark_id IN (${placeholders})\r\n           AND t.deleted_at IS NULL\r\n         ORDER BY t.name`\r\n      )\r\n        .bind(...bookmarkIds)\r\n        .all<{ bookmark_id: string; id: string; name: string; color: string | null }>()\r\n\r\n      allTags = tagRows || []\r\n    }\r\n\r\n    const tagsByBookmark = new Map<string, Array<{ id: string; name: string; color: string | null }>>()\r\n\r\n    for (const tag of allTags) {\r\n      if (!tagsByBookmark.has(tag.bookmark_id)) {\r\n        tagsByBookmark.set(tag.bookmark_id, [])\r\n      }\r\n      const tags = tagsByBookmark.get(tag.bookmark_id)\r\n      if (tags) {\r\n        tags.push({ id: tag.id, name: tag.name, color: tag.color })\r\n      }\r\n    }\r\n\r\n    const bookmarks = bookmarksToProcess.map((row) => ({\r\n      ...normalizeBookmark(row),\r\n      tags: tagsByBookmark.get(row.id) || [],\r\n    }))\r\n\r\n    // （）\r\n    let tags: Array<{ id: string; name: string; color: string | null; bookmark_count: number }> = []\r\n\r\n    if (!usePagination || !pageCursor) {\r\n      const tagsCacheKey = generateCacheKey('publicShare', `${slug}:tags`)\r\n      \r\n      // \r\n      const cachedTags = await cache.get<typeof tags>('publicShare', tagsCacheKey)\r\n\r\n      if (cachedTags) {\r\n        tags = cachedTags\r\n      } else {\r\n        // \r\n        const { results: tagStats } = await context.env.DB.prepare(\r\n          `SELECT t.id, t.name, t.color, COUNT(DISTINCT bt.bookmark_id) as bookmark_count\r\n           FROM tags t\r\n           INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n           INNER JOIN bookmarks b ON bt.bookmark_id = b.id\r\n           WHERE b.user_id = ?\r\n             AND b.is_public = 1\r\n             AND b.deleted_at IS NULL\r\n             AND t.deleted_at IS NULL\r\n           GROUP BY t.id, t.name, t.color\r\n           ORDER BY t.name`\r\n        )\r\n          .bind(user.user_id)\r\n          .all<{ id: string; name: string; color: string | null; bookmark_count: number }>()\r\n\r\n        tags = tagStats || []\r\n\r\n        // （30）\r\n        if (tags.length > 0) {\r\n          await cache.set('publicShare', tagsCacheKey, tags, { async: true })\r\n        }\r\n      }\r\n    }\r\n\r\n    // \r\n    if (usePagination) {\r\n      const paginatedPayload: PublicSharePaginatedPayload = {\r\n        profile: {\r\n          username: user.username,\r\n          title: user.public_page_title,\r\n          description: user.public_page_description,\r\n          slug: user.public_slug,\r\n        },\r\n        bookmarks,\r\n        tags,\r\n        meta: {\r\n          page_size: pageSize,\r\n          count: bookmarks.length,\r\n          next_cursor: nextCursor,\r\n          has_more: hasMore,\r\n        },\r\n      }\r\n\r\n      //  (30 TTL, Level 0 )\r\n      await cache.set('publicShare', cacheKey, paginatedPayload, { async: true })\r\n\r\n      return success(paginatedPayload)\r\n    } else {\r\n      const payload: PublicSharePayload = {\r\n        profile: {\r\n          username: user.username,\r\n          title: user.public_page_title,\r\n          description: user.public_page_description,\r\n          slug: user.public_slug,\r\n        },\r\n        bookmarks,\r\n        tags,\r\n        generated_at: new Date().toISOString(),\r\n      }\r\n\r\n      //  (30 TTL, Level 0 )\r\n      await cache.set('publicShare', cacheKey, payload, { async: true })\r\n\r\n      return success(payload)\r\n    }\r\n  } catch (error) {\r\n    console.error('Public share error:', error)\r\n    return internalError('Failed to load shared bookmarks')\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/api/share/[token].ts",
    "content": "/**\r\n *  API\r\n * : /api/share/:token\r\n * : （）\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../lib/types'\r\nimport { success, notFound, internalError } from '../../lib/response'\r\n\r\ninterface ShareRow {\r\n  id: string\r\n  group_id: string\r\n  user_id: string\r\n  share_token: string\r\n  is_public: number\r\n  view_count: number\r\n  created_at: string\r\n  expires_at: string | null\r\n}\r\n\r\ninterface TabGroupRow {\r\n  id: string\r\n  user_id: string\r\n  title: string\r\n  color: string | null\r\n  tags: string | null\r\n  created_at: string\r\n  updated_at: string\r\n}\r\n\r\ninterface TabGroupItemRow {\r\n  id: string\r\n  group_id: string\r\n  title: string\r\n  url: string\r\n  favicon: string | null\r\n  position: number\r\n  is_pinned: number\r\n  is_todo: number\r\n  created_at: string\r\n}\r\n\r\n// GET /api/share/:token - \r\nexport const onRequestGet: PagesFunction<Env, RouteParams> = async (context) => {\r\n  const shareToken = context.params.token\r\n\r\n  try {\r\n    // Get share\r\n    const share = await context.env.DB.prepare(\r\n      'SELECT * FROM shares WHERE share_token = ?'\r\n    )\r\n      .bind(shareToken)\r\n      .first<ShareRow>()\r\n\r\n    if (!share) {\r\n      return notFound('Share not found')\r\n    }\r\n\r\n    // Check if share is public\r\n    if (share.is_public !== 1) {\r\n      return notFound('Share is private')\r\n    }\r\n\r\n    // Check if share has expired\r\n    if (share.expires_at) {\r\n      const expiresAt = new Date(share.expires_at)\r\n      if (expiresAt < new Date()) {\r\n        return notFound('Share has expired')\r\n      }\r\n    }\r\n\r\n    // Get tab group\r\n    const groupRow = await context.env.DB.prepare(\r\n      'SELECT * FROM tab_groups WHERE id = ? AND is_deleted = 0'\r\n    )\r\n      .bind(share.group_id)\r\n      .first<TabGroupRow>()\r\n\r\n    if (!groupRow) {\r\n      return notFound('Tab group not found')\r\n    }\r\n\r\n    // Get tab group items\r\n    const { results: items } = await context.env.DB.prepare(\r\n      'SELECT * FROM tab_group_items WHERE group_id = ? ORDER BY position ASC'\r\n    )\r\n      .bind(share.group_id)\r\n      .all<TabGroupItemRow>()\r\n\r\n    // Parse tags\r\n    let tags: string[] | null = null\r\n    if (groupRow.tags) {\r\n      try {\r\n        tags = JSON.parse(groupRow.tags)\r\n      } catch {\r\n        tags = null\r\n      }\r\n    }\r\n\r\n    // Increment view count\r\n    await context.env.DB.prepare(\r\n      'UPDATE shares SET view_count = view_count + 1 WHERE id = ?'\r\n    )\r\n      .bind(share.id)\r\n      .run()\r\n\r\n    return success({\r\n      tab_group: {\r\n        ...groupRow,\r\n        tags,\r\n        items: items || [],\r\n        item_count: items?.length || 0,\r\n      },\r\n      share_info: {\r\n        view_count: share.view_count + 1,\r\n        created_at: share.created_at,\r\n        expires_at: share.expires_at,\r\n      },\r\n    })\r\n  } catch (error) {\r\n    console.error('Get shared tab group error:', error)\r\n    return internalError('Failed to get shared tab group')\r\n  }\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/functions/api/shared/cache.ts",
    "content": "import type { Env } from '../../lib/types'\r\nimport { CacheService } from '../../lib/cache'\r\nimport { getCacheInvalidationPrefix } from '../../lib/cache/strategies'\r\n\r\n/**\r\n * \r\n\n */\r\nexport async function invalidatePublicShareCache(env: Env, userId: string) {\r\n  //  slug\r\n  const record = await env.DB.prepare(\r\n    `SELECT public_slug FROM users WHERE id = ? AND public_share_enabled = 1`\r\n  )\r\n    .bind(userId)\r\n    .first<{ public_slug: string | null }>()\r\n\r\n  if (!record?.public_slug) {\r\n    return\r\n  }\r\n\r\n  //  CacheService \r\n  const cache = new CacheService(env)\r\n  const prefix = getCacheInvalidationPrefix(record.public_slug.toLowerCase(), 'publicShare')\r\n  await cache.invalidate(prefix)\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/api/snapshot-images/[hash].ts",
    "content": "/**\r\n *  API\r\n * : /api/snapshot-images/:hash\r\n *  R2 \r\n * \r\n * :  API \r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../lib/types'\r\nimport { notFound, internalError } from '../../lib/response'\r\nimport { generateImageSig } from '../../lib/image-sig'\r\n\r\n// OPTIONS /api/snapshot-images/:hash - CORS \r\nexport const onRequestOptions: PagesFunction<Env, 'hash'> = async () => {\r\n  return new Response(null, {\r\n    status: 204,\r\n    headers: {\r\n      'Access-Control-Allow-Origin': '*',\r\n      'Access-Control-Allow-Methods': 'GET, OPTIONS',\r\n      'Access-Control-Allow-Headers': 'Content-Type',\r\n      'Access-Control-Max-Age': '86400',\r\n    },\r\n  })\r\n}\r\n\r\n// GET /api/snapshot-images/:hash - \r\nexport const onRequestGet: PagesFunction<Env, 'hash'> = async (context) => {\r\n  try {\r\n    const hash = context.params.hash as string\r\n    const bucket = context.env.SNAPSHOTS_BUCKET\r\n    const db = context.env.DB\r\n\r\n    if (!bucket || !db) {\r\n      return internalError('Storage not configured')\r\n    }\r\n\r\n    //  URL \r\n    const url = new URL(context.request.url)\r\n    const userId = url.searchParams.get('u')\r\n    const bookmarkId = url.searchParams.get('b')\r\n    const version = url.searchParams.get('v')\r\n\r\n    console.log(`[Snapshot Image API] Request: hash=${hash}, u=${userId}, b=${bookmarkId}, v=${version}`)\r\n\r\n    if (!userId || !bookmarkId || !version) {\r\n      console.warn(`[Snapshot Image API] Missing parameters: u=${userId}, b=${bookmarkId}, v=${version}`)\r\n      return new Response('Missing required parameters', {\r\n        status: 400,\r\n        headers: {\r\n          'Content-Type': 'text/plain',\r\n          'Access-Control-Allow-Origin': '*',\r\n        },\r\n      })\r\n    }\r\n\r\n    // \r\n    // \r\n    const snapshot = await db\r\n      .prepare(\r\n        `SELECT s.id \r\n         FROM bookmark_snapshots s\r\n         JOIN bookmarks b ON s.bookmark_id = b.id\r\n         WHERE s.bookmark_id = ? \r\n           AND s.user_id = ? \r\n           AND s.version = ?\r\n           AND b.deleted_at IS NULL`\r\n      )\r\n      .bind(bookmarkId, userId, parseInt(version, 10) || 0)\r\n      .first()\r\n\r\n    if (!snapshot) {\r\n      console.warn(`[Snapshot Image API] Snapshot not found or access denied: u=${userId}, b=${bookmarkId}, v=${version}, hash=${hash}`)\r\n      return notFound('Snapshot not found or access denied')\r\n    }\r\n\r\n    // \r\n    const sig = url.searchParams.get('sig')\r\n    if (!sig) {\r\n      return new Response('Missing image signature', {\r\n        status: 403,\r\n        headers: {\r\n          'Content-Type': 'text/plain',\r\n          'Access-Control-Allow-Origin': '*',\r\n        },\r\n      })\r\n    }\r\n    const expectedSig = await generateImageSig(hash, userId, bookmarkId, context.env.JWT_SECRET)\r\n    if (sig !== expectedSig) {\r\n      return new Response('Invalid image signature', {\r\n        status: 403,\r\n        headers: {\r\n          'Content-Type': 'text/plain',\r\n          'Access-Control-Allow-Origin': '*',\r\n        },\r\n      })\r\n    }\r\n\r\n    //  R2 \r\n    let imageKey = `${userId}/${bookmarkId}/v${version}/images/${hash}`\r\n\r\n    console.log(`[Snapshot Image API] Fetching: ${imageKey}`)\r\n\r\n    //  R2 \r\n    let r2Object = await bucket.get(imageKey)\r\n\r\n    // ，（/）\r\n    if (!r2Object) {\r\n      console.log(`[Snapshot Image API] Not found, trying alternative formats...`)\r\n      \r\n      //  hash ，\r\n      if (hash.includes('.')) {\r\n        const hashWithoutExt = hash.replace(/\\.(webp|jpg|jpeg|png|gif)$/i, '')\r\n        const altKey = `${userId}/${bookmarkId}/v${version}/images/${hashWithoutExt}`\r\n        console.log(`[Snapshot Image API] Trying without extension: ${altKey}`)\r\n        r2Object = await bucket.get(altKey)\r\n        if (r2Object) imageKey = altKey\r\n      } else {\r\n        //  hash ，\r\n        const extensions = ['.webp', '.jpg', '.jpeg', '.png', '.gif']\r\n        for (const ext of extensions) {\r\n          const altKey = `${userId}/${bookmarkId}/v${version}/images/${hash}${ext}`\r\n          console.log(`[Snapshot Image API] Trying with extension: ${altKey}`)\r\n          r2Object = await bucket.get(altKey)\r\n          if (r2Object) {\r\n            imageKey = altKey\r\n            break\r\n          }\r\n        }\r\n      }\r\n    }\r\n\r\n    if (!r2Object) {\r\n      console.warn(`[Snapshot Image API] Image not found in R2 (tried all formats): ${hash}`)\r\n      return new Response('Image not found', {\r\n        status: 404,\r\n        headers: {\r\n          'Content-Type': 'text/plain',\r\n          'Access-Control-Allow-Origin': '*',\r\n        },\r\n      })\r\n    }\r\n\r\n    // \r\n    const imageData = await r2Object.arrayBuffer()\r\n    const contentType = r2Object.httpMetadata?.contentType || 'image/jpeg'\r\n\r\n    console.log(`[Snapshot Image API] Serving: ${imageKey}, ${(imageData.byteLength / 1024).toFixed(1)}KB, type: ${contentType}`)\r\n\r\n    return new Response(imageData, {\r\n      headers: {\r\n        'Content-Type': contentType,\r\n        'Cache-Control': 'public, max-age=31536000, immutable', // \r\n        'Access-Control-Allow-Origin': '*', // （）\r\n        'Access-Control-Allow-Methods': 'GET, OPTIONS',\r\n        'Access-Control-Allow-Headers': 'Content-Type',\r\n      },\r\n    })\r\n  } catch (error) {\r\n    console.error('[Snapshot Image API] Error:', error)\r\n    // ，\r\n    return new Response('Failed to load image', {\r\n      status: 500,\r\n      headers: {\r\n        'Content-Type': 'text/plain',\r\n        'Access-Control-Allow-Origin': '*',\r\n      },\r\n    })\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/click.ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/bookmarks/:id/click\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../lib/types'\r\nimport { success, notFound, internalError } from '../../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'\r\n\r\n// POST /api/tab/bookmarks/:id/click - \r\nexport const onRequestPost: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.update'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      const db = context.env.DB\r\n      const now = new Date().toISOString()\r\n\r\n      // \r\n      const bookmark = await db.prepare(\r\n        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!bookmark) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      // ，\r\n      await db.batch([\r\n        db.prepare(\r\n          'UPDATE bookmarks SET click_count = click_count + 1, last_clicked_at = ? WHERE id = ?'\r\n        ).bind(now, bookmarkId),\r\n        db.prepare(\r\n          'INSERT INTO bookmark_click_events (bookmark_id, user_id, clicked_at) VALUES (?, ?, ?)'\r\n        ).bind(bookmarkId, userId, now),\r\n      ])\r\n\r\n      return success({\r\n        message: 'Click recorded successfully',\r\n        clicked_at: now,\r\n      })\r\n    } catch (error) {\r\n      console.error('Record bookmark click error:', error)\r\n      return internalError('Failed to record click')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/permanent.ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/bookmarks/:id/permanent\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../lib/types'\r\nimport { noContent, notFound, internalError } from '../../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'\r\n\r\n// DELETE /api/tab/bookmarks/:id/permanent - （）\r\nexport const onRequestDelete: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.delete'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      // \r\n      const existing = await context.env.DB.prepare(\r\n        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!existing) {\r\n        return notFound('Bookmark not found in trash')\r\n      }\r\n\r\n      // \r\n      await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ?')\r\n        .bind(bookmarkId)\r\n        .run()\r\n\r\n      // \r\n      await context.env.DB.prepare('DELETE FROM bookmark_snapshots WHERE bookmark_id = ?')\r\n        .bind(bookmarkId)\r\n        .run()\r\n\r\n      // \r\n      await context.env.DB.prepare('DELETE FROM bookmarks WHERE id = ?')\r\n        .bind(bookmarkId)\r\n        .run()\r\n\r\n      return noContent()\r\n    } catch (error) {\r\n      console.error('Permanent delete bookmark error:', error)\r\n      return internalError('Failed to permanently delete bookmark')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/restore.ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/bookmarks/:id/restore\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, BookmarkRow, RouteParams } from '../../../../lib/types'\r\nimport { success, notFound, internalError } from '../../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'\r\nimport { normalizeBookmark } from '../../../../lib/bookmark-utils'\r\n\r\n// PATCH /api/tab/bookmarks/:id/restore - \r\nexport const onRequestPatch: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.update'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      // \r\n      const existing = await context.env.DB.prepare(\r\n        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!existing) {\r\n        return notFound('Bookmark not found in trash')\r\n      }\r\n\r\n      const now = new Date().toISOString()\r\n\r\n      // ： deleted_at\r\n      await context.env.DB.prepare(\r\n        'UPDATE bookmarks SET deleted_at = NULL, updated_at = ? WHERE id = ?'\r\n      )\r\n        .bind(now, bookmarkId)\r\n        .run()\r\n\r\n      // \r\n      const bookmarkRow = await context.env.DB.prepare(\r\n        'SELECT * FROM bookmarks WHERE id = ?'\r\n      )\r\n        .bind(bookmarkId)\r\n        .first<BookmarkRow>()\r\n\r\n      if (!bookmarkRow) {\r\n        return internalError('Failed to load bookmark after restore')\r\n      }\r\n\r\n      // \r\n      const { results: tags } = await context.env.DB.prepare(\r\n        `SELECT t.id, t.name, t.color\r\n         FROM tags t\r\n         INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n         WHERE bt.bookmark_id = ? AND t.deleted_at IS NULL`\r\n      )\r\n        .bind(bookmarkId)\r\n        .all<{ id: string; name: string; color: string | null }>()\r\n\r\n      return success({\r\n        bookmark: {\r\n          ...normalizeBookmark(bookmarkRow),\r\n          tags: tags || [],\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Restore bookmark error:', error)\r\n      return internalError('Failed to restore bookmark')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/snapshot-upload.ts",
    "content": "/**\n * \n *  Base64  R2 \n */\n\nimport type { R2Bucket } from '@cloudflare/workers-types'\n\nconst R2_UPLOAD_CONCURRENCY = 6\n\ninterface ImageInput {\n  hash: string\n  data: string // base64\n  type: string\n}\n\ninterface DecodedImage {\n  hash: string\n  bytes: Uint8Array\n  type: string\n}\n\n/**  Base64 ， charCodeAt */\nexport function decodeBase64Image(image: ImageInput): DecodedImage | null {\n  try {\n    const base64Data = image.data.includes(',')\n      ? image.data.split(',')[1]\n      : image.data\n    const binaryString = atob(base64Data)\n    const bytes = new Uint8Array(binaryString.length)\n    for (let i = 0; i < binaryString.length; i++) {\n      bytes[i] = binaryString.charCodeAt(i)\n    }\n    return { hash: image.hash, bytes, type: image.type }\n  } catch {\n    return null\n  }\n}\n\ninterface UploadResult {\n  uploadedHashes: string[]\n  totalImageSize: number\n}\n\n/**\n *  R2\n *  N  R2_UPLOAD_CONCURRENCY \n */\nexport async function uploadImagesConcurrently(\n  decoded: DecodedImage[],\n  bucket: R2Bucket,\n  userId: string,\n  bookmarkId: string,\n  version: number,\n  timestamp: number,\n): Promise<UploadResult> {\n  const uploadedHashes: string[] = []\n  let totalImageSize = 0\n\n  for (let i = 0; i < decoded.length; i += R2_UPLOAD_CONCURRENCY) {\n    const batch = decoded.slice(i, i + R2_UPLOAD_CONCURRENCY)\n    const results = await Promise.allSettled(\n      batch.map(async (img) => {\n        const imageKey = `${userId}/${bookmarkId}/v${version}/images/${img.hash}`\n        await bucket.put(imageKey, img.bytes, {\n          httpMetadata: { contentType: img.type },\n          customMetadata: {\n            userId,\n            bookmarkId,\n            version: version.toString(),\n            snapshotTimestamp: timestamp.toString(),\n          },\n        })\n        return img\n      })\n    )\n\n    for (const result of results) {\n      if (result.status === 'fulfilled') {\n        uploadedHashes.push(result.value.hash)\n        totalImageSize += result.value.bytes.length\n      } else {\n        console.error('[Snapshot Upload] Image upload failed:', result.reason)\n      }\n    }\n  }\n\n  return { uploadedHashes, totalImageSize }\n}\n\n/**\n *  HTML  URL\n * ， O(n*m)  replace\n */\nexport function replaceImagePlaceholders(\n  html: string,\n  uploadedHashes: string[],\n  userId: string,\n  bookmarkId: string,\n  version: number,\n): string {\n  if (uploadedHashes.length === 0) return html\n\n  const hashSet = new Set(uploadedHashes)\n  // Match all /api/snapshot-images/{hash} placeholders in one pass\n  return html.replace(\n    /\\/api\\/snapshot-images\\/([a-f0-9]+)/g,\n    (match, hash) => {\n      if (hashSet.has(hash)) {\n        return `/api/snapshot-images/${hash}?u=${userId}&b=${bookmarkId}&v=${version}`\n      }\n      return match\n    }\n  )\n}\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/snapshots/[snapshotId].ts",
    "content": "/**\r\n *  API\r\n * : /api/tab/bookmarks/:id/snapshots/:snapshotId\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../../lib/types'\r\nimport { success, notFound, internalError } from '../../../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../../middleware/api-key-auth-pages'\r\n\r\n// GET /api/tab/bookmarks/:id/snapshots/:snapshotId - \r\nexport const onRequestGet: PagesFunction<Env, 'id' | 'snapshotId', ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.read'),\r\n  async (context) => {\r\n    try {\r\n      const userId = context.data.user_id\r\n      const bookmarkId = context.params.id as string\r\n      const snapshotId = context.params.snapshotId as string\r\n      const db = context.env.DB\r\n      const bucket = context.env.SNAPSHOTS_BUCKET\r\n\r\n      if (!bucket) {\r\n        return internalError('Storage not configured')\r\n      }\r\n\r\n      // \r\n      const snapshot = await db\r\n        .prepare(\r\n          `SELECT s.*, b.url as bookmark_url\r\n           FROM bookmark_snapshots s\r\n           JOIN bookmarks b ON s.bookmark_id = b.id\r\n           WHERE s.id = ? AND s.bookmark_id = ? AND s.user_id = ?`\r\n        )\r\n        .bind(snapshotId, bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!snapshot) {\r\n        return notFound('Snapshot not found')\r\n      }\r\n\r\n      //  R2 \r\n      const r2Object = await bucket.get(snapshot.r2_key as string)\r\n\r\n      if (!r2Object) {\r\n        return notFound('Snapshot file not found')\r\n      }\r\n\r\n      //  HTML \r\n      let htmlContent = await r2Object.text()\r\n      \r\n      //  data URL （）\r\n      const dataUrlCount = (htmlContent.match(/src=\"data:/g) || []).length\r\n      const htmlSize = new Blob([htmlContent]).size\r\n      console.log(`[Snapshot API] Retrieved from R2: ${(htmlSize / 1024).toFixed(1)}KB, data URLs: ${dataUrlCount}`)\r\n\r\n      //  CSP meta  HTML head （）\r\n      const cspMetaTag = '<meta http-equiv=\"Content-Security-Policy\" content=\"default-src * \\'unsafe-inline\\' \\'unsafe-eval\\' data: blob:; img-src * data: blob:; font-src * data:; style-src * \\'unsafe-inline\\'; script-src * \\'unsafe-inline\\' \\'unsafe-eval\\'; frame-src *; connect-src *;\">';\r\n      if (htmlContent.includes('<head>')) {\r\n        htmlContent = htmlContent.replace('<head>', `<head>${cspMetaTag}`);\r\n        console.log(`[Snapshot API] Injected CSP meta tag`);\r\n      } else if (htmlContent.includes('<HEAD>')) {\r\n        htmlContent = htmlContent.replace('<HEAD>', `<HEAD>${cspMetaTag}`);\r\n        console.log(`[Snapshot API] Injected CSP meta tag`);\r\n      }\r\n\r\n      //  V2 （ /api/snapshot-images/ ）\r\n      const isV2 = htmlContent.includes('/api/snapshot-images/')\r\n      \r\n      if (isV2) {\r\n        const version = (snapshot as Record<string, unknown>).version as number || 1\r\n        \r\n        //  URL： URL，\r\n        let replacedCount = 0\r\n        htmlContent = htmlContent.replace(\r\n          /\\/api\\/snapshot-images\\/([a-zA-Z0-9._-]+?)(?:\\?[^\"\\s)]*)?(?=[\"\\s)]|$)/g,\r\n          (_match: string, hash: string) => {\r\n            replacedCount++\r\n            // ，（）\r\n            return `/api/snapshot-images/${hash}?u=${userId}&b=${bookmarkId}&v=${version}`;\r\n          }\r\n        )\r\n        console.log(`[Snapshot API] V2 format detected, normalized ${replacedCount} image URLs`)\r\n      }\r\n\r\n      return new Response(htmlContent, {\r\n        headers: {\r\n          'Content-Type': 'text/html; charset=utf-8',\r\n          'Cache-Control': 'public, max-age=3600',\r\n          'X-Content-Type-Options': 'nosniff',\r\n          //  CSP （）\r\n          'Content-Security-Policy': \"default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; img-src * data: blob:; font-src * data:; style-src * 'unsafe-inline'; script-src * 'unsafe-inline' 'unsafe-eval'; frame-src *; connect-src *;\",\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Get snapshot error:', error)\r\n      return internalError('Failed to get snapshot')\r\n    }\r\n  },\r\n]\r\n\r\n// DELETE /api/tab/bookmarks/:id/snapshots/:snapshotId - \r\nexport const onRequestDelete: PagesFunction<Env, 'id' | 'snapshotId', ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.delete'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id as string\r\n    const snapshotId = context.params.snapshotId as string\r\n\r\n    try {\r\n      const db = context.env.DB\r\n      const bucket = context.env.SNAPSHOTS_BUCKET\r\n\r\n      if (!bucket) {\r\n        return internalError('Storage not configured')\r\n      }\r\n\r\n      // \r\n      const snapshot = await db\r\n        .prepare(\r\n          `SELECT id, r2_key, is_latest\r\n           FROM bookmark_snapshots\r\n           WHERE id = ? AND bookmark_id = ? AND user_id = ?`\r\n        )\r\n        .bind(snapshotId, bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!snapshot) {\r\n        return notFound('Snapshot not found')\r\n      }\r\n\r\n      //  R2 \r\n      await bucket.delete(snapshot.r2_key as string)\r\n\r\n      // \r\n      await db\r\n        .prepare('DELETE FROM bookmark_snapshots WHERE id = ?')\r\n        .bind(snapshotId)\r\n        .run()\r\n\r\n      // （1）\r\n      await db\r\n        .prepare(\r\n          `UPDATE bookmarks \r\n           SET snapshot_count = MAX(0, snapshot_count - 1)\r\n           WHERE id = ?`\r\n        )\r\n        .bind(bookmarkId)\r\n        .run()\r\n\r\n      // ，\r\n      if (snapshot.is_latest) {\r\n        const nextLatest = await db\r\n          .prepare(\r\n            `SELECT id FROM bookmark_snapshots\r\n             WHERE bookmark_id = ? AND user_id = ?\r\n             ORDER BY version DESC\r\n             LIMIT 1`\r\n          )\r\n          .bind(bookmarkId, userId)\r\n          .first()\r\n\r\n        if (nextLatest) {\r\n          await db\r\n            .prepare(\r\n              `UPDATE bookmark_snapshots \r\n               SET is_latest = 1 \r\n               WHERE id = ?`\r\n            )\r\n            .bind(nextLatest.id)\r\n            .run()\r\n        } else {\r\n          // ，\r\n          await db\r\n            .prepare(\r\n              `UPDATE bookmarks \r\n               SET has_snapshot = 0, \r\n                   latest_snapshot_at = NULL,\r\n                   snapshot_count = 0\r\n               WHERE id = ?`\r\n            )\r\n            .bind(bookmarkId)\r\n            .run()\r\n        }\r\n      }\r\n\r\n      return success({ message: 'Snapshot deleted successfully' })\r\n    } catch (error) {\r\n      console.error('Delete snapshot error:', error)\r\n      return internalError('Failed to delete snapshot')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/snapshots/cleanup.ts",
    "content": "/**\r\n *  API\r\n * : /api/v1/bookmarks/:id/snapshots/cleanup\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../../middleware/auth'\r\n\r\ninterface CleanupRequest {\r\n  keep_count?: number\r\n  older_than_days?: number\r\n  verify_and_fix?: boolean\r\n}\r\n\r\ninterface RouteParams {\r\n  id: string\r\n}\r\n\r\ninterface SnapshotRow {\r\n  id: string\r\n  r2_key: string\r\n  file_size: number\r\n}\r\n\r\nasync function deleteSnapshotRows(\r\n  db: D1Database, ids: string[], userId: string,\r\n): Promise<void> {\r\n  const placeholders = ids.map(() => '?').join(',')\r\n  await db\r\n    .prepare(`DELETE FROM bookmark_snapshots WHERE id IN (${placeholders}) AND user_id = ?`)\r\n    .bind(...ids, userId)\r\n    .run()\r\n}\r\n\r\nasync function updateBookmarkSnapshotCount(\r\n  db: D1Database, bookmarkId: string, userId: string,\r\n): Promise<void> {\r\n  const remaining = await db\r\n    .prepare(\r\n      `SELECT COUNT(*) as count FROM bookmark_snapshots\r\n       WHERE bookmark_id = ? AND user_id = ?`,\r\n    )\r\n    .bind(bookmarkId, userId)\r\n    .first()\r\n\r\n  const remainingCount = (remaining?.count as number) || 0\r\n\r\n  if (remainingCount === 0) {\r\n    await db\r\n      .prepare(\r\n        `UPDATE bookmarks\r\n         SET has_snapshot = 0, latest_snapshot_at = NULL, snapshot_count = 0\r\n         WHERE id = ? AND user_id = ?`,\r\n      )\r\n      .bind(bookmarkId, userId)\r\n      .run()\r\n  } else {\r\n    await db\r\n      .prepare(`UPDATE bookmarks SET snapshot_count = ? WHERE id = ? AND user_id = ?`)\r\n      .bind(remainingCount, bookmarkId, userId)\r\n      .run()\r\n  }\r\n}\r\n\r\nasync function handleVerifyAndFix(\r\n  db: D1Database, bucket: R2Bucket, bookmarkId: string, userId: string,\r\n) {\r\n  const { results: allSnapshots } = await db\r\n    .prepare(\r\n      `SELECT id, r2_key, file_size FROM bookmark_snapshots\r\n       WHERE bookmark_id = ? AND user_id = ?`,\r\n    )\r\n    .bind(bookmarkId, userId)\r\n    .all<SnapshotRow>()\r\n\r\n  const orphaned: SnapshotRow[] = []\r\n\r\n  for (const snapshot of allSnapshots || []) {\r\n    try {\r\n      const r2Object = await bucket.head(snapshot.r2_key)\r\n      if (!r2Object) orphaned.push(snapshot)\r\n    } catch {\r\n      orphaned.push(snapshot)\r\n    }\r\n  }\r\n\r\n  if (orphaned.length === 0) {\r\n    return success({\r\n      deleted_count: 0,\r\n      freed_space: 0,\r\n      message: 'All snapshots are valid, no orphaned records found',\r\n    })\r\n  }\r\n\r\n  await deleteSnapshotRows(db, orphaned.map((s) => s.id), userId)\r\n  await updateBookmarkSnapshotCount(db, bookmarkId, userId)\r\n\r\n  return success({\r\n    deleted_count: orphaned.length,\r\n    freed_space: 0,\r\n    message: `Fixed ${orphaned.length} orphaned snapshot records`,\r\n  })\r\n}\r\n\r\n// POST /api/v1/bookmarks/:id/snapshots/cleanup\r\nexport const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      const body = await context.request.json() as CleanupRequest\r\n      const { keep_count, older_than_days, verify_and_fix } = body\r\n\r\n      const db = context.env.DB\r\n      const bucket = context.env.SNAPSHOTS_BUCKET\r\n\r\n      if (!bucket) {\r\n        return internalError('Storage not configured')\r\n      }\r\n\r\n      const bookmark = await db\r\n        .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL')\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!bookmark) return notFound('Bookmark not found')\r\n\r\n      if (verify_and_fix) {\r\n        return handleVerifyAndFix(db, bucket, bookmarkId, userId)\r\n      }\r\n\r\n      let toDelete: SnapshotRow[] = []\r\n\r\n      if (keep_count !== undefined && keep_count >= 0) {\r\n        const result = await db\r\n          .prepare(\r\n            `SELECT id, r2_key, file_size FROM bookmark_snapshots\r\n             WHERE bookmark_id = ? AND user_id = ?\r\n             ORDER BY version DESC LIMIT -1 OFFSET ?`,\r\n          )\r\n          .bind(bookmarkId, userId, keep_count)\r\n          .all()\r\n        toDelete = result.results || []\r\n      } else if (older_than_days !== undefined && older_than_days > 0) {\r\n        const cutoffDate = new Date()\r\n        cutoffDate.setDate(cutoffDate.getDate() - older_than_days)\r\n        const result = await db\r\n          .prepare(\r\n            `SELECT id, r2_key, file_size FROM bookmark_snapshots\r\n             WHERE bookmark_id = ? AND user_id = ? AND created_at < ?\r\n             ORDER BY version ASC`,\r\n          )\r\n          .bind(bookmarkId, userId, cutoffDate.toISOString())\r\n          .all()\r\n        toDelete = result.results || []\r\n      } else {\r\n        return badRequest('Must specify keep_count or older_than_days')\r\n      }\r\n\r\n      if (toDelete.length === 0) {\r\n        return success({ deleted_count: 0, freed_space: 0, message: 'No snapshots to delete' })\r\n      }\r\n\r\n      const freedSpace = toDelete.reduce((sum, s) => sum + (s.file_size as number || 0), 0)\r\n\r\n      for (const snapshot of toDelete) {\r\n        await bucket.delete(snapshot.r2_key as string)\r\n      }\r\n\r\n      await deleteSnapshotRows(db, toDelete.map((s) => s.id), userId)\r\n      await updateBookmarkSnapshotCount(db, bookmarkId, userId)\r\n\r\n      return success({\r\n        deleted_count: toDelete.length,\r\n        freed_space: freedSpace,\r\n        message: `Deleted ${toDelete.length} snapshots`,\r\n      })\r\n    } catch (error) {\r\n      console.error('Cleanup snapshots error:', error)\r\n      return internalError('Failed to cleanup snapshots')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/snapshots-v2.ts",
    "content": "/**\r\n *  API V2 - \r\n * : /api/tab/bookmarks/:id/snapshots-v2\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'\r\nimport { generateSignedUrl } from '../../../../lib/signed-url'\r\nimport { checkR2Quota } from '../../../../lib/storage-quota'\r\nimport {\r\n  decodeBase64Image,\r\n  uploadImagesConcurrently,\r\n  replaceImagePlaceholders,\r\n} from './snapshot-upload'\r\n\r\nfunction generateNanoId(): string {\r\n  const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'\r\n  const randomValues = new Uint8Array(21)\r\n  crypto.getRandomValues(randomValues)\r\n  let id = ''\r\n  for (let i = 0; i < 21; i++) {\r\n    id += alphabet[randomValues[i] % alphabet.length]\r\n  }\r\n  return id\r\n}\r\n\r\nasync function sha256(content: string): Promise<string> {\r\n  const data = new TextEncoder().encode(content)\r\n  const hashBuffer = await crypto.subtle.digest('SHA-256', data)\r\n  const hashArray = Array.from(new Uint8Array(hashBuffer))\r\n  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')\r\n}\r\n\r\ninterface CreateSnapshotV2Request {\r\n  html_content: string\r\n  title: string\r\n  url: string\r\n  images: Array<{ hash: string; data: string; type: string }>\r\n  force?: boolean\r\n}\r\n\r\n// POST /api/tab/bookmarks/:id/snapshots-v2 - （V2）\r\nexport const onRequestPost: PagesFunction<Env, 'id', ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.create'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id as string\r\n\r\n    try {\r\n      const body = await context.request.json() as CreateSnapshotV2Request\r\n      const { html_content, title, url, images = [], force = false } = body\r\n\r\n      if (!html_content || !title || !url) {\r\n        return badRequest('Missing required fields')\r\n      }\r\n\r\n      const db = context.env.DB\r\n      const bucket = context.env.SNAPSHOTS_BUCKET\r\n\r\n      if (!bucket) {\r\n        return internalError('Storage not configured')\r\n      }\r\n\r\n      // \r\n      const bookmark = await db\r\n        .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL')\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!bookmark) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      //  & （）\r\n      const [contentHash, latestSnapshot, versionResult] = await Promise.all([\r\n        sha256(html_content),\r\n        force ? Promise.resolve(null) : db\r\n          .prepare('SELECT content_hash FROM bookmark_snapshots WHERE bookmark_id = ? AND is_latest = 1')\r\n          .bind(bookmarkId)\r\n          .first(),\r\n        db\r\n          .prepare('SELECT COALESCE(MAX(version), 0) + 1 as next_version FROM bookmark_snapshots WHERE bookmark_id = ?')\r\n          .bind(bookmarkId)\r\n          .first(),\r\n      ])\r\n\r\n      if (!force && latestSnapshot && latestSnapshot.content_hash === contentHash) {\r\n        return success({ message: 'Content unchanged, no new snapshot created', is_duplicate: true })\r\n      }\r\n\r\n      const version = (versionResult && typeof versionResult.next_version === 'number')\r\n        ? versionResult.next_version\r\n        : 1\r\n      const timestamp = Date.now()\r\n\r\n      // 1. （CPU ，）\r\n      const decoded = images\r\n        .map(decodeBase64Image)\r\n        .filter((d): d is NonNullable<typeof d> => d !== null)\r\n\r\n      // 2. ：\r\n      const htmlBytes = new TextEncoder().encode(html_content)\r\n      const totalImageBytes = decoded.reduce((sum, d) => sum + d.bytes.length, 0)\r\n      const totalSize = htmlBytes.length + totalImageBytes\r\n\r\n      const quota = await checkR2Quota(db, context.env, totalSize)\r\n      if (!quota.allowed) {\r\n        const usedGB = quota.usedBytes / (1024 * 1024 * 1024)\r\n        const limitGB = quota.limitBytes / (1024 * 1024 * 1024)\r\n        return badRequest({\r\n          code: 'R2_STORAGE_LIMIT_EXCEEDED',\r\n          message: `Snapshot storage limit exceeded. Used ${usedGB.toFixed(2)}GB of ${limitGB.toFixed(2)}GB.`,\r\n        })\r\n      }\r\n\r\n      // 3.  R2（6）\r\n      const { uploadedHashes, totalImageSize } = await uploadImagesConcurrently(\r\n        decoded, bucket, userId, bookmarkId, version, timestamp\r\n      )\r\n\r\n      // 4.  HTML \r\n      const processedHtml = replaceImagePlaceholders(\r\n        html_content, uploadedHashes, userId, bookmarkId, version\r\n      )\r\n\r\n      // 5.  HTML\r\n      const htmlKey = `${userId}/${bookmarkId}/snapshot-${timestamp}-v${version}.html`\r\n      const processedHtmlBytes = new TextEncoder().encode(processedHtml)\r\n\r\n      await bucket.put(htmlKey, processedHtmlBytes, {\r\n        httpMetadata: { contentType: 'text/html; charset=utf-8' },\r\n        customMetadata: {\r\n          userId,\r\n          bookmarkId,\r\n          version: version.toString(),\r\n          title,\r\n          imageCount: uploadedHashes.length.toString(),\r\n          snapshotVersion: '2',\r\n        },\r\n      })\r\n\r\n      // 6. \r\n      const snapshotId = generateNanoId()\r\n      const now = new Date().toISOString()\r\n      const finalSize = processedHtmlBytes.length + totalImageSize\r\n\r\n      await db.batch([\r\n        db.prepare(\r\n          `INSERT INTO bookmark_snapshots\r\n           (id, bookmark_id, user_id, version, is_latest, content_hash,\r\n            r2_key, r2_bucket, file_size, mime_type, snapshot_url,\r\n            snapshot_title, snapshot_status, created_at, updated_at)\r\n           VALUES (?, ?, ?, ?, 1, ?, ?, 'tmarks-snapshots', ?, 'text/html', ?, ?, 'completed', ?, ?)`\r\n        ).bind(\r\n          snapshotId, bookmarkId, userId, version, contentHash,\r\n          htmlKey, finalSize, url, title, now, now\r\n        ),\r\n        db.prepare('UPDATE bookmark_snapshots SET is_latest = 0 WHERE bookmark_id = ? AND user_id = ? AND id != ?')\r\n          .bind(bookmarkId, userId, snapshotId),\r\n        db.prepare(\r\n          'UPDATE bookmarks SET has_snapshot = 1, latest_snapshot_at = ?, snapshot_count = snapshot_count + 1 WHERE id = ? AND user_id = ?'\r\n        ).bind(now, bookmarkId, userId),\r\n      ])\r\n\r\n      //  URL（24 ）\r\n      const baseUrl = new URL(context.request.url).origin\r\n      const { signature, expires } = await generateSignedUrl(\r\n        { userId, resourceId: snapshotId, expiresIn: 24 * 3600, action: 'view' },\r\n        context.env.JWT_SECRET\r\n      )\r\n      const viewUrl = `${baseUrl}/api/v1/bookmarks/${bookmarkId}/snapshots/${snapshotId}/view?sig=${signature}&exp=${expires}&u=${userId}&a=view`\r\n\r\n      return success({\r\n        snapshot: {\r\n          id: snapshotId,\r\n          version,\r\n          file_size: finalSize,\r\n          image_count: uploadedHashes.length,\r\n          content_hash: contentHash,\r\n          snapshot_title: title,\r\n          is_latest: true,\r\n          created_at: now,\r\n          view_url: viewUrl,\r\n        },\r\n        message: 'Snapshot created successfully (V2)',\r\n      })\r\n    } catch (error) {\r\n      console.error('[Snapshot V2 API] Error:', error)\r\n      return internalError('Failed to create snapshot')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/snapshots.ts",
    "content": "/**\r\n *  API\r\n * : /api/tab/bookmarks/:id/snapshots\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'\r\nimport { checkR2Quota } from '../../../../lib/storage-quota'\r\n\r\n//  nanoid  ID（21 ）\r\nfunction generateNanoId(): string {\r\n  const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'\r\n  const length = 21\r\n  const randomValues = new Uint8Array(length)\r\n  crypto.getRandomValues(randomValues)\r\n  \r\n  let id = ''\r\n  for (let i = 0; i < length; i++) {\r\n    id += alphabet[randomValues[i] % alphabet.length]\r\n  }\r\n  return id\r\n}\r\n\r\n//  Web Crypto API  SHA-256 \r\nasync function sha256(content: string): Promise<string> {\r\n  const encoder = new TextEncoder()\r\n  const data = encoder.encode(content)\r\n  const hashBuffer = await crypto.subtle.digest('SHA-256', data)\r\n  const hashArray = Array.from(new Uint8Array(hashBuffer))\r\n  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')\r\n}\r\n\r\ninterface CreateSnapshotRequest {\r\n  html_content: string\r\n  title: string\r\n  url: string\r\n  force?: boolean\r\n}\r\n\r\n// \r\nconst MAX_SNAPSHOT_SIZE = 50 * 1024 * 1024 // 50MB\r\n\r\n// GET /api/tab/bookmarks/:id/snapshots - \r\nexport const onRequestGet: PagesFunction<Env, 'id', ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.read'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      const db = context.env.DB\r\n\r\n      // \r\n      const bookmark = await db\r\n        .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL')\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!bookmark) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      // \r\n      const snapshots = await db\r\n        .prepare(\r\n          `SELECT id, version, file_size, content_hash, snapshot_title, \r\n                  is_latest, created_at\r\n           FROM bookmark_snapshots\r\n           WHERE bookmark_id = ? AND user_id = ?\r\n           ORDER BY version DESC`\r\n        )\r\n        .bind(bookmarkId, userId)\r\n        .all()\r\n\r\n      return success({\r\n        snapshots: snapshots.results || [],\r\n        total: snapshots.results?.length || 0,\r\n      })\r\n    } catch (error) {\r\n      console.error('Get snapshots error:', error)\r\n      return internalError('Failed to get snapshots')\r\n    }\r\n  },\r\n]\r\n\r\n// POST /api/tab/bookmarks/:id/snapshots - \r\nexport const onRequestPost: PagesFunction<Env, 'id', ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.create'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      const body = await context.request.json() as CreateSnapshotRequest\r\n      const { html_content, title, url, force = false } = body\r\n\r\n      if (!html_content || !title || !url) {\r\n        return badRequest('Missing required fields')\r\n      }\r\n\r\n      // \r\n      const originalSize = new Blob([html_content]).size\r\n      if (originalSize > MAX_SNAPSHOT_SIZE) {\r\n        return badRequest(\r\n          `Snapshot too large (${(originalSize / 1024 / 1024).toFixed(2)}MB). Maximum size is ${MAX_SNAPSHOT_SIZE / 1024 / 1024}MB.`\r\n        )\r\n      }\r\n      \r\n      //  data URL （）\r\n      const dataUrlCount = (html_content.match(/src=\"data:/g) || []).length\r\n      console.log(`[Snapshot API] Received HTML: ${(originalSize / 1024).toFixed(1)}KB, data URLs: ${dataUrlCount}`)\r\n\r\n      const db = context.env.DB\r\n      const bucket = context.env.SNAPSHOTS_BUCKET\r\n\r\n      if (!bucket) {\r\n        return internalError('Storage not configured')\r\n      }\r\n\r\n      // \r\n      const bookmark = await db\r\n        .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL')\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!bookmark) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      // \r\n      const contentHash = await sha256(html_content)\r\n\r\n      // （）\r\n      if (!force) {\r\n        const latestSnapshot = await db\r\n          .prepare(\r\n            `SELECT content_hash FROM bookmark_snapshots\r\n             WHERE bookmark_id = ? AND is_latest = 1`\r\n          )\r\n          .bind(bookmarkId)\r\n          .first()\r\n\r\n        if (latestSnapshot && latestSnapshot.content_hash === contentHash) {\r\n          return success({\r\n            message: 'Content unchanged, no new snapshot created',\r\n            is_duplicate: true,\r\n          })\r\n        }\r\n      }\r\n\r\n      // \r\n      const versionResult = await db\r\n        .prepare(\r\n          `SELECT COALESCE(MAX(version), 0) + 1 as next_version\r\n           FROM bookmark_snapshots\r\n           WHERE bookmark_id = ?`\r\n        )\r\n        .bind(bookmarkId)\r\n        .first()\r\n\r\n      const version = versionResult?.next_version as number || 1\r\n\r\n      //  R2 \r\n      const timestamp = Date.now()\r\n      const r2Key = `${userId}/${bookmarkId}/snapshot-${timestamp}-v${version}.html`\r\n\r\n      //  HTML  UTF-8 \r\n      const encoder = new TextEncoder()\r\n      const htmlBytes = encoder.encode(html_content)\r\n\r\n      console.log(`[Snapshot API] Encoded to UTF-8: ${(htmlBytes.length / 1024).toFixed(1)}KB`)\r\n\r\n      // \r\n      const quota = await checkR2Quota(db, context.env, htmlBytes.length)\r\n      if (!quota.allowed) {\r\n        const usedGB = quota.usedBytes / (1024 * 1024 * 1024)\r\n        const limitGB = quota.limitBytes / (1024 * 1024 * 1024)\r\n        return badRequest({\r\n          code: 'R2_STORAGE_LIMIT_EXCEEDED',\r\n          message: `Snapshot storage limit exceeded. Used ${usedGB.toFixed(2)}GB of ${limitGB.toFixed(2)}GB. Please delete some snapshots or images and try again.`,\r\n        })\r\n      }\r\n\r\n      //  UTF-8  R2\r\n      await bucket.put(r2Key, htmlBytes, {\r\n        httpMetadata: {\r\n          contentType: 'text/html; charset=utf-8',\r\n        },\r\n        customMetadata: {\r\n          userId,\r\n          bookmarkId,\r\n          version: version.toString(),\r\n          title,\r\n          fileSize: htmlBytes.length.toString(),\r\n          dataUrlCount: dataUrlCount.toString(),\r\n        },\r\n      })\r\n\r\n      console.log(`[Snapshot API] Uploaded to R2: ${r2Key}`)\r\n      const snapshotId = generateNanoId()\r\n      const now = new Date().toISOString()\r\n\r\n      // （ INSERT ，）\r\n      const batch = [\r\n        // ，\r\n        db.prepare(\r\n          `INSERT INTO bookmark_snapshots\r\n           (id, bookmark_id, user_id, version, is_latest, content_hash,\r\n            r2_key, r2_bucket, file_size, mime_type, snapshot_url,\r\n            snapshot_title, snapshot_status, created_at, updated_at)\r\n           VALUES (?, ?, ?,\r\n            (SELECT COALESCE(MAX(version), 0) + 1 FROM bookmark_snapshots WHERE bookmark_id = ?),\r\n            1, ?, ?, 'tmarks-snapshots', ?, 'text/html', ?, ?, 'completed', ?, ?)`\r\n        ).bind(\r\n          snapshotId,\r\n          bookmarkId,\r\n          userId,\r\n          bookmarkId,\r\n          contentHash,\r\n          r2Key,\r\n          originalSize,\r\n          url,\r\n          title,\r\n          now,\r\n          now\r\n        ),\r\n\r\n        //  is_latest \r\n        db.prepare(\r\n          `UPDATE bookmark_snapshots \r\n           SET is_latest = 0 \r\n           WHERE bookmark_id = ? AND id != ?`\r\n        ).bind(bookmarkId, snapshotId),\r\n\r\n        // （）\r\n        db.prepare(\r\n          `UPDATE bookmarks \r\n           SET has_snapshot = 1, \r\n               latest_snapshot_at = ?,\r\n               snapshot_count = snapshot_count + 1\r\n           WHERE id = ?`\r\n        ).bind(now, bookmarkId),\r\n      ]\r\n\r\n      await db.batch(batch)\r\n\r\n      // \r\n      await cleanupOldSnapshots(db, bucket, bookmarkId, userId)\r\n\r\n      return success({\r\n        snapshot: {\r\n          id: snapshotId,\r\n          version,\r\n          file_size: originalSize,\r\n          content_hash: contentHash,\r\n          created_at: now,\r\n        },\r\n        message: 'Snapshot created successfully',\r\n      })\r\n    } catch (error) {\r\n      console.error('Create snapshot error:', error)\r\n      return internalError('Failed to create snapshot')\r\n    }\r\n  },\r\n]\r\n\r\n// \r\nasync function cleanupOldSnapshots(\r\n  db: D1Database,\r\n  bucket: R2Bucket,\r\n  bookmarkId: string,\r\n  userId: string\r\n) {\r\n  try {\r\n    // \r\n    const bookmarkSettings = await db\r\n      .prepare('SELECT snapshot_retention_count FROM bookmarks WHERE id = ?')\r\n      .bind(bookmarkId)\r\n      .first()\r\n\r\n    const userSettings = await db\r\n      .prepare('SELECT snapshot_retention_count FROM user_preferences WHERE user_id = ?')\r\n      .bind(userId)\r\n      .first()\r\n\r\n    const retentionCount =\r\n      (bookmarkSettings?.snapshot_retention_count as number | null) ??\r\n      (userSettings?.snapshot_retention_count as number | null) ??\r\n      5\r\n\r\n    // -1 \r\n    if (retentionCount === -1) {\r\n      return\r\n    }\r\n\r\n    // \r\n    const toDelete = await db\r\n      .prepare(\r\n        `SELECT id, r2_key\r\n         FROM bookmark_snapshots\r\n         WHERE bookmark_id = ? AND user_id = ?\r\n         ORDER BY version DESC\r\n         LIMIT -1 OFFSET ?`\r\n      )\r\n      .bind(bookmarkId, userId, retentionCount)\r\n      .all()\r\n\r\n    if (!toDelete.results || toDelete.results.length === 0) {\r\n      return\r\n    }\r\n\r\n    //  R2 （）\r\n    const deletedIds: unknown[] = []\r\n    for (const snapshot of toDelete.results) {\r\n      try {\r\n        await bucket.delete(snapshot.r2_key as string)\r\n        deletedIds.push(snapshot.id)\r\n      } catch (error) {\r\n        console.error('Failed to delete R2 file:', snapshot.r2_key, error)\r\n      }\r\n    }\r\n\r\n    if (deletedIds.length === 0) return\r\n\r\n    //  R2 \r\n    const placeholders = deletedIds.map(() => '?').join(',')\r\n    await db\r\n      .prepare(`DELETE FROM bookmark_snapshots WHERE id IN (${placeholders})`)\r\n      .bind(...deletedIds)\r\n      .run()\r\n  } catch (error) {\r\n    console.error('Cleanup snapshots error:', error)\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id]/trash.ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/bookmarks/:id/trash\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../lib/types'\r\nimport { success, notFound, internalError } from '../../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'\r\n\r\n// PATCH /api/tab/bookmarks/:id/trash - （）\r\nexport const onRequestPatch: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.delete'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      // \r\n      const existing = await context.env.DB.prepare(\r\n        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!existing) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      const now = new Date().toISOString()\r\n\r\n      // ： deleted_at\r\n      await context.env.DB.prepare(\r\n        'UPDATE bookmarks SET deleted_at = ?, updated_at = ? WHERE id = ?'\r\n      )\r\n        .bind(now, now, bookmarkId)\r\n        .run()\r\n\r\n      return success({ message: 'Bookmark moved to trash' })\r\n    } catch (error) {\r\n      console.error('Trash bookmark error:', error)\r\n      return internalError('Failed to trash bookmark')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/[id].ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/bookmarks/:id\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, BookmarkRow, RouteParams, SQLParam } from '../../../lib/types'\r\nimport { success, badRequest, notFound, noContent, internalError } from '../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'\nimport { isValidUrl, sanitizeString } from '../../../lib/validation'\nimport { normalizeBookmark } from '../../../lib/bookmark-utils'\nimport { getValidTagIds, replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags'\nimport { invalidatePublicShareCache } from '../../shared/cache'\n\r\ninterface UpdateBookmarkRequest {\r\n  title?: string\r\n  url?: string\r\n  description?: string\r\n  cover_image?: string\r\n  favicon?: string\r\n  tag_ids?: string[]  // ： ID \r\n  tags?: string[]     // ：（）\r\n  is_pinned?: boolean\r\n  is_archived?: boolean\r\n  is_public?: boolean\r\n}\r\n\r\n// GET /api/bookmarks/:id - \r\nexport const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.read'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      const bookmarkRow = await context.env.DB.prepare(\r\n        'SELECT * FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first<BookmarkRow>()\r\n\r\n      if (!bookmarkRow) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      const { results: tags } = await context.env.DB.prepare(\r\n        `SELECT t.id, t.name, t.color\r\n         FROM tags t\r\n         INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n         WHERE bt.bookmark_id = ? AND bt.user_id = ? AND t.deleted_at IS NULL`\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .all<{ id: string; name: string; color: string | null }>()\r\n\r\n      const snapshotCountResult = await context.env.DB.prepare(\r\n        `SELECT COUNT(*) as count FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ?`\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first<{ count: number }>()\r\n\r\n      const snapshotCount = snapshotCountResult?.count || 0\r\n\r\n      await invalidatePublicShareCache(context.env, userId)\r\n\r\n      return success({\r\n        bookmark: {\r\n          ...normalizeBookmark(bookmarkRow),\r\n          tags: tags || [],\r\n          snapshot_count: snapshotCount,\r\n          has_snapshot: snapshotCount > 0,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Get bookmark error:', error)\r\n      return internalError('Failed to get bookmark')\r\n    }\r\n  },\r\n]\r\n\r\n// PATCH /api/bookmarks/:id - \r\nexport const onRequestPatch: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.update'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      const existing = await context.env.DB.prepare(\r\n        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!existing) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      const body = (await context.request.json()) as UpdateBookmarkRequest\r\n      const updates: string[] = []\r\n      const values: SQLParam[] = []\r\n\r\n      if (body.title !== undefined) {\r\n        if (!body.title.trim()) return badRequest('Title cannot be empty')\r\n        updates.push('title = ?')\r\n        values.push(sanitizeString(body.title, 500))\r\n      }\r\n\r\n      if (body.url !== undefined) {\r\n        if (!body.url.trim()) return badRequest('URL cannot be empty')\r\n        if (!isValidUrl(body.url)) return badRequest('Invalid URL format')\r\n        updates.push('url = ?')\r\n        values.push(sanitizeString(body.url, 2000))\r\n      }\r\n\r\n      if (body.description !== undefined) {\r\n        updates.push('description = ?')\r\n        values.push(body.description ? sanitizeString(body.description, 1000) : null)\r\n      }\r\n\r\n      if (body.cover_image !== undefined) {\r\n        updates.push('cover_image = ?')\r\n        values.push(body.cover_image ? sanitizeString(body.cover_image, 2000) : null)\r\n      }\r\n\r\n      if (body.favicon !== undefined) {\r\n        updates.push('favicon = ?')\r\n        values.push(body.favicon ? sanitizeString(body.favicon, 2000) : null)\r\n      }\r\n\r\n      if (body.is_pinned !== undefined) {\r\n        updates.push('is_pinned = ?')\r\n        values.push(body.is_pinned ? 1 : 0)\r\n      }\r\n\r\n      if (body.is_archived !== undefined) {\r\n        updates.push('is_archived = ?')\r\n        values.push(body.is_archived ? 1 : 0)\r\n      }\r\n\r\n      if (body.is_public !== undefined) {\r\n        updates.push('is_public = ?')\r\n        values.push(body.is_public ? 1 : 0)\r\n      }\r\n\r\n      const now = new Date().toISOString()\r\n\r\n      if (updates.length > 0) {\r\n        updates.push('updated_at = ?')\r\n        values.push(now)\r\n        values.push(bookmarkId, userId)\r\n\r\n        await context.env.DB.prepare(\r\n          `UPDATE bookmarks SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`\r\n        )\r\n          .bind(...values)\r\n          .run()\r\n      }\r\n\r\n      if (body.tags !== undefined) {\n        await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, body.tags, userId, now)\n      } else if (body.tag_ids !== undefined) {\n        const validTagIds = await getValidTagIds(context.env.DB, userId, body.tag_ids)\n        await replaceBookmarkTags(context.env.DB, bookmarkId, userId, validTagIds, now)\n      }\n\r\n      const bookmarkRow = await context.env.DB.prepare(\r\n        'SELECT * FROM bookmarks WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first<BookmarkRow>()\r\n\r\n      const { results: tags } = await context.env.DB.prepare(\r\n        `SELECT t.id, t.name, t.color\r\n         FROM tags t\r\n         INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n         WHERE bt.bookmark_id = ? AND bt.user_id = ?`\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .all<{ id: string; name: string; color: string | null }>()\r\n\r\n      if (!bookmarkRow) {\r\n        return internalError('Failed to load bookmark after update')\r\n      }\r\n\r\n      return success({\r\n        bookmark: {\r\n          ...normalizeBookmark(bookmarkRow),\r\n          tags: tags || [],\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Update bookmark error:', error)\r\n      return internalError('Failed to update bookmark')\r\n    }\r\n  },\r\n]\r\n\r\n// DELETE /api/bookmarks/:id - \r\nexport const onRequestDelete: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.delete'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      const existing = await context.env.DB.prepare(\r\n        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!existing) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      const now = new Date().toISOString()\r\n\r\n      await context.env.DB.prepare(\r\n        'UPDATE bookmarks SET deleted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(now, now, bookmarkId, userId)\r\n        .run()\r\n\r\n      await invalidatePublicShareCache(context.env, userId)\r\n\r\n      return noContent()\r\n    } catch (error) {\r\n      console.error('Delete bookmark error:', error)\r\n      return internalError('Failed to delete bookmark')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/batch/index.ts",
    "content": "/**\r\n *  API\r\n * : /api/tab/bookmarks/batch\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../lib/types'\r\nimport { success, badRequest, internalError } from '../../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'\r\nimport { isValidUrl, sanitizeString } from '../../../../lib/validation'\r\nimport { generateUUID } from '../../../../lib/crypto'\r\nimport { invalidatePublicShareCache } from '../../../shared/cache'\r\nimport { replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../../lib/tags'\r\n\r\ninterface BatchCreateBookmarkItem {\r\n  title: string\r\n  url: string\r\n  description?: string\r\n  cover_image?: string\r\n  favicon?: string\r\n  tags?: string[]\r\n  is_pinned?: boolean\r\n  is_archived?: boolean\r\n  is_public?: boolean\r\n}\r\n\r\ninterface BatchCreateRequest {\r\n  bookmarks: BatchCreateBookmarkItem[]\r\n}\r\n\r\ninterface BatchCreateResult {\r\n  success: number\r\n  failed: number\r\n  skipped: number\r\n  total: number\r\n  errors?: Array<{\r\n    index: number\r\n    url: string\r\n    error: string\r\n  }>\r\n  created_bookmarks: Array<{\r\n    id: string\r\n    url: string\r\n    title: string\r\n  }>\r\n}\r\n\r\n/**\r\n * GET /api/tab/bookmarks/batch\r\n\r\n */\r\nexport const onRequestGet: PagesFunction<Env, RouteParams>[] = [\r\n  async () => {\r\n    return new Response(JSON.stringify({\r\n      error: {\r\n        code: 'METHOD_NOT_ALLOWED',\r\n        message: 'GET method is not supported for batch operations. Use POST instead.'\r\n      }\r\n    }), {\r\n      status: 405,\r\n      headers: {\r\n        'Content-Type': 'application/json',\r\n        'Allow': 'POST'\r\n      }\r\n    })\r\n  }\r\n]\r\n\r\n/**\r\n * POST /api/tab/bookmarks/batch\r\n * \r\n */\r\nexport const onRequestPost: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.create'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n\r\n    try {\r\n      const body = (await context.request.json()) as BatchCreateRequest\r\n\r\n      if (!body.bookmarks || !Array.isArray(body.bookmarks) || body.bookmarks.length === 0) {\r\n        return badRequest('bookmarks array is required and cannot be empty')\r\n      }\r\n\r\n      if (body.bookmarks.length > 100) {\r\n        return badRequest('Cannot create more than 100 bookmarks at once')\r\n      }\r\n\r\n      const result: BatchCreateResult = {\r\n        success: 0,\r\n        failed: 0,\r\n        skipped: 0,\r\n        total: body.bookmarks.length,\r\n        errors: [],\r\n        created_bookmarks: []\r\n      }\r\n\r\n      const now = new Date().toISOString()\r\n\r\n      for (let i = 0; i < body.bookmarks.length; i++) {\r\n        const item = body.bookmarks[i]\r\n\r\n        try {\r\n          if (!item.title || !item.url) {\r\n            result.failed++\r\n            result.errors!.push({ index: i, url: item.url || '', error: 'Title and URL are required' })\r\n            continue\r\n          }\r\n\r\n          if (!isValidUrl(item.url)) {\r\n            result.failed++\r\n            result.errors!.push({ index: i, url: item.url, error: 'Invalid URL format' })\r\n            continue\r\n          }\r\n\r\n          const title = sanitizeString(item.title, 500)\r\n          const url = sanitizeString(item.url, 2000)\r\n          const description = item.description ? sanitizeString(item.description, 1000) : null\r\n          const coverImage = item.cover_image ? sanitizeString(item.cover_image, 2000) : null\r\n          const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null\r\n          const isPinned = item.is_pinned ? 1 : 0\r\n          const isArchived = item.is_archived ? 1 : 0\r\n          const isPublic = item.is_public ? 1 : 0\r\n\r\n          const existing = await context.env.DB.prepare(\r\n            'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?'\r\n          )\r\n            .bind(userId, url)\r\n            .first<{ id: string; deleted_at: string | null }>()\r\n          const restoredDeletedBookmark = Boolean(existing?.deleted_at)\r\n\r\n          let bookmarkId: string\r\n\r\n          if (existing) {\r\n            if (!existing.deleted_at) {\r\n              result.skipped++\r\n              continue\r\n            }\r\n\r\n            bookmarkId = existing.id\r\n            await context.env.DB.prepare(\r\n              `UPDATE bookmarks\r\n               SET title = ?, description = ?, cover_image = ?, favicon = ?,\r\n                   is_pinned = ?, is_archived = ?, is_public = ?,\r\n                   deleted_at = NULL, updated_at = ?\r\n               WHERE id = ? AND user_id = ?`\r\n            )\r\n              .bind(title, description, coverImage, favicon, isPinned, isArchived, isPublic, now, bookmarkId, userId)\r\n              .run()\r\n          } else {\r\n            bookmarkId = generateUUID()\r\n            await context.env.DB.prepare(\r\n              `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_archived, is_public, created_at, updated_at)\r\n               VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`\r\n            )\r\n              .bind(bookmarkId, userId, title, url, description, coverImage, null, favicon, isPinned, isArchived, isPublic, now, now)\r\n              .run()\r\n          }\r\n\r\n          if (item.tags !== undefined) {\r\n            await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, item.tags, userId, now)\r\n          } else if (restoredDeletedBookmark) {\r\n            await replaceBookmarkTags(context.env.DB, bookmarkId, userId, [], now)\r\n          }\r\n\r\n          result.success++\r\n          result.created_bookmarks.push({ id: bookmarkId, url, title })\r\n\r\n        } catch (error) {\r\n          result.failed++\r\n          result.errors!.push({ index: i, url: item.url || '', error: 'Failed to create bookmark' })\r\n          console.error(`[Batch Create] Failed to create bookmark ${i}:`, error)\r\n        }\r\n      }\r\n\r\n      if (result.errors!.length === 0) {\r\n        delete result.errors\r\n      }\r\n\r\n      if (result.success > 0) {\r\n        await context.env.DB.prepare(\r\n          `UPDATE tags\r\n           SET usage_count = (\r\n             SELECT COUNT(*) FROM bookmark_tags\r\n             WHERE tag_id = tags.id AND user_id = ?\r\n           )\r\n           WHERE user_id = ? AND deleted_at IS NULL`\r\n        )\r\n          .bind(userId, userId)\r\n          .run()\r\n      }\r\n\r\n      await context.env.DB.prepare(\r\n        `INSERT INTO audit_logs (user_id, event_type, payload, created_at)\r\n         VALUES (?, 'batch_create_bookmarks', ?, datetime('now'))`\r\n      )\r\n        .bind(userId, JSON.stringify({\r\n          total: result.total,\r\n          success: result.success,\r\n          failed: result.failed,\r\n          skipped: result.skipped\r\n        }))\r\n        .run()\r\n\r\n      await invalidatePublicShareCache(context.env, userId)\r\n\r\n      return success(result)\r\n\r\n    } catch (error) {\r\n      console.error('Batch create bookmarks error:', error)\r\n      return internalError('Failed to batch create bookmarks')\r\n    }\r\n  }\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/batch-handler.ts",
    "content": "/**\r\n\r\n */\r\n\r\nimport type { EventContext } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../lib/types'\r\nimport type { ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'\r\nimport { success, badRequest } from '../../../lib/response'\r\nimport { isValidUrl, sanitizeString } from '../../../lib/validation'\r\nimport { generateUUID } from '../../../lib/crypto'\r\nimport { replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags'\r\nimport { invalidatePublicShareCache } from '../../shared/cache'\r\n\r\ninterface BatchCreateBookmarkItem {\r\n  title: string\r\n  url: string\r\n  description?: string\r\n  cover_image?: string\r\n  favicon?: string\r\n  tags?: string[]\r\n  is_pinned?: boolean\r\n  is_archived?: boolean\r\n  is_public?: boolean\r\n}\r\n\r\ninterface BatchCreateResult {\r\n  success: number\r\n  failed: number\r\n  skipped: number\r\n  total: number\r\n  errors?: Array<{\r\n    index: number\r\n    url: string\r\n    error: string\r\n  }>\r\n  created_bookmarks: Array<{\r\n    id: string\r\n    url: string\r\n    title: string\r\n  }>\r\n}\r\n\r\nexport async function batchCreateBookmarks(\r\n  context: EventContext<Env, RouteParams, ApiKeyAuthContext>,\r\n  userId: string,\r\n  bookmarks: BatchCreateBookmarkItem[]\r\n): Promise<Response> {\r\n  console.log('[Batch Handler] Starting batch create')\r\n  console.log('[Batch Handler] User ID:', userId)\r\n  console.log('[Batch Handler] Bookmarks count:', bookmarks?.length)\r\n\r\n  if (!bookmarks || !Array.isArray(bookmarks) || bookmarks.length === 0) {\r\n    return badRequest('bookmarks array is required and cannot be empty')\r\n  }\r\n\r\n  // \r\n  if (bookmarks.length > 100) {\r\n    return badRequest('Cannot create more than 100 bookmarks at once')\r\n  }\r\n\r\n  const result: BatchCreateResult = {\r\n    success: 0,\r\n    failed: 0,\r\n    skipped: 0,\r\n    total: bookmarks.length,\r\n    errors: [],\r\n    created_bookmarks: []\r\n  }\r\n\r\n  const now = new Date().toISOString()\r\n\r\n  // \r\n  for (let i = 0; i < bookmarks.length; i++) {\r\n    const item = bookmarks[i]\r\n\r\n    try {\r\n      // \r\n      if (!item.title || !item.url) {\r\n        result.failed++\r\n        result.errors!.push({\r\n          index: i,\r\n          url: item.url || '',\r\n          error: 'Title and URL are required'\r\n        })\r\n        continue\r\n      }\r\n\r\n      //  URL \r\n      if (!isValidUrl(item.url)) {\r\n        result.failed++\r\n        result.errors!.push({\r\n          index: i,\r\n          url: item.url,\r\n          error: 'Invalid URL format'\r\n        })\r\n        continue\r\n      }\r\n\r\n      const title = sanitizeString(item.title, 500)\r\n      const url = sanitizeString(item.url, 2000)\r\n      const description = item.description ? sanitizeString(item.description, 1000) : null\r\n      const coverImage = item.cover_image ? sanitizeString(item.cover_image, 2000) : null\r\n      const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null\r\n      const isPinned = item.is_pinned ? 1 : 0\r\n      const isArchived = item.is_archived ? 1 : 0\r\n      const isPublic = item.is_public ? 1 : 0\r\n\r\n      const existing = await context.env.DB.prepare(\r\n        'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?'\r\n      )\r\n        .bind(userId, url)\r\n        .first<{ id: string; deleted_at: string | null }>()\r\n      const restoredDeletedBookmark = Boolean(existing?.deleted_at)\r\n\r\n      let bookmarkId: string\r\n\r\n      if (existing) {\r\n        if (!existing.deleted_at) {\r\n          // ，\r\n          result.skipped++\r\n          continue\r\n        }\r\n\r\n        //\r\n        bookmarkId = existing.id\r\n        await context.env.DB.prepare(\r\n          `UPDATE bookmarks\r\n           SET title = ?, description = ?, cover_image = ?, favicon = ?,\r\n               is_pinned = ?, is_archived = ?, is_public = ?,\r\n               deleted_at = NULL, updated_at = ?\r\n           WHERE id = ? AND user_id = ?`\r\n        )\r\n          .bind(\r\n            title,\r\n            description,\r\n            coverImage,\r\n            favicon,\r\n            isPinned,\r\n            isArchived,\r\n            isPublic,\r\n            now,\r\n            bookmarkId,\r\n            userId\r\n          )\r\n          .run()\r\n      } else {\r\n\r\n        bookmarkId = generateUUID()\r\n        await context.env.DB.prepare(\r\n          `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_archived, is_public, created_at, updated_at)\r\n           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`\r\n        )\r\n          .bind(\r\n            bookmarkId,\r\n            userId,\r\n            title,\r\n            url,\r\n            description,\r\n            coverImage,\r\n            null, // cover_image_id\r\n            favicon,\r\n            isPinned,\r\n            isArchived,\r\n            isPublic,\r\n            now,\r\n            now\r\n          )\r\n          .run()\r\n      }\r\n\r\n      // \r\n      if (item.tags !== undefined) {\r\n        await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, item.tags, userId, now)\r\n      } else if (restoredDeletedBookmark) {\r\n        await replaceBookmarkTags(context.env.DB, bookmarkId, userId, [], now)\r\n      }\r\n\r\n      result.success++\r\n      result.created_bookmarks.push({\r\n        id: bookmarkId,\r\n        url,\r\n        title\r\n      })\r\n\r\n    } catch (error) {\r\n      result.failed++\r\n      console.error(`[Batch Handler] Failed to create bookmark ${i}:`, error)\r\n      result.errors!.push({\r\n        index: i,\r\n        url: item.url || '',\r\n        error: 'Failed to create bookmark'\r\n      })\r\n    }\r\n  }\r\n\r\n  if (result.errors!.length === 0) {\r\n    delete result.errors\r\n  }\r\n\r\n  //  usage_count\r\n  if (result.success > 0) {\r\n    await context.env.DB.prepare(\r\n      `UPDATE tags\r\n       SET usage_count = (\r\n         SELECT COUNT(*) FROM bookmark_tags\r\n         WHERE tag_id = tags.id AND user_id = ?\r\n       )\r\n       WHERE user_id = ? AND deleted_at IS NULL`\r\n    )\r\n      .bind(userId, userId)\r\n      .run()\r\n  }\r\n\r\n  // \r\n  await invalidatePublicShareCache(context.env, userId)\r\n\r\n  console.log('[Batch Handler] Complete:', result)\r\n  return success(result)\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/bookmark-batch.ts",
    "content": "import type { D1Database } from '../../../lib/types'\nimport { isValidUrl, sanitizeString } from '../../../lib/validation'\nimport { generateUUID } from '../../../lib/crypto'\nimport { replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags'\n\nexport type BatchCreateResult = {\n  success: number\n  failed: number\n  skipped: number\n  total: number\n  errors?: Array<{ index: number; url: string; error: string }>\n  created_bookmarks: Array<{ id: string; url: string; title: string }>\n}\n\nexport async function handleBatchCreate(\n  db: D1Database,\n  userId: string,\n  bookmarks: Array<{\n    title: string\n    url: string\n    description?: string\n    cover_image?: string\n    favicon?: string\n    tags?: string[]\n    is_pinned?: boolean\n    is_archived?: boolean\n    is_public?: boolean\n  }>,\n  now: string\n): Promise<BatchCreateResult> {\n  const result: BatchCreateResult = {\n    success: 0,\n    failed: 0,\n    skipped: 0,\n    total: bookmarks.length,\n    errors: [],\n    created_bookmarks: [],\n  }\n\n  for (let i = 0; i < bookmarks.length; i++) {\n    const item = bookmarks[i]\n\n    try {\n      if (!item.title || !item.url) {\n        result.failed++\n        result.errors.push({\n          index: i,\n          url: item.url || '',\n          error: 'Title and URL are required',\n        })\n        continue\n      }\n\n      if (!isValidUrl(item.url)) {\n        result.failed++\n        result.errors.push({\n          index: i,\n          url: item.url,\n          error: 'Invalid URL format',\n        })\n        continue\n      }\n\n      const title = sanitizeString(item.title, 500)\n      const url = sanitizeString(item.url, 2000)\n      const description = item.description ? sanitizeString(item.description, 1000) : null\n      const coverImage = item.cover_image ? sanitizeString(item.cover_image, 2000) : null\n      const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null\n      const isPinned = item.is_pinned ? 1 : 0\n      const isArchived = item.is_archived ? 1 : 0\n      const isPublic = item.is_public ? 1 : 0\n\n      const existing = await db.prepare(\n        'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?'\n      )\n        .bind(userId, url)\n        .first<{ id: string; deleted_at: string | null }>()\n      const restoredDeletedBookmark = Boolean(existing?.deleted_at)\n\n      let bookmarkId: string\n\n      if (existing) {\n        if (!existing.deleted_at) {\n          result.skipped++\n          continue\n        }\n\n        bookmarkId = existing.id\n        await db.prepare(\n          `UPDATE bookmarks\n           SET title = ?, description = ?, cover_image = ?, favicon = ?,\n               is_pinned = ?, is_archived = ?, is_public = ?,\n               deleted_at = NULL, updated_at = ?\n           WHERE id = ? AND user_id = ?`\n        )\n          .bind(title, description, coverImage, favicon, isPinned, isArchived, isPublic, now, bookmarkId, userId)\n          .run()\n      } else {\n        bookmarkId = generateUUID()\n        await db.prepare(\n          `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_archived, is_public, created_at, updated_at)\n           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`\n        )\n          .bind(bookmarkId, userId, title, url, description, coverImage, null, favicon, isPinned, isArchived, isPublic, now, now)\n          .run()\n      }\n\n      if (item.tags !== undefined) {\n        await replaceBookmarkTagsByNames(db, bookmarkId, item.tags, userId, now)\n      } else if (restoredDeletedBookmark) {\n        await replaceBookmarkTags(db, bookmarkId, userId, [], now)\n      }\n\n      result.success++\n      result.created_bookmarks.push({ id: bookmarkId, url, title })\n    } catch (error) {\n      result.failed++\n      result.errors.push({\n        index: i,\n        url: item.url || '',\n        error: 'Failed to create bookmark',\n      })\n      console.error(`[Batch] Failed to create bookmark ${i}:`, error)\n    }\n  }\n\n  if (result.errors.length === 0) {\n    delete result.errors\n  }\n\n  if (result.success > 0) {\n    await db.prepare(\n      `UPDATE tags\n       SET usage_count = (\n         SELECT COUNT(*) FROM bookmark_tags\n         WHERE tag_id = tags.id AND user_id = ?\n       )\n       WHERE user_id = ? AND deleted_at IS NULL`\n    )\n      .bind(userId, userId)\n      .run()\n  }\n\n  return result\n}\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/bookmark-list.ts",
    "content": "import type { Bookmark, BookmarkRow, SQLParam, D1Database } from '../../../lib/types'\n\nexport interface BookmarkWithTags extends Bookmark {\n  tags: Array<{ id: string; name: string; color: string | null }>\n}\n\nexport interface BookmarkListRow extends BookmarkRow {\n  pin_order?: number | null\n}\n\nexport interface BookmarkPageCursor {\n  id: string\n  isPinned: boolean\n  pinOrder: number | null\n  sortValue: string\n}\n\nexport function parseBookmarkPageCursor(raw: string | null): BookmarkPageCursor | null {\n  if (!raw) return null\n\n  try {\n    const parsed = JSON.parse(raw) as Partial<BookmarkPageCursor>\n    if (\n      typeof parsed?.id === 'string' &&\n      typeof parsed?.isPinned === 'boolean' &&\n      typeof parsed?.sortValue === 'string' &&\n      (typeof parsed?.pinOrder === 'number' || parsed?.pinOrder === null || parsed?.pinOrder === undefined)\n    ) {\n      return {\n        id: parsed.id,\n        isPinned: parsed.isPinned,\n        pinOrder: typeof parsed.pinOrder === 'number' ? parsed.pinOrder : null,\n        sortValue: parsed.sortValue,\n      }\n    }\n  } catch {\n    return null\n  }\n\n  return null\n}\n\nexport function createBookmarkPageCursor(row: BookmarkListRow, sortBy: 'created' | 'updated' | 'pinned'): string {\n  return JSON.stringify({\n    id: row.id,\n    isPinned: Boolean(row.is_pinned),\n    pinOrder: row.is_pinned ? Number(row.pin_order ?? 0) : null,\n    sortValue: sortBy === 'updated' ? row.updated_at : row.created_at,\n  } satisfies BookmarkPageCursor)\n}\n\nexport function buildBookmarkListQuery(\n  userId: string,\n  url: URL\n): { query: string; params: SQLParam[]; pageSize: number; sortBy: 'created' | 'updated' | 'pinned' } {\n  const keyword = url.searchParams.get('keyword')\n  const tags = url.searchParams.get('tags')\n  const pageSize = Math.max(1, Math.min(parseInt(url.searchParams.get('page_size') || '100') || 100, 200))\n  const pageCursor = url.searchParams.get('page_cursor')\n  const parsedCursor = parseBookmarkPageCursor(pageCursor)\n  const sortBy = (url.searchParams.get('sort') as 'created' | 'updated' | 'pinned') || 'created'\n  const pinnedParam = url.searchParams.get('pinned')\n  const pinned = pinnedParam ? pinnedParam === 'true' : undefined\n  const sortField = sortBy === 'updated' ? 'b.updated_at' : 'b.created_at'\n\n  // \n  let query = `\n    SELECT DISTINCT b.*\n    FROM bookmarks b\n    WHERE b.user_id = ? AND b.deleted_at IS NULL\n  `\n  const params: SQLParam[] = [userId]\n\n  // \n  if (pinned !== undefined) {\n    query += ` AND b.is_pinned = ?`\n    params.push(pinned ? 1 : 0)\n  }\n\n  if (keyword) {\n    query += ` AND (b.title LIKE ? OR b.description LIKE ? OR b.url LIKE ?)`\n    const searchPattern = `%${keyword}%`\n    params.push(searchPattern, searchPattern, searchPattern)\n  }\n\n  // （：）\n  if (tags) {\n    const tagIds = tags.split(',').filter(Boolean)\n    if (tagIds.length > 0) {\n      query += ` AND b.id IN (\n        SELECT bt.bookmark_id\n        FROM bookmark_tags bt\n        WHERE bt.tag_id IN (${tagIds.map(() => '?').join(',')})\n        GROUP BY bt.bookmark_id\n        HAVING COUNT(DISTINCT bt.tag_id) = ?\n      )`\n      params.push(...tagIds, tagIds.length)\n    }\n  }\n\n  // \n  if (parsedCursor) {\n    if (pinned === true) {\n      query += ` AND (\n        b.pin_order > ?\n        OR (b.pin_order = ? AND (${sortField} < ? OR (${sortField} = ? AND b.id < ?)))\n      )`\n      params.push(\n        parsedCursor.pinOrder ?? 0,\n        parsedCursor.pinOrder ?? 0,\n        parsedCursor.sortValue,\n        parsedCursor.sortValue,\n        parsedCursor.id\n      )\n    } else if (pinned === false) {\n      query += ` AND (\n        ${sortField} < ?\n        OR (${sortField} = ? AND b.id < ?)\n      )`\n      params.push(parsedCursor.sortValue, parsedCursor.sortValue, parsedCursor.id)\n    } else if (parsedCursor.isPinned) {\n      query += ` AND (\n        b.is_pinned = 0\n        OR (\n          b.is_pinned = 1 AND (\n            b.pin_order > ?\n            OR (b.pin_order = ? AND (${sortField} < ? OR (${sortField} = ? AND b.id < ?)))\n          )\n        )\n      )`\n      params.push(\n        parsedCursor.pinOrder ?? 0,\n        parsedCursor.pinOrder ?? 0,\n        parsedCursor.sortValue,\n        parsedCursor.sortValue,\n        parsedCursor.id\n      )\n    } else {\n      query += ` AND b.is_pinned = 0 AND (\n        ${sortField} < ?\n        OR (${sortField} = ? AND b.id < ?)\n      )`\n      params.push(parsedCursor.sortValue, parsedCursor.sortValue, parsedCursor.id)\n    }\n  } else if (pageCursor) {\n    query += ` AND b.id < ?`\n    params.push(pageCursor)\n  }\n\n  let orderBy = ''\n  switch (sortBy) {\n    case 'updated':\n      orderBy = 'ORDER BY b.is_pinned DESC, CASE WHEN b.is_pinned = 1 THEN b.pin_order ELSE NULL END ASC, b.updated_at DESC, b.id DESC'\n      break\n    case 'pinned':\n      orderBy = 'ORDER BY b.is_pinned DESC, CASE WHEN b.is_pinned = 1 THEN b.pin_order ELSE NULL END ASC, b.created_at DESC, b.id DESC'\n      break\n    case 'created':\n    default:\n      orderBy = 'ORDER BY b.is_pinned DESC, CASE WHEN b.is_pinned = 1 THEN b.pin_order ELSE NULL END ASC, b.created_at DESC, b.id DESC'\n      break\n  }\n\n  query += ` ${orderBy} LIMIT ?`\n  params.push(pageSize + 1)\n\n  return { query, params, pageSize, sortBy }\n}\n\nexport async function fetchBookmarkTags(\n  db: D1Database,\n  bookmarkIds: string[]\n): Promise<Map<string, Array<{ id: string; name: string; color: string | null }>>> {\n  const tagsByBookmarkId = new Map<string, Array<{ id: string; name: string; color: string | null }>>()\n  if (bookmarkIds.length === 0) return tagsByBookmarkId\n\n  const placeholders = bookmarkIds.map(() => '?').join(',')\n  const { results: tagResults } = await db.prepare(\n    `SELECT\n       bt.bookmark_id,\n       t.id,\n       t.name,\n       t.color\n     FROM tags t\n     INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\n     WHERE bt.bookmark_id IN (${placeholders})\n       AND t.deleted_at IS NULL\n     ORDER BY bt.bookmark_id, t.name`\n  )\n    .bind(...bookmarkIds)\n    .all<{ bookmark_id: string; id: string; name: string; color: string | null }>()\n\n  const allTags = tagResults ?? []\n  for (const tag of allTags) {\n    if (!tagsByBookmarkId.has(tag.bookmark_id)) {\n      tagsByBookmarkId.set(tag.bookmark_id, [])\n    }\n    tagsByBookmarkId.get(tag.bookmark_id)!.push({\n      id: tag.id,\n      name: tag.name,\n      color: tag.color,\n    })\n  }\n  return tagsByBookmarkId\n}\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/index.ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/bookmarks\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, BookmarkRow, RouteParams } from '../../../lib/types'\r\nimport { success, badRequest, created, internalError } from '../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'\r\nimport { isValidUrl, sanitizeString } from '../../../lib/validation'\r\nimport { generateUUID } from '../../../lib/crypto'\nimport { normalizeBookmark } from '../../../lib/bookmark-utils'\nimport { invalidatePublicShareCache } from '../../shared/cache'\nimport { uploadCoverImageToR2 } from '../../../lib/image-upload'\nimport { getValidTagIds, replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags'\nimport { \r\n  buildBookmarkListQuery, \r\n  fetchBookmarkTags, \r\n  createBookmarkPageCursor,\r\n  BookmarkListRow,\r\n  BookmarkWithTags\r\n} from './bookmark-list'\r\nimport { handleBatchCreate } from './bookmark-batch'\r\n\r\ninterface CreateBookmarkRequest {\r\n  title?: string\r\n  url?: string\r\n  description?: string\r\n  cover_image?: string\r\n  favicon?: string\r\n  tag_ids?: string[]  // ：\n  tags?: string[]     // ：（\n  is_pinned?: boolean\r\n  is_public?: boolean\r\n  bookmarks?: Array<{  // \r\n    title: string\r\n    url: string\r\n    description?: string\r\n    cover_image?: string\r\n    favicon?: string\r\n    tags?: string[]\r\n    is_pinned?: boolean\r\n    is_archived?: boolean\r\n    is_public?: boolean\r\n  }>\r\n}\r\n\r\n// GET /api/bookmarks - \r\nexport const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.read'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const url = new URL(context.request.url)\r\n\r\n    try {\r\n      const { query, params, pageSize, sortBy } = buildBookmarkListQuery(userId, url)\r\n      const { results } = await context.env.DB.prepare(query).bind(...params).all<BookmarkListRow>()\r\n\r\n      const hasMore = results.length > pageSize\r\n      const bookmarks = hasMore ? results.slice(0, pageSize) : results\r\n      const nextCursor = hasMore && bookmarks.length > 0\r\n        ? createBookmarkPageCursor(bookmarks[bookmarks.length - 1], sortBy)\r\n        : null\r\n\r\n      const bookmarkIds = bookmarks.map(b => b.id)\r\n      const tagsByBookmarkId = await fetchBookmarkTags(context.env.DB, bookmarkIds)\r\n\n      const bookmarksWithTags: BookmarkWithTags[] = bookmarks.map(row => {\r\n        const normalized = normalizeBookmark(row)\r\n        return {\r\n          ...normalized,\r\n          tags: tagsByBookmarkId.get(row.id) || [],\r\n        }\r\n      })\r\n\r\n      return success({\r\n        bookmarks: bookmarksWithTags,\r\n        meta: {\r\n          page_size: pageSize,\r\n          count: bookmarks.length,\r\n          next_cursor: nextCursor,\r\n          has_more: hasMore,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Get bookmarks error:', error)\r\n      return internalError('Failed to get bookmarks')\r\n    }\r\n  },\r\n]\r\n\nexport const onRequestPost: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.create'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n\r\n    try {\r\n      const body = (await context.request.json()) as CreateBookmarkRequest\r\n\r\n      // \r\n      if (body.bookmarks && Array.isArray(body.bookmarks) && body.bookmarks.length > 0) {\r\n        if (body.bookmarks.length > 100) {\r\n          return badRequest('Cannot create more than 100 bookmarks at once')\r\n        }\r\n\r\n        const now = new Date().toISOString()\r\n        const result = await handleBatchCreate(context.env.DB, userId, body.bookmarks, now)\r\n        await invalidatePublicShareCache(context.env, userId)\r\n        return success(result)\r\n      }\r\n\r\n      // \r\n      if (!body.title || !body.url) {\r\n        return badRequest({\r\n          message: 'Title and URL are required',\r\n          code: 'MISSING_FIELDS'\r\n        })\r\n      }\r\n\r\n      if (!isValidUrl(body.url)) {\r\n        return badRequest('Invalid URL format')\r\n      }\r\n\r\n      const title = sanitizeString(body.title, 500)\r\n      const url = sanitizeString(body.url, 2000)\r\n      const description = body.description ? sanitizeString(body.description, 1000) : null\r\n      let coverImage = body.cover_image ? sanitizeString(body.cover_image, 2000) : null\r\n      const favicon = body.favicon ? sanitizeString(body.favicon, 2000) : null\r\n\n      const existing = await context.env.DB.prepare(\n        'SELECT id, deleted_at FROM bookmarks WHERE user_id = ? AND url = ?'\n      )\n        .bind(userId, url)\n        .first<{ id: string; deleted_at: string | null }>()\n      const restoredDeletedBookmark = Boolean(existing?.deleted_at)\n\r\n      const now = new Date().toISOString()\r\n      let bookmarkId: string\r\n      const isPinned = body.is_pinned ? 1 : 0\r\n      const isPublic = body.is_public ? 1 : 0\r\n\r\n      //  R2 bucket， R2\r\n      let coverImageId: string | null = null\r\n      if (coverImage && context.env.SNAPSHOTS_BUCKET && context.env.R2_PUBLIC_URL) {\r\n        const tempBookmarkId = existing?.id || generateUUID()\r\n        const uploadResult = await uploadCoverImageToR2(\r\n          coverImage,\r\n          userId,\r\n          tempBookmarkId,\r\n          context.env.SNAPSHOTS_BUCKET,\r\n          context.env.DB,\r\n          context.env.R2_PUBLIC_URL,\r\n          context.env\r\n        )\r\n\r\n        if (uploadResult.success && uploadResult.r2Url) {\r\n          coverImage = uploadResult.r2Url\r\n          coverImageId = uploadResult.imageId || null\r\n        }\r\n      }\r\n\r\n      if (existing) {\r\n        if (!existing.deleted_at) {\r\n          // \r\n          const bookmarkRow = await context.env.DB.prepare('SELECT * FROM bookmarks WHERE id = ? AND user_id = ?')\r\n            .bind(existing.id, userId)\r\n            .first<BookmarkRow>()\r\n\r\n          const { results: tags } = await context.env.DB.prepare(\r\n            `SELECT t.id, t.name, t.color\r\n             FROM tags t\r\n             INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n             WHERE bt.bookmark_id = ? AND bt.user_id = ?`\r\n          )\r\n            .bind(existing.id, userId)\r\n            .all<{ id: string; name: string; color: string | null }>()\r\n\r\n          const snapshotCountResult = await context.env.DB.prepare(\r\n            `SELECT COUNT(*) as count FROM bookmark_snapshots WHERE bookmark_id = ? AND user_id = ?`\r\n          )\r\n            .bind(existing.id, userId)\r\n            .first<{ count: number }>()\r\n\r\n          const snapshotCount = snapshotCountResult?.count || 0\r\n\r\n          if (!bookmarkRow) {\r\n            return internalError('Failed to retrieve bookmark')\r\n          }\r\n\r\n          const bookmark = normalizeBookmark(bookmarkRow)\r\n          return success(\r\n            {\r\n              bookmark: {\r\n                ...bookmark,\r\n                tags: tags || [],\r\n                snapshot_count: snapshotCount,\r\n                has_snapshot: snapshotCount > 0,\r\n              },\r\n            },\r\n            {\r\n              message: 'Bookmark already exists',\r\n              code: 'BOOKMARK_EXISTS',\r\n            }\r\n          )\r\n        }\r\n\r\n        // \r\n        bookmarkId = existing.id\r\n        await context.env.DB.prepare(\n          `UPDATE bookmarks\n           SET title = ?, description = ?, cover_image = ?, favicon = ?,\n               is_pinned = ?, is_public = ?,\n               deleted_at = NULL, updated_at = ?\n           WHERE id = ? AND user_id = ?`\n        )\n          .bind(title, description, coverImage, favicon, isPinned, isPublic, now, bookmarkId, userId)\n          .run()\n      } else {\n\n        bookmarkId = generateUUID()\r\n        await context.env.DB.prepare(\r\n          `INSERT INTO bookmarks (id, user_id, title, url, description, cover_image, cover_image_id, favicon, is_pinned, is_public, created_at, updated_at)\r\n           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`\r\n        )\r\n          .bind(bookmarkId, userId, title, url, description, coverImage, coverImageId, favicon, isPinned, isPublic, now, now)\r\n          .run()\r\n      }\r\n\r\n      // \r\n      if (body.tags !== undefined) {\n        await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, body.tags, userId, now)\n      } else if (body.tag_ids !== undefined) {\n        const validTagIds = await getValidTagIds(context.env.DB, userId, body.tag_ids)\n        await replaceBookmarkTags(context.env.DB, bookmarkId, userId, validTagIds, now)\n      } else if (restoredDeletedBookmark) {\n        await replaceBookmarkTags(context.env.DB, bookmarkId, userId, [], now)\n      }\n\n      const bookmarkRow = await context.env.DB.prepare('SELECT * FROM bookmarks WHERE id = ? AND user_id = ?')\r\n        .bind(bookmarkId, userId)\r\n        .first<BookmarkRow>()\r\n\r\n      const { results: tags } = await context.env.DB.prepare(\r\n        `SELECT t.id, t.name, t.color\r\n         FROM tags t\r\n         INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n         WHERE bt.bookmark_id = ? AND bt.user_id = ?`\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .all<{ id: string; name: string; color: string | null }>()\r\n\r\n      if (!bookmarkRow) {\r\n        return internalError('Failed to load bookmark after creation')\r\n      }\r\n\r\n      await invalidatePublicShareCache(context.env, userId)\r\n\r\n      return created({\r\n        bookmark: {\r\n          ...normalizeBookmark(bookmarkRow),\r\n          tags: tags || [],\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Create bookmark error:', error)\r\n      return internalError('Failed to create bookmark')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/reorder-pinned.ts",
    "content": "/**\r\n * \r\n * POST /api/tab/bookmarks/reorder-pinned\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../lib/types'\r\nimport { success, badRequest, internalError } from '../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'\r\n\r\ninterface ReorderPinnedRequest {\r\n  bookmark_ids: string[]\r\n}\r\n\r\nexport const onRequestPost: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.write'),\r\n  async (context) => {\r\n    try {\r\n      const userId = context.data.user_id\r\n      const body = (await context.request.json()) as ReorderPinnedRequest\r\n\r\n      if (!body.bookmark_ids || !Array.isArray(body.bookmark_ids) || body.bookmark_ids.length === 0) {\r\n        return badRequest('bookmark_ids is required and must be a non-empty array')\r\n      }\r\n\r\n      // \r\n      const placeholders = body.bookmark_ids.map(() => '?').join(',')\r\n      const { results: bookmarks } = await context.env.DB.prepare(\r\n        `SELECT id FROM bookmarks \r\n         WHERE id IN (${placeholders}) \r\n         AND user_id = ? \r\n         AND is_pinned = 1 \r\n         AND deleted_at IS NULL`\r\n      )\r\n        .bind(...body.bookmark_ids, userId)\r\n        .all<{ id: string }>()\r\n\r\n      if (bookmarks.length !== body.bookmark_ids.length) {\r\n        return badRequest('Some bookmarks are not found, not pinned, or do not belong to you')\r\n      }\r\n\r\n      // \r\n      const now = new Date().toISOString()\r\n      const updates = body.bookmark_ids.map((id, index) => {\r\n        return context.env.DB.prepare(\r\n          'UPDATE bookmarks SET pin_order = ?, updated_at = ? WHERE id = ? AND user_id = ?'\r\n        ).bind(index, now, id, userId)\r\n      })\r\n\r\n      await context.env.DB.batch(updates)\r\n\r\n      return success({\r\n        message: 'Pinned bookmarks reordered successfully',\r\n        count: body.bookmark_ids.length,\r\n      })\r\n    } catch (error) {\r\n      console.error('Reorder pinned bookmarks error:', error)\r\n      return internalError('Failed to reorder pinned bookmarks')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/trash/empty.ts",
    "content": "/**\r\n\r\n * : /api/tab/bookmarks/trash/empty\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../lib/types'\r\nimport { success, internalError } from '../../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'\r\n\r\nexport const onRequestDelete: PagesFunction<Env, string, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.delete'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n\r\n    try {\r\n\r\n      const { results: trashBookmarks } = await context.env.DB.prepare(\r\n        'SELECT id FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL'\r\n      )\r\n        .bind(userId)\r\n        .all<{ id: string }>()\r\n\r\n      if (trashBookmarks.length === 0) {\r\n        return success({ message: 'Trash is already empty', count: 0 })\r\n      }\r\n\r\n      const bookmarkIds = trashBookmarks.map(b => b.id)\r\n\r\n      // \r\n      for (const id of bookmarkIds) {\r\n        await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ?')\r\n          .bind(id)\r\n          .run()\r\n      }\r\n\r\n      // \r\n      for (const id of bookmarkIds) {\r\n        await context.env.DB.prepare('DELETE FROM bookmark_snapshots WHERE bookmark_id = ?')\r\n          .bind(id)\r\n          .run()\r\n      }\r\n\r\n      // \r\n      await context.env.DB.prepare(\r\n        'DELETE FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL'\r\n      )\r\n        .bind(userId)\r\n        .run()\r\n\r\n      return success({\r\n        message: 'Trash emptied successfully',\r\n        count: bookmarkIds.length,\r\n      })\r\n    } catch (error) {\r\n      console.error('Empty trash error:', error)\r\n      return internalError('Failed to empty trash')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/bookmarks/trash.ts",
    "content": "/**\r\n\r\n * : /api/tab/bookmarks/trash\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, BookmarkRow } from '../../../lib/types'\r\nimport { success, internalError } from '../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'\r\nimport { normalizeBookmark } from '../../../lib/bookmark-utils'\r\n\r\ninterface TrashQueryParams {\r\n  page_size?: string\r\n  page_cursor?: string\r\n  sort?: string\r\n}\r\n\r\nexport const onRequestGet: PagesFunction<Env, string, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('bookmarks.read'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const url = new URL(context.request.url)\r\n    \r\n    const params: TrashQueryParams = {\r\n      page_size: url.searchParams.get('page_size') || undefined,\r\n      page_cursor: url.searchParams.get('page_cursor') || undefined,\r\n      sort: url.searchParams.get('sort') || undefined,\r\n    }\r\n\r\n    try {\r\n      const pageSize = Math.min(Math.max(parseInt(params.page_size || '20', 10) || 20, 1), 100)\r\n      const sort = params.sort === 'deleted_at_asc' ? 'ASC' : 'DESC'\r\n\r\n      let query = `\r\n        SELECT * FROM bookmarks \r\n        WHERE user_id = ? AND deleted_at IS NOT NULL\r\n      `\r\n      const queryParams: (string | number)[] = [userId]\r\n\r\n      // \r\n      if (params.page_cursor) {\r\n        query += ` AND deleted_at < ?`\r\n        queryParams.push(params.page_cursor)\r\n      }\r\n\r\n      query += ` ORDER BY deleted_at ${sort} LIMIT ?`\r\n      queryParams.push(pageSize + 1)\r\n\r\n      const { results: bookmarks } = await context.env.DB.prepare(query)\r\n        .bind(...queryParams)\r\n        .all<BookmarkRow>()\r\n\r\n      const hasMore = bookmarks.length > pageSize\r\n      const items = hasMore ? bookmarks.slice(0, pageSize) : bookmarks\r\n\r\n      const bookmarksWithTags = await Promise.all(\r\n        items.map(async (bookmark) => {\r\n          const { results: tags } = await context.env.DB.prepare(\r\n            `SELECT t.id, t.name, t.color\r\n             FROM tags t\r\n             INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n             WHERE bt.bookmark_id = ? AND t.deleted_at IS NULL`\r\n          )\r\n            .bind(bookmark.id)\r\n            .all<{ id: string; name: string; color: string | null }>()\r\n\r\n          return {\r\n            ...normalizeBookmark(bookmark),\r\n            tags: tags || [],\r\n          }\r\n        })\r\n      )\r\n\r\n      // \r\n      const countResult = await context.env.DB.prepare(\r\n        'SELECT COUNT(*) as count FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL'\r\n      )\r\n        .bind(userId)\r\n        .first<{ count: number }>()\r\n\r\n      return success({\r\n        bookmarks: bookmarksWithTags,\r\n        meta: {\r\n          total: countResult?.count || 0,\r\n          page_size: pageSize,\r\n          has_more: hasMore,\r\n          next_cursor: hasMore && items.length > 0 ? items[items.length - 1].deleted_at : null,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Get trash bookmarks error:', error)\r\n      return internalError('Failed to get trash bookmarks')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/me.ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/me\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../lib/types'\r\nimport { success, internalError } from '../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../middleware/api-key-auth-pages'\r\n\r\n// GET /api/me - \r\ntype BookmarkStats = {\r\n  total_bookmarks: number | null\r\n  pinned_bookmarks: number | null\r\n}\r\n\r\nexport const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('user.read'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n\r\n    try {\r\n      // \r\n      const user = await context.env.DB.prepare(\r\n        'SELECT id, username, email, created_at FROM users WHERE id = ?'\r\n      )\r\n        .bind(userId)\r\n        .first()\r\n\r\n      if (!user) {\r\n        return internalError('User not found')\r\n      }\r\n\r\n      // \r\n      const stats = await context.env.DB.prepare(\r\n        `SELECT\r\n          COUNT(CASE WHEN deleted_at IS NULL THEN 1 END) as total_bookmarks,\r\n          COUNT(CASE WHEN deleted_at IS NULL AND is_pinned = 1 THEN 1 END) as pinned_bookmarks\r\n        FROM bookmarks\r\n        WHERE user_id = ?`\r\n      )\r\n        .bind(userId)\r\n        .first<BookmarkStats>()\r\n\r\n      const tagCount = await context.env.DB.prepare(\r\n        'SELECT COUNT(*) as count FROM tags WHERE user_id = ? AND deleted_at IS NULL'\r\n      )\r\n        .bind(userId)\r\n        .first<{ count: number }>()\r\n\r\n      return success({\r\n        user: {\r\n          ...user,\r\n          stats: {\r\n            total_bookmarks: stats?.total_bookmarks ?? 0,\r\n            pinned_bookmarks: stats?.pinned_bookmarks ?? 0,\r\n            total_tags: tagCount?.count || 0,\r\n          },\r\n        },\r\n        api_key: {\r\n          id: context.data.api_key_id,\r\n          permissions: context.data.api_key_permissions,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Get user info error:', error)\r\n      return internalError('Failed to get user info')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/search.ts",
    "content": "/**\n * External API - Global Search\n * Path: /api/tab/search\n * Auth: API Key (X-API-Key header)\n */\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, Bookmark, RouteParams } from '../../lib/types'\nimport { success, badRequest, internalError } from '../../lib/response'\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../middleware/api-key-auth-pages'\n\n// GET /api/search - Global search for bookmarks and tags\ntype BookmarkWithTags = Bookmark & {\n  tags: Array<{ id: string; name: string; color: string | null }>\n}\n\nexport const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\n  requireApiKeyAuth('bookmarks.read'),\n  async (context) => {\n    const userId = context.data.user_id\n    const url = new URL(context.request.url)\n    const query = url.searchParams.get('q')\n\n    if (!query || query.trim().length === 0) {\n      return badRequest('Search query is required')\n    }\n\n    const searchTerm = `%${query.trim()}%`\n    const limit = Math.min(parseInt(url.searchParams.get('limit') || '20', 10) || 20, 100)\n\n    try {\n      // Search bookmarks\n      const { results: bookmarks } = await context.env.DB.prepare(\n        `SELECT b.*\n         FROM bookmarks b\n         WHERE b.user_id = ? AND b.deleted_at IS NULL\n         AND (b.title LIKE ? OR b.description LIKE ? OR b.url LIKE ?)\n         ORDER BY b.is_pinned DESC, b.updated_at DESC\n         LIMIT ?`\n      )\n        .bind(userId, searchTerm, searchTerm, searchTerm, limit)\n        .all<Bookmark>()\n\n      // Optimize: Use single query to get all bookmark tags\n      let bookmarksWithTags: BookmarkWithTags[] = (bookmarks || []).map(bookmark => ({\n        ...bookmark,\n        tags: [],\n      }))\n\n      if (bookmarksWithTags.length > 0) {\n        const bookmarkIds = bookmarksWithTags.map(b => b.id)\n\n        // Get all tags for bookmarks at once\n        const { results: allTags } = await context.env.DB.prepare(\n          `SELECT\n             bt.bookmark_id,\n             t.id,\n             t.name,\n             t.color\n           FROM tags t\n           INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\n           WHERE bt.bookmark_id IN (${bookmarkIds.map(() => '?').join(',')})\n             AND t.deleted_at IS NULL\n           ORDER BY bt.bookmark_id, t.name`\n        )\n          .bind(...bookmarkIds)\n          .all<{ bookmark_id: string; id: string; name: string; color: string | null }>()\n\n        // Group tags by bookmark ID\n        const tagsByBookmarkId = new Map<string, Array<{ id: string; name: string; color: string | null }>>()\n        for (const tag of allTags || []) {\n          if (!tagsByBookmarkId.has(tag.bookmark_id)) {\n            tagsByBookmarkId.set(tag.bookmark_id, [])\n          }\n          const tags = tagsByBookmarkId.get(tag.bookmark_id)\n          if (tags) {\n            tags.push({\n              id: tag.id,\n              name: tag.name,\n              color: tag.color,\n            })\n          }\n        }\n\n        // Assemble bookmarks with tags\n        bookmarksWithTags = bookmarksWithTags.map(bookmark => ({\n          ...bookmark,\n          tags: tagsByBookmarkId.get(bookmark.id) || [],\n        }))\n      }\n\n      // Search tags\n      const { results: tags } = await context.env.DB.prepare(\n        `SELECT\n          t.id,\n          t.name,\n          t.color,\n          t.created_at,\n          t.updated_at,\n          COUNT(bt.bookmark_id) as bookmark_count\n        FROM tags t\n        LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id AND bt.user_id = t.user_id\n        LEFT JOIN bookmarks b ON bt.bookmark_id = b.id AND b.deleted_at IS NULL\n        WHERE t.user_id = ? AND t.deleted_at IS NULL\n        AND t.name LIKE ?\n        GROUP BY t.id\n        ORDER BY t.name ASC\n        LIMIT ?`\n      )\n        .bind(userId, searchTerm, limit)\n        .all()\n\n      return success({\n        query,\n        results: {\n          bookmarks: bookmarksWithTags,\n          tags: tags || [],\n        },\n        meta: {\n          bookmark_count: bookmarksWithTags.length,\n          tag_count: (tags || []).length,\n        },\n      })\n    } catch (error) {\n      console.error('Search error:', error)\n      return internalError('Failed to search')\n    }\n  },\n]\n"
  },
  {
    "path": "tmarks/functions/api/tab/statistics/index.ts",
    "content": "\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env } from '../../../lib/types'\nimport { success, internalError } from '../../../lib/response'\nimport { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth'\n\ninterface DomainCount {\n  domain: string\n  count: number\n}\n\n// GET /api/tab/statistics - Retrieve tab statistics\nexport const onRequestGet: PagesFunction<Env, string, DualAuthContext>[] = [\n  requireDualAuth('tab_groups.read'),\n  async (context) => {\n    const userId = context.data.user_id\n    const url = new URL(context.request.url)\n    const days = parseInt(url.searchParams.get('days') || '30', 10) || 30\n\n    try {\n\n      const startDate = new Date()\n      startDate.setDate(startDate.getDate() - days)\n      const startDateStr = startDate.toISOString().split('T')[0]\n\n      const [\n        groupsResult,\n        deletedGroupsResult,\n        itemsResult,\n        sharesResult,\n        groupsTrend,\n        itemsTrend,\n        domains,\n        groupSizes\n      ] = await Promise.all([\n\n        context.env.DB.prepare(\n          'SELECT COUNT(*) as count FROM tab_groups WHERE user_id = ? AND is_deleted = 0'\n        )\n          .bind(userId)\n          .all<{ count: number }>(),\n\n        context.env.DB.prepare(\n          'SELECT COUNT(*) as count FROM tab_groups WHERE user_id = ? AND is_deleted = 1'\n        )\n          .bind(userId)\n          .all<{ count: number }>(),\n\n        context.env.DB.prepare(\n          'SELECT COUNT(*) as count FROM tab_group_items WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?)'\n        )\n          .bind(userId)\n          .all<{ count: number }>(),\n\n        context.env.DB.prepare(\n          'SELECT COUNT(*) as count FROM shares WHERE user_id = ?'\n        )\n          .bind(userId)\n          .all<{ count: number }>(),\n\n        context.env.DB.prepare(\n          `SELECT DATE(created_at) as date, COUNT(*) as count \n           FROM tab_groups \n           WHERE user_id = ? AND DATE(created_at) >= ? \n           GROUP BY DATE(created_at) \n           ORDER BY date ASC`\n        )\n          .bind(userId, startDateStr)\n          .all<{ date: string; count: number }>(),\n\n        context.env.DB.prepare(\n          `SELECT DATE(created_at) as date, COUNT(*) as count \n           FROM tab_group_items \n           WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?) \n           AND DATE(created_at) >= ? \n           GROUP BY DATE(created_at) \n           ORDER BY date ASC`\n        )\n          .bind(userId, startDateStr)\n          .all<{ date: string; count: number }>(),\n\n        context.env.DB.prepare(\n          `SELECT \n            CASE \n              WHEN url LIKE 'http://%' THEN SUBSTR(url, 8, INSTR(SUBSTR(url, 8), '/') - 1)\n              WHEN url LIKE 'https://%' THEN SUBSTR(url, 9, INSTR(SUBSTR(url, 9), '/') - 1)\n              ELSE url\n            END as domain,\n            COUNT(*) as count\n           FROM tab_group_items \n           WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?)\n           GROUP BY domain\n           ORDER BY count DESC\n           LIMIT 10`\n        )\n          .bind(userId)\n          .all<DomainCount>(),\n\n        context.env.DB.prepare(\n          `SELECT \n            CASE \n              WHEN item_count = 0 THEN '0'\n              WHEN item_count <= 5 THEN '1-5'\n              WHEN item_count <= 10 THEN '6-10'\n              WHEN item_count <= 20 THEN '11-20'\n              WHEN item_count <= 50 THEN '21-50'\n              ELSE '50+'\n            END as range,\n            COUNT(*) as count\n           FROM (\n             SELECT g.id, COUNT(i.id) as item_count\n             FROM tab_groups g\n             LEFT JOIN tab_group_items i ON g.id = i.group_id\n             WHERE g.user_id = ? AND g.is_deleted = 0\n             GROUP BY g.id\n           )\n           GROUP BY range\n           ORDER BY \n             CASE range\n               WHEN '0' THEN 1\n               WHEN '1-5' THEN 2\n               WHEN '6-10' THEN 3\n               WHEN '11-20' THEN 4\n               WHEN '21-50' THEN 5\n               ELSE 6\n             END`\n        )\n          .bind(userId)\n          .all<{ range: string; count: number }>()\n      ])\n\n      return success({\n        summary: {\n          total_groups: groupsResult.results?.[0]?.count || 0,\n          total_deleted_groups: deletedGroupsResult.results?.[0]?.count || 0,\n          total_items: itemsResult.results?.[0]?.count || 0,\n          total_shares: sharesResult.results?.[0]?.count || 0,\n        },\n        trends: {\n          groups: groupsTrend.results || [],\n          items: itemsTrend.results || [],\n        },\n        top_domains: domains.results || [],\n        group_size_distribution: groupSizes.results || [],\n      })\n    } catch (error) {\n      console.error('Get statistics error:', error)\n      return internalError('Failed to get statistics')\n    }\n  },\n]\n"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/[id]/items/batch.ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/tab-groups/:id/items/batch\r\n * : API Key (X-API-Key header)  JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../../../../lib/response'\r\nimport { requireDualAuth, DualAuthContext } from '../../../../../middleware/dual-auth'\r\nimport { sanitizeString } from '../../../../../lib/validation'\r\nimport { generateUUID } from '../../../../../lib/crypto'\r\n\r\ninterface TabGroupRow {\r\n  id: string\r\n  user_id: string\r\n  title: string\r\n}\r\n\r\ninterface BatchAddItemsRequest {\r\n  items: Array<{\r\n    title: string\r\n    url: string\r\n    favicon?: string\r\n  }>\r\n}\r\n\r\n// POST /api/tab/tab-groups/:id/items/batch - \r\nexport const onRequestPost: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\r\n  requireDualAuth('tab_groups.update'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      const body = (await context.request.json()) as BatchAddItemsRequest\r\n\r\n      if (!body.items || !Array.isArray(body.items) || body.items.length === 0) {\r\n        return badRequest('items array is required and must not be empty')\r\n      }\r\n\r\n      // \r\n      const group = await context.env.DB.prepare(\r\n        'SELECT id, user_id, title FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!group) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      //  position\r\n      const maxPositionResult = await context.env.DB.prepare(\r\n        'SELECT MAX(position) as max_position FROM tab_group_items WHERE group_id = ?'\r\n      )\r\n        .bind(groupId)\r\n        .first<{ max_position: number | null }>()\r\n\r\n      let currentPosition = (maxPositionResult?.max_position ?? -1) + 1\r\n\r\n      // \r\n      const timestamp = new Date().toISOString()\r\n      const insertPromises = body.items.map((item) => {\r\n        const itemId = generateUUID()\r\n        const itemTitle = sanitizeString(item.title, 500)\r\n        const itemUrl = sanitizeString(item.url, 2000)\r\n        const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null\r\n\r\n        const promise = context.env.DB.prepare(\r\n          'INSERT INTO tab_group_items (id, group_id, title, url, favicon, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'\r\n        )\r\n          .bind(itemId, groupId, itemTitle, itemUrl, favicon, currentPosition, timestamp)\r\n          .run()\r\n\r\n        currentPosition++\r\n        return promise\r\n      })\r\n\r\n      await Promise.all(insertPromises)\r\n\r\n      //  (with user_id verification for security)\r\n      const { results: items } = await context.env.DB.prepare(\r\n        `SELECT tgi.*\r\n         FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.group_id = ? AND tg.user_id = ?\r\n         ORDER BY tgi.position ASC`\r\n      )\r\n        .bind(groupId, userId)\r\n        .all()\r\n\r\n      return success({\r\n        message: `Successfully added ${body.items.length} items`,\r\n        added_count: body.items.length,\r\n        total_items: items?.length || 0,\r\n        items: items || [],\r\n      })\r\n    } catch (error) {\r\n      console.error('Batch add items error:', error)\r\n      return internalError('Failed to batch add items')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/[id]/permanent-delete.ts",
    "content": "/**\n *  API\n * : /api/tab/tab-groups/:id/permanent-delete\n * : API Key (X-API-Key header)  JWT Token (Bearer)\n */\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams } from '../../../../lib/types'\nimport { notFound, internalError } from '../../../../lib/response'\nimport { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth'\n\ninterface TabGroupRow {\n  id: string\n  user_id: string\n  is_deleted: number\n}\n\n// DELETE /api/tab/tab-groups/:id/permanent-delete - \nexport const onRequestDelete: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\n  requireDualAuth('tab_groups.delete'),\n  async (context) => {\n    const userId = context.data.user_id\n    const groupId = context.params.id\n\n    try {\n      // Check if tab group exists and is deleted\n      const groupRow = await context.env.DB.prepare(\n        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND is_deleted = 1'\n      )\n        .bind(groupId, userId)\n        .first<TabGroupRow>()\n\n      if (!groupRow) {\n        return notFound('Tab group not found in trash')\n      }\n\n      // Delete tab group items first\n      await context.env.DB.prepare('DELETE FROM tab_group_items WHERE group_id = ?')\n        .bind(groupId)\n        .run()\n\n      // Delete shares\n      await context.env.DB.prepare('DELETE FROM shares WHERE group_id = ?')\n        .bind(groupId)\n        .run()\n\n      // Permanently delete tab group\n      await context.env.DB.prepare('DELETE FROM tab_groups WHERE id = ?')\n        .bind(groupId)\n        .run()\n\n      return new Response(null, { status: 204 })\n    } catch (error) {\n      console.error('Permanent delete tab group error:', error)\n      return internalError('Failed to permanently delete tab group')\n    }\n  },\n]\n\n"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/[id]/restore.ts",
    "content": "/**\n *  API\n * : /api/tab/tab-groups/:id/restore\n * : API Key (X-API-Key header)  JWT Token (Bearer)\n */\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams } from '../../../../lib/types'\nimport { success, notFound, internalError } from '../../../../lib/response'\nimport { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth'\n\ninterface TabGroupRow {\n  id: string\n  user_id: string\n  is_deleted: number\n}\n\n// POST /api/tab/tab-groups/:id/restore - \nexport const onRequestPost: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\n  requireDualAuth('tab_groups.update'),\n  async (context) => {\n    const userId = context.data.user_id\n    const groupId = context.params.id\n\n    try {\n      // Check if tab group exists and is deleted\n      const groupRow = await context.env.DB.prepare(\n        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND is_deleted = 1'\n      )\n        .bind(groupId, userId)\n        .first<TabGroupRow>()\n\n      if (!groupRow) {\n        return notFound('Tab group not found in trash')\n      }\n\n      // Restore tab group\n      await context.env.DB.prepare(\n        'UPDATE tab_groups SET is_deleted = 0, deleted_at = NULL, updated_at = ? WHERE id = ?'\n      )\n        .bind(new Date().toISOString(), groupId)\n        .run()\n\n      return success({ message: 'Tab group restored successfully' })\n    } catch (error) {\n      console.error('Restore tab group error:', error)\n      return internalError('Failed to restore tab group')\n    }\n  },\n]\n\n"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/[id]/share.ts",
    "content": "/**\n *  API\n * : /api/tab/tab-groups/:id/share\n * : API Key (X-API-Key header)  JWT Token (Bearer)\n */\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams } from '../../../../lib/types'\nimport { success, notFound, internalError } from '../../../../lib/response'\nimport { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth'\nimport { generateUUID } from '../../../../lib/crypto'\n\ninterface TabGroupRow {\n  id: string\n  user_id: string\n  is_deleted: number\n}\n\ninterface ShareRow {\n  id: string\n  group_id: string\n  user_id: string\n  share_token: string\n  is_public: number\n  view_count: number\n  created_at: string\n  expires_at: string | null\n}\n\ninterface CreateShareRequest {\n  is_public?: boolean\n  expires_in_days?: number\n}\n\n// POST /api/tab/tab-groups/:id/share - \nexport const onRequestPost: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\n  requireDualAuth('tab_groups.update'),\n  async (context) => {\n    const userId = context.data.user_id\n    const groupId = context.params.id\n\n    try {\n      const body = (await context.request.json().catch(() => ({}))) as CreateShareRequest\n\n      // Check if tab group exists and belongs to user\n      const groupRow = await context.env.DB.prepare(\n        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND is_deleted = 0'\n      )\n        .bind(groupId, userId)\n        .first<TabGroupRow>()\n\n      if (!groupRow) {\n        return notFound('Tab group not found')\n      }\n\n      // Check if share already exists\n      const existingShare = await context.env.DB.prepare(\n        'SELECT * FROM shares WHERE group_id = ? AND user_id = ?'\n      )\n        .bind(groupId, userId)\n        .first<ShareRow>()\n\n      if (existingShare) {\n        return success({\n          share: existingShare,\n          share_url: `${new URL(context.request.url).origin}/share/${existingShare.share_token}`,\n        })\n      }\n\n      // Generate share token\n      const shareToken = generateUUID().replace(/-/g, '').substring(0, 16)\n      const shareId = generateUUID()\n      const now = new Date().toISOString()\n      const isPublic = body.is_public !== false ? 1 : 0\n\n      let expiresAt: string | null = null\n      if (body.expires_in_days && body.expires_in_days > 0) {\n        const expiresDate = new Date()\n        expiresDate.setDate(expiresDate.getDate() + body.expires_in_days)\n        expiresAt = expiresDate.toISOString()\n      }\n\n      // Create share\n      await context.env.DB.prepare(\n        'INSERT INTO shares (id, group_id, user_id, share_token, is_public, view_count, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'\n      )\n        .bind(shareId, groupId, userId, shareToken, isPublic, 0, now, expiresAt)\n        .run()\n\n      const share = {\n        id: shareId,\n        group_id: groupId,\n        user_id: userId,\n        share_token: shareToken,\n        is_public: isPublic,\n        view_count: 0,\n        created_at: now,\n        expires_at: expiresAt,\n      }\n\n      return success({\n        share,\n        share_url: `${new URL(context.request.url).origin}/share/${shareToken}`,\n      })\n    } catch (error) {\n      console.error('Create share error:', error)\n      return internalError('Failed to create share')\n    }\n  },\n]\n\n// GET /api/tab/tab-groups/:id/share - \nexport const onRequestGet: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\n  requireDualAuth('tab_groups.read'),\n  async (context) => {\n    const userId = context.data.user_id\n    const groupId = context.params.id\n\n    try {\n      // Get share\n      const share = await context.env.DB.prepare(\n        'SELECT * FROM shares WHERE group_id = ? AND user_id = ?'\n      )\n        .bind(groupId, userId)\n        .first<ShareRow>()\n\n      if (!share) {\n        return notFound('Share not found')\n      }\n\n      return success({\n        share,\n        share_url: `${new URL(context.request.url).origin}/share/${share.share_token}`,\n      })\n    } catch (error) {\n      console.error('Get share error:', error)\n      return internalError('Failed to get share')\n    }\n  },\n]\n\n// DELETE /api/tab/tab-groups/:id/share - \nexport const onRequestDelete: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\n  requireDualAuth('tab_groups.delete'),\n  async (context) => {\n    const userId = context.data.user_id\n    const groupId = context.params.id\n\n    try {\n      // Delete share\n      await context.env.DB.prepare('DELETE FROM shares WHERE group_id = ? AND user_id = ?')\n        .bind(groupId, userId)\n        .run()\n\n      return new Response(null, { status: 204 })\n    } catch (error) {\n      console.error('Delete share error:', error)\n      return internalError('Failed to delete share')\n    }\n  },\n]\n\n"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/[id].ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/tab-groups/:id\r\n * : API Key (X-API-Key header)  JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../../lib/response'\r\nimport { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth'\r\nimport { sanitizeString } from '../../../lib/validation'\r\n\r\ninterface TabGroupRow {\r\n  id: string\r\n  user_id: string\r\n  title: string\r\n  color: string | null\r\n  tags: string | null\r\n  parent_id: string | null\r\n  is_folder: number\r\n  is_deleted: number\r\n  deleted_at: string | null\r\n  position: number\r\n  created_at: string\r\n  updated_at: string\r\n}\r\n\r\ninterface TabGroupItemRow {\r\n  id: string\r\n  group_id: string\r\n  title: string\r\n  url: string\r\n  favicon: string | null\r\n  position: number\r\n  created_at: string\r\n}\r\n\r\ninterface UpdateTabGroupRequest {\r\n  title?: string\r\n  color?: string | null\r\n  tags?: string[] | null\r\n  parent_id?: string | null\r\n  position?: number\r\n}\r\n\r\n// GET /api/tab/tab-groups/:id - \r\nexport const onRequestGet: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\r\n  requireDualAuth('tab_groups.read'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      // Get tab group (exclude deleted by default)\r\n      let groupRow: TabGroupRow | null = null\r\n      try {\r\n        groupRow = await context.env.DB.prepare(\r\n          'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND (is_deleted IS NULL OR is_deleted = 0)'\r\n        )\r\n          .bind(groupId, userId)\r\n          .first<TabGroupRow>()\r\n      } catch {\r\n        // Fallback: query without is_deleted column\r\n        groupRow = await context.env.DB.prepare(\r\n          'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?'\r\n        )\r\n          .bind(groupId, userId)\r\n          .first<TabGroupRow>()\r\n      }\r\n\r\n      if (!groupRow) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      // Parse tags if exists\r\n      let tags: string[] | null = null\r\n      if (groupRow.tags) {\r\n        try {\r\n          tags = JSON.parse(groupRow.tags)\r\n        } catch {\r\n          tags = null\r\n        }\r\n      }\r\n\r\n      // Get tab group items (with user_id verification for security)\r\n      const { results: items } = await context.env.DB.prepare(\r\n        `SELECT tgi.*\r\n         FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.group_id = ? AND tg.user_id = ?\r\n         ORDER BY COALESCE(tgi.is_pinned, 0) DESC, tgi.position ASC`\r\n      )\r\n        .bind(groupId, userId)\r\n        .all<TabGroupItemRow>()\r\n\r\n      return success({\r\n        tab_group: {\r\n          ...groupRow,\r\n          tags,\r\n          items: items || [],\r\n          item_count: items?.length || 0,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Get tab group error:', error)\r\n      return internalError('Failed to get tab group')\r\n    }\r\n  },\r\n]\r\n\r\n// PATCH /api/tab/tab-groups/:id - \r\nexport const onRequestPatch: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\r\n  requireDualAuth('tab_groups.update'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      let body: UpdateTabGroupRequest\r\n      try {\r\n        body = (await context.request.json()) as UpdateTabGroupRequest\r\n      } catch (parseError) {\r\n        console.error('Failed to parse request body:', parseError)\r\n        return badRequest('Invalid request body: ' + (parseError instanceof Error ? parseError.message : 'JSON parse error'))\r\n      }\r\n\r\n      // Check if tab group exists and belongs to user\r\n      const groupRow = await context.env.DB.prepare(\r\n        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!groupRow) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      // Update tab group\r\n      const updates: string[] = []\r\n      const params: (string | number | null)[] = []\r\n\r\n      if (body.title !== undefined) {\r\n        updates.push('title = ?')\r\n        params.push(sanitizeString(body.title, 200))\r\n      }\r\n\r\n      // Only add color/tags if they exist in the request\r\n      // Try to update, if column doesn't exist, skip silently\r\n      let hasColorOrTags = false\r\n      if (body.color !== undefined) {\r\n        updates.push('color = ?')\r\n        params.push(body.color)\r\n        hasColorOrTags = true\r\n      }\r\n\r\n      if (body.tags !== undefined) {\r\n        updates.push('tags = ?')\r\n        params.push(body.tags ? JSON.stringify(body.tags) : null)\r\n        hasColorOrTags = true\r\n      }\r\n\r\n      if (body.parent_id !== undefined) {\r\n        updates.push('parent_id = ?')\r\n        params.push(body.parent_id)\r\n      }\r\n\r\n      if (body.position !== undefined) {\r\n        updates.push('position = ?')\r\n        params.push(body.position)\r\n      }\r\n\r\n      if (updates.length === 0) {\r\n        return badRequest('No fields to update')\r\n      }\r\n\r\n      updates.push('updated_at = ?')\r\n      params.push(new Date().toISOString())\r\n      params.push(groupId)\r\n\r\n      // Add user_id to WHERE clause params\r\n      params.push(userId)\r\n\r\n      try {\r\n        await context.env.DB.prepare(\r\n          `UPDATE tab_groups SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`\r\n        )\r\n          .bind(...params)\r\n          .run()\r\n      } catch (e) {\r\n        // If update fails (likely due to missing columns), try without color/tags\r\n        if (hasColorOrTags && body.title !== undefined) {\r\n          await context.env.DB.prepare(\r\n            'UPDATE tab_groups SET title = ?, updated_at = ? WHERE id = ? AND user_id = ?'\r\n          )\r\n            .bind(sanitizeString(body.title, 200), new Date().toISOString(), groupId, userId)\r\n            .run()\r\n        } else {\r\n          throw e\r\n        }\r\n      }\r\n\r\n      // Get updated tab group with items\r\n      const updatedGroup = await context.env.DB.prepare(\r\n        'SELECT * FROM tab_groups WHERE id = ?'\r\n      )\r\n        .bind(groupId)\r\n        .first<TabGroupRow>()\r\n\r\n      const { results: items } = await context.env.DB.prepare(\r\n        `SELECT tgi.*\r\n         FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.group_id = ? AND tg.user_id = ?\r\n         ORDER BY tgi.position ASC`\r\n      )\r\n        .bind(groupId, userId)\r\n        .all<TabGroupItemRow>()\r\n\r\n      if (!updatedGroup) {\r\n        return internalError('Failed to load tab group after update')\r\n      }\r\n\r\n      return success({\r\n        tab_group: {\r\n          ...updatedGroup,\r\n          items: items || [],\r\n          item_count: items?.length || 0,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Update tab group error:', error)\r\n      return internalError('Failed to update tab group')\r\n    }\r\n  },\r\n]\r\n\r\n// DELETE /api/tab/tab-groups/:id - （）\r\nexport const onRequestDelete: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\r\n  requireDualAuth('tab_groups.delete'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      // Check if tab group exists and belongs to user\r\n      let groupRow: TabGroupRow | null = null\r\n      try {\r\n        groupRow = await context.env.DB.prepare(\r\n          'SELECT * FROM tab_groups WHERE id = ? AND user_id = ? AND (is_deleted IS NULL OR is_deleted = 0)'\r\n        )\r\n          .bind(groupId, userId)\r\n          .first<TabGroupRow>()\r\n      } catch {\r\n        // Fallback: query without is_deleted column\r\n        groupRow = await context.env.DB.prepare(\r\n          'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?'\r\n        )\r\n          .bind(groupId, userId)\r\n          .first<TabGroupRow>()\r\n      }\r\n\r\n      if (!groupRow) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      // Soft delete - mark as deleted (only if column exists)\r\n      try {\r\n        await context.env.DB.prepare(\r\n          'UPDATE tab_groups SET is_deleted = 1, deleted_at = ?, updated_at = ? WHERE id = ?'\r\n        )\r\n          .bind(new Date().toISOString(), new Date().toISOString(), groupId)\r\n          .run()\r\n      } catch {\r\n        // If is_deleted column doesn't exist, do hard delete\r\n        await context.env.DB.prepare('DELETE FROM tab_groups WHERE id = ?')\r\n          .bind(groupId)\r\n          .run()\r\n      }\r\n\r\n      return new Response(null, { status: 204 })\r\n    } catch (error) {\r\n      console.error('Delete tab group error:', error)\r\n      return internalError('Failed to delete tab group')\r\n    }\r\n  },\r\n]\r\n\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/index.ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/tab-groups\r\n\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams, SQLParam } from '../../../lib/types'\r\nimport { success, created, internalError } from '../../../lib/response'\r\nimport { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth'\r\nimport { sanitizeString } from '../../../lib/validation'\r\nimport { generateUUID } from '../../../lib/crypto'\r\n\r\ninterface TabGroupRow {\r\n  id: string\r\n  user_id: string\r\n  title: string\r\n  color: string | null\r\n  tags: string | null\r\n  parent_id: string | null\r\n  is_folder: number\r\n  is_deleted: number\r\n  deleted_at: string | null\r\n  position: number\r\n  created_at: string\r\n  updated_at: string\r\n}\r\n\r\ninterface TabGroupItemRow {\r\n  id: string\r\n  group_id: string\r\n  title: string\r\n  url: string\r\n  favicon: string | null\r\n  position: number\r\n  created_at: string\r\n}\r\n\r\ninterface CreateTabGroupRequest {\r\n  title?: string\r\n  parent_id?: string | null\r\n  is_folder?: boolean\r\n  items?: Array<{\r\n    title: string\r\n    url: string\r\n    favicon?: string\r\n  }>\r\n}\r\n\r\nfunction parseTags(group: TabGroupRow): string[] | null {\r\n  if (!group.tags) return null\r\n  try {\r\n    return JSON.parse(group.tags)\r\n  } catch {\r\n    return null\r\n  }\r\n}\r\n\r\n// GET /api/tab/tab-groups\r\nexport const onRequestGet: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\r\n  requireDualAuth('tab_groups.read'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const url = new URL(context.request.url)\r\n\r\n    const pageSize = Math.min(parseInt(url.searchParams.get('page_size') || '30', 10) || 30, 100)\r\n    const pageCursor = url.searchParams.get('page_cursor') || ''\r\n\r\n    try {\r\n      let groups: TabGroupRow[] = []\r\n      try {\r\n        let query = `\r\n          SELECT *\r\n          FROM tab_groups\r\n          WHERE user_id = ? AND (is_deleted IS NULL OR is_deleted = 0)\r\n        `\r\n        const params: SQLParam[] = [userId]\r\n\r\n        if (pageCursor) {\r\n          query += ` AND created_at < ?`\r\n          params.push(pageCursor)\r\n        }\r\n\r\n        query += ` ORDER BY created_at DESC LIMIT ?`\r\n        params.push(pageSize + 1)\r\n\r\n        const result = await context.env.DB.prepare(query)\r\n          .bind(...params)\r\n          .all<TabGroupRow>()\r\n        groups = result.results\r\n      } catch {\r\n        let query = `\r\n          SELECT *\r\n          FROM tab_groups\r\n          WHERE user_id = ?\r\n        `\r\n        const params: SQLParam[] = [userId]\r\n\r\n        if (pageCursor) {\r\n          query += ` AND created_at < ?`\r\n          params.push(pageCursor)\r\n        }\r\n\r\n        query += ` ORDER BY created_at DESC LIMIT ?`\r\n        params.push(pageSize + 1)\r\n\r\n        const result = await context.env.DB.prepare(query)\r\n          .bind(...params)\r\n          .all<TabGroupRow>()\r\n        groups = result.results\r\n      }\r\n\r\n      const hasMore = groups.length > pageSize\r\n      const tabGroups = hasMore ? groups.slice(0, pageSize) : groups\r\n      const nextCursor = hasMore ? tabGroups[tabGroups.length - 1].created_at : undefined\r\n\r\n      // Batch fetch all items (avoids N+1)\r\n      const groupIds = tabGroups.map((g) => g.id)\r\n      let allItems: TabGroupItemRow[] = []\r\n\r\n      if (groupIds.length > 0) {\r\n        const placeholders = groupIds.map(() => '?').join(',')\r\n        const { results: items } = await context.env.DB.prepare(\r\n          `SELECT tgi.*\r\n           FROM tab_group_items tgi\r\n           JOIN tab_groups tg ON tgi.group_id = tg.id\r\n           WHERE tgi.group_id IN (${placeholders}) AND tg.user_id = ?\r\n           ORDER BY COALESCE(tgi.is_pinned, 0) DESC, tgi.position ASC`\r\n        )\r\n          .bind(...groupIds, userId)\r\n          .all<TabGroupItemRow>()\r\n        allItems = items || []\r\n      }\r\n\r\n      const itemsByGroup = new Map<string, TabGroupItemRow[]>()\r\n      for (const item of allItems) {\r\n        const arr = itemsByGroup.get(item.group_id) || []\r\n        arr.push(item)\r\n        itemsByGroup.set(item.group_id, arr)\r\n      }\r\n\r\n      const groupsWithItems = tabGroups.map((group) => {\r\n        const items = itemsByGroup.get(group.id) || []\r\n        return {\r\n          ...group,\r\n          tags: parseTags(group),\r\n          items,\r\n          item_count: items.length,\r\n        }\r\n      })\r\n\r\n      return success({\r\n        tab_groups: groupsWithItems,\r\n        meta: {\r\n          page_size: pageSize,\r\n          next_cursor: nextCursor,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Get tab groups error:', error)\r\n      return internalError('Failed to get tab groups')\r\n    }\r\n  },\r\n]\r\n\r\n// POST /api/tab/tab-groups\r\nexport const onRequestPost: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\r\n  requireDualAuth('tab_groups.create'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n\r\n    try {\r\n      const body = (await context.request.json()) as CreateTabGroupRequest\r\n\r\n      const isFolder = body.is_folder || false\r\n\r\n      const now = new Date()\r\n      const defaultTitle = body.title || (isFolder ? '' : now.toLocaleString('zh-CN', {\r\n        year: 'numeric',\r\n        month: '2-digit',\r\n        day: '2-digit',\r\n        hour: '2-digit',\r\n        minute: '2-digit',\r\n        hour12: false,\r\n      }).replace(/\\//g, '-'))\r\n\r\n      const title = sanitizeString(defaultTitle, 200)\r\n      const groupId = generateUUID()\r\n      const timestamp = now.toISOString()\r\n      const parentId = body.parent_id || null\r\n\r\n      // Atomic batch: group + all items\r\n      const stmts = [\r\n        context.env.DB.prepare(\r\n          'INSERT INTO tab_groups (id, user_id, title, parent_id, is_folder, is_deleted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)'\r\n        ).bind(groupId, userId, title, parentId, isFolder ? 1 : 0, timestamp, timestamp),\r\n      ]\r\n\r\n      if (!isFolder && body.items && body.items.length > 0) {\r\n        for (let i = 0; i < body.items.length; i++) {\r\n          const item = body.items[i]\r\n          const itemId = generateUUID()\r\n          const itemTitle = sanitizeString(item.title, 500)\r\n          const itemUrl = sanitizeString(item.url, 2000)\r\n          const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null\r\n\r\n          stmts.push(\r\n            context.env.DB.prepare(\r\n              'INSERT INTO tab_group_items (id, group_id, title, url, favicon, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'\r\n            ).bind(itemId, groupId, itemTitle, itemUrl, favicon, i, timestamp)\r\n          )\r\n        }\r\n      }\r\n\r\n      await context.env.DB.batch(stmts)\r\n\r\n      const groupRow = await context.env.DB.prepare(\r\n        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!groupRow) {\r\n        return internalError('Failed to load tab group after creation')\r\n      }\r\n\r\n      const { results: items } = await context.env.DB.prepare(\r\n        `SELECT tgi.*\r\n         FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.group_id = ? AND tg.user_id = ?\r\n         ORDER BY tgi.position ASC`\r\n      )\r\n        .bind(groupId, userId)\r\n        .all<TabGroupItemRow>()\r\n\r\n      return created({\r\n        tab_group: {\r\n          ...groupRow,\r\n          items: items || [],\r\n          item_count: items?.length || 0,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Create tab group error:', error)\r\n      return internalError('Failed to create tab group')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/items/[id]/move.ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/tab-groups/items/:id/move\r\n * : API Key (X-API-Key header)  JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../../../../lib/response'\r\nimport { requireDualAuth, DualAuthContext } from '../../../../../middleware/dual-auth'\r\n\r\ninterface TabGroupItemRow {\r\n  id: string\r\n  group_id: string\r\n  title: string\r\n  url: string\r\n  favicon: string | null\r\n  position: number\r\n  created_at: string\r\n  is_pinned?: number\r\n  is_todo?: number\r\n}\r\n\r\ninterface MoveItemRequest {\r\n  target_group_id: string\r\n  position?: number\r\n}\r\n\r\n// POST /api/tab/tab-groups/items/:id/move - \r\nexport const onRequestPost: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\r\n  requireDualAuth('tab_groups.update'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const itemId = context.params.id\r\n\r\n    try {\r\n      const body = (await context.request.json()) as MoveItemRequest\r\n\r\n      if (!body.target_group_id) {\r\n        return badRequest('target_group_id is required')\r\n      }\r\n\r\n      // 1. \r\n      const item = await context.env.DB.prepare(\r\n        `SELECT tgi.*, tg.user_id \r\n         FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.id = ?`\r\n      )\r\n        .bind(itemId)\r\n        .first<TabGroupItemRow & { user_id: string }>()\r\n\r\n      if (!item) {\r\n        return notFound('Tab group item not found')\r\n      }\r\n\r\n      if (item.user_id !== userId) {\r\n        return notFound('Tab group item not found')\r\n      }\r\n\r\n      // 2. \r\n      const targetGroup = await context.env.DB.prepare(\r\n        'SELECT id, user_id FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(body.target_group_id, userId)\r\n        .first<{ id: string; user_id: string }>()\r\n\r\n      if (!targetGroup) {\r\n        return badRequest('Target group not found or access denied')\r\n      }\r\n\r\n      // 3. ，\r\n      if (item.group_id === body.target_group_id) {\r\n        if (body.position !== undefined) {\r\n          // \r\n          await context.env.DB.prepare(\r\n            'UPDATE tab_group_items SET position = ? WHERE id = ?'\r\n          )\r\n            .bind(body.position, itemId)\r\n            .run()\r\n\r\n          // \r\n          await context.env.DB.prepare(\r\n            `UPDATE tab_group_items \r\n             SET position = position + 1 \r\n             WHERE group_id = ? AND id != ? AND position >= ?`\r\n          )\r\n            .bind(item.group_id, itemId, body.position)\r\n            .run()\r\n        }\r\n      } else {\r\n        // 4. \r\n\r\n        // 4.1 \r\n        const maxPositionResult = await context.env.DB.prepare(\r\n          'SELECT MAX(position) as max_position FROM tab_group_items WHERE group_id = ?'\r\n        )\r\n          .bind(body.target_group_id)\r\n          .first<{ max_position: number | null }>()\r\n\r\n        const targetPosition =\r\n          body.position !== undefined\r\n            ? body.position\r\n            : (maxPositionResult?.max_position ?? -1) + 1\r\n\r\n        // 4.2 \r\n        await context.env.DB.prepare(\r\n          `UPDATE tab_group_items \r\n           SET group_id = ?, position = ? \r\n           WHERE id = ?`\r\n        )\r\n          .bind(body.target_group_id, targetPosition, itemId)\r\n          .run()\r\n\r\n        // 4.3 （）\r\n        await context.env.DB.prepare(\r\n          `UPDATE tab_group_items \r\n           SET position = position - 1 \r\n           WHERE group_id = ? AND position > ?`\r\n        )\r\n          .bind(item.group_id, item.position)\r\n          .run()\r\n\r\n        // 4.4 （）\r\n        if (body.position !== undefined) {\r\n          await context.env.DB.prepare(\r\n            `UPDATE tab_group_items \r\n             SET position = position + 1 \r\n             WHERE group_id = ? AND id != ? AND position >= ?`\r\n          )\r\n            .bind(body.target_group_id, itemId, targetPosition)\r\n            .run()\r\n        }\r\n      }\r\n\r\n      // 5. \r\n      const updatedItem = await context.env.DB.prepare(\r\n        'SELECT * FROM tab_group_items WHERE id = ?'\r\n      )\r\n        .bind(itemId)\r\n        .first<TabGroupItemRow>()\r\n\r\n      if (!updatedItem) {\r\n        return internalError('Failed to load item after move')\r\n      }\r\n\r\n      return success({\r\n        item: updatedItem,\r\n        message: 'Item moved successfully',\r\n      })\r\n    } catch (error) {\r\n      console.error('Move tab group item error:', error)\r\n      return internalError('Failed to move tab group item')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/items/[id].ts",
    "content": "/**\n *  API - \n * : /api/tab/tab-groups/items/:id\n * : API Key (X-API-Key header)  JWT Token (Bearer)\n */\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams } from '../../../../lib/types'\nimport { success, badRequest, notFound, internalError } from '../../../../lib/response'\nimport { requireDualAuth, DualAuthContext } from '../../../../middleware/dual-auth'\nimport { sanitizeString } from '../../../../lib/validation'\n\ninterface TabGroupItemRow {\n  id: string\n  group_id: string\n  title: string\n  url: string\n  favicon: string | null\n  position: number\n  created_at: string\n  is_pinned?: number\n  is_todo?: number\n  is_archived?: number\n}\n\ninterface UpdateTabGroupItemRequest {\n  title?: string\n  is_pinned?: boolean\n  is_todo?: boolean\n  is_archived?: boolean\n  position?: number\n}\n\n// PATCH /api/tab/tab-groups/items/:id - \nexport const onRequestPatch: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\n  requireDualAuth('tab_groups.update'),\n  async (context) => {\n    const userId = context.data.user_id\n    const itemId = context.params.id\n\n    try {\n      const body = (await context.request.json()) as UpdateTabGroupItemRequest\n\n      // Check if item exists and user has permission\n      const item = await context.env.DB.prepare(\n        `SELECT tgi.*, tg.user_id \n         FROM tab_group_items tgi\n         JOIN tab_groups tg ON tgi.group_id = tg.id\n         WHERE tgi.id = ?`\n      )\n        .bind(itemId)\n        .first<TabGroupItemRow & { user_id: string }>()\n\n      if (!item) {\n        return notFound('Tab group item not found')\n      }\n\n      if (item.user_id !== userId) {\n        return notFound('Tab group item not found')\n      }\n\n      // Build update query\n      const updates: string[] = []\n      const params: (string | number)[] = []\n\n      if (body.title !== undefined) {\n        updates.push('title = ?')\n        params.push(sanitizeString(body.title, 500))\n      }\n\n      if (body.is_pinned !== undefined) {\n        updates.push('is_pinned = ?')\n        params.push(body.is_pinned ? 1 : 0)\n        \n        // If pinning, set position to 0 and shift others\n        if (body.is_pinned) {\n          await context.env.DB.prepare(\n            'UPDATE tab_group_items SET position = position + 1 WHERE group_id = ? AND id != ?'\n          )\n            .bind(item.group_id, itemId)\n            .run()\n          \n          updates.push('position = ?')\n          params.push(0)\n        }\n      }\n\n      if (body.is_todo !== undefined) {\n        updates.push('is_todo = ?')\n        params.push(body.is_todo ? 1 : 0)\n      }\n\n      if (body.is_archived !== undefined) {\n        updates.push('is_archived = ?')\n        params.push(body.is_archived ? 1 : 0)\n      }\n\n      if (body.position !== undefined) {\n        updates.push('position = ?')\n        params.push(body.position)\n      }\n\n      if (updates.length === 0) {\n        return badRequest('No fields to update')\n      }\n\n      params.push(itemId, item.group_id, userId)\n\n      await context.env.DB.prepare(\n        `UPDATE tab_group_items SET ${updates.join(', ')} WHERE id = ? AND group_id IN (SELECT id FROM tab_groups WHERE id = ? AND user_id = ?)`\n      )\n        .bind(...params)\n        .run()\n\n      // Get updated item\n      const updatedItem = await context.env.DB.prepare(\n        `SELECT tgi.* FROM tab_group_items tgi\n         JOIN tab_groups tg ON tgi.group_id = tg.id\n         WHERE tgi.id = ? AND tg.user_id = ?`\n      )\n        .bind(itemId, userId)\n        .first<TabGroupItemRow>()\n\n      if (!updatedItem) {\n        return internalError('Failed to load item after update')\n      }\n\n      return success({\n        item: updatedItem,\n      })\n    } catch (error) {\n      console.error('Update tab group item error:', error)\n      return internalError('Failed to update tab group item')\n    }\n  },\n]\n\n// DELETE /api/tab/tab-groups/items/:id - \nexport const onRequestDelete: PagesFunction<Env, RouteParams, DualAuthContext>[] = [\n  requireDualAuth('tab_groups.delete'),\n  async (context) => {\n    const userId = context.data.user_id\n    const itemId = context.params.id\n\n    try {\n      // Check if item exists and user has permission\n      const item = await context.env.DB.prepare(\n        `SELECT tgi.*, tg.user_id \n         FROM tab_group_items tgi\n         JOIN tab_groups tg ON tgi.group_id = tg.id\n         WHERE tgi.id = ?`\n      )\n        .bind(itemId)\n        .first<TabGroupItemRow & { user_id: string }>()\n\n      if (!item) {\n        return notFound('Tab group item not found')\n      }\n\n      if (item.user_id !== userId) {\n        return notFound('Tab group item not found')\n      }\n\n      // Delete item with ownership check\n      await context.env.DB.prepare(\n        'DELETE FROM tab_group_items WHERE id = ? AND group_id IN (SELECT id FROM tab_groups WHERE id = ? AND user_id = ?)'\n      )\n        .bind(itemId, item.group_id, userId)\n        .run()\n\n      // Reorder remaining items\n      await context.env.DB.prepare(\n        'UPDATE tab_group_items SET position = position - 1 WHERE group_id = ? AND position > ?'\n      )\n        .bind(item.group_id, item.position)\n        .run()\n\n      return new Response(null, { status: 204 })\n    } catch (error) {\n      console.error('Delete tab group item error:', error)\n      return internalError('Failed to delete tab group item')\n    }\n  },\n]\n\n"
  },
  {
    "path": "tmarks/functions/api/tab/tab-groups/trash.ts",
    "content": "\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env } from '../../../lib/types'\nimport { success, internalError } from '../../../lib/response'\nimport { requireDualAuth, DualAuthContext } from '../../../middleware/dual-auth'\n\ninterface TabGroupRow {\n  id: string\n  user_id: string\n  title: string\n  color: string | null\n  tags: string | null\n  is_deleted: number\n  deleted_at: string | null\n  created_at: string\n  updated_at: string\n}\n\n// GET /api/tab/tab-groups/trash - Retrieve trashed tab groups\nexport const onRequestGet: PagesFunction<Env, string, DualAuthContext>[] = [\n  requireDualAuth('tab_groups.read'),\n  async (context) => {\n    const userId = context.data.user_id\n\n    try {\n\n      const { results: groups } = await context.env.DB.prepare(\n        'SELECT * FROM tab_groups WHERE user_id = ? AND is_deleted = 1 ORDER BY deleted_at DESC'\n      )\n        .bind(userId)\n        .all<TabGroupRow>()\n\n      const groupsWithCounts = await Promise.all(\n        (groups || []).map(async (group) => {\n          const { results: items } = await context.env.DB.prepare(\n            'SELECT COUNT(*) as count FROM tab_group_items WHERE group_id = ?'\n          )\n            .bind(group.id)\n            .all<{ count: number }>()\n\n          let tags: string[] | null = null\n          if (group.tags) {\n            try {\n              tags = JSON.parse(group.tags)\n            } catch {\n              tags = null\n            }\n          }\n\n          return {\n            ...group,\n            tags,\n            item_count: items?.[0]?.count || 0,\n          }\n        })\n      )\n\n      return success({\n        tab_groups: groupsWithCounts,\n        total: groupsWithCounts.length,\n      })\n    } catch (error) {\n      console.error('Get trash error:', error)\n      return internalError('Failed to get trash')\n    }\n  },\n]\n"
  },
  {
    "path": "tmarks/functions/api/tab/tags/[id]/click.ts",
    "content": "/**\r\n *  API - \r\n * : /api/tab/tags/:id/click\r\n * : API Key (X-API-Key header)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../lib/types'\r\nimport { success, notFound, internalError } from '../../../../lib/response'\r\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../../middleware/api-key-auth-pages'\r\n\r\n// PATCH /api/tags/:id/click - \r\nexport const onRequestPatch: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\r\n  requireApiKeyAuth('tags.update'),\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const tagId = context.params.id\r\n\r\n    try {\r\n      // \r\n      const existing = await context.env.DB.prepare(\r\n        'SELECT id FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\r\n      )\r\n        .bind(tagId, userId)\r\n        .first()\r\n\r\n      if (!existing) {\r\n        return notFound('Tag not found')\r\n      }\r\n\r\n      const now = new Date().toISOString()\r\n\r\n      // \r\n      await context.env.DB.prepare(\r\n        `UPDATE tags \r\n         SET click_count = click_count + 1, \r\n             last_clicked_at = ?,\r\n             updated_at = ?\r\n         WHERE id = ? AND user_id = ?`\r\n      )\r\n        .bind(now, now, tagId, userId)\r\n        .run()\r\n\r\n      return success({ message: 'Click count incremented' })\r\n    } catch (error) {\r\n      console.error('Increment tag click count error:', error)\r\n      return internalError('Failed to increment click count')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/tab/tags/[id].ts",
    "content": "/**\n *  API - \n * : /api/tab/tags/:id\n * : API Key (X-API-Key header)\n */\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams, SQLParam } from '../../../lib/types'\nimport { success, badRequest, notFound, noContent, internalError } from '../../../lib/response'\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'\nimport { sanitizeString } from '../../../lib/validation'\n\ninterface UpdateTagRequest {\n  name?: string\n  color?: string\n}\n\n// GET /api/tags/:id - \nexport const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\n  requireApiKeyAuth('tags.read'),\n  async (context) => {\n    const userId = context.data.user_id\n    const tagId = context.params.id\n\n    try {\n      const tag = await context.env.DB.prepare(\n        `SELECT\n          t.id,\n          t.name,\n          t.color,\n          t.created_at,\n          t.updated_at,\n          COUNT(bt.bookmark_id) as bookmark_count\n        FROM tags t\n        LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id AND bt.user_id = t.user_id\n        LEFT JOIN bookmarks b ON bt.bookmark_id = b.id AND b.deleted_at IS NULL\n        WHERE t.id = ? AND t.user_id = ? AND t.deleted_at IS NULL\n        GROUP BY t.id`\n      )\n        .bind(tagId, userId)\n        .first()\n\n      if (!tag) {\n        return notFound('Tag not found')\n      }\n\n      return success({ tag })\n    } catch (error) {\n      console.error('Get tag error:', error)\n      return internalError('Failed to get tag')\n    }\n  },\n]\n\n// PATCH /api/tags/:id - \nexport const onRequestPatch: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\n  requireApiKeyAuth('tags.update'),\n  async (context) => {\n    const userId = context.data.user_id\n    const tagId = context.params.id\n\n    try {\n      const existing = await context.env.DB.prepare(\n        'SELECT id FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\n      )\n        .bind(tagId, userId)\n        .first()\n\n      if (!existing) {\n        return notFound('Tag not found')\n      }\n\n      const body = (await context.request.json()) as UpdateTagRequest\n      const updates: string[] = []\n      const values: SQLParam[] = []\n\n      // \n      if (body.name !== undefined) {\n        if (!body.name.trim()) {\n          return badRequest('Tag name cannot be empty')\n        }\n        const name = sanitizeString(body.name, 50)\n\n        // \n        const duplicate = await context.env.DB.prepare(\n          'SELECT id FROM tags WHERE user_id = ? AND LOWER(name) = LOWER(?) AND id != ? AND deleted_at IS NULL'\n        )\n          .bind(userId, name, tagId)\n          .first()\n\n        if (duplicate) {\n          return badRequest('Tag with this name already exists')\n        }\n\n        updates.push('name = ?')\n        values.push(name)\n      }\n\n      // \n      if (body.color !== undefined) {\n        updates.push('color = ?')\n        values.push(body.color ? sanitizeString(body.color, 20) : null)\n      }\n\n      if (updates.length === 0) {\n        return badRequest('No fields to update')\n      }\n\n      const now = new Date().toISOString()\n      updates.push('updated_at = ?')\n      values.push(now)\n      values.push(tagId, userId)\n\n      await context.env.DB.prepare(\n        `UPDATE tags SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`\n      )\n        .bind(...values)\n        .run()\n\n      const tag = await context.env.DB.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?')\n        .bind(tagId, userId)\n        .first()\n\n      return success({ tag })\n    } catch (error) {\n      console.error('Update tag error:', error)\n      return internalError('Failed to update tag')\n    }\n  },\n]\n\n// DELETE /api/tags/:id - \nexport const onRequestDelete: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\n  requireApiKeyAuth('tags.delete'),\n  async (context) => {\n    const userId = context.data.user_id\n    const tagId = context.params.id\n\n    try {\n      const existing = await context.env.DB.prepare(\n        'SELECT id FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\n      )\n        .bind(tagId, userId)\n        .first()\n\n      if (!existing) {\n        return notFound('Tag not found')\n      }\n\n      const now = new Date().toISOString()\n\n      // \n      await context.env.DB.prepare(\n        'UPDATE tags SET deleted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?'\n      )\n        .bind(now, now, tagId, userId)\n        .run()\n\n      await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE tag_id = ? AND user_id = ?')\n        .bind(tagId, userId)\n        .run()\n\n      return noContent()\n    } catch (error) {\n      console.error('Delete tag error:', error)\n      return internalError('Failed to delete tag')\n    }\n  },\n]\n"
  },
  {
    "path": "tmarks/functions/api/tab/tags/index.ts",
    "content": "/**\n *  API - \n * : /api/tab/tags\n * : API Key (X-API-Key header)\n */\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams } from '../../../lib/types'\nimport { success, badRequest, created, internalError } from '../../../lib/response'\nimport { requireApiKeyAuth, ApiKeyAuthContext } from '../../../middleware/api-key-auth-pages'\nimport { sanitizeString } from '../../../lib/validation'\nimport { generateUUID } from '../../../lib/crypto'\n\ninterface CreateTagRequest {\n  name: string\n  color?: string\n}\n\ninterface TagWithCount {\n  id: string\n  name: string\n  color: string | null\n  bookmark_count: number\n  created_at: string\n  updated_at: string\n}\n\n// GET /api/tags - \nexport const onRequestGet: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\n  requireApiKeyAuth('tags.read'),\n  async (context) => {\n    const userId = context.data.user_id\n\n    try {\n      const { results: tags } = await context.env.DB.prepare(\n        `SELECT\n          t.id,\n          t.name,\n          t.color,\n          t.created_at,\n          t.updated_at,\n          COUNT(bt.bookmark_id) as bookmark_count\n        FROM tags t\n        LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id AND bt.user_id = t.user_id\n        LEFT JOIN bookmarks b ON bt.bookmark_id = b.id AND b.deleted_at IS NULL\n        WHERE t.user_id = ? AND t.deleted_at IS NULL\n        GROUP BY t.id\n        ORDER BY t.name ASC`\n      )\n        .bind(userId)\n        .all<TagWithCount>()\n\n      return success({ tags: tags || [] })\n    } catch (error) {\n      console.error('Get tags error:', error)\n      return internalError('Failed to get tags')\n    }\n  },\n]\n\n// POST /api/tags - \nexport const onRequestPost: PagesFunction<Env, RouteParams, ApiKeyAuthContext>[] = [\n  requireApiKeyAuth('tags.create'),\n  async (context) => {\n    const userId = context.data.user_id\n\n    try {\n      const body = (await context.request.json()) as CreateTagRequest\n\n      if (!body.name || !body.name.trim()) {\n        return badRequest('Tag name is required')\n      }\n\n      const name = sanitizeString(body.name, 50)\n      const color = body.color ? sanitizeString(body.color, 20) : null\n\n      // \n      const existing = await context.env.DB.prepare(\n        'SELECT id FROM tags WHERE user_id = ? AND LOWER(name) = LOWER(?) AND deleted_at IS NULL'\n      )\n        .bind(userId, name)\n        .first()\n\n      if (existing) {\n        return badRequest('Tag with this name already exists')\n      }\n\n      const now = new Date().toISOString()\n      const tagId = generateUUID()\n\n      await context.env.DB.prepare(\n        `INSERT INTO tags (id, user_id, name, color, created_at, updated_at)\n         VALUES (?, ?, ?, ?, ?, ?)`\n      )\n        .bind(tagId, userId, name, color, now, now)\n        .run()\n\n      const tag = await context.env.DB.prepare('SELECT * FROM tags WHERE id = ?')\n        .bind(tagId)\n        .first()\n\n      return created({ tag })\n    } catch (error) {\n      console.error('Create tag error:', error)\n      return internalError('Failed to create tag')\n    }\n  },\n]\n"
  },
  {
    "path": "tmarks/functions/api/v1/auth/login.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, User } from '../../../lib/types'\r\nimport { badRequest, unauthorized, success, internalError } from '../../../lib/response'\r\nimport { verifyPassword, generateToken, hashRefreshToken, generateUUID } from '../../../lib/crypto'\r\nimport { generateJWT, parseExpiry } from '../../../lib/jwt'\r\nimport { loginRateLimiter } from '../../../lib/rate-limit'\r\nimport { getJwtAccessTokenExpiresIn, getJwtRefreshTokenExpiresIn } from '../../../lib/config'\r\n\r\ninterface LoginRequest {\r\n  username: string\r\n  password: string\r\n  remember_me?: boolean\r\n}\r\n\r\nexport const onRequestPost: PagesFunction<Env>[] = [\r\n  loginRateLimiter,\r\n  async (context) => {\r\n  try {\r\n    const body = await context.request.json() as LoginRequest\r\n\r\n    if (!body.username || !body.password) {\r\n      return badRequest('Username and password are required')\r\n    }\r\n\r\n    // （）\r\n    type DbUser = User & { role?: string | null }\r\n\r\n    let user: DbUser | null = null\r\n\r\n    try {\r\n      user = await context.env.DB.prepare(\r\n        `SELECT id, username, email, password_hash, role\r\n         FROM users\r\n         WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)`\r\n      )\r\n        .bind(body.username, body.username)\r\n        .first<DbUser>()\r\n    } catch (error) {\r\n      if (error instanceof Error && /no such column: role/i.test(error.message)) {\r\n        user = await context.env.DB.prepare(\r\n          `SELECT id, username, email, password_hash\r\n           FROM users\r\n           WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)`\r\n        )\r\n          .bind(body.username, body.username)\r\n          .first<DbUser>()\r\n      } else {\r\n        throw error\r\n      }\r\n    }\r\n\r\n    if (!user) {\r\n\n      const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'\r\n      await context.env.DB.prepare(\r\n        `INSERT INTO audit_logs (event_type, payload, ip, created_at)\r\n         VALUES ('auth.login_failed', ?, ?, ?)`\r\n      )\r\n        .bind(\r\n          JSON.stringify({ username: body.username, reason: 'user_not_found' }),\r\n          ip,\r\n          new Date().toISOString()\r\n        )\r\n        .run()\r\n\r\n      return unauthorized('Invalid username or password')\r\n    }\r\n\r\n    // \r\n    const isValid = await verifyPassword(body.password, user.password_hash)\r\n\r\n    if (!isValid) {\r\n\n      const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'\r\n      await context.env.DB.prepare(\r\n        `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at)\r\n         VALUES (?, 'auth.login_failed', ?, ?, ?)`\r\n      )\r\n        .bind(\r\n          user.id,\r\n          JSON.stringify({ username: body.username, reason: 'invalid_password' }),\r\n          ip,\r\n          new Date().toISOString()\r\n        )\r\n        .run()\r\n\r\n      return unauthorized('Invalid username or password')\r\n    }\r\n\r\n    //  session_id\r\n    const sessionId = generateUUID()\r\n\r\n    const role = user.role ?? 'user'\r\n\r\n    // （）\r\n    const accessTokenExpiresInStr = getJwtAccessTokenExpiresIn(context.env)\r\n    const accessTokenExpiresIn = parseExpiry(accessTokenExpiresInStr)\r\n\r\n    const accessToken = await generateJWT(\r\n      { sub: user.id, session_id: sessionId },\r\n      context.env.JWT_SECRET,\r\n      accessTokenExpiresInStr\r\n    )\r\n\r\n    // \r\n    const refreshToken = generateToken(32)\r\n    const refreshTokenHash = await hashRefreshToken(refreshToken)\r\n\r\n    // \r\n    const refreshTokenExpiresInStr = getJwtRefreshTokenExpiresIn(context.env)\r\n    const refreshTokenExpiresIn = parseExpiry(refreshTokenExpiresInStr)\r\n    const refreshTokenExpiresAt = new Date(Date.now() + refreshTokenExpiresIn * 1000)\r\n\r\n    // \r\n    await context.env.DB.prepare(\r\n      `INSERT INTO auth_tokens (user_id, refresh_token_hash, expires_at, created_at)\r\n       VALUES (?, ?, ?, ?)`\r\n    )\r\n      .bind(user.id, refreshTokenHash, refreshTokenExpiresAt.toISOString(), new Date().toISOString())\r\n      .run()\r\n\n    const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'\r\n    const userAgent = context.request.headers.get('User-Agent') || 'unknown'\r\n\r\n    await context.env.DB.prepare(\r\n      `INSERT INTO audit_logs (user_id, event_type, payload, ip, user_agent, created_at)\r\n       VALUES (?, 'auth.login_success', ?, ?, ?, ?)`\r\n    )\r\n      .bind(\r\n        user.id,\r\n        JSON.stringify({ session_id: sessionId, remember_me: body.remember_me }),\r\n        ip,\r\n        userAgent,\r\n        new Date().toISOString()\r\n      )\r\n      .run()\r\n\r\n    return success({\r\n      access_token: accessToken,\r\n      refresh_token: refreshToken,\r\n      token_type: 'Bearer',\r\n      expires_in: accessTokenExpiresIn,\r\n      user: {\r\n        id: user.id,\r\n        username: user.username,\r\n        email: user.email,\r\n        role,\r\n      },\r\n    })\r\n  } catch (error) {\r\n    console.error('Login error:', error)\r\n    return internalError('Login failed')\r\n  }\r\n},\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/auth/logout.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams } from '../../../lib/types'\nimport { badRequest, noContent, internalError } from '../../../lib/response'\nimport { hashRefreshToken } from '../../../lib/crypto'\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\n\ninterface LogoutRequest {\n  refresh_token: string\n  revoke_all?: boolean // \n}\n\nexport const onRequest: PagesFunction<Env, RouteParams, AuthContext>[] = [\n  requireAuth,\n  async (context) => {\n    try {\n      const body = await context.request.json() as LogoutRequest\n\n      if (!body.refresh_token) {\n        return badRequest('Refresh token is required')\n      }\n\n      const userId = context.data.user_id\n      const now = new Date().toISOString()\n\n      if (body.revoke_all) {\n        await context.env.DB.prepare(\n\n          `UPDATE auth_tokens\n           SET revoked_at = ?\n           WHERE user_id = ? AND revoked_at IS NULL`\n        )\n          .bind(now, userId)\n          .run()\n\n        // \n        const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'\n        await context.env.DB.prepare(\n          `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at)\n           VALUES (?, 'auth.logout_all_devices', ?, ?, ?)`\n        )\n          .bind(userId, JSON.stringify({ revoked_count: 'all' }), ip, now)\n          .run()\n      } else {\n        // \n        const tokenHash = await hashRefreshToken(body.refresh_token)\n\n        await context.env.DB.prepare(\n          `UPDATE auth_tokens\n           SET revoked_at = ?\n           WHERE refresh_token_hash = ? AND user_id = ? AND revoked_at IS NULL`\n        )\n          .bind(now, tokenHash, userId)\n          .run()\n\n        // \n        const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'\n        await context.env.DB.prepare(\n          `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at)\n           VALUES (?, 'auth.logout', ?, ?, ?)`\n        )\n          .bind(userId, JSON.stringify({ single_device: true }), ip, now)\n          .run()\n      }\n\n      return noContent()\n    } catch (error) {\n      console.error('Logout error:', error)\n      return internalError('Logout failed')\n    }\n  },\n]\n"
  },
  {
    "path": "tmarks/functions/api/v1/auth/refresh.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../lib/types'\r\nimport { badRequest, unauthorized, success, internalError } from '../../../lib/response'\r\nimport { hashRefreshToken, generateUUID } from '../../../lib/crypto'\r\nimport { generateJWT } from '../../../lib/jwt'\r\nimport { getJwtAccessTokenExpiresIn } from '../../../lib/config'\r\n\r\ninterface RefreshRequest {\r\n  refresh_token: string\r\n}\r\n\r\nexport const onRequestPost: PagesFunction<Env> = async (context) => {\r\n  try {\r\n    const body = await context.request.json() as RefreshRequest\r\n\r\n    if (!body.refresh_token) {\r\n      return badRequest('Refresh token is required')\r\n    }\r\n\r\n    // \r\n    const tokenHash = await hashRefreshToken(body.refresh_token)\r\n\r\n    // \r\n    const tokenRecord = await context.env.DB.prepare(\r\n      `SELECT id, user_id, expires_at, revoked_at\r\n       FROM auth_tokens\r\n       WHERE refresh_token_hash = ?`\r\n    )\r\n      .bind(tokenHash)\r\n      .first<{\r\n        id: number\r\n        user_id: string\r\n        expires_at: string\r\n        revoked_at: string | null\r\n      }>()\r\n\r\n    if (!tokenRecord) {\r\n      return unauthorized('Invalid refresh token')\r\n    }\r\n\r\n    // \r\n    if (tokenRecord.revoked_at) {\r\n      return unauthorized('Refresh token has been revoked')\r\n    }\r\n\n    const expiresAt = new Date(tokenRecord.expires_at)\r\n    if (expiresAt < new Date()) {\r\n      return unauthorized('Refresh token has expired')\r\n    }\r\n\r\n    //  session_id\r\n    const sessionId = generateUUID()\r\n\r\n    // \r\n    const accessToken = await generateJWT(\r\n      { sub: tokenRecord.user_id, session_id: sessionId },\r\n      context.env.JWT_SECRET,\r\n      getJwtAccessTokenExpiresIn(context.env)\r\n    )\r\n\r\n    // \r\n    type DbUser = { id: string; username: string; email: string | null; role?: string | null }\r\n\r\n    let user: DbUser | null = null\r\n\r\n    try {\r\n      user = await context.env.DB.prepare(\r\n        'SELECT id, username, email, role FROM users WHERE id = ?'\r\n      )\r\n        .bind(tokenRecord.user_id)\r\n        .first<DbUser>()\r\n    } catch (error) {\r\n      if (error instanceof Error && /no such column: role/i.test(error.message)) {\r\n        user = await context.env.DB.prepare(\r\n          'SELECT id, username, email FROM users WHERE id = ?'\r\n        )\r\n          .bind(tokenRecord.user_id)\r\n          .first<DbUser>()\r\n      } else {\r\n        throw error\r\n      }\r\n    }\r\n\r\n    if (!user) {\r\n      return unauthorized('User not found')\r\n    }\r\n\r\n    const role = user.role ?? 'user'\r\n\r\n    // \r\n    const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'\r\n    await context.env.DB.prepare(\r\n      `INSERT INTO audit_logs (user_id, event_type, payload, ip, created_at)\r\n       VALUES (?, 'auth.token_refreshed', ?, ?, ?)`\r\n    )\r\n      .bind(\r\n        tokenRecord.user_id,\r\n        JSON.stringify({ session_id: sessionId }),\r\n        ip,\r\n        new Date().toISOString()\r\n      )\r\n      .run()\r\n\r\n    return success({\r\n      access_token: accessToken,\r\n      token_type: 'Bearer',\r\n      expires_in: parseExpiresInToSeconds(getJwtAccessTokenExpiresIn(context.env)),\r\n      user: {\r\n        id: user.id,\r\n        username: user.username,\r\n        email: user.email,\r\n        role,\r\n      },\r\n    })\r\n  } catch (error) {\r\n    console.error('Refresh error:', error)\r\n    return internalError('Token refresh failed')\r\n  }\r\n}\r\n\r\nfunction parseExpiresInToSeconds(expiresIn: string): number {\r\n  const match = expiresIn.match(/^(\\d+)(s|m|h|d)$/)\r\n  if (!match) return 31536000\r\n  const value = parseInt(match[1], 10)\r\n  switch (match[2]) {\r\n    case 's': return value\r\n    case 'm': return value * 60\r\n    case 'h': return value * 3600\r\n    case 'd': return value * 86400\r\n    default: return 31536000\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/auth/register.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../lib/types'\r\nimport { badRequest, created, conflict, internalError } from '../../../lib/response'\r\nimport { isValidUsername, isValidPassword, isValidEmail, sanitizeString } from '../../../lib/validation'\r\nimport { hashPassword, generateUUID } from '../../../lib/crypto'\r\n\r\ninterface RegisterRequest {\r\n  username: string\r\n  password: string\r\n  email?: string\r\n}\r\n\r\nexport const onRequestPost: PagesFunction<Env> = async (context) => {\r\n  try {\r\n    const db = context.env.DB\r\n\r\n    // Rate limiting: Max 5 registration attempts per IP per hour\r\n    const clientIP = context.request.headers.get('CF-Connecting-IP') || 'unknown'\r\n\r\n    // Log registration attempt (ignore errors)\r\n    try {\r\n      await db.prepare(\r\n        `INSERT INTO audit_logs (user_id, event_type, ip, payload, created_at)\r\n         VALUES ('system', 'register_attempt', ?, ?, datetime('now'))`\r\n      ).bind(clientIP, JSON.stringify({ ip: clientIP })).run()\r\n    } catch {\r\n      // Ignore audit log errors\r\n    }\r\n\r\n    const rateCheck = await db.prepare(\r\n      `SELECT COUNT(*) as cnt FROM audit_logs\r\n       WHERE event_type = 'register_attempt'\r\n       AND ip = ?\r\n       AND created_at > datetime('now', '-1 hour')`\r\n    ).bind(clientIP).first<{ cnt: number }>()\r\n    \r\n    if (rateCheck && rateCheck.cnt >= 5) {\r\n      return new Response(JSON.stringify({ code: 'RATE_LIMITED', message: 'Too many registration attempts' }), {\r\n        status: 429,\r\n        headers: { 'Content-Type': 'application/json', 'Retry-After': '3600' }\r\n      })\r\n    }\r\n\r\n    // Check if registration is enabled\r\n    if (context.env.ALLOW_REGISTRATION !== 'true') {\r\n      return badRequest('Registration is currently disabled')\r\n    }\r\n\r\n    const body = await context.request.json() as RegisterRequest\r\n\r\n    // \r\n    if (!body.username || !body.password) {\r\n      return badRequest('Username and password are required')\r\n    }\r\n\r\n    if (!isValidUsername(body.username)) {\r\n      return badRequest('Username must be 3-20 characters and contain only letters, numbers, and underscores')\r\n    }\r\n\r\n    if (!isValidPassword(body.password)) {\r\n      return badRequest('Password must be at least 8 characters')\r\n    }\r\n\r\n    if (body.email && !isValidEmail(body.email)) {\r\n      return badRequest('Invalid email format')\r\n    }\r\n\r\n    const username = sanitizeString(body.username, 20)\r\n    const email = body.email ? sanitizeString(body.email, 255) : null\r\n\r\n    // Check if username exists\r\n    const existingUser = await db.prepare(\r\n      'SELECT id FROM users WHERE LOWER(username) = LOWER(?)'\r\n    )\r\n      .bind(username)\r\n      .first()\r\n\r\n    if (existingUser) {\r\n      return conflict('Username already exists')\r\n    }\r\n\r\n    // Check if email exists\r\n    if (email) {\r\n      const existingEmail = await db.prepare(\r\n        'SELECT id FROM users WHERE LOWER(email) = LOWER(?)'\r\n      )\r\n        .bind(email)\r\n        .first()\r\n\r\n      if (existingEmail) {\r\n        return conflict('Email already exists')\r\n      }\r\n    }\r\n\r\n    // Hash password\r\n    const passwordHash = await hashPassword(body.password)\r\n\r\n    // Generate UUID\r\n    const userId = generateUUID()\r\n\r\n    const now = new Date()\r\n    const nowISO = now.toISOString()\r\n    const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'\r\n    const userAgent = context.request.headers.get('User-Agent') || 'unknown'\r\n\r\n    // Create user\r\n    await db.prepare(\r\n      `INSERT INTO users (id, username, email, password_hash, created_at, updated_at)\r\n       VALUES (?, ?, ?, ?, ?, ?)`\r\n    )\r\n      .bind(userId, username, email, passwordHash, nowISO, nowISO)\r\n      .run()\r\n\r\n    // Create default preferences\r\n    try {\r\n      await db.prepare(\r\n        `INSERT INTO user_preferences (user_id, theme, page_size, view_mode, density, tag_layout, sort_by, updated_at)\r\n         VALUES (?, 'light', 30, 'list', 'normal', 'grid', 'popular', ?)`\r\n      )\r\n        .bind(userId, nowISO)\r\n        .run()\r\n    } catch (error) {\r\n      if (error instanceof Error && (/no such column: tag_layout/i.test(error.message) || /no such column: sort_by/i.test(error.message))) {\r\n        // Fallback for older schema without tag_layout and sort_by\r\n        await db.prepare(\r\n          `INSERT INTO user_preferences (user_id, theme, page_size, view_mode, density, updated_at)\r\n           VALUES (?, 'light', 30, 'list', 'normal', ?)`\r\n        )\r\n          .bind(userId, nowISO)\r\n          .run()\r\n      } else {\r\n        throw error\r\n      }\r\n    }\r\n\r\n    // Log registration (ignore errors)\r\n    try {\r\n      await db.prepare(\r\n        `INSERT INTO audit_logs (user_id, event_type, payload, ip, user_agent, created_at)\r\n         VALUES (?, 'user.registered', ?, ?, ?, ?)`\r\n      )\r\n        .bind(\r\n          userId,\r\n          JSON.stringify({ username, email: email || null }),\r\n          ip,\r\n          userAgent,\r\n          nowISO\r\n        )\r\n        .run()\r\n    } catch (auditError) {\r\n\n      console.error('Failed to create audit log:', auditError)\r\n    }\r\n\r\n    return created({\r\n      user: {\r\n        id: userId,\r\n        username,\r\n        email: email || null,\r\n        created_at: nowISO,\r\n      },\r\n    })\r\n  } catch (error) {\r\n    console.error('Register error:', error)\r\n    return internalError('Registration failed')\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/click.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../lib/types'\r\nimport { success, notFound, internalError } from '../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../middleware/auth'\r\n\r\n// POST /api/v1/bookmarks/:id/click - \r\nexport const onRequestPost: PagesFunction<Env, 'id', AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    try {\r\n      const userId = context.data.user_id\r\n      const bookmarkId = context.params.id as string\r\n      const now = new Date().toISOString()\r\n      const db = context.env.DB\r\n\r\n      // \r\n      const bookmark = await db.prepare(\r\n        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!bookmark) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      // ，\r\n      await db.batch([\r\n        db.prepare(\r\n          'UPDATE bookmarks SET click_count = click_count + 1, last_clicked_at = ? WHERE id = ?'\r\n        ).bind(now, bookmarkId),\r\n        db.prepare(\r\n          'INSERT INTO bookmark_click_events (bookmark_id, user_id, clicked_at) VALUES (?, ?, ?)'\r\n        ).bind(bookmarkId, userId, now),\r\n      ])\r\n\r\n      return success({\r\n        message: 'Click recorded successfully',\r\n        clicked_at: now,\r\n      })\r\n    } catch (error) {\r\n      console.error('Record click error:', error)\r\n      return internalError('Failed to record click')\r\n    }\r\n  },\r\n]"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/permanent.ts",
    "content": "/**\r\n *  API\r\n * : /api/v1/bookmarks/:id/permanent\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../lib/types'\r\nimport { noContent, notFound, internalError } from '../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../middleware/auth'\r\n\r\n// DELETE /api/v1/bookmarks/:id/permanent - （）\r\nexport const onRequestDelete: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      // \r\n      const existing = await context.env.DB.prepare(\r\n        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!existing) {\r\n        return notFound('Bookmark not found in trash')\r\n      }\r\n\r\n      // \r\n      await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ?')\r\n        .bind(bookmarkId)\r\n        .run()\r\n\r\n      // \r\n      await context.env.DB.prepare('DELETE FROM bookmark_snapshots WHERE bookmark_id = ?')\r\n        .bind(bookmarkId)\r\n        .run()\r\n\r\n      // \r\n      await context.env.DB.prepare('DELETE FROM bookmarks WHERE id = ?')\r\n        .bind(bookmarkId)\r\n        .run()\r\n\r\n      return noContent()\r\n    } catch (error) {\r\n      console.error('Permanent delete bookmark error:', error)\r\n      return internalError('Failed to permanently delete bookmark')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/restore.ts",
    "content": "/**\r\n *  API\r\n * : /api/v1/bookmarks/:id/restore\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, BookmarkRow, RouteParams } from '../../../../lib/types'\r\nimport { success, notFound, internalError } from '../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../middleware/auth'\r\nimport { normalizeBookmark } from '../../../../lib/bookmark-utils'\r\n\r\n// PATCH /api/v1/bookmarks/:id/restore - \r\nexport const onRequestPatch: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      // \r\n      const existing = await context.env.DB.prepare(\r\n        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!existing) {\r\n        return notFound('Bookmark not found in trash')\r\n      }\r\n\r\n      const now = new Date().toISOString()\r\n\r\n      // ： deleted_at\r\n      await context.env.DB.prepare(\r\n        'UPDATE bookmarks SET deleted_at = NULL, updated_at = ? WHERE id = ?'\r\n      )\r\n        .bind(now, bookmarkId)\r\n        .run()\r\n\r\n      // \r\n      const bookmarkRow = await context.env.DB.prepare(\r\n        'SELECT * FROM bookmarks WHERE id = ?'\r\n      )\r\n        .bind(bookmarkId)\r\n        .first<BookmarkRow>()\r\n\r\n      if (!bookmarkRow) {\r\n        return internalError('Failed to load bookmark after restore')\r\n      }\r\n\r\n      // \r\n      const { results: tags } = await context.env.DB.prepare(\r\n        `SELECT t.id, t.name, t.color\r\n         FROM tags t\r\n         INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n         WHERE bt.bookmark_id = ? AND t.deleted_at IS NULL`\r\n      )\r\n        .bind(bookmarkId)\r\n        .all<{ id: string; name: string; color: string | null }>()\r\n\r\n      return success({\r\n        bookmark: {\r\n          ...normalizeBookmark(bookmarkRow),\r\n          tags: tags || [],\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Restore bookmark error:', error)\r\n      return internalError('Failed to restore bookmark')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/snapshot-cleanup.ts",
    "content": "/**\n *  — \n */\n\nexport async function cleanupOldSnapshots(\n  db: D1Database,\n  bucket: R2Bucket,\n  bookmarkId: string,\n  userId: string\n) {\n  try {\n    const bookmarkSettings = await db\n      .prepare('SELECT snapshot_retention_count FROM bookmarks WHERE id = ? AND user_id = ?')\n      .bind(bookmarkId, userId)\n      .first()\n\n    const userSettings = await db\n      .prepare('SELECT snapshot_retention_count FROM user_preferences WHERE user_id = ?')\n      .bind(userId)\n      .first()\n\n    const retentionCount =\n      (bookmarkSettings?.snapshot_retention_count as number | null) ??\n      (userSettings?.snapshot_retention_count as number | null) ??\n      5\n\n    if (retentionCount === -1) {\n      return\n    }\n\n    const toDelete = await db\n      .prepare(\n        `SELECT id, r2_key\n         FROM bookmark_snapshots\n         WHERE bookmark_id = ? AND user_id = ?\n         ORDER BY version DESC\n         LIMIT -1 OFFSET ?`\n      )\n      .bind(bookmarkId, userId, retentionCount)\n      .all()\n\n    if (!toDelete.results || toDelete.results.length === 0) {\n      return\n    }\n\n    const deletedIds: unknown[] = []\n    for (const snapshot of toDelete.results) {\n      try {\n        await bucket.delete(snapshot.r2_key as string)\n        deletedIds.push(snapshot.id)\n      } catch (error) {\n        console.error('Failed to delete R2 file:', snapshot.r2_key, error)\n      }\n    }\n\n    if (deletedIds.length === 0) return\n\n    const placeholders = deletedIds.map(() => '?').join(',')\n    await db\n      .prepare(`DELETE FROM bookmark_snapshots WHERE id IN (${placeholders}) AND user_id = ?`)\n      .bind(...deletedIds, userId)\n      .run()\n  } catch (error) {\n    console.error('Cleanup snapshots error:', error)\n  }\n}\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/snapshots/[snapshotId]/view.ts",
    "content": "/**\r\n *  API -  URL\r\n * : /api/v1/bookmarks/:id/snapshots/:snapshotId/view\r\n * :  URL（ JWT Token）\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../../../lib/types'\r\nimport { unauthorized, notFound, internalError } from '../../../../../../lib/response'\r\nimport { verifySignedUrl, extractSignedParams } from '../../../../../../lib/signed-url'\r\nimport { generateImageSig } from '../../../../../../lib/image-sig'\r\n\r\n// GET /api/v1/bookmarks/:id/snapshots/:snapshotId/view -  URL \r\nexport const onRequestGet: PagesFunction<Env> = async (context) => {\r\n  try {\r\n    const bookmarkId = context.params.id as string\r\n    const snapshotId = context.params.snapshotId as string\r\n    const db = context.env.DB\r\n    const bucket = context.env.SNAPSHOTS_BUCKET\r\n    if (!bucket) {\r\n      return internalError('Storage not configured')\r\n    }\r\n\r\n    // \r\n    const { signature, expires, userId, action } = extractSignedParams(context.request as unknown as Request)\r\n\r\n    if (!signature || !expires || !userId) {\r\n      return unauthorized('Missing signature parameters')\r\n    }\r\n\r\n    // \r\n    const verification = await verifySignedUrl(\r\n      signature,\r\n      expires,\r\n      userId,\r\n      snapshotId,\r\n      context.env.JWT_SECRET,\r\n      action || undefined\r\n    )\r\n\r\n    if (!verification.valid) {\r\n      return unauthorized(verification.error || 'Invalid signature')\r\n    }\r\n\r\n    // \r\n    const snapshot = await db\r\n      .prepare(\r\n        `SELECT s.*, b.url as bookmark_url\r\n         FROM bookmark_snapshots s\r\n         JOIN bookmarks b ON s.bookmark_id = b.id\r\n         WHERE s.id = ? AND s.bookmark_id = ? AND s.user_id = ?`\r\n      )\r\n      .bind(snapshotId, bookmarkId, userId)\r\n      .first()\r\n\r\n    if (!snapshot) {\r\n      return notFound('Snapshot not found')\r\n    }\r\n\r\n    //  R2 \r\n    const r2Object = await bucket.get(snapshot.r2_key as string)\r\n\r\n    if (!r2Object) {\r\n      return notFound('Snapshot file not found')\r\n    }\r\n\r\n    //  HTML \r\n    let htmlContent = await r2Object.text()\r\n    \r\n    const htmlSize = new Blob([htmlContent]).size\r\n    console.log(`[Snapshot View API] Retrieved from R2: ${(htmlSize / 1024).toFixed(1)}KB`)\r\n\r\n    //  V2 （ /api/snapshot-images/ ）\r\n    const isV2 = htmlContent.includes('/api/snapshot-images/')\r\n    \r\n    if (isV2) {\r\n      const version = (snapshot as Record<string, unknown>).version as number || 1\r\n      \r\n      //  hash \r\n      const imgUrlRegex = /\\/api\\/snapshot-images\\/([a-zA-Z0-9._-]+?)(?:\\?[^\"\\s)]*)?(?=[\"\\s)]|$)/g\r\n      const matches = Array.from(htmlContent.matchAll(imgUrlRegex))\r\n      const uniqueHashes = [...new Set(matches.map(m => m[1]).filter(h => h.length <= 128))]\r\n      \r\n      // \r\n      const sigMap = new Map<string, string>()\r\n      for (const hash of uniqueHashes) {\r\n        sigMap.set(hash, await generateImageSig(hash, userId, bookmarkId, context.env.JWT_SECRET))\r\n      }\r\n      \r\n      let replacedCount = 0\r\n      htmlContent = htmlContent.replace(imgUrlRegex, (_match: string, hash: string) => {\r\n        replacedCount++\r\n        const sig = sigMap.get(hash) || ''\r\n        return `/api/snapshot-images/${hash}?u=${userId}&b=${bookmarkId}&v=${version}&sig=${sig}`\r\n      })\r\n      console.log(`[Snapshot View API] V2 format: normalized ${replacedCount} image URLs with signatures`)\r\n    }\r\n\r\n    return new Response(htmlContent, {\r\n      headers: {\r\n        'Content-Type': 'text/html; charset=utf-8',\r\n        'Cache-Control': 'public, max-age=3600',\r\n        'X-Content-Type-Options': 'nosniff',\r\n        //  CSP ：，\r\n        'Content-Security-Policy': \"default-src 'none'; img-src * data: blob:; style-src 'unsafe-inline' *; font-src * data:; frame-src 'none'; script-src 'none'; connect-src 'none';\",\r\n        //  X-Frame-Options  iframe \r\n        'X-Frame-Options': 'DENY',\r\n      },\r\n    })\r\n  } catch (error) {\r\n    console.error('[Snapshot View API] Error:', error)\r\n    return internalError('Failed to get snapshot')\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/snapshots/[snapshotId].ts",
    "content": "/**\r\n *  API (V1 - JWT Auth)\r\n * : /api/v1/bookmarks/:id/snapshots/:snapshotId\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../../lib/types'\r\nimport { notFound, internalError } from '../../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../../middleware/auth'\r\n\r\n// GET /api/v1/bookmarks/:id/snapshots/:snapshotId - \r\nexport const onRequestGet: PagesFunction<Env, 'id' | 'snapshotId', AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    try {\r\n      const userId = context.data.user_id\r\n      const bookmarkId = context.params.id as string\r\n      const snapshotId = context.params.snapshotId as string\r\n      const db = context.env.DB\r\n      const bucket = context.env.SNAPSHOTS_BUCKET\r\n\r\n      if (!bucket) {\r\n        return internalError('Storage not configured')\r\n      }\r\n\r\n      // \r\n      const snapshot = await db\r\n        .prepare(\r\n          `SELECT s.*, b.url as bookmark_url\r\n           FROM bookmark_snapshots s\r\n           JOIN bookmarks b ON s.bookmark_id = b.id\r\n           WHERE s.id = ? AND s.bookmark_id = ? AND s.user_id = ?`\r\n        )\r\n        .bind(snapshotId, bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!snapshot) {\r\n        return notFound('Snapshot not found')\r\n      }\r\n\r\n      //  R2 \r\n      const r2Object = await bucket.get(snapshot.r2_key as string)\r\n\r\n      if (!r2Object) {\r\n        return notFound('Snapshot file not found')\r\n      }\r\n\r\n      //  HTML \r\n      let htmlContent = await r2Object.text()\r\n      \r\n      //  data URL （）\r\n      const dataUrlCount = (htmlContent.match(/src=\"data:/g) || []).length\r\n      const htmlSize = new Blob([htmlContent]).size\r\n      console.log(`[Snapshot API V1] Retrieved from R2: ${(htmlSize / 1024).toFixed(1)}KB, data URLs: ${dataUrlCount}`)\r\n\r\n      //  CSP meta  HTML head （）\r\n      const cspMetaTag = '<meta http-equiv=\"Content-Security-Policy\" content=\"default-src * \\'unsafe-inline\\' \\'unsafe-eval\\' data: blob:; img-src * data: blob:; font-src * data:; style-src * \\'unsafe-inline\\'; script-src * \\'unsafe-inline\\' \\'unsafe-eval\\'; frame-src *; connect-src *;\">';\r\n      if (htmlContent.includes('<head>')) {\r\n        htmlContent = htmlContent.replace('<head>', `<head>${cspMetaTag}`);\r\n        console.log(`[Snapshot API V1] Injected CSP meta tag`);\r\n      } else if (htmlContent.includes('<HEAD>')) {\r\n        htmlContent = htmlContent.replace('<HEAD>', `<HEAD>${cspMetaTag}`);\r\n        console.log(`[Snapshot API V1] Injected CSP meta tag`);\r\n      }\r\n\r\n      //  V2 （ /api/snapshot-images/ ）\r\n      const isV2 = htmlContent.includes('/api/snapshot-images/')\r\n      \r\n      if (isV2) {\r\n        const version = (snapshot as Record<string, unknown>).version as number || 1\r\n        \r\n        //  URL： URL，\r\n        let replacedCount = 0\r\n        htmlContent = htmlContent.replace(\r\n          /\\/api\\/snapshot-images\\/([a-zA-Z0-9._-]+?)(?:\\?[^\"\\s)]*)?(?=[\"\\s)]|$)/g,\r\n          (_match: string, hash: string) => {\r\n            replacedCount++\r\n            // ，（）\r\n            return `/api/snapshot-images/${hash}?u=${userId}&b=${bookmarkId}&v=${version}`;\r\n          }\r\n        )\r\n        console.log(`[Snapshot API V1] V2 format detected, normalized ${replacedCount} image URLs`)\r\n      }\r\n\r\n      return new Response(htmlContent, {\r\n        headers: {\r\n          'Content-Type': 'text/html; charset=utf-8',\r\n          'Cache-Control': 'public, max-age=3600',\r\n          'X-Content-Type-Options': 'nosniff',\r\n          //  CSP （）\r\n          'Content-Security-Policy': \"default-src * 'unsafe-inline' 'unsafe-eval' data: blob:; img-src * data: blob:; font-src * data:; style-src * 'unsafe-inline'; script-src * 'unsafe-inline' 'unsafe-eval'; frame-src *; connect-src *;\",\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('[Snapshot API V1] Get snapshot error:', error)\r\n      return internalError('Failed to get snapshot')\r\n    }\r\n  },\r\n]\r\n\r\n// DELETE /api/v1/bookmarks/:id/snapshots/:snapshotId - \r\nexport const onRequestDelete: PagesFunction<Env, 'id' | 'snapshotId', AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id as string\r\n    const snapshotId = context.params.snapshotId as string\r\n\r\n    try {\r\n      const db = context.env.DB\r\n      const bucket = context.env.SNAPSHOTS_BUCKET\r\n\r\n      if (!bucket) {\r\n        return internalError('Storage not configured')\r\n      }\r\n\r\n      // \r\n      const snapshot = await db\r\n        .prepare(\r\n          `SELECT id, r2_key, is_latest, version\r\n           FROM bookmark_snapshots\r\n           WHERE id = ? AND bookmark_id = ? AND user_id = ?`\r\n        )\r\n        .bind(snapshotId, bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!snapshot) {\r\n        return notFound('Snapshot not found')\r\n      }\r\n\r\n      const version = (snapshot as Record<string, unknown>).version as number || 1\r\n\r\n      //  R2 HTML \r\n      await bucket.delete(snapshot.r2_key as string)\r\n\r\n      //  V2 （）\r\n      try {\r\n        // \r\n        const imagePrefix = `${userId}/${bookmarkId}/v${version}/images/`\r\n        const imageList = await bucket.list({ prefix: imagePrefix })\r\n        \r\n        if (imageList.objects && imageList.objects.length > 0) {\r\n          console.log(`[Snapshot API V1] Deleting ${imageList.objects.length} images for version ${version}`)\r\n          \r\n          // \r\n          for (const obj of imageList.objects) {\r\n            await bucket.delete(obj.key)\r\n          }\r\n        }\r\n      } catch (error) {\r\n        console.warn('[Snapshot API V1] Failed to delete images:', error)\r\n        // ，\r\n      }\r\n\r\n      // \r\n      await db\r\n        .prepare('DELETE FROM bookmark_snapshots WHERE id = ?')\r\n        .bind(snapshotId)\r\n        .run()\r\n\r\n      // （1）\r\n      await db\r\n        .prepare(\r\n          `UPDATE bookmarks \r\n           SET snapshot_count = MAX(0, snapshot_count - 1)\r\n           WHERE id = ?`\r\n        )\r\n        .bind(bookmarkId)\r\n        .run()\r\n\r\n      // ，\r\n      if (snapshot.is_latest) {\r\n        const nextLatest = await db\r\n          .prepare(\r\n            `SELECT id FROM bookmark_snapshots\r\n             WHERE bookmark_id = ? AND user_id = ?\r\n             ORDER BY version DESC\r\n             LIMIT 1`\r\n          )\r\n          .bind(bookmarkId, userId)\r\n          .first()\r\n\r\n        if (nextLatest) {\r\n          await db\r\n            .prepare(\r\n              `UPDATE bookmark_snapshots \r\n               SET is_latest = 1 \r\n               WHERE id = ?`\r\n            )\r\n            .bind(nextLatest.id)\r\n            .run()\r\n        } else {\r\n          // ，\r\n          await db\r\n            .prepare(\r\n              `UPDATE bookmarks \r\n               SET has_snapshot = 0, \r\n                   latest_snapshot_at = NULL,\r\n                   snapshot_count = 0\r\n               WHERE id = ?`\r\n            )\r\n            .bind(bookmarkId)\r\n            .run()\r\n        }\r\n      }\r\n\r\n      return new Response(JSON.stringify({ \r\n        success: true,\r\n        data: { message: 'Snapshot deleted successfully' }\r\n      }), {\r\n        headers: { 'Content-Type': 'application/json' }\r\n      })\r\n    } catch (error) {\r\n      console.error('[Snapshot API V1] Delete snapshot error:', error)\r\n      return internalError('Failed to delete snapshot')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/snapshots/cleanup.ts",
    "content": "/**\r\n *  API\r\n * : /api/v1/bookmarks/:id/snapshots/cleanup\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../../middleware/auth'\r\n\r\ninterface CleanupRequest {\r\n  keep_count?: number\r\n  older_than_days?: number\r\n  verify_and_fix?: boolean\r\n}\r\n\r\ninterface RouteParams {\r\n  id: string\r\n}\r\n\r\ninterface SnapshotRow {\r\n  id: string\r\n  r2_key: string\r\n  file_size: number\r\n}\r\n\r\nasync function deleteSnapshotRows(\r\n  db: D1Database, ids: string[], userId: string,\r\n): Promise<void> {\r\n  const placeholders = ids.map(() => '?').join(',')\r\n  await db\r\n    .prepare(`DELETE FROM bookmark_snapshots WHERE id IN (${placeholders}) AND user_id = ?`)\r\n    .bind(...ids, userId)\r\n    .run()\r\n}\r\n\r\nasync function updateBookmarkSnapshotCount(\r\n  db: D1Database, bookmarkId: string, userId: string,\r\n): Promise<void> {\r\n  const remaining = await db\r\n    .prepare(\r\n      `SELECT COUNT(*) as count FROM bookmark_snapshots\r\n       WHERE bookmark_id = ? AND user_id = ?`,\r\n    )\r\n    .bind(bookmarkId, userId)\r\n    .first()\r\n\r\n  const remainingCount = (remaining?.count as number) || 0\r\n\r\n  if (remainingCount === 0) {\r\n    await db\r\n      .prepare(\r\n        `UPDATE bookmarks\r\n         SET has_snapshot = 0, latest_snapshot_at = NULL, snapshot_count = 0\r\n         WHERE id = ? AND user_id = ?`,\r\n      )\r\n      .bind(bookmarkId, userId)\r\n      .run()\r\n  } else {\r\n    await db\r\n      .prepare(`UPDATE bookmarks SET snapshot_count = ? WHERE id = ? AND user_id = ?`)\r\n      .bind(remainingCount, bookmarkId, userId)\r\n      .run()\r\n  }\r\n}\r\n\r\nasync function handleVerifyAndFix(\r\n  db: D1Database, bucket: R2Bucket, bookmarkId: string, userId: string,\r\n) {\r\n  const { results: allSnapshots } = await db\r\n    .prepare(\r\n      `SELECT id, r2_key, file_size FROM bookmark_snapshots\r\n       WHERE bookmark_id = ? AND user_id = ?`,\r\n    )\r\n    .bind(bookmarkId, userId)\r\n    .all<SnapshotRow>()\r\n\r\n  const orphaned: SnapshotRow[] = []\r\n\r\n  for (const snapshot of allSnapshots || []) {\r\n    try {\r\n      const r2Object = await bucket.head(snapshot.r2_key)\r\n      if (!r2Object) orphaned.push(snapshot)\r\n    } catch {\r\n      orphaned.push(snapshot)\r\n    }\r\n  }\r\n\r\n  if (orphaned.length === 0) {\r\n    return success({\r\n      deleted_count: 0,\r\n      freed_space: 0,\r\n      message: 'All snapshots are valid, no orphaned records found',\r\n    })\r\n  }\r\n\r\n  await deleteSnapshotRows(db, orphaned.map((s) => s.id), userId)\r\n  await updateBookmarkSnapshotCount(db, bookmarkId, userId)\r\n\r\n  return success({\r\n    deleted_count: orphaned.length,\r\n    freed_space: 0,\r\n    message: `Fixed ${orphaned.length} orphaned snapshot records`,\r\n  })\r\n}\r\n\r\n// POST /api/v1/bookmarks/:id/snapshots/cleanup\r\nexport const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      const body = await context.request.json() as CleanupRequest\r\n      const { keep_count, older_than_days, verify_and_fix } = body\r\n\r\n      const db = context.env.DB\r\n      const bucket = context.env.SNAPSHOTS_BUCKET\r\n\r\n      if (!bucket) {\r\n        return internalError('Storage not configured')\r\n      }\r\n\r\n      const bookmark = await db\r\n        .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL')\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!bookmark) return notFound('Bookmark not found')\r\n\r\n      if (verify_and_fix) {\r\n        return handleVerifyAndFix(db, bucket, bookmarkId, userId)\r\n      }\r\n\r\n      let toDelete: SnapshotRow[] = []\r\n\r\n      if (keep_count !== undefined && keep_count >= 0) {\r\n        const result = await db\r\n          .prepare(\r\n            `SELECT id, r2_key, file_size FROM bookmark_snapshots\r\n             WHERE bookmark_id = ? AND user_id = ?\r\n             ORDER BY version DESC LIMIT -1 OFFSET ?`,\r\n          )\r\n          .bind(bookmarkId, userId, keep_count)\r\n          .all()\r\n        toDelete = result.results || []\r\n      } else if (older_than_days !== undefined && older_than_days > 0) {\r\n        const cutoffDate = new Date()\r\n        cutoffDate.setDate(cutoffDate.getDate() - older_than_days)\r\n        const result = await db\r\n          .prepare(\r\n            `SELECT id, r2_key, file_size FROM bookmark_snapshots\r\n             WHERE bookmark_id = ? AND user_id = ? AND created_at < ?\r\n             ORDER BY version ASC`,\r\n          )\r\n          .bind(bookmarkId, userId, cutoffDate.toISOString())\r\n          .all()\r\n        toDelete = result.results || []\r\n      } else {\r\n        return badRequest('Must specify keep_count or older_than_days')\r\n      }\r\n\r\n      if (toDelete.length === 0) {\r\n        return success({ deleted_count: 0, freed_space: 0, message: 'No snapshots to delete' })\r\n      }\r\n\r\n      const freedSpace = toDelete.reduce((sum, s) => sum + (s.file_size as number || 0), 0)\r\n\r\n      for (const snapshot of toDelete) {\r\n        await bucket.delete(snapshot.r2_key as string)\r\n      }\r\n\r\n      await deleteSnapshotRows(db, toDelete.map((s) => s.id), userId)\r\n      await updateBookmarkSnapshotCount(db, bookmarkId, userId)\r\n\r\n      return success({\r\n        deleted_count: toDelete.length,\r\n        freed_space: freedSpace,\r\n        message: `Deleted ${toDelete.length} snapshots`,\r\n      })\r\n    } catch (error) {\r\n      console.error('Cleanup snapshots error:', error)\r\n      return internalError('Failed to cleanup snapshots')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id]/snapshots.ts",
    "content": "/**\r\n *  API\r\n * : /api/v1/bookmarks/:id/snapshots\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../middleware/auth'\r\nimport { generateSignedUrl } from '../../../../lib/signed-url'\r\nimport { generateNanoId } from '../../../../lib/crypto'\r\nimport { checkR2Quota } from '../../../../lib/storage-quota'\r\nimport { cleanupOldSnapshots } from './snapshot-cleanup'\r\n\r\n//  Web Crypto API  SHA-256 \r\nasync function sha256(content: string): Promise<string> {\r\n  const encoder = new TextEncoder()\r\n  const data = encoder.encode(content)\r\n  const hashBuffer = await crypto.subtle.digest('SHA-256', data)\r\n  const hashArray = Array.from(new Uint8Array(hashBuffer))\r\n  return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')\r\n}\r\n\r\ninterface CreateSnapshotRequest {\r\n  html_content: string\r\n  title: string\r\n  url: string\r\n  force?: boolean\r\n}\r\n\r\n// \r\nconst MAX_SNAPSHOT_SIZE = 50 * 1024 * 1024 // 50MB\r\n\r\n// GET /api/v1/bookmarks/:id/snapshots - \r\nexport const onRequestGet: PagesFunction<Env, 'id', AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      const db = context.env.DB\r\n\r\n      // \r\n      const bookmark = await db\r\n        .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL')\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!bookmark) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      // \r\n      const snapshots = await db\r\n        .prepare(\r\n          `SELECT id, version, file_size, content_hash, snapshot_title, \r\n                  is_latest, created_at\r\n           FROM bookmark_snapshots\r\n           WHERE bookmark_id = ? AND user_id = ?\r\n           ORDER BY version DESC`\r\n        )\r\n        .bind(bookmarkId, userId)\r\n        .all()\r\n\r\n      //  URL\r\n      const snapshotsWithUrls = await Promise.all(\r\n        (snapshots.results || []).map(async (snapshot: Record<string, unknown>) => {\r\n          //  24  URL\r\n          const { signature, expires } = await generateSignedUrl(\r\n            {\r\n              userId,\r\n              resourceId: snapshot.id,\r\n              expiresIn: 24 * 3600, // 24 \r\n              action: 'view',\r\n            },\r\n            context.env.JWT_SECRET\r\n          )\r\n\r\n          //  URL\r\n          const baseUrl = new URL(context.request.url).origin\r\n          const viewUrl = `${baseUrl}/api/v1/bookmarks/${bookmarkId}/snapshots/${snapshot.id}/view?sig=${signature}&exp=${expires}&u=${userId}&a=view`\r\n\r\n          return {\r\n            ...snapshot,\r\n            view_url: viewUrl,\r\n          }\r\n        })\r\n      )\r\n\r\n      return success({\r\n        snapshots: snapshotsWithUrls,\r\n        total: snapshotsWithUrls.length,\r\n      })\r\n    } catch (error) {\r\n      console.error('Get snapshots error:', error)\r\n      return internalError('Failed to get snapshots')\r\n    }\r\n  },\r\n]\r\n\r\n// POST /api/v1/bookmarks/:id/snapshots - \r\nexport const onRequestPost: PagesFunction<Env, 'id', AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const bookmarkId = context.params.id\r\n\r\n    try {\r\n      const body = await context.request.json() as CreateSnapshotRequest\r\n      const { html_content, title, url, force = false } = body\r\n\r\n      if (!html_content || !title || !url) {\r\n        return badRequest('Missing required fields')\r\n      }\r\n\r\n      // \r\n      const originalSize = new Blob([html_content]).size\r\n      if (originalSize > MAX_SNAPSHOT_SIZE) {\r\n        return badRequest(\r\n          `Snapshot too large (${(originalSize / 1024 / 1024).toFixed(2)}MB). Maximum size is ${MAX_SNAPSHOT_SIZE / 1024 / 1024}MB.`\r\n        )\r\n      }\r\n\r\n      const db = context.env.DB\r\n      const bucket = context.env.SNAPSHOTS_BUCKET\r\n\r\n      if (!bucket) {\r\n        return internalError('Storage not configured')\r\n      }\r\n\r\n      // \r\n      const bookmark = await db\r\n        .prepare('SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL')\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!bookmark) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      // \r\n      const contentHash = await sha256(html_content)\r\n\r\n      // （）\r\n      if (!force) {\r\n        const latestSnapshot = await db\r\n          .prepare(\r\n            `SELECT content_hash FROM bookmark_snapshots\r\n             WHERE bookmark_id = ? AND is_latest = 1`\r\n          )\r\n          .bind(bookmarkId)\r\n          .first()\r\n\r\n        if (latestSnapshot && latestSnapshot.content_hash === contentHash) {\r\n          return success({\r\n            message: 'Content unchanged, no new snapshot created',\r\n            is_duplicate: true,\r\n          })\r\n        }\r\n      }\r\n\r\n      // \r\n      const versionResult = await db\r\n        .prepare(\r\n          `SELECT COALESCE(MAX(version), 0) + 1 as next_version\r\n           FROM bookmark_snapshots\r\n           WHERE bookmark_id = ?`\r\n        )\r\n        .bind(bookmarkId)\r\n        .first()\r\n\r\n      const version = versionResult?.next_version as number || 1\r\n\r\n      //  R2 \r\n      const timestamp = Date.now()\r\n      const r2Key = `${userId}/${bookmarkId}/snapshot-${timestamp}-v${version}.html`\r\n\r\n      //  HTML  UTF-8 \r\n      const encoder = new TextEncoder()\r\n      const htmlBytes = encoder.encode(html_content)\r\n\r\n      // （ bookmark_snapshots.file_size ）\r\n      const quota = await checkR2Quota(db, context.env, htmlBytes.length)\r\n      if (!quota.allowed) {\r\n        const usedGB = quota.usedBytes / (1024 * 1024 * 1024)\r\n        const limitGB = quota.limitBytes / (1024 * 1024 * 1024)\r\n        return badRequest({\r\n          code: 'R2_STORAGE_LIMIT_EXCEEDED',\r\n          message: `Snapshot storage limit exceeded. Used ${usedGB.toFixed(2)}GB of ${limitGB.toFixed(2)}GB. Please delete some snapshots or images and try again.`,\r\n        })\r\n      }\r\n\r\n      //  UTF-8  R2\r\n      await bucket.put(r2Key, htmlBytes, {\r\n        httpMetadata: {\r\n          contentType: 'text/html; charset=utf-8',\r\n        },\r\n        customMetadata: {\r\n          userId,\r\n          bookmarkId,\r\n          version: version.toString(),\r\n          title,\r\n          fileSize: htmlBytes.length.toString(),\r\n        },\r\n      })\r\n      const snapshotId = generateNanoId()\r\n      const now = new Date().toISOString()\r\n\r\n      // （ INSERT ，）\r\n      const batch = [\r\n        // ，\r\n        db.prepare(\r\n          `INSERT INTO bookmark_snapshots\r\n           (id, bookmark_id, user_id, version, is_latest, content_hash,\r\n            r2_key, r2_bucket, file_size, mime_type, snapshot_url,\r\n            snapshot_title, snapshot_status, created_at, updated_at)\r\n           VALUES (?, ?, ?,\r\n            (SELECT COALESCE(MAX(version), 0) + 1 FROM bookmark_snapshots WHERE bookmark_id = ?),\r\n            1, ?, ?, 'tmarks-snapshots', ?, 'text/html', ?, ?, 'completed', ?, ?)`\r\n        ).bind(\r\n          snapshotId,\r\n          bookmarkId,\r\n          userId,\r\n          bookmarkId,\r\n          contentHash,\r\n          r2Key,\r\n          htmlBytes.length,\r\n          url,\r\n          title,\r\n          now,\r\n          now\r\n        ),\r\n\r\n        //  is_latest \r\n        db.prepare(\r\n          `UPDATE bookmark_snapshots \r\n           SET is_latest = 0 \r\n           WHERE bookmark_id = ? AND id != ?`\r\n        ).bind(bookmarkId, snapshotId),\r\n\r\n        // （）\r\n        db.prepare(\r\n          `UPDATE bookmarks \r\n           SET has_snapshot = 1, \r\n               latest_snapshot_at = ?,\r\n               snapshot_count = snapshot_count + 1\r\n           WHERE id = ?`\r\n        ).bind(now, bookmarkId),\r\n      ]\r\n\r\n      await db.batch(batch)\r\n\r\n      // \r\n      await cleanupOldSnapshots(db, bucket, bookmarkId, userId)\r\n\r\n      //  URL（24 ）\r\n      const { signature, expires } = await generateSignedUrl(\r\n        {\r\n          userId,\r\n          resourceId: snapshotId,\r\n          expiresIn: 24 * 3600,\r\n          action: 'view',\r\n        },\r\n        context.env.JWT_SECRET\r\n      )\r\n\r\n      //  URL\r\n      const baseUrl = new URL(context.request.url).origin\r\n      const viewUrl = `${baseUrl}/api/v1/bookmarks/${bookmarkId}/snapshots/${snapshotId}/view?sig=${signature}&exp=${expires}&u=${userId}&a=view`\r\n\r\n      return success({\r\n        snapshot: {\r\n          id: snapshotId,\r\n          version,\r\n          file_size: htmlBytes.length,\r\n          content_hash: contentHash,\r\n          snapshot_title: title,\r\n          is_latest: true,\r\n          created_at: now,\r\n          view_url: viewUrl,\r\n        },\r\n        message: 'Snapshot created successfully',\r\n      })\r\n    } catch (error) {\r\n      console.error('Create snapshot error:', error)\r\n      return internalError('Failed to create snapshot')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/[id].ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, Bookmark, BookmarkRow, RouteParams, SQLParam } from '../../../lib/types'\r\nimport { success, badRequest, notFound, noContent, internalError } from '../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\nimport { isValidUrl, sanitizeString } from '../../../lib/validation'\nimport { normalizeBookmark } from '../../../lib/bookmark-utils'\nimport { getValidTagIds, replaceBookmarkTags, replaceBookmarkTagsByNames } from '../../../lib/tags'\nimport { invalidatePublicShareCache } from '../../shared/cache'\n\r\ninterface UpdateBookmarkRequest {\r\n  title?: string\r\n  url?: string\r\n  description?: string\r\n  cover_image?: string\r\n  favicon?: string\r\n  tag_ids?: string[]  // ： ID \r\n  tags?: string[]     // ：（）\r\n  is_pinned?: boolean\r\n  is_public?: boolean\r\n}\r\n\r\n// PATCH /api/v1/bookmarks/:id - \r\nexport const onRequestPatch: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    try {\r\n      const userId = context.data.user_id\r\n      const bookmarkId = context.params.id\r\n      const body = await context.request.json() as UpdateBookmarkRequest\r\n\r\n      // \r\n      const bookmarkRow = await context.env.DB.prepare(\r\n        'SELECT * FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first<BookmarkRow>()\r\n\r\n      if (!bookmarkRow) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      // \r\n      if (body.url && !isValidUrl(body.url)) {\r\n        return badRequest('Invalid URL format')\r\n      }\r\n\r\n      // \r\n      const updates: string[] = []\r\n      const values: SQLParam[] = []\r\n\r\n      if (body.title !== undefined) {\r\n        updates.push('title = ?')\r\n        values.push(sanitizeString(body.title, 500))\r\n      }\r\n\r\n      if (body.url !== undefined) {\r\n        updates.push('url = ?')\r\n        values.push(sanitizeString(body.url, 2000))\r\n      }\r\n\r\n      if (body.description !== undefined) {\r\n        updates.push('description = ?')\r\n        values.push(body.description ? sanitizeString(body.description, 1000) : null)\r\n      }\r\n\r\n      if (body.cover_image !== undefined) {\r\n        updates.push('cover_image = ?')\r\n        values.push(body.cover_image ? sanitizeString(body.cover_image, 2000) : null)\r\n      }\r\n\r\n      if (body.favicon !== undefined) {\r\n        updates.push('favicon = ?')\r\n        values.push(body.favicon ? sanitizeString(body.favicon, 2000) : null)\r\n      }\r\n\r\n      if (body.is_pinned !== undefined) {\r\n        updates.push('is_pinned = ?')\r\n        values.push(body.is_pinned ? 1 : 0)\r\n      }\r\n\r\n      if (body.is_public !== undefined) {\r\n        updates.push('is_public = ?')\r\n        values.push(body.is_public ? 1 : 0)\r\n      }\r\n\r\n      const now = new Date().toISOString()\r\n\r\n      if (updates.length > 0) {\r\n        updates.push('updated_at = ?')\r\n        values.push(now)\r\n        values.push(bookmarkId, userId)\r\n\r\n        await context.env.DB.prepare(\r\n          `UPDATE bookmarks SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`\r\n        )\r\n          .bind(...values)\r\n          .run()\r\n      }\r\n\r\n      // \n      if (body.tags !== undefined) {\n        await replaceBookmarkTagsByNames(context.env.DB, bookmarkId, body.tags, userId, now)\n      } else if (body.tag_ids !== undefined) {\n        const validTagIds = await getValidTagIds(context.env.DB, userId, body.tag_ids)\n        await replaceBookmarkTags(context.env.DB, bookmarkId, userId, validTagIds, now)\n      }\n\r\n      // \r\n      const updatedBookmarkRow = await context.env.DB.prepare('SELECT * FROM bookmarks WHERE id = ? AND user_id = ?')\r\n        .bind(bookmarkId, userId)\r\n        .first<BookmarkRow>()\r\n\r\n      const { results: tags } = await context.env.DB.prepare(\r\n        `SELECT t.id, t.name, t.color\r\n         FROM tags t\r\n         INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n         WHERE bt.bookmark_id = ? AND bt.user_id = ? AND t.deleted_at IS NULL`\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .all<{ id: string; name: string; color: string | null }>()\r\n\r\n      if (!updatedBookmarkRow) {\r\n        return internalError('Failed to load bookmark after update')\r\n      }\r\n\r\n      await invalidatePublicShareCache(context.env, userId)\r\n\r\n      return success({\r\n        bookmark: {\r\n          ...normalizeBookmark(updatedBookmarkRow),\r\n          tags: tags || [],\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Update bookmark error:', error)\r\n      return internalError('Failed to update bookmark')\r\n    }\r\n  },\r\n]\r\n\r\n// DELETE /api/v1/bookmarks/:id - \r\nexport const onRequestDelete: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    try {\r\n      const userId = context.data.user_id\r\n      const bookmarkId = context.params.id\r\n\r\n      // \r\n      const bookmark = await context.env.DB.prepare(\r\n        'SELECT id FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first()\r\n\r\n      if (!bookmark) {\r\n        return notFound('Bookmark not found')\r\n      }\r\n\r\n      const now = new Date().toISOString()\r\n      await context.env.DB.prepare(\r\n        'UPDATE bookmarks SET deleted_at = ?, updated_at = ?, click_count = 0, last_clicked_at = NULL WHERE id = ? AND user_id = ?'\r\n      ).bind(now, now, bookmarkId, userId).run()\r\n\r\n      await invalidatePublicShareCache(context.env, userId)\r\n\r\n      return noContent()\r\n    } catch (error) {\r\n      console.error('Delete bookmark error:', error)\r\n      return internalError('Failed to delete bookmark')\r\n    }\r\n  },\r\n]\r\n\r\n// PUT /api/v1/bookmarks/:id - \r\nexport const onRequestPut: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    try {\r\n      const userId = context.data.user_id\r\n      const bookmarkId = context.params.id\r\n\r\n      // 、\r\n      const bookmark = await context.env.DB.prepare(\r\n        'SELECT * FROM bookmarks WHERE id = ? AND user_id = ? AND deleted_at IS NOT NULL'\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .first<Bookmark>()\r\n\r\n      if (!bookmark) {\r\n        return notFound('Deleted bookmark not found')\r\n      }\r\n\r\n      // \r\n      const now = new Date().toISOString()\r\n      await context.env.DB.prepare(\r\n        'UPDATE bookmarks SET deleted_at = NULL, updated_at = ? WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(now, bookmarkId, userId)\r\n        .run()\r\n\r\n      // \r\n      const restoredBookmarkRow = await context.env.DB.prepare('SELECT * FROM bookmarks WHERE id = ? AND user_id = ?')\r\n        .bind(bookmarkId, userId)\r\n        .first<BookmarkRow>()\r\n\r\n      const { results: tags } = await context.env.DB.prepare(\r\n        `SELECT t.id, t.name, t.color\r\n         FROM tags t\r\n         INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n         WHERE bt.bookmark_id = ? AND bt.user_id = ? AND t.deleted_at IS NULL`\r\n      )\r\n        .bind(bookmarkId, userId)\r\n        .all<{ id: string; name: string; color: string | null }>()\r\n\r\n      if (!restoredBookmarkRow) {\r\n        return internalError('Failed to load bookmark after restore')\r\n      }\r\n\r\n      await invalidatePublicShareCache(context.env, userId)\r\n\r\n      return success({\r\n        bookmark: {\r\n          ...normalizeBookmark(restoredBookmarkRow),\r\n          tags: tags || [],\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Restore bookmark error:', error)\r\n      return internalError('Failed to restore bookmark')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/bulk.ts",
    "content": "import type { PagesFunction, D1PreparedStatement } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../lib/types'\r\nimport { requireAuth, type AuthContext } from '../../../middleware/auth'\r\nimport { invalidatePublicShareCache } from '../../shared/cache'\r\nimport { CacheService } from '../../../lib/cache'\r\nimport { createBookmarkCacheManager } from '../../../lib/cache/bookmark-cache'\r\n\r\ntype BatchActionType = 'delete' | 'update_tags' | 'pin' | 'unpin' | 'archive' | 'unarchive'\r\ninterface BatchActionRequest {\r\n  action: BatchActionType\r\n  bookmark_ids: string[]\r\n  add_tag_ids?: string[]\r\n  remove_tag_ids?: string[]\r\n}\r\ninterface BatchActionResponse {\r\n  success: boolean\r\n  affected_count: number\r\n  errors?: Array<{ bookmark_id: string; message: string }>\r\n}\r\n\r\nexport const onRequestPatch: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    let body: BatchActionRequest | null = null\r\n    try {\r\n      body = (await context.request.json()) as BatchActionRequest\r\n      const { action, bookmark_ids, add_tag_ids, remove_tag_ids } = body\r\n      if (!action || !bookmark_ids || !Array.isArray(bookmark_ids) || bookmark_ids.length === 0) {\r\n        return new Response(JSON.stringify({ code: 'INVALID_REQUEST', message: 'action and bookmark_ids are required' }), { status: 400, headers: { 'Content-Type': 'application/json' } })\r\n      }\r\n      if (bookmark_ids.length > 100) {\r\n        return new Response(JSON.stringify({ code: 'TOO_MANY_ITEMS', message: 'Cannot process more than 100 bookmarks at once' }), { status: 400, headers: { 'Content-Type': 'application/json' } })\r\n      }\r\n      const db = context.env.DB\r\n      const placeholders = bookmark_ids.map(() => '?').join(',')\r\n      let affectedCount = 0\r\n      const errors: Array<{ bookmark_id: string; message: string }> = []\r\n\r\n      switch (action) {\r\n        case 'delete': {\r\n          const result = await db.prepare(\r\n            `UPDATE bookmarks SET deleted_at = datetime('now'), click_count = 0, last_clicked_at = NULL WHERE id IN (${placeholders}) AND user_id = ? AND deleted_at IS NULL`\r\n          ).bind(...bookmark_ids, userId).run()\r\n          affectedCount = result.meta.changes || 0\r\n          await db.prepare(`INSERT INTO audit_logs (user_id, event_type, payload, created_at) VALUES (?, 'batch_delete_bookmarks', ?, datetime('now'))`).bind(userId, JSON.stringify({ bookmark_ids, count: affectedCount })).run()\r\n          break\r\n        }\r\n        case 'pin': {\r\n          const result = await db.prepare(`UPDATE bookmarks SET is_pinned = 1, updated_at = datetime('now') WHERE id IN (${placeholders}) AND user_id = ? AND deleted_at IS NULL`).bind(...bookmark_ids, userId).run()\r\n          affectedCount = result.meta.changes || 0\r\n          break\r\n        }\r\n        case 'unpin': {\r\n          const result = await db.prepare(`UPDATE bookmarks SET is_pinned = 0, updated_at = datetime('now') WHERE id IN (${placeholders}) AND user_id = ? AND deleted_at IS NULL`).bind(...bookmark_ids, userId).run()\r\n          affectedCount = result.meta.changes || 0\r\n          break\r\n        }\r\n        case 'update_tags': {\r\n          if (add_tag_ids && add_tag_ids.length > 50) {\r\n            return new Response(JSON.stringify({ code: 'INVALID_REQUEST', message: 'Cannot add more than 50 tags at once' }), { status: 400, headers: { 'Content-Type': 'application/json' } })\r\n          }\r\n          if (remove_tag_ids && remove_tag_ids.length > 50) {\r\n            return new Response(JSON.stringify({ code: 'INVALID_REQUEST', message: 'Cannot remove more than 50 tags at once' }), { status: 400, headers: { 'Content-Type': 'application/json' } })\r\n          }\r\n          const verifyResult = await db.prepare(`SELECT id FROM bookmarks WHERE id IN (${placeholders}) AND user_id = ? AND deleted_at IS NULL`).bind(...bookmark_ids, userId).all<{ id: string }>()\r\n          const validBookmarkIds = verifyResult.results.map((row: { id: string }) => row.id)\r\n          if (validBookmarkIds.length === 0) {\r\n            return new Response(JSON.stringify({ code: 'NO_VALID_BOOKMARKS', message: 'No valid bookmarks found' }), { status: 404, headers: { 'Content-Type': 'application/json' } })\r\n          }\r\n          // Collect all tag mutation statements for atomic batch execution\r\n          const tagStmts: D1PreparedStatement[] = []\r\n\r\n          if (remove_tag_ids && remove_tag_ids.length > 0) {\r\n            const tagPlaceholders = remove_tag_ids.map(() => '?').join(',')\r\n            const bookmarkPlaceholders = validBookmarkIds.map(() => '?').join(',')\r\n            tagStmts.push(\r\n              db.prepare(`DELETE FROM bookmark_tags WHERE bookmark_id IN (${bookmarkPlaceholders}) AND tag_id IN (${tagPlaceholders}) AND user_id = ?`).bind(...validBookmarkIds, ...remove_tag_ids, userId)\r\n            )\r\n          }\r\n\r\n          let validTagIds: string[] = []\r\n          if (add_tag_ids && add_tag_ids.length > 0) {\r\n            const tagPlaceholders = add_tag_ids.map(() => '?').join(',')\r\n            const tagsResult = await db.prepare(`SELECT id FROM tags WHERE id IN (${tagPlaceholders}) AND user_id = ? AND deleted_at IS NULL`).bind(...add_tag_ids, userId).all<{ id: string }>()\r\n            validTagIds = tagsResult.results.map((row: { id: string }) => row.id)\r\n            if (validTagIds.length > 0) {\r\n              for (const bookmarkId of validBookmarkIds) {\r\n                for (const tagId of validTagIds) {\r\n                  tagStmts.push(\r\n                    db.prepare(`INSERT OR IGNORE INTO bookmark_tags (bookmark_id, tag_id, user_id, created_at) VALUES (?, ?, ?, datetime('now'))`).bind(bookmarkId, tagId, userId)\r\n                  )\r\n                }\r\n              }\r\n            }\r\n          }\r\n\r\n          // Add usage count updates, bookmark timestamps, and audit log\r\n          const bookmarkPlaceholders = validBookmarkIds.map(() => '?').join(',')\r\n          if (validTagIds.length > 0) {\r\n            for (const tagId of validTagIds) {\r\n              tagStmts.push(\r\n                db.prepare(`UPDATE tags SET usage_count = (SELECT COUNT(*) FROM bookmark_tags WHERE tag_id = ? AND user_id = ?) WHERE id = ? AND user_id = ?`).bind(tagId, userId, tagId, userId)\r\n              )\r\n            }\r\n          }\r\n          if (remove_tag_ids && remove_tag_ids.length > 0) {\r\n            for (const tagId of remove_tag_ids) {\r\n              tagStmts.push(\r\n                db.prepare(`UPDATE tags SET usage_count = (SELECT COUNT(*) FROM bookmark_tags WHERE tag_id = ? AND user_id = ?) WHERE id = ? AND user_id = ?`).bind(tagId, userId, tagId, userId)\r\n              )\r\n            }\r\n          }\r\n          tagStmts.push(\r\n            db.prepare(`UPDATE bookmarks SET updated_at = datetime('now') WHERE id IN (${bookmarkPlaceholders}) AND user_id = ?`).bind(...validBookmarkIds, userId),\r\n            db.prepare(`INSERT INTO audit_logs (user_id, event_type, payload, created_at) VALUES (?, 'batch_update_tags', ?, datetime('now'))`).bind(userId, JSON.stringify({ bookmark_ids: validBookmarkIds, add_tag_ids, remove_tag_ids })),\r\n          )\r\n          affectedCount = validBookmarkIds.length\r\n\r\n          try {\r\n            await db.batch(tagStmts)\r\n          } catch (e) {\r\n            console.error('Batch tag update failed:', e)\r\n            errors.push({ bookmark_id: 'batch', message: 'Failed to update tags in batch' })\r\n          }\r\n          break\r\n        }\r\n        default:\r\n          return new Response(JSON.stringify({ code: 'INVALID_ACTION', message: `Invalid action: ${action}` }), { status: 400, headers: { 'Content-Type': 'application/json' } })\r\n      }\r\n\r\n      const response: BatchActionResponse = { success: true, affected_count: affectedCount }\r\n      if (errors.length > 0) response.errors = errors\r\n      const cache = new CacheService(context.env)\r\n      const bookmarkCache = createBookmarkCacheManager(cache)\r\n      await bookmarkCache.handleBatchOperation(userId)\r\n      await invalidatePublicShareCache(context.env, userId)\r\n      return new Response(JSON.stringify(response), { status: 200, headers: { 'Content-Type': 'application/json' } })\r\n    } catch (error) {\r\n      console.error('Batch operation error:', error)\r\n      return new Response(JSON.stringify({ code: 'INTERNAL_ERROR', message: 'Failed to perform batch operation' }), { status: 500, headers: { 'Content-Type': 'application/json' } })\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/index.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, BookmarkRow, RouteParams, SQLParam } from '../../../lib/types'\r\nimport { success, badRequest, internalError } from '../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\r\nimport { fetchFullBookmarks } from '../../../lib/data-fetchers'\r\n\r\nexport const onRequestGet: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const { env, request, data } = context\r\n    const db = env.DB\r\n    const userId = data.user_id\r\n\r\n    if (!userId) {\r\n      return badRequest('User not authenticated')\r\n    }\r\n\r\n    try {\r\n      const url = new URL(request.url)\r\n      const keyword = url.searchParams.get('keyword')\r\n      const tagIds = url.searchParams.get('tagIds')?.split(',').filter(Boolean)\r\n      const groupId = url.searchParams.get('groupId')\r\n      const sortBy = url.searchParams.get('sortBy') || 'created_at'\r\n      const sortOrder = url.searchParams.get('sortOrder') || 'DESC'\r\n      const limit = parseInt(url.searchParams.get('limit') || '50', 10) || 50\r\n      const offset = parseInt(url.searchParams.get('offset') || '0', 10) || 0\r\n\r\n      let query = `\r\n        SELECT DISTINCT b.*\r\n        FROM bookmarks b\r\n        LEFT JOIN bookmark_tags bt ON b.id = bt.bookmark_id\r\n        WHERE b.user_id = ?\r\n      `\r\n      const params: SQLParam[] = [userId]\r\n\r\n      const conditions: string[] = []\r\n      const conditionParams: SQLParam[] = []\r\n\r\n      if (keyword) {\r\n        conditions.push('(b.title LIKE ? OR b.description LIKE ? OR b.url LIKE ?)')\r\n        const searchPattern = `%${keyword}%`\r\n        conditionParams.push(searchPattern, searchPattern, searchPattern)\r\n      }\r\n\r\n      if (tagIds && tagIds.length > 0) {\r\n        conditions.push(`bt.tag_id IN (${tagIds.map(() => '?').join(',')})`)\r\n        conditionParams.push(...tagIds)\r\n      }\r\n\r\n      if (groupId) {\r\n        if (groupId === 'none') {\r\n          conditions.push('b.group_id IS NULL')\r\n        } else {\r\n          conditions.push('b.group_id = ?')\r\n          conditionParams.push(groupId)\r\n        }\r\n      }\r\n\r\n      if (conditions.length > 0) {\r\n        query += ' AND ' + conditions.join(' AND ')\r\n      }\r\n\r\n      const countQuery = `SELECT COUNT(DISTINCT b.id) as total FROM bookmarks b LEFT JOIN bookmark_tags bt ON b.id = bt.bookmark_id WHERE b.user_id = ? ${conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : ''}`\r\n      const totalResult = await db.prepare(countQuery).bind(userId, ...conditionParams).first<{ total: number }>()\r\n      const total = totalResult?.total || 0\r\n\r\n      const validSortFields = ['created_at', 'updated_at', 'title']\r\n      const sortField = validSortFields.includes(sortBy) ? sortBy : 'created_at'\r\n      const direction = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'\r\n\r\n      query += ` ORDER BY b.${sortField} ${direction} LIMIT ? OFFSET ?`\r\n      params.push(...conditionParams, limit, offset)\r\n\r\n      const { results: rows } = await db.prepare(query).bind(...params).all<BookmarkRow>()\r\n\r\n      const bookmarks = await fetchFullBookmarks(db, rows, userId)\r\n\r\n      return success({\r\n        bookmarks,\r\n        pagination: {\r\n          total,\r\n          limit,\r\n          offset,\r\n        },\r\n      })\r\n    } catch (error: unknown) {\r\n      console.error('Fetch bookmarks error:', error)\r\n      return internalError('Failed to fetch bookmarks')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/statistics-helpers.ts",
    "content": "export interface BookmarkStatistics {\n  summary: {\n    total_bookmarks: number\n    total_tags: number\n    total_clicks: number\n    public_bookmarks: number\n  }\n  top_bookmarks: Array<{\n    id: string\n    title: string\n    url: string\n    click_count: number\n    last_clicked_at: string | null\n  }>\n  top_tags: Array<{\n    id: string\n    name: string\n    color: string | null\n    click_count: number\n    bookmark_count: number\n  }>\n  top_domains: Array<{\n    domain: string\n    count: number\n  }>\n  bookmark_clicks: Array<{\n    id: string\n    title: string\n    url: string\n    click_count: number\n  }>\n  recent_clicks: Array<{\n    id: string\n    title: string\n    url: string\n    last_clicked_at: string\n  }>\n  trends: {\n    bookmarks: Array<{ date: string; count: number }>\n    clicks: Array<{ date: string; count: number }>\n  }\n}\n\n/**\n *  SQL \n * @param granularity : day, week, month, year\n * @param field \n */\nexport function getDateGroupSql(granularity: string, field: string) {\n  let dateGroupBy = ''\n  let dateSelect = ''\n\n  switch (granularity) {\n    case 'year':\n      dateGroupBy = `strftime('%Y', ${field})`\n      dateSelect = `strftime('%Y', ${field}) as date`\n      break\n    case 'month':\n      dateGroupBy = `strftime('%Y-%m', ${field})`\n      dateSelect = `strftime('%Y-%m', ${field}) as date`\n      break\n    case 'week':\n      dateGroupBy = `strftime('%Y-W%W', ${field})`\n      dateSelect = `strftime('%Y-W%W', ${field}) as date`\n      break\n    case 'day':\n    default:\n      dateGroupBy = `DATE(${field})`\n      dateSelect = `DATE(${field}) as date`\n      break\n  }\n\n  return { dateGroupBy, dateSelect }\n}\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/statistics.ts",
    "content": "/**\r\n *  API\r\n * : /api/v1/bookmarks/statistics\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../lib/types'\r\nimport { success, internalError } from '../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\r\nimport { BookmarkStatistics, getDateGroupSql } from './statistics-helpers'\r\n\r\n// GET /api/v1/bookmarks/statistics - \r\nexport const onRequestGet: PagesFunction<Env, string, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const url = new URL(context.request.url)\r\n    \r\n    // \r\n    const granularity = url.searchParams.get('granularity') || 'day' // day, week, month, year\r\n    const startDate = url.searchParams.get('start_date') // YYYY-MM-DD\r\n    const endDate = url.searchParams.get('end_date') // YYYY-MM-DD\r\n\r\n    try {\r\n      const db = context.env.DB\r\n\n      const { dateGroupBy, dateSelect } = getDateGroupSql(granularity, 'created_at')\r\n\n      const { dateGroupBy: clickDateGroupBy, dateSelect: clickDateSelect } = getDateGroupSql(granularity, 'clicked_at')\r\n\n      const [\r\n        summary,\r\n        tagCount,\r\n        topBookmarks,\r\n        topTags,\r\n        topDomains,\r\n        recentClicks,\r\n        bookmarkTrends,\r\n        clickTrends,\r\n        bookmarkClickStats,\r\n      ] = await Promise.all([\r\n\n        db.prepare(\r\n          `SELECT \r\n            COUNT(*) as total_bookmarks,\r\n            SUM(CASE WHEN deleted_at IS NULL THEN 1 ELSE 0 END) as active_bookmarks,\r\n            SUM(CASE WHEN is_public = 1 AND deleted_at IS NULL THEN 1 ELSE 0 END) as public_bookmarks,\r\n            SUM(click_count) as total_clicks\r\n          FROM bookmarks \r\n          WHERE user_id = ? AND deleted_at IS NULL`\r\n        )\r\n          .bind(userId)\r\n          .first(),\r\n\r\n        // 2. \r\n        db.prepare(\r\n          `SELECT COUNT(*) as total_tags \r\n          FROM tags \r\n          WHERE user_id = ? AND deleted_at IS NULL`\r\n        )\r\n          .bind(userId)\r\n          .first(),\r\n\r\n        // 3.  Top 10\r\n        db.prepare(\r\n          `SELECT id, title, url, click_count, last_clicked_at\r\n          FROM bookmarks\r\n          WHERE user_id = ? AND deleted_at IS NULL AND click_count > 0\r\n          ORDER BY click_count DESC, last_clicked_at DESC\r\n          LIMIT 10`\r\n        )\r\n          .bind(userId)\r\n          .all(),\r\n\r\n        // 4.  Top 10（）\r\n        db.prepare(\r\n          `SELECT \r\n            t.id, \r\n            t.name, \r\n            t.color, \r\n            t.click_count,\r\n            COUNT(DISTINCT bt.bookmark_id) as bookmark_count\r\n          FROM tags t\r\n          LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n          WHERE t.user_id = ? AND t.deleted_at IS NULL\r\n          GROUP BY t.id, t.name, t.color, t.click_count\r\n          ORDER BY t.click_count DESC, bookmark_count DESC\r\n          LIMIT 10`\r\n        )\r\n          .bind(userId)\r\n          .all(),\r\n\r\n        // 5.  Top 10\r\n        db.prepare(\r\n          `SELECT \r\n            CASE \r\n              WHEN url LIKE 'http://%' THEN substr(url, 8, instr(substr(url, 8), '/') - 1)\r\n              WHEN url LIKE 'https://%' THEN substr(url, 9, instr(substr(url, 9), '/') - 1)\r\n              ELSE url\r\n            END as domain,\r\n            COUNT(*) as count\r\n          FROM bookmarks\r\n          WHERE user_id = ? AND deleted_at IS NULL\r\n          GROUP BY domain\r\n          ORDER BY count DESC\r\n          LIMIT 10`\r\n        )\r\n          .bind(userId)\r\n          .all(),\r\n\n        db.prepare(\r\n          `SELECT id, title, url, last_clicked_at\r\n          FROM bookmarks\r\n          WHERE user_id = ? AND deleted_at IS NULL AND last_clicked_at IS NOT NULL\r\n          ORDER BY last_clicked_at DESC\r\n          LIMIT 10`\r\n        )\r\n          .bind(userId)\r\n          .all(),\r\n\r\n        // 7. \r\n        db.prepare(\r\n          `SELECT \r\n            ${dateSelect},\r\n            COUNT(*) as count\r\n          FROM bookmarks\r\n          WHERE user_id = ? AND deleted_at IS NULL \r\n            ${startDate ? `AND DATE(created_at) >= ?` : ''}\r\n            ${endDate ? `AND DATE(created_at) <= ?` : ''}\r\n          GROUP BY ${dateGroupBy}\r\n          ORDER BY date ASC`\r\n        )\r\n          .bind(userId, ...[startDate, endDate].filter(Boolean))\r\n          .all(),\r\n\n        db.prepare(\r\n          `SELECT\r\n            ${clickDateSelect},\r\n            COUNT(*) as count\r\n          FROM bookmark_click_events\r\n          WHERE user_id = ?\r\n            ${startDate ? `AND DATE(clicked_at) >= ?` : ''}\r\n            ${endDate ? `AND DATE(clicked_at) <= ?` : ''}\r\n          GROUP BY ${clickDateGroupBy}\r\n          ORDER BY date ASC`\r\n        )\r\n          .bind(userId, ...[startDate, endDate].filter(Boolean))\r\n          .all(),\r\n\r\n        // 9. \r\n        db.prepare(\r\n          `SELECT\r\n            b.id,\r\n            b.title,\r\n            b.url,\r\n            COUNT(e.id) as click_count\r\n          FROM bookmark_click_events e\r\n          JOIN bookmarks b ON e.bookmark_id = b.id\r\n          WHERE e.user_id = ? AND b.deleted_at IS NULL\r\n            ${startDate ? `AND DATE(e.clicked_at) >= ?` : ''}\r\n            ${endDate ? `AND DATE(e.clicked_at) <= ?` : ''}\r\n          GROUP BY b.id, b.title, b.url\r\n          ORDER BY click_count DESC`\r\n        )\r\n          .bind(userId, ...[startDate, endDate].filter(Boolean))\r\n          .all()\r\n      ])\r\n\r\n      const statistics: BookmarkStatistics = {\r\n        summary: {\r\n          total_bookmarks: (summary?.total_bookmarks as number) || 0,\r\n          total_tags: (tagCount?.total_tags as number) || 0,\r\n          total_clicks: (summary?.total_clicks as number) || 0,\r\n          public_bookmarks: (summary?.public_bookmarks as number) || 0,\r\n        },\r\n        top_bookmarks: (topBookmarks.results || []) as Array<{\r\n          id: string\r\n          title: string\r\n          url: string\r\n          click_count: number\r\n          last_clicked_at: string | null\r\n        }>,\r\n        top_tags: (topTags.results || []) as Array<{\r\n          id: string\r\n          name: string\r\n          color: string | null\r\n          click_count: number\r\n          bookmark_count: number\r\n        }>,\r\n        top_domains: (topDomains.results || []) as Array<{\r\n          domain: string\r\n          count: number\r\n        }>,\r\n        recent_clicks: (recentClicks.results || []) as Array<{\r\n          id: string\r\n          title: string\r\n          url: string\r\n          last_clicked_at: string\r\n        }>,\r\n        bookmark_clicks: (bookmarkClickStats.results || []) as Array<{\r\n          id: string\r\n          title: string\r\n          url: string\r\n          click_count: number\r\n        }>,\r\n        trends: {\r\n          bookmarks: (bookmarkTrends.results || []) as Array<{ date: string; count: number }>,\r\n          clicks: (clickTrends.results || []) as Array<{ date: string; count: number }>,\r\n        },\r\n      }\r\n\r\n      return success(statistics)\r\n    } catch (error) {\r\n      console.error('Get bookmark statistics error:', error)\r\n      return internalError('Failed to get statistics')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/trash/empty.ts",
    "content": "/**\r\n\r\n * : /api/v1/bookmarks/trash/empty\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../../lib/types'\r\nimport { success, internalError } from '../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../middleware/auth'\r\n\r\nexport const onRequestDelete: PagesFunction<Env, string, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n\r\n    try {\r\n\r\n      const { results: trashBookmarks } = await context.env.DB.prepare(\r\n        'SELECT id FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL'\r\n      )\r\n        .bind(userId)\r\n        .all<{ id: string }>()\r\n\r\n      if (trashBookmarks.length === 0) {\r\n        return success({ message: 'Trash is already empty', count: 0 })\r\n      }\r\n\r\n      const bookmarkIds = trashBookmarks.map(b => b.id)\r\n\r\n      // \r\n      for (const id of bookmarkIds) {\r\n        await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ?')\r\n          .bind(id)\r\n          .run()\r\n      }\r\n\r\n      // \r\n      for (const id of bookmarkIds) {\r\n        await context.env.DB.prepare('DELETE FROM bookmark_snapshots WHERE bookmark_id = ?')\r\n          .bind(id)\r\n          .run()\r\n      }\r\n\r\n      // \r\n      await context.env.DB.prepare(\r\n        'DELETE FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL'\r\n      )\r\n        .bind(userId)\r\n        .run()\r\n\r\n      return success({\r\n        message: 'Trash emptied successfully',\r\n        count: bookmarkIds.length,\r\n      })\r\n    } catch (error) {\r\n      console.error('Empty trash error:', error)\r\n      return internalError('Failed to empty trash')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/bookmarks/trash.ts",
    "content": "/**\r\n\r\n * : /api/v1/bookmarks/trash\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, BookmarkRow } from '../../../lib/types'\r\nimport { success, internalError } from '../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\r\nimport { normalizeBookmark } from '../../../lib/bookmark-utils'\r\n\r\ninterface TrashQueryParams {\r\n  page_size?: string\r\n  page_cursor?: string\r\n  sort?: string\r\n}\r\n\r\nexport const onRequestGet: PagesFunction<Env, string, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const url = new URL(context.request.url)\r\n    \r\n    const params: TrashQueryParams = {\r\n      page_size: url.searchParams.get('page_size') || undefined,\r\n      page_cursor: url.searchParams.get('page_cursor') || undefined,\r\n      sort: url.searchParams.get('sort') || undefined,\r\n    }\r\n\r\n    try {\r\n      const pageSize = Math.min(Math.max(parseInt(params.page_size || '20', 10) || 20, 1), 100)\r\n      const sort = params.sort === 'deleted_at_asc' ? 'ASC' : 'DESC'\r\n\r\n      let query = `\r\n        SELECT * FROM bookmarks \r\n        WHERE user_id = ? AND deleted_at IS NOT NULL\r\n      `\r\n      const queryParams: (string | number)[] = [userId]\r\n\r\n      // \r\n      if (params.page_cursor) {\r\n        query += ` AND deleted_at < ?`\r\n        queryParams.push(params.page_cursor)\r\n      }\r\n\r\n      query += ` ORDER BY deleted_at ${sort} LIMIT ?`\r\n      queryParams.push(pageSize + 1)\r\n\r\n      const { results: bookmarks } = await context.env.DB.prepare(query)\r\n        .bind(...queryParams)\r\n        .all<BookmarkRow>()\r\n\r\n      const hasMore = bookmarks.length > pageSize\r\n      const items = hasMore ? bookmarks.slice(0, pageSize) : bookmarks\r\n\r\n      const bookmarksWithTags = await Promise.all(\r\n        items.map(async (bookmark) => {\r\n          const { results: tags } = await context.env.DB.prepare(\r\n            `SELECT t.id, t.name, t.color\r\n             FROM tags t\r\n             INNER JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n             WHERE bt.bookmark_id = ? AND t.deleted_at IS NULL`\r\n          )\r\n            .bind(bookmark.id)\r\n            .all<{ id: string; name: string; color: string | null }>()\r\n\r\n          return {\r\n            ...normalizeBookmark(bookmark),\r\n            tags: tags || [],\r\n          }\r\n        })\r\n      )\r\n\r\n      // \r\n      const countResult = await context.env.DB.prepare(\r\n        'SELECT COUNT(*) as count FROM bookmarks WHERE user_id = ? AND deleted_at IS NOT NULL'\r\n      )\r\n        .bind(userId)\r\n        .first<{ count: number }>()\r\n\r\n      return success({\r\n        bookmarks: bookmarksWithTags,\r\n        meta: {\r\n          total: countResult?.count || 0,\r\n          page_size: pageSize,\r\n          has_more: hasMore,\r\n          next_cursor: hasMore && items.length > 0 ? items[items.length - 1].deleted_at : null,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Get trash bookmarks error:', error)\r\n      return internalError('Failed to get trash bookmarks')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/change-password.ts",
    "content": "/**\r\n * Change Password API\r\n * Path: /api/v1/change-password\r\n * Auth: JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../lib/types'\r\nimport { success, badRequest, unauthorized, internalError } from '../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../middleware/auth'\r\nimport { hashPassword, verifyPassword } from '../../lib/crypto'\r\n\r\ninterface ChangePasswordRequest {\r\n  current_password: string\r\n  new_password: string\r\n}\r\n\r\n// POST /api/v1/change-password - Change password\r\nexport const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n\r\n    try {\r\n      const body = (await context.request.json()) as ChangePasswordRequest\r\n\r\n      // Validate request parameters\r\n      if (!body.current_password || !body.new_password) {\r\n        return badRequest('Current password and new password are required')\r\n      }\r\n\r\n      // Validate new password length\r\n      if (body.new_password.length < 6) {\r\n        return badRequest('New password must be at least 6 characters')\r\n      }\r\n\r\n      // Get user's current password hash\r\n      const user = await context.env.DB.prepare(\r\n        'SELECT password_hash FROM users WHERE id = ?'\r\n      )\r\n        .bind(userId)\r\n        .first<{ password_hash: string }>()\r\n\r\n      if (!user) {\r\n        return unauthorized('User not found')\r\n      }\r\n\r\n      // Verify current password\r\n      const isCurrentPasswordValid = await verifyPassword(\r\n        body.current_password,\r\n        user.password_hash\r\n      )\r\n\r\n      if (!isCurrentPasswordValid) {\r\n        return unauthorized('Current password is incorrect')\r\n      }\r\n\r\n      // Generate new password hash\r\n      const newPasswordHash = await hashPassword(body.new_password)\r\n\r\n      // Update password\r\n      const now = new Date().toISOString()\r\n      await context.env.DB.prepare(\r\n        'UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?'\r\n      )\r\n        .bind(newPasswordHash, now, userId)\r\n        .run()\r\n\r\n      return success({\r\n        message: 'Password changed successfully',\r\n      })\r\n    } catch (error) {\r\n      console.error('Change password error:', error)\r\n      return internalError('Failed to change password')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/export.ts",
    "content": "/**\n * Export API endpoint\n * GET  /api/v1/export        -> download JSON export\n * POST /api/v1/export        -> preview stats (counts + estimated size)\n */\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams } from '../../lib/types'\nimport { requireAuth, type AuthContext } from '../../middleware/auth'\nimport type { ExportOptions } from '../../../shared/import-export-types'\nimport { createJsonExporter } from '../../lib/import-export/exporters/json-exporter'\nimport { collectExportData } from '../../lib/import-export/collect-export-data'\nimport { parseExportScope } from '../../lib/import-export/export-scope'\nimport { getExportFilename } from '../../lib/import-export/export-scope'\nimport { estimateExportSize, getExportStats } from '../../lib/import-export/export-stats'\n\ninterface ExportPreviewRequest {\n  format?: string\n  scope?: string\n  include_deleted?: boolean\n}\n\nfunction parseCommonOptions(url: URL): {\n  scope: ReturnType<typeof parseExportScope>\n  includeDeleted: boolean\n  options: ExportOptions\n} {\n  const requestedFormat = url.searchParams.get('format') ?? 'json'\n  if (requestedFormat !== 'json') {\n    throw new Error(`Unsupported export format: ${requestedFormat}`)\n  }\n\n  const scope = parseExportScope(url.searchParams.get('scope'))\n  const includeDeleted = url.searchParams.get('include_deleted') === 'true'\n\n  const includeMetadata = url.searchParams.get('include_metadata') !== 'false'\n  const includeTags = url.searchParams.get('include_tags') !== 'false'\n  const prettyPrint = url.searchParams.get('pretty_print') !== 'false'\n\n  const options: ExportOptions = {\n    include_tags: includeTags,\n    include_metadata: includeMetadata,\n    format_options: {\n      pretty_print: prettyPrint,\n      include_click_stats: url.searchParams.get('include_stats') === 'true',\n      include_user_info: url.searchParams.get('include_user') === 'true',\n    },\n  }\n\n  return { scope, includeDeleted, options }\n}\n\nexport const onRequestGet: PagesFunction<Env, RouteParams, AuthContext>[] = [\n  requireAuth,\n  async (context) => {\n    try {\n      const userId = context.data.user_id\n      const url = new URL(context.request.url)\n      let scope: ReturnType<typeof parseExportScope>\n      let includeDeleted: boolean\n      let options: ExportOptions\n      try {\n        ;({ scope, includeDeleted, options } = parseCommonOptions(url))\n      } catch (error) {\n        const message = error instanceof Error ? error.message : String(error)\n        if (message.startsWith('Unsupported export format:')) {\n          return new Response(\n            JSON.stringify({ error: 'Unsupported export format', message }),\n            { status: 400, headers: { 'Content-Type': 'application/json' } }\n          )\n        }\n        throw error\n      }\n\n      const exportData = await collectExportData(context.env.DB, userId, scope, includeDeleted)\n      const jsonExporter = createJsonExporter()\n      const result = await jsonExporter.export(exportData, options)\n\n      const filename = getExportFilename(exportData.exported_at, scope)\n      return new Response(result.content, {\n        status: 200,\n        headers: {\n          'Content-Type': result.mimeType,\n          'Content-Disposition': `attachment; filename=\"${filename}\"`,\n          'Content-Length': result.size.toString(),\n          'Cache-Control': 'no-cache, no-store, must-revalidate',\n        },\n      })\n    } catch (error) {\n      console.error('Export error:', error)\n      return new Response(\n        JSON.stringify({\n          error: 'Export failed',\n          message: error instanceof Error ? error.message : 'Unknown error',\n        }),\n        { status: 500, headers: { 'Content-Type': 'application/json' } }\n      )\n    }\n  },\n]\n\nexport const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [\n  requireAuth,\n  async (context) => {\n    try {\n      const userId = context.data.user_id\n      const body = (await context.request.json()) as ExportPreviewRequest\n\n      const requestedFormat = body.format ?? 'json'\n      if (requestedFormat !== 'json') {\n        return new Response(\n          JSON.stringify({\n            error: 'Unsupported export format',\n            message: `Unsupported export format: ${requestedFormat}`,\n          }),\n          { status: 400, headers: { 'Content-Type': 'application/json' } }\n        )\n      }\n\n      const scope = parseExportScope(body.scope)\n      const includeDeleted = Boolean(body.include_deleted)\n\n      const stats = await getExportStats(context.env.DB, userId, scope, includeDeleted)\n      const estimatedSize = estimateExportSize(stats)\n      const estimatedFilename = getExportFilename(new Date().toISOString(), scope)\n\n      return new Response(\n        JSON.stringify({\n          stats,\n          estimated_size: estimatedSize,\n          format: 'json',\n          estimated_filename: estimatedFilename,\n        }),\n        { status: 200, headers: { 'Content-Type': 'application/json' } }\n      )\n    } catch (error) {\n      console.error('Export preview error:', error)\n      return new Response(JSON.stringify({ error: 'Failed to get export preview' }), {\n        status: 500,\n        headers: { 'Content-Type': 'application/json' },\n      })\n    }\n  },\n]\n"
  },
  {
    "path": "tmarks/functions/api/v1/health.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\n\nexport const onRequestGet: PagesFunction = async () => {\n  return Response.json({\n    status: 'ok',\n    timestamp: new Date().toISOString(),\n    version: '0.1.0',\n  })\n}\n"
  },
  {
    "path": "tmarks/functions/api/v1/preferences-helpers.ts",
    "content": "export interface UserPreferences {\n  user_id: string\n  theme: 'light' | 'dark' | 'system'\n  page_size: number\n  view_mode: 'list' | 'card' | 'minimal' | 'title'\n  density: 'compact' | 'normal' | 'comfortable'\n  tag_layout?: 'grid' | 'masonry'\n  sort_by?: 'created' | 'updated' | 'pinned' | 'popular'\n  search_auto_clear_seconds?: number\n  tag_selection_auto_clear_seconds?: number\n  enable_search_auto_clear?: number\n  enable_tag_selection_auto_clear?: number\n  default_bookmark_icon?: string\n  snapshot_retention_count?: number\n  snapshot_auto_create?: number\n  snapshot_auto_dedupe?: number\n  snapshot_auto_cleanup_days?: number\n  updated_at: string\n}\n\nexport interface UpdatePreferencesRequest {\n  theme?: 'light' | 'dark' | 'system'\n  page_size?: number\n  view_mode?: 'list' | 'card' | 'minimal' | 'title'\n  density?: 'compact' | 'normal' | 'comfortable'\n  tag_layout?: 'grid' | 'masonry'\n  sort_by?: 'created' | 'updated' | 'pinned' | 'popular'\n  search_auto_clear_seconds?: number\n  tag_selection_auto_clear_seconds?: number\n  enable_search_auto_clear?: boolean\n  enable_tag_selection_auto_clear?: boolean\n  default_bookmark_icon?: string\n  snapshot_retention_count?: number\n  snapshot_auto_create?: boolean\n  snapshot_auto_dedupe?: boolean\n  snapshot_auto_cleanup_days?: number\n}\n\nexport async function hasTagLayoutColumn(db: D1Database): Promise<boolean> {\n  try {\n    await db.prepare('SELECT tag_layout FROM user_preferences LIMIT 1').first()\n    return true\n  } catch (error) {\n    if (error instanceof Error && /no such column: tag_layout/i.test(error.message)) {\n      return false\n    }\n    throw error\n  }\n}\n\nexport async function hasSortByColumn(db: D1Database): Promise<boolean> {\n  try {\n    await db.prepare('SELECT sort_by FROM user_preferences LIMIT 1').first()\n    return true\n  } catch (error) {\n    if (error instanceof Error && /no such column: sort_by/i.test(error.message)) {\n      return false\n    }\n    throw error\n  }\n}\n\nexport async function hasAutomationColumns(db: D1Database): Promise<boolean> {\n  try {\n    await db\n      .prepare('SELECT search_auto_clear_seconds FROM user_preferences LIMIT 1')\n      .first()\n    return true\n  } catch (error) {\n    if (\n      error instanceof Error &&\n      /no such column: search_auto_clear_seconds/i.test(error.message)\n    ) {\n      return false\n    }\n    throw error\n  }\n}\n\nexport function mapPreferences(preferences: UserPreferences) {\n  return {\n    theme: preferences.theme,\n    page_size: preferences.page_size,\n    view_mode: preferences.view_mode,\n    density: preferences.density,\n    tag_layout: preferences.tag_layout ?? 'grid',\n    sort_by: preferences.sort_by ?? 'popular',\n    search_auto_clear_seconds: preferences.search_auto_clear_seconds ?? 15,\n    tag_selection_auto_clear_seconds: preferences.tag_selection_auto_clear_seconds ?? 30,\n    enable_search_auto_clear: preferences.enable_search_auto_clear === 1,\n    enable_tag_selection_auto_clear: preferences.enable_tag_selection_auto_clear === 1,\n    default_bookmark_icon: preferences.default_bookmark_icon ?? 'bookmark',\n    snapshot_retention_count: preferences.snapshot_retention_count ?? 5,\n    snapshot_auto_create: preferences.snapshot_auto_create === 1,\n    snapshot_auto_dedupe: preferences.snapshot_auto_dedupe === 1,\n    snapshot_auto_cleanup_days: preferences.snapshot_auto_cleanup_days ?? 0,\n    updated_at: preferences.updated_at,\n  }\n}\n\nexport function validatePreferences(body: UpdatePreferencesRequest): string | null {\n  if (body.theme && !['light', 'dark', 'system'].includes(body.theme)) {\n    return 'Invalid theme value'\n  }\n\n  if (body.page_size && (body.page_size < 10 || body.page_size > 100)) {\n    return 'Page size must be between 10 and 100'\n  }\n\n  if (body.view_mode && !['list', 'card', 'minimal', 'title'].includes(body.view_mode)) {\n    return 'Invalid view mode'\n  }\n\n  if (body.density && !['compact', 'normal', 'comfortable'].includes(body.density)) {\n    return 'Invalid density value'\n  }\n\n  if (body.tag_layout && !['grid', 'masonry'].includes(body.tag_layout)) {\n    return 'Invalid tag layout value'\n  }\n\n  if (body.sort_by && !['created', 'updated', 'pinned', 'popular'].includes(body.sort_by)) {\n    return 'Invalid sort_by value'\n  }\n\n  if (body.search_auto_clear_seconds !== undefined && (body.search_auto_clear_seconds < 5 || body.search_auto_clear_seconds > 120)) {\n    return 'Search auto clear seconds must be between 5 and 120'\n  }\n\n  if (body.tag_selection_auto_clear_seconds !== undefined && (body.tag_selection_auto_clear_seconds < 10 || body.tag_selection_auto_clear_seconds > 300)) {\n    return 'Tag selection auto clear seconds must be between 10 and 300'\n  }\n\n  if (\n    body.default_bookmark_icon &&\n    !['gradient-glow', 'pulse-breath', 'orbital-spinner', 'bookmark'].includes(\n      body.default_bookmark_icon,\n    )\n  ) {\n    return 'Invalid default bookmark icon value'\n  }\n\n  if (body.snapshot_retention_count !== undefined && (body.snapshot_retention_count < -1 || body.snapshot_retention_count > 100)) {\n    return 'Snapshot retention count must be between -1 and 100'\n  }\n\n  if (body.snapshot_auto_cleanup_days !== undefined && (body.snapshot_auto_cleanup_days < 0 || body.snapshot_auto_cleanup_days > 365)) {\n    return 'Snapshot auto cleanup days must be between 0 and 365'\n  }\n\n  return null\n}\n"
  },
  {
    "path": "tmarks/functions/api/v1/preferences.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams, SQLParam } from '../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../middleware/auth'\r\nimport {\r\n  UserPreferences,\r\n  UpdatePreferencesRequest,\r\n  hasTagLayoutColumn,\r\n  hasSortByColumn,\r\n  hasAutomationColumns,\r\n  mapPreferences,\r\n  validatePreferences\r\n} from './preferences-helpers'\r\n\r\n// GET /api/v1/preferences - Retrieve user preferences\r\nexport const onRequestGet: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    try {\r\n      const userId = context.data.user_id\r\n\r\n      const preferences = await context.env.DB.prepare(\r\n        'SELECT * FROM user_preferences WHERE user_id = ?'\r\n      )\r\n        .bind(userId)\r\n        .first<UserPreferences>()\r\n\r\n      if (!preferences) {\r\n        return notFound('Preferences not found')\r\n      }\r\n\r\n      return success({\r\n        preferences: mapPreferences(preferences),\r\n      })\r\n    } catch (error) {\r\n      console.error('Get preferences error:', error)\r\n      return internalError('Failed to get preferences')\r\n    }\r\n  },\r\n]\r\n\r\n// PATCH /api/v1/preferences - Update user preferences\r\nexport const onRequestPatch: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    try {\r\n      const userId = context.data.user_id\r\n      const body = await context.request.json() as UpdatePreferencesRequest\r\n      const tagLayoutSupported = await hasTagLayoutColumn(context.env.DB)\r\n      const sortBySupported = await hasSortByColumn(context.env.DB)\r\n      const automationSupported = await hasAutomationColumns(context.env.DB)\r\n\r\n      const validationError = validatePreferences(body)\r\n      if (validationError) {\r\n        return badRequest(validationError)\r\n      }\r\n\r\n      const updates: string[] = []\r\n      const values: SQLParam[] = []\r\n\r\n      if (body.theme !== undefined) {\r\n        updates.push('theme = ?')\r\n        values.push(body.theme)\r\n      }\r\n\r\n      if (body.page_size !== undefined) {\r\n        updates.push('page_size = ?')\r\n        values.push(body.page_size)\r\n      }\r\n\r\n      if (body.view_mode !== undefined) {\r\n        updates.push('view_mode = ?')\r\n        values.push(body.view_mode)\r\n      }\r\n\r\n      if (body.density !== undefined) {\r\n        updates.push('density = ?')\r\n        values.push(body.density)\r\n      }\r\n\r\n      if (body.tag_layout !== undefined && tagLayoutSupported) {\r\n        updates.push('tag_layout = ?')\r\n        values.push(body.tag_layout)\r\n      }\r\n\r\n      if (body.sort_by !== undefined && sortBySupported) {\r\n        updates.push('sort_by = ?')\r\n        values.push(body.sort_by)\r\n      }\r\n\r\n      if (automationSupported) {\r\n        if (body.search_auto_clear_seconds !== undefined) {\r\n          updates.push('search_auto_clear_seconds = ?')\r\n          values.push(body.search_auto_clear_seconds)\r\n        }\r\n\r\n        if (body.tag_selection_auto_clear_seconds !== undefined) {\r\n          updates.push('tag_selection_auto_clear_seconds = ?')\r\n          values.push(body.tag_selection_auto_clear_seconds)\r\n        }\r\n\r\n        if (body.enable_search_auto_clear !== undefined) {\r\n          updates.push('enable_search_auto_clear = ?')\r\n          values.push(body.enable_search_auto_clear ? 1 : 0)\r\n        }\r\n\r\n        if (body.enable_tag_selection_auto_clear !== undefined) {\r\n          updates.push('enable_tag_selection_auto_clear = ?')\r\n          values.push(body.enable_tag_selection_auto_clear ? 1 : 0)\r\n        }\r\n      }\r\n\r\n      if (automationSupported) {\r\n        if (body.default_bookmark_icon !== undefined) {\r\n          updates.push('default_bookmark_icon = ?')\r\n          values.push(body.default_bookmark_icon)\r\n        }\r\n\r\n        if (body.snapshot_retention_count !== undefined) {\r\n          updates.push('snapshot_retention_count = ?')\r\n          values.push(body.snapshot_retention_count)\r\n        }\r\n\r\n        if (body.snapshot_auto_create !== undefined) {\r\n          updates.push('snapshot_auto_create = ?')\r\n          values.push(body.snapshot_auto_create ? 1 : 0)\r\n        }\r\n\r\n        if (body.snapshot_auto_dedupe !== undefined) {\r\n          updates.push('snapshot_auto_dedupe = ?')\r\n          values.push(body.snapshot_auto_dedupe ? 1 : 0)\r\n        }\r\n\r\n        if (body.snapshot_auto_cleanup_days !== undefined) {\r\n          updates.push('snapshot_auto_cleanup_days = ?')\r\n          values.push(body.snapshot_auto_cleanup_days)\r\n        }\r\n      }\r\n\r\n      if (updates.length === 0) {\r\n        if ((body.tag_layout !== undefined && !tagLayoutSupported) ||\r\n            (body.sort_by !== undefined && !sortBySupported)) {\r\n          const preferences = await context.env.DB.prepare(\r\n            'SELECT * FROM user_preferences WHERE user_id = ?'\r\n          )\r\n            .bind(userId)\r\n            .first<UserPreferences>()\r\n\r\n          if (!preferences) {\r\n            return internalError('Failed to load preferences')\r\n          }\r\n\r\n          return success({\r\n            preferences: mapPreferences(preferences),\r\n          })\r\n        }\r\n\r\n        return badRequest('No valid fields to update')\r\n      }\r\n\r\n      const now = new Date().toISOString()\r\n      updates.push('updated_at = ?')\r\n      values.push(now)\r\n      values.push(userId)\r\n\r\n      const insertStmt = context.env.DB.prepare(\r\n        `INSERT INTO user_preferences (user_id)\r\n         VALUES (?)\r\n         ON CONFLICT(user_id) DO NOTHING`\r\n      ).bind(userId)\r\n\r\n      const updateStmt = context.env.DB.prepare(\r\n        `UPDATE user_preferences\r\n         SET ${updates.join(', ')}\r\n         WHERE user_id = ?`\r\n      ).bind(...values)\r\n\r\n      await context.env.DB.batch([insertStmt, updateStmt])\r\n\r\n      const preferences = await context.env.DB.prepare(\r\n        'SELECT * FROM user_preferences WHERE user_id = ?'\r\n      )\r\n        .bind(userId)\r\n        .first<UserPreferences>()\r\n\r\n      if (!preferences) {\r\n        return internalError('Failed to load preferences after update')\r\n      }\r\n\r\n      return success({\r\n        preferences: mapPreferences(preferences),\r\n      })\r\n    } catch (error) {\r\n      console.error('Update preferences error:', error)\r\n      return internalError('Failed to update preferences')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/settings/api-keys/[id].ts",
    "content": "/**\n *  API Key \n * GET /api/v1/settings/api-keys/:id -  API Key \n * PATCH /api/v1/settings/api-keys/:id -  API Key\n * DELETE /api/v1/settings/api-keys/:id -  API Key\n */\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams, SQLParam } from '../../../../lib/types'\nimport { success, badRequest, notFound, internalError } from '../../../../lib/response'\nimport { requireAuth, AuthContext } from '../../../../middleware/auth'\nimport { getApiKeyStats } from '../../../../lib/api-key/logger'\nimport { PERMISSION_TEMPLATES } from '../../../../../shared/permissions'\n\ninterface UpdateApiKeyRequest {\n  name?: string\n  description?: string\n  permissions?: string[]\n  template?: 'READ_ONLY' | 'BASIC' | 'FULL'\n  expires_at?: string | null\n}\n\n// GET /api/v1/settings/api-keys/:id -  API Key \ninterface ApiKeyDetail {\n  id: string\n  key_prefix: string\n  name: string\n  description: string | null\n  permissions: string\n  status: string\n  expires_at: string | null\n  last_used_at: string | null\n  last_used_ip: string | null\n  created_at: string\n  updated_at: string\n}\n\nexport const onRequestGet: PagesFunction<Env, RouteParams, AuthContext>[] = [\n  requireAuth,\n  async (context) => {\n    const userId = context.data.user_id\n    const keyId = context.params.id\n\n    try {\n      const keyData = await context.env.DB.prepare(\n        `SELECT id, key_prefix, name, description, permissions, status,\n                expires_at, last_used_at, last_used_ip, created_at, updated_at\n         FROM api_keys\n         WHERE id = ? AND user_id = ?`\n      )\n        .bind(keyId, userId)\n        .first<ApiKeyDetail>()\n\n      if (!keyData) {\n        return notFound('API Key not found')\n      }\n\n      // \n      const stats = await getApiKeyStats(keyId, context.env.DB)\n\n      return success({\n        ...keyData,\n        permissions: JSON.parse(keyData.permissions) as string[],\n        stats,\n      })\n    } catch (error) {\n      console.error('Failed to get API key:', error)\n      return internalError('Failed to get API key details')\n    }\n  },\n]\n\n// PATCH /api/v1/settings/api-keys/:id -  API Key\nexport const onRequestPatch: PagesFunction<Env, RouteParams, AuthContext>[] = [\n  requireAuth,\n  async (context) => {\n    const userId = context.data.user_id\n    const keyId = context.params.id\n\n    try {\n      // 1.  Key \n      const existingKey = await context.env.DB.prepare(\n        `SELECT id FROM api_keys WHERE id = ? AND user_id = ?`\n      )\n        .bind(keyId, userId)\n        .first()\n\n      if (!existingKey) {\n        return notFound('API Key not found')\n      }\n\n      // 2. \n      const body = (await context.request.json()) as UpdateApiKeyRequest\n      const { name, description, permissions, expires_at, template } = body\n\n      const updates: string[] = []\n      const values: SQLParam[] = []\n\n      if (name !== undefined) {\n        if (!name.trim()) {\n          return badRequest({\n            code: 'INVALID_INPUT',\n            message: 'Name cannot be empty',\n          })\n        }\n        updates.push('name = ?')\n        values.push(name.trim())\n      }\n\n      if (description !== undefined) {\n        updates.push('description = ?')\n        values.push(description?.trim() || null)\n      }\n\n      if (template || permissions) {\n        let permissionsList: string[] = []\n\n        if (template && PERMISSION_TEMPLATES[template]) {\n          permissionsList = PERMISSION_TEMPLATES[template].permissions\n        } else if (permissions && Array.isArray(permissions)) {\n          permissionsList = permissions\n        }\n\n        if (permissionsList.length > 0) {\n          updates.push('permissions = ?')\n          values.push(JSON.stringify(permissionsList))\n        }\n      }\n\n      if (expires_at !== undefined) {\n        if (expires_at === null) {\n          updates.push('expires_at = NULL')\n        } else {\n          let expiresDate: Date\n\n          //  (30d, 90d )  ISO \n          if (expires_at.match(/^\\d+d$/)) {\n            const days = parseInt(expires_at.slice(0, -1), 10)\n            expiresDate = new Date()\n            expiresDate.setDate(expiresDate.getDate() + days)\n          } else {\n            expiresDate = new Date(expires_at)\n          }\n\n          if (expiresDate <= new Date()) {\n            return badRequest({\n              code: 'INVALID_INPUT',\n              message: 'Expiration date must be in the future',\n            })\n          }\n          updates.push('expires_at = ?')\n          values.push(expiresDate.toISOString())\n        }\n      }\n\n      if (updates.length === 0) {\n        return badRequest({\n          code: 'INVALID_INPUT',\n          message: 'No valid fields to update',\n        })\n      }\n\n      // 3. \n      updates.push(\"updated_at = datetime('now')\")\n      values.push(keyId, userId)\n\n      await context.env.DB.prepare(\n        `UPDATE api_keys\n         SET ${updates.join(', ')}\n         WHERE id = ? AND user_id = ?`\n      )\n        .bind(...values)\n        .run()\n\n      // 4. \n      const updatedKey = await context.env.DB.prepare(\n        `SELECT id, key_prefix, name, description, permissions, status,\n                expires_at, last_used_at, last_used_ip, created_at, updated_at\n         FROM api_keys\n         WHERE id = ?`\n      )\n        .bind(keyId)\n        .first<ApiKeyDetail>()\n\n      if (!updatedKey) {\n        return internalError('Failed to load updated API key')\n      }\n\n      return success({\n        ...updatedKey,\n        permissions: JSON.parse(updatedKey.permissions) as string[],\n      })\n    } catch (error) {\n      console.error('Failed to update API key:', error)\n      return internalError('Failed to update API key')\n    }\n  },\n]\n\n// DELETE /api/v1/settings/api-keys/:id -  API Key\nexport const onRequestDelete: PagesFunction<Env, RouteParams, AuthContext>[] = [\n  requireAuth,\n  async (context) => {\n    const userId = context.data.user_id\n    const keyId = context.params.id\n    const url = new URL(context.request.url)\n    const hardDelete = url.searchParams.get('hard') === 'true'\n\n    try {\n      // 1.  Key \n      const existingKey = await context.env.DB.prepare(\n        `SELECT id FROM api_keys WHERE id = ? AND user_id = ?`\n      )\n        .bind(keyId, userId)\n        .first()\n\n      if (!existingKey) {\n        return notFound('API Key not found')\n      }\n\n      if (hardDelete) {\n        try {\n          await context.env.DB.batch([\n            context.env.DB.prepare('DELETE FROM api_key_logs WHERE api_key_id = ?').bind(keyId),\n            context.env.DB.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?').bind(keyId, userId),\n          ])\n        } catch (error) {\n          console.error('Failed to delete API key records:', error)\n          throw error\n        }\n\n        return success({\n          message: 'API Key deleted permanently',\n        })\n      }\n\n      // 2. （）\n      await context.env.DB.prepare(\n        `UPDATE api_keys\n         SET status = 'revoked', updated_at = datetime('now')\n         WHERE id = ? AND user_id = ?`\n      )\n        .bind(keyId, userId)\n        .run()\n\n      return success({\n        message: 'API Key revoked successfully',\n      })\n    } catch (error) {\n      console.error('Failed to revoke API key:', error)\n      return internalError('Failed to revoke API key')\n    }\n  },\n]\n"
  },
  {
    "path": "tmarks/functions/api/v1/settings/api-keys/index.ts",
    "content": "\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../lib/types'\r\nimport { success, badRequest, created, internalError } from '../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../middleware/auth'\r\nimport { generateApiKey } from '../../../../lib/api-key/generator'\r\nimport { PERMISSION_TEMPLATES } from '../../../../../shared/permissions'\r\n\r\ninterface CreateApiKeyRequest {\r\n  name: string\r\n  description?: string\r\n  permissions?: string[]\r\n  template?: 'READ_ONLY' | 'BASIC' | 'FULL'\r\n  expires_at?: string | null\r\n}\r\n\r\nasync function getUserApiKeyLimit(_db: D1Database, _userId: string): Promise<number> {\r\n  \r\n  void _db\r\n  void _userId\r\n  return 999\r\n}\r\n\r\n// GET /api/v1/settings/api-keys - Retrieve API keys\r\ninterface ApiKeyRow {\r\n  id: string\r\n  key_prefix: string\r\n  name: string\r\n  description: string | null\r\n  permissions: string\r\n  status: string\r\n  expires_at: string | null\r\n  last_used_at: string | null\r\n  last_used_ip: string | null\r\n  created_at: string\r\n  updated_at: string\r\n}\r\n\r\nexport const onRequestGet: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n\r\n    try {\r\n      const keys = await context.env.DB.prepare(\r\n        `SELECT id, key_prefix, name, description, permissions, status,\r\n                expires_at, last_used_at, last_used_ip, created_at, updated_at\r\n         FROM api_keys\r\n         WHERE user_id = ?\r\n         ORDER BY created_at DESC`\r\n      )\r\n        .bind(userId)\r\n        .all<ApiKeyRow>()\r\n\r\n      const quota = await context.env.DB.prepare(\r\n        `SELECT COUNT(*) as count FROM api_keys WHERE user_id = ? AND status = 'active'`\r\n      )\r\n        .bind(userId)\r\n        .first<{ count: number }>()\r\n\r\n      const used = quota?.count || 0\r\n      const limit = await getUserApiKeyLimit(context.env.DB, userId)\r\n\r\n      return success({\r\n        keys: (keys.results ?? []).map((key) => ({\r\n          ...key,\r\n          permissions: JSON.parse(key.permissions) as string[],\r\n        })),\r\n        quota: {\r\n          used,\r\n          limit,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Failed to list API keys:', error)\r\n      return internalError('Failed to list API keys')\r\n    }\r\n  },\r\n]\r\n\r\n// POST /api/v1/settings/api-keys - Create new API Key\r\nexport const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n\r\n    try {\r\n      \r\n      const quota = await context.env.DB.prepare(\r\n        `SELECT COUNT(*) as count FROM api_keys WHERE user_id = ? AND status = 'active'`\r\n      )\r\n        .bind(userId)\r\n        .first<{ count: number }>()\r\n\r\n      const used = quota?.count || 0\r\n      const limit = await getUserApiKeyLimit(context.env.DB, userId)\r\n\r\n      if (used >= limit) {\r\n        return badRequest({\r\n          code: 'QUOTA_EXCEEDED',\r\n          message: `Maximum ${limit} API keys allowed per user`,\r\n          quota: { used, limit },\r\n        })\r\n      }\r\n\r\n      const body = (await context.request.json()) as CreateApiKeyRequest\r\n      const { name, description, permissions, expires_at, template } = body\r\n\r\n      if (!name || !name.trim()) {\r\n        return badRequest({\r\n          code: 'INVALID_INPUT',\r\n          message: 'Name is required',\r\n        })\r\n      }\r\n\r\n      let permissionsList: string[] = []\r\n\r\n      if (template && PERMISSION_TEMPLATES[template]) {\r\n        \r\n        permissionsList = PERMISSION_TEMPLATES[template].permissions\r\n      } else if (permissions && Array.isArray(permissions)) {\r\n        \r\n        permissionsList = permissions\r\n      } else {\r\n        \r\n        permissionsList = PERMISSION_TEMPLATES.BASIC.permissions\r\n      }\r\n\r\n      if (permissionsList.length === 0) {\r\n        return badRequest({\r\n          code: 'INVALID_INPUT',\r\n          message: 'At least one permission is required',\r\n        })\r\n      }\r\n\r\n      let expiresAt: string | null = null\r\n      if (expires_at) {\r\n        let expiresDate: Date\r\n\r\n        if (expires_at.match(/^\\d+d$/)) {\r\n          const days = parseInt(expires_at.slice(0, -1), 10)\r\n          expiresDate = new Date()\r\n          expiresDate.setDate(expiresDate.getDate() + days)\r\n        } else {\r\n          expiresDate = new Date(expires_at)\r\n        }\r\n\r\n        if (expiresDate <= new Date()) {\r\n          return badRequest({\r\n            code: 'INVALID_INPUT',\r\n            message: 'Expiration date must be in the future',\r\n          })\r\n        }\r\n        expiresAt = expiresDate.toISOString()\r\n      }\r\n\r\n      const { key, prefix, hash } = await generateApiKey('live')\r\n\r\n      const keyId = crypto.randomUUID()\r\n\r\n      await context.env.DB.prepare(\r\n        `INSERT INTO api_keys\r\n         (id, user_id, key_hash, key_prefix, name, description, permissions, status, expires_at)\r\n         VALUES (?, ?, ?, ?, ?, ?, ?, 'active', ?)`\r\n      )\r\n        .bind(\r\n          keyId,\r\n          userId,\r\n          hash,\r\n          prefix,\r\n          name.trim(),\r\n          description?.trim() || null,\r\n          JSON.stringify(permissionsList),\r\n          expiresAt\r\n        )\r\n        .run()\r\n\r\n      return created({\r\n        id: keyId,\r\n        key, \r\n        key_prefix: prefix,\r\n        name: name.trim(),\r\n        description: description?.trim() || null,\r\n        permissions: permissionsList,\r\n        status: 'active',\r\n        expires_at: expiresAt,\r\n        created_at: new Date().toISOString(),\r\n      })\r\n    } catch (error) {\r\n      console.error('Failed to create API key:', error)\r\n      return internalError('Failed to create API key')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/settings/share.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams } from '../../../lib/types'\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\nimport { success, badRequest, conflict, internalError } from '../../../lib/response'\nimport { sanitizeString } from '../../../lib/validation'\nimport { generateSlug } from '../../../lib/utils'\nimport { invalidatePublicShareCache } from '../../shared/cache'\n\ninterface UpdateShareSettingsRequest {\n  enabled?: boolean\n  slug?: string | null\n  title?: string | null\n  description?: string | null\n  regenerate_slug?: boolean\n}\n\nconst SLUG_MAX_LENGTH = 64\n\nexport const onRequestGet: PagesFunction<Env, RouteParams, AuthContext>[] = [\n  requireAuth,\n  async (context) => {\n    const userId = context.data.user_id\n\n    try {\n      const record = await context.env.DB.prepare(\n        `SELECT public_share_enabled, public_slug, public_page_title, public_page_description\n         FROM users\n         WHERE id = ?`\n      )\n        .bind(userId)\n        .first<{ public_share_enabled: number; public_slug: string | null; public_page_title: string | null; public_page_description: string | null }>()\n\n      return success({\n        share: {\n          enabled: Boolean(record?.public_share_enabled),\n          slug: record?.public_slug || null,\n          title: record?.public_page_title || null,\n          description: record?.public_page_description || null,\n        },\n      })\n    } catch (error) {\n      console.error('Get share settings error:', error)\n      return internalError('Failed to load share settings')\n    }\n  },\n]\n\nexport const onRequestPut: PagesFunction<Env, RouteParams, AuthContext>[] = [\n  requireAuth,\n  async (context) => {\n    const userId = context.data.user_id\n\n    try {\n      const body = (await context.request.json()) as UpdateShareSettingsRequest\n\n      const updates: string[] = []\n      const values: Array<string | number | null> = []\n      let newSlug: string | null | undefined\n\n      if (body.regenerate_slug) {\n        newSlug = await generateUniqueSlug(context.env, userId)\n      } else if (body.slug !== undefined) {\n        if (body.slug && !/^[a-z0-9-]+$/i.test(body.slug)) {\n          return badRequest('Slug can only contain letters, numbers, and hyphen')\n        }\n        newSlug = body.slug ? sanitizeString(body.slug.toLowerCase(), SLUG_MAX_LENGTH) : null\n      }\n\n      if (newSlug !== undefined) {\n        if (newSlug) {\n          const exist = await context.env.DB.prepare(\n            `SELECT id FROM users WHERE LOWER(public_slug) = ? AND id != ?`\n          )\n            .bind(newSlug.toLowerCase(), userId)\n            .first()\n\n          if (exist) {\n            return conflict('Slug already in use')\n          }\n        }\n        updates.push('public_slug = ?')\n        values.push(newSlug)\n      }\n\n      if (body.title !== undefined) {\n        updates.push('public_page_title = ?')\n        values.push(body.title ? sanitizeString(body.title, 200) : null)\n      }\n\n      if (body.description !== undefined) {\n        updates.push('public_page_description = ?')\n        values.push(body.description ? sanitizeString(body.description, 500) : null)\n      }\n\n      if (body.enabled !== undefined) {\n        updates.push('public_share_enabled = ?')\n        values.push(body.enabled ? 1 : 0)\n      }\n\n      if (updates.length === 0) {\n        return success({ message: 'No changes applied' })\n      }\n\n      updates.push('updated_at = datetime(\"now\")')\n\n      await context.env.DB.prepare(\n        `UPDATE users SET ${updates.join(', ')} WHERE id = ?`\n      )\n        .bind(...values, userId)\n        .run()\n\n      await invalidatePublicShareCache(context.env, userId)\n\n      const record = await context.env.DB.prepare(\n        `SELECT public_share_enabled, public_slug, public_page_title, public_page_description\n         FROM users\n         WHERE id = ?`\n      )\n        .bind(userId)\n        .first<{ public_share_enabled: number; public_slug: string | null; public_page_title: string | null; public_page_description: string | null }>()\n\n      return success({\n        share: {\n          enabled: Boolean(record?.public_share_enabled),\n          slug: record?.public_slug || null,\n          title: record?.public_page_title || null,\n          description: record?.public_page_description || null,\n        },\n      })\n    } catch (error) {\n      console.error('Update share settings error:', error)\n      return internalError('Failed to update share settings')\n    }\n  },\n]\n\nasync function generateUniqueSlug(env: Env, userId: string): Promise<string> {\n  for (let i = 0; i < 5; i += 1) {\n    const candidate = generateSlug()\n    const existing = await env.DB.prepare(\n      `SELECT id FROM users WHERE LOWER(public_slug) = ? AND id != ?`\n    )\n      .bind(candidate.toLowerCase(), userId)\n      .first()\n\n    if (!existing) {\n      return candidate\n    }\n  }\n  throw new Error('Failed to generate unique slug')\n}\n"
  },
  {
    "path": "tmarks/functions/api/v1/settings/storage.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../lib/types'\r\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\r\nimport { success, internalError } from '../../../lib/response'\r\nimport { getCurrentR2UsageBytes, getR2MaxTotalBytes } from '../../../lib/storage-quota'\r\n\nexport const onRequestGet: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    try {\r\n      const usedBytes = await getCurrentR2UsageBytes(context.env.DB)\r\n      const limitBytes = getR2MaxTotalBytes(context.env)\r\n\r\n      const unlimited = !Number.isFinite(limitBytes)\r\n      const safeLimitBytes = unlimited ? null : limitBytes\r\n\r\n      return success({\r\n        quota: {\r\n          used_bytes: usedBytes,\r\n          limit_bytes: safeLimitBytes,\r\n          unlimited,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Get R2 storage quota error:', error)\r\n      return internalError('Failed to load storage quota')\r\n    }\r\n  },\r\n]\r\n\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/shared/cache.ts",
    "content": "// Deprecated shim: keep old import path working but delegate to new implementation\r\nexport { invalidatePublicShareCache } from '../../shared/cache'\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/statistics/index.ts",
    "content": "/**\r\n *  API\r\n * : /api/v1/statistics\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../lib/types'\r\nimport { success, internalError } from '../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\r\n\r\ninterface DomainCount {\r\n  domain: string\r\n  count: number\r\n}\r\n\r\n// GET /api/v1/statistics - Retrieve user statistics\r\nexport const onRequestGet: PagesFunction<Env, string, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const url = new URL(context.request.url)\r\n    const days = parseInt(url.searchParams.get('days') || '30', 10) || 30\r\n\r\n    const startDate = new Date()\r\n    startDate.setDate(startDate.getDate() - days)\r\n    const startDateStr = startDate.toISOString().split('T')[0]\r\n\r\n    try {\r\n      \r\n      const [\r\n        groupsResult,\r\n        deletedGroupsResult,\r\n        itemsResult,\r\n        sharesResult,\r\n        groupsTrend,\r\n        itemsTrend,\r\n        domains,\r\n        groupSizes\r\n      ] = await Promise.all([\r\n        \r\n        context.env.DB.prepare(\r\n          'SELECT COUNT(*) as count FROM tab_groups WHERE user_id = ? AND is_deleted = 0'\r\n        )\r\n          .bind(userId)\r\n          .all<{ count: number }>(),\r\n\r\n        context.env.DB.prepare(\r\n          'SELECT COUNT(*) as count FROM tab_groups WHERE user_id = ? AND is_deleted = 1'\r\n        )\r\n          .bind(userId)\r\n          .all<{ count: number }>(),\r\n\r\n        context.env.DB.prepare(\r\n          'SELECT COUNT(*) as count FROM tab_group_items WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?)'\r\n        )\r\n          .bind(userId)\r\n          .all<{ count: number }>(),\r\n\r\n        context.env.DB.prepare(\r\n          'SELECT COUNT(*) as count FROM tab_group_shares WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?)'\r\n        )\r\n          .bind(userId)\r\n          .all<{ count: number }>(),\r\n\r\n        context.env.DB.prepare(\r\n          `SELECT DATE(created_at) as date, COUNT(*) as count \r\n           FROM tab_groups \r\n           WHERE user_id = ? AND DATE(created_at) >= ? \r\n           GROUP BY DATE(created_at) \r\n           ORDER BY date ASC`\r\n        )\r\n          .bind(userId, startDateStr)\r\n          .all<{ date: string; count: number }>(),\r\n\r\n        context.env.DB.prepare(\r\n          `SELECT DATE(created_at) as date, COUNT(*) as count \r\n           FROM tab_group_items \r\n           WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?) \r\n           AND DATE(created_at) >= ? \r\n           GROUP BY DATE(created_at) \r\n           ORDER BY date ASC`\r\n        )\r\n          .bind(userId, startDateStr)\r\n          .all<{ date: string; count: number }>(),\r\n\r\n        context.env.DB.prepare(\r\n          `SELECT \r\n            CASE \r\n              WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 \r\n              THEN SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1)\r\n              ELSE SUBSTR(url, INSTR(url, '://') + 3)\r\n            END as domain,\r\n            COUNT(*) as count\r\n           FROM tab_group_items\r\n           WHERE group_id IN (SELECT id FROM tab_groups WHERE user_id = ?)\r\n           GROUP BY domain\r\n           ORDER BY count DESC\r\n           LIMIT 10`\r\n        )\r\n          .bind(userId)\r\n          .all<DomainCount>(),\r\n\r\n        context.env.DB.prepare(\r\n          `SELECT \r\n            CASE \r\n              WHEN item_count = 0 THEN '0'\r\n              WHEN item_count BETWEEN 1 AND 5 THEN '1-5'\r\n              WHEN item_count BETWEEN 6 AND 10 THEN '6-10'\r\n              WHEN item_count BETWEEN 11 AND 20 THEN '11-20'\r\n              WHEN item_count BETWEEN 21 AND 50 THEN '21-50'\r\n              ELSE '50+'\r\n            END as range,\r\n            COUNT(*) as count\r\n           FROM (\r\n             SELECT tg.id, COUNT(tgi.id) as item_count\r\n             FROM tab_groups tg\r\n             LEFT JOIN tab_group_items tgi ON tg.id = tgi.group_id\r\n             WHERE tg.user_id = ? AND tg.is_deleted = 0 AND tg.is_folder = 0\r\n             GROUP BY tg.id\r\n           )\r\n           GROUP BY range\r\n           ORDER BY \r\n             CASE range\r\n               WHEN '0' THEN 0\r\n               WHEN '1-5' THEN 1\r\n               WHEN '6-10' THEN 2\r\n               WHEN '11-20' THEN 3\r\n               WHEN '21-50' THEN 4\r\n               ELSE 5\r\n             END`\r\n        )\r\n          .bind(userId)\r\n          .all<{ range: string; count: number }>()\r\n      ])\r\n\r\n      return success({\r\n        summary: {\r\n          total_groups: groupsResult.results?.[0]?.count || 0,\r\n          total_deleted_groups: deletedGroupsResult.results?.[0]?.count || 0,\r\n          total_items: itemsResult.results?.[0]?.count || 0,\r\n          total_shares: sharesResult.results?.[0]?.count || 0,\r\n        },\r\n        trends: {\r\n          groups: groupsTrend.results || [],\r\n          items: itemsTrend.results || [],\r\n        },\r\n        top_domains: domains.results || [],\r\n        group_size_distribution: groupSizes.results || [],\r\n      })\r\n    } catch (error) {\r\n      console.error('Get statistics error:', error)\r\n      return internalError('Failed to get statistics')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/[id]/items/batch.ts",
    "content": "/**\r\n *  API - \r\n * : /api/v1/tab-groups/:id/items/batch\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction, D1PreparedStatement } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../../middleware/auth'\r\nimport { sanitizeString } from '../../../../../lib/validation'\r\nimport { generateUUID } from '../../../../../lib/crypto'\r\n\r\ninterface TabGroupRow {\r\n  id: string\r\n  user_id: string\r\n  title: string\r\n}\r\n\r\ninterface BatchAddItemsRequest {\r\n  items: Array<{\r\n    title: string\r\n    url: string\r\n    favicon?: string\r\n  }>\r\n}\r\n\r\ninterface TabGroupItemRow {\r\n  id: string\r\n  group_id: string\r\n  title: string\r\n  url: string\r\n  favicon: string | null\r\n  position: number\r\n  created_at: string\r\n}\r\n\r\n// POST /api/v1/tab-groups/:id/items/batch - \r\nexport const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      const body = (await context.request.json()) as BatchAddItemsRequest\r\n\r\n      if (!body.items || !Array.isArray(body.items) || body.items.length === 0) {\r\n        return badRequest('items array is required and must not be empty')\r\n      }\r\n\r\n      // Verify group exists and belongs to user\r\n      const group = await context.env.DB.prepare(\r\n        'SELECT id, user_id, title FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!group) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      // Get current max position\r\n      const maxPositionResult = await context.env.DB.prepare(\r\n        'SELECT MAX(position) as max_position FROM tab_group_items WHERE group_id = ?'\r\n      )\r\n        .bind(groupId)\r\n        .first<{ max_position: number | null }>()\r\n\r\n      let currentPosition = (maxPositionResult?.max_position ?? -1) + 1\r\n\r\n      // Insert items\r\n      const insertedItems: TabGroupItemRow[] = []\r\n      const stmts: D1PreparedStatement[] = []\r\n      const now = new Date().toISOString()\r\n\r\n      for (const item of body.items) {\r\n        if (!item.url || !item.title) {\r\n          continue // Skip invalid items\r\n        }\r\n\r\n        const itemId = generateUUID()\r\n        const sanitizedTitle = sanitizeString(item.title, 500)\r\n        const sanitizedUrl = sanitizeString(item.url, 2000)\r\n        const sanitizedFavicon = item.favicon ? sanitizeString(item.favicon, 2000) : null\r\n\r\n        stmts.push(\r\n          context.env.DB.prepare(\r\n            `INSERT INTO tab_group_items (id, group_id, title, url, favicon, position, created_at)\r\n             VALUES (?, ?, ?, ?, ?, ?, ?)`\r\n          ).bind(itemId, groupId, sanitizedTitle, sanitizedUrl, sanitizedFavicon, currentPosition, now)\r\n        )\r\n\r\n        insertedItems.push({\r\n          id: itemId,\r\n          group_id: groupId,\r\n          title: sanitizedTitle,\r\n          url: sanitizedUrl,\r\n          favicon: sanitizedFavicon,\r\n          position: currentPosition,\r\n          created_at: now,\r\n        })\r\n\r\n        currentPosition++\r\n      }\r\n\r\n      if (stmts.length > 0) {\r\n        await context.env.DB.batch(stmts)\r\n      }\r\n\r\n      return success({\r\n        message: 'Items added successfully',\r\n        added_count: insertedItems.length,\r\n        total_items: currentPosition,\r\n        items: insertedItems,\r\n      })\r\n    } catch (error) {\r\n      console.error('Batch add items error:', error)\r\n      return internalError('Failed to add items to group')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/[id]/permanent-delete.ts",
    "content": "/**\r\n *  API\r\n * : /api/v1/tab-groups/:id/permanent-delete\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../lib/types'\r\nimport { success, notFound, internalError } from '../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../middleware/auth'\r\n\r\ninterface TabGroupRow {\r\n  id: string\r\n  user_id: string\r\n  is_deleted: number\r\n}\r\n\r\n// DELETE /api/v1/tab-groups/:id/permanent-delete - \r\nexport const onRequestDelete: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      // Check if group exists and is in trash\r\n      const group = await context.env.DB.prepare(\r\n        'SELECT id, user_id, is_deleted FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!group) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      if (group.is_deleted !== 1) {\r\n        return notFound('Tab group must be in trash before permanent deletion')\r\n      }\r\n\r\n      // Delete all items in the group\r\n      await context.env.DB.prepare('DELETE FROM tab_group_items WHERE group_id = ?')\r\n        .bind(groupId)\r\n        .run()\r\n\r\n      // Delete the group\r\n      await context.env.DB.prepare('DELETE FROM tab_groups WHERE id = ?')\r\n        .bind(groupId)\r\n        .run()\r\n\r\n      return success({ message: 'Tab group permanently deleted' })\r\n    } catch (error) {\r\n      console.error('Permanent delete tab group error:', error)\r\n      return internalError('Failed to permanently delete tab group')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/[id]/restore.ts",
    "content": "/**\r\n *  API\r\n * : /api/v1/tab-groups/:id/restore\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../lib/types'\r\nimport { success, notFound, internalError } from '../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../middleware/auth'\r\n\r\ninterface TabGroupRow {\r\n  id: string\r\n  user_id: string\r\n  is_deleted: number\r\n}\r\n\r\n// POST /api/v1/tab-groups/:id/restore - \r\nexport const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      // Check if group exists and is deleted\r\n      const group = await context.env.DB.prepare(\r\n        'SELECT id, user_id, is_deleted FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!group) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      if (group.is_deleted !== 1) {\r\n        return success({ message: 'Tab group is not in trash' })\r\n      }\r\n\r\n      // Restore the group\r\n      await context.env.DB.prepare(\r\n        'UPDATE tab_groups SET is_deleted = 0, deleted_at = NULL, updated_at = ? WHERE id = ?'\r\n      )\r\n        .bind(new Date().toISOString(), groupId)\r\n        .run()\r\n\r\n      return success({ message: 'Tab group restored successfully' })\r\n    } catch (error) {\r\n      console.error('Restore tab group error:', error)\r\n      return internalError('Failed to restore tab group')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/[id]/share.ts",
    "content": "/**\r\n *  API\r\n * : /api/v1/tab-groups/:id/share\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../lib/types'\r\nimport { success, notFound, internalError } from '../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../middleware/auth'\r\nimport { generateUUID } from '../../../../lib/crypto'\r\n\r\ninterface TabGroupRow {\r\n  id: string\r\n  user_id: string\r\n  title: string\r\n}\r\n\r\ninterface ShareRow {\r\n  id: string\r\n  group_id: string\r\n  share_token: string\r\n  is_public: number\r\n  expires_at: string | null\r\n  created_at: string\r\n}\r\n\r\ninterface CreateShareRequest {\r\n  is_public?: boolean\r\n  expires_in_days?: number\r\n}\r\n\r\n// POST /api/v1/tab-groups/:id/share - \r\nexport const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      const body = (await context.request.json()) as CreateShareRequest\r\n\r\n      // Verify group exists and belongs to user\r\n      const group = await context.env.DB.prepare(\r\n        'SELECT id, user_id, title FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!group) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      // Check if share already exists\r\n      const existingShare = await context.env.DB.prepare(\r\n        'SELECT * FROM tab_group_shares WHERE group_id = ?'\r\n      )\r\n        .bind(groupId)\r\n        .first<ShareRow>()\r\n\r\n      if (existingShare) {\r\n        // Update existing share\r\n        const expiresAt = body.expires_in_days\r\n          ? new Date(Date.now() + body.expires_in_days * 24 * 60 * 60 * 1000).toISOString()\r\n          : null\r\n\r\n        await context.env.DB.prepare(\r\n          'UPDATE tab_group_shares SET is_public = ?, expires_at = ? WHERE id = ?'\r\n        )\r\n          .bind(body.is_public ? 1 : 0, expiresAt, existingShare.id)\r\n          .run()\r\n\r\n        return success({\r\n          share_token: existingShare.share_token,\r\n          is_public: body.is_public ?? false,\r\n          expires_at: expiresAt,\r\n          share_url: `${new URL(context.request.url).origin}/share/${existingShare.share_token}`,\r\n        })\r\n      }\r\n\r\n      // Create new share\r\n      const shareId = generateUUID()\r\n      const shareToken = generateUUID()\r\n      const isPublic = body.is_public ?? false\r\n      const expiresAt = body.expires_in_days\r\n        ? new Date(Date.now() + body.expires_in_days * 24 * 60 * 60 * 1000).toISOString()\r\n        : null\r\n      const now = new Date().toISOString()\r\n\r\n      await context.env.DB.prepare(\r\n        `INSERT INTO tab_group_shares (id, group_id, share_token, is_public, expires_at, created_at)\r\n         VALUES (?, ?, ?, ?, ?, ?)`\r\n      )\r\n        .bind(shareId, groupId, shareToken, isPublic ? 1 : 0, expiresAt, now)\r\n        .run()\r\n\r\n      return success({\r\n        share_token: shareToken,\r\n        is_public: isPublic,\r\n        expires_at: expiresAt,\r\n        share_url: `${new URL(context.request.url).origin}/share/${shareToken}`,\r\n      })\r\n    } catch (error) {\r\n      console.error('Create share error:', error)\r\n      return internalError('Failed to create share')\r\n    }\r\n  },\r\n]\r\n\r\n// GET /api/v1/tab-groups/:id/share - \r\nexport const onRequestGet: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      // Verify group exists and belongs to user\r\n      const group = await context.env.DB.prepare(\r\n        'SELECT id, user_id FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!group) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      // Get share info\r\n      const share = await context.env.DB.prepare(\r\n        'SELECT * FROM tab_group_shares WHERE group_id = ?'\r\n      )\r\n        .bind(groupId)\r\n        .first<ShareRow>()\r\n\r\n      if (!share) {\r\n        return notFound('Share not found')\r\n      }\r\n\r\n      return success({\r\n        share_token: share.share_token,\r\n        is_public: share.is_public === 1,\r\n        expires_at: share.expires_at,\r\n        share_url: `${new URL(context.request.url).origin}/share/${share.share_token}`,\r\n        created_at: share.created_at,\r\n      })\r\n    } catch (error) {\r\n      console.error('Get share error:', error)\r\n      return internalError('Failed to get share info')\r\n    }\r\n  },\r\n]\r\n\r\n// DELETE /api/v1/tab-groups/:id/share - \r\nexport const onRequestDelete: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      // Verify group exists and belongs to user\r\n      const group = await context.env.DB.prepare(\r\n        'SELECT id, user_id FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!group) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      // Delete share\r\n      await context.env.DB.prepare('DELETE FROM tab_group_shares WHERE group_id = ?')\r\n        .bind(groupId)\r\n        .run()\r\n\r\n      return success({ message: 'Share deleted successfully' })\r\n    } catch (error) {\r\n      console.error('Delete share error:', error)\r\n      return internalError('Failed to delete share')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/[id].ts",
    "content": "/**\r\n *  API - \r\n * : /api/v1/tab-groups/:id\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../lib/types'\r\nimport { success, badRequest, notFound, noContent, internalError } from '../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\r\nimport { sanitizeString } from '../../../lib/validation'\r\n\r\ninterface TabGroupRow {\r\n  id: string\r\n  user_id: string\r\n  title: string\r\n  color: string | null\r\n  tags: string | null\r\n  parent_id: string | null\r\n  is_folder: number\r\n  is_deleted: number\r\n  deleted_at: string | null\r\n  position: number\r\n  created_at: string\r\n  updated_at: string\r\n}\r\n\r\ninterface TabGroupItemRow {\r\n  id: string\r\n  group_id: string\r\n  title: string\r\n  url: string\r\n  favicon: string | null\r\n  position: number\r\n  created_at: string\r\n  is_pinned?: number\r\n  is_todo?: number\r\n}\r\n\r\ninterface UpdateTabGroupRequest {\r\n  title?: string\r\n  color?: string | null\r\n  tags?: string[] | null\r\n  parent_id?: string | null\r\n  position?: number\r\n}\r\n\r\n// GET /api/v1/tab-groups/:id - \r\nexport const onRequestGet: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      // Get tab group\r\n      const groupRow = await context.env.DB.prepare(\r\n        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!groupRow) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      // Get tab group items (with user_id verification for security)\r\n      const { results: items } = await context.env.DB.prepare(\r\n        `SELECT tgi.*\r\n         FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.group_id = ? AND tg.user_id = ?\r\n         ORDER BY COALESCE(tgi.is_pinned, 0) DESC, tgi.position ASC`\r\n      )\r\n        .bind(groupId, userId)\r\n        .all<TabGroupItemRow>()\r\n\r\n      return success({\r\n        tab_group: {\r\n          ...groupRow,\r\n          items: items || [],\r\n          item_count: items?.length || 0,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Get tab group error:', error)\r\n      return internalError('Failed to get tab group')\r\n    }\r\n  },\r\n]\r\n\r\n// PATCH /api/v1/tab-groups/:id - \r\nexport const onRequestPatch: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      let body: UpdateTabGroupRequest\r\n      try {\r\n        body = (await context.request.json()) as UpdateTabGroupRequest\r\n      } catch (parseError) {\r\n        console.error('Failed to parse request body:', parseError)\r\n        return badRequest('Invalid request body: ' + (parseError instanceof Error ? parseError.message : 'JSON parse error'))\r\n      }\r\n\r\n      // Check if tab group exists and belongs to user\r\n      const groupRow = await context.env.DB.prepare(\r\n        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!groupRow) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      // Build update query\r\n      const updates: string[] = []\r\n      const params: Array<string | number | null> = []\r\n\r\n      if (body.title !== undefined) {\r\n        updates.push('title = ?')\r\n        params.push(sanitizeString(body.title, 200))\r\n      }\r\n\r\n      if (body.parent_id !== undefined) {\r\n        updates.push('parent_id = ?')\r\n        params.push(body.parent_id)\r\n      }\r\n\r\n      if (body.position !== undefined) {\r\n        updates.push('position = ?')\r\n        params.push(body.position)\r\n      }\r\n\r\n      if (body.color !== undefined) {\r\n        updates.push('color = ?')\r\n        params.push(body.color)\r\n      }\r\n\r\n      if (body.tags !== undefined) {\r\n        updates.push('tags = ?')\r\n        params.push(body.tags ? JSON.stringify(body.tags) : null)\r\n      }\r\n\r\n      if (updates.length === 0) {\r\n        return badRequest('No valid fields to update')\r\n      }\r\n\r\n      // Always update updated_at\r\n      updates.push('updated_at = ?')\r\n      params.push(new Date().toISOString())\r\n\r\n      // Add WHERE clause params\r\n      params.push(groupId, userId)\r\n\r\n      await context.env.DB.prepare(\r\n        `UPDATE tab_groups SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`\r\n      )\r\n        .bind(...params)\r\n        .run()\r\n\r\n      // Fetch updated group with items\r\n      const updatedGroup = await context.env.DB.prepare('SELECT * FROM tab_groups WHERE id = ?')\r\n        .bind(groupId)\r\n        .first<TabGroupRow>()\r\n\r\n      const { results: items } = await context.env.DB.prepare(\r\n        `SELECT tgi.*\r\n         FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.group_id = ? AND tg.user_id = ?\r\n         ORDER BY tgi.position ASC`\r\n      )\r\n        .bind(groupId, userId)\r\n        .all<TabGroupItemRow>()\r\n\r\n      if (!updatedGroup) {\r\n        return internalError('Failed to load tab group after update')\r\n      }\r\n\r\n      return success({\r\n        tab_group: {\r\n          ...updatedGroup,\r\n          items: items || [],\r\n          item_count: items?.length || 0,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Update tab group error:', error)\r\n      return internalError('Failed to update tab group')\r\n    }\r\n  },\r\n]\r\n\r\n// DELETE /api/v1/tab-groups/:id - \r\nexport const onRequestDelete: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const groupId = context.params.id\r\n\r\n    try {\r\n      // Check if tab group exists and belongs to user\r\n      const groupRow = await context.env.DB.prepare(\r\n        'SELECT * FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(groupId, userId)\r\n        .first<TabGroupRow>()\r\n\r\n      if (!groupRow) {\r\n        return notFound('Tab group not found')\r\n      }\r\n\r\n      // Delete tab group (items will be cascade deleted)\r\n      await context.env.DB.prepare('DELETE FROM tab_groups WHERE id = ? AND user_id = ?')\r\n        .bind(groupId, userId)\r\n        .run()\r\n\r\n      return noContent()\r\n    } catch (error) {\r\n      console.error('Delete tab group error:', error)\r\n      return internalError('Failed to delete tab group')\r\n    }\r\n  },\r\n]\r\n\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/batch-update.ts",
    "content": "/**\n * PATCH /api/v1/tab-groups/batch-update\n * Batch update tab group positions\n */\n\nimport type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, RouteParams } from '../../../lib/types'\nimport { success, badRequest, internalError } from '../../../lib/response'\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\n\ninterface BatchUpdateItem {\n  id: string\n  position: number\n  parent_id: string | null\n}\n\ninterface BatchUpdateRequest {\n  updates: BatchUpdateItem[]\n}\n\nexport const onRequestPatch: PagesFunction<Env, RouteParams, AuthContext>[] = [\n  requireAuth,\n  async (context) => {\n    const userId = context.data.user_id\n\n    try {\n      const body = (await context.request.json()) as BatchUpdateRequest\n\n      if (!body.updates || !Array.isArray(body.updates) || body.updates.length === 0) {\n        return badRequest('updates array is required')\n      }\n\n      if (body.updates.length > 200) {\n        return badRequest('Cannot update more than 200 items at once')\n      }\n\n      const db = context.env.DB\n      const stmts = body.updates.map(item =>\n        db.prepare(\n          `UPDATE tab_groups\n           SET position = ?, parent_id = ?, updated_at = datetime('now')\n           WHERE id = ? AND user_id = ?`\n        ).bind(item.position, item.parent_id, item.id, userId)\n      )\n\n      await db.batch(stmts)\n\n      return success({\n        message: 'Batch update successful',\n        updated_count: body.updates.length,\n      })\n    } catch (error) {\n      console.error('Batch update tab groups error:', error)\n      return internalError('Failed to batch update tab groups')\n    }\n  },\n]\n"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/index.ts",
    "content": "/**\r\n *  API - \r\n * : /api/v1/tab-groups\r\n * : JWT Token\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams, SQLParam } from '../../../lib/types'\r\nimport { success, created, internalError } from '../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\r\nimport { sanitizeString } from '../../../lib/validation'\r\nimport { generateUUID } from '../../../lib/crypto'\r\n\r\ninterface TabGroupRow {\r\n  id: string\r\n  user_id: string\r\n  title: string\r\n  color: string | null\r\n  tags: string | null\r\n  parent_id: string | null\r\n  is_folder: number\r\n  is_deleted: number\r\n  deleted_at: string | null\r\n  position: number\r\n  created_at: string\r\n  updated_at: string\r\n}\r\n\r\ninterface TabGroupItemRow {\r\n  id: string\r\n  group_id: string\r\n  title: string\r\n  url: string\r\n  favicon: string | null\r\n  position: number\r\n  created_at: string\r\n  is_pinned?: number\r\n  is_todo?: number\r\n}\r\n\r\ninterface CreateTabGroupRequest {\r\n  title?: string\r\n  parent_id?: string | null\r\n  is_folder?: boolean\r\n  items?: Array<{\r\n    title: string\r\n    url: string\r\n    favicon?: string\r\n  }>\r\n}\r\n\r\n// GET /api/v1/tab-groups\r\nexport const onRequestGet: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const url = new URL(context.request.url)\r\n\r\n    const pageSize = Math.min(parseInt(url.searchParams.get('page_size') || '30', 10) || 30, 100)\r\n    const pageCursor = url.searchParams.get('page_cursor') || ''\r\n\r\n    try {\r\n      let query = `\r\n        SELECT *\r\n        FROM tab_groups\r\n        WHERE user_id = ? AND (is_deleted IS NULL OR is_deleted = 0)\r\n      `\r\n      const params: SQLParam[] = [userId]\r\n\r\n      if (pageCursor) {\r\n        query += ' AND created_at < ?'\r\n        params.push(pageCursor)\r\n      }\r\n\r\n      query += ' ORDER BY created_at DESC LIMIT ?'\r\n      params.push(pageSize + 1)\r\n\r\n      const { results } = await context.env.DB.prepare(query)\r\n        .bind(...params)\r\n        .all<TabGroupRow>()\r\n\r\n      const hasMore = results.length > pageSize\r\n      const tabGroups = hasMore ? results.slice(0, pageSize) : results\r\n      const nextCursor = hasMore ? tabGroups[tabGroups.length - 1].created_at : null\r\n\r\n      // Batch fetch all items for returned groups (avoids N+1)\r\n      const groupIds = tabGroups.map((g) => g.id)\r\n      let allItems: TabGroupItemRow[] = []\r\n\r\n      if (groupIds.length > 0) {\r\n        const placeholders = groupIds.map(() => '?').join(',')\r\n        const { results: items } = await context.env.DB.prepare(\r\n          `SELECT tgi.*\r\n           FROM tab_group_items tgi\r\n           JOIN tab_groups tg ON tgi.group_id = tg.id\r\n           WHERE tgi.group_id IN (${placeholders}) AND tg.user_id = ?\r\n           ORDER BY COALESCE(tgi.is_pinned, 0) DESC, tgi.position ASC`\r\n        )\r\n          .bind(...groupIds, userId)\r\n          .all<TabGroupItemRow>()\r\n        allItems = items || []\r\n      }\r\n\r\n      // Group items by group_id in memory\r\n      const itemsByGroup = new Map<string, TabGroupItemRow[]>()\r\n      for (const item of allItems) {\r\n        const arr = itemsByGroup.get(item.group_id) || []\r\n        arr.push(item)\r\n        itemsByGroup.set(item.group_id, arr)\r\n      }\r\n\r\n      const groupsWithItems = tabGroups.map((group) => {\r\n        const items = itemsByGroup.get(group.id) || []\r\n        return {\r\n          ...group,\r\n          items,\r\n          item_count: items.length,\r\n        }\r\n      })\r\n\r\n      return success({\r\n        tab_groups: groupsWithItems,\r\n        meta: {\r\n          page_size: pageSize,\r\n          count: tabGroups.length,\r\n          next_cursor: nextCursor,\r\n          has_more: hasMore,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Get tab groups error:', error)\r\n      return internalError('Failed to get tab groups')\r\n    }\r\n  },\r\n]\r\n\r\n// POST /api/v1/tab-groups\r\nexport const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n\r\n    try {\r\n      const body = (await context.request.json()) as CreateTabGroupRequest\r\n\r\n      const isFolder = body.is_folder || false\r\n\r\n      const now = new Date()\r\n      const defaultTitle = body.title || (isFolder ? '' : now.toLocaleString('zh-CN', {\r\n        year: 'numeric',\r\n        month: '2-digit',\r\n        day: '2-digit',\r\n        hour: '2-digit',\r\n        minute: '2-digit',\r\n        hour12: false,\r\n      }).replace(/\\//g, '-'))\r\n\r\n      const title = sanitizeString(defaultTitle, 200)\r\n      const groupId = generateUUID()\r\n      const timestamp = now.toISOString()\r\n      const parentId = body.parent_id || null\r\n\r\n      // Build all statements for atomic batch execution\r\n      const stmts = [\r\n        context.env.DB.prepare(\r\n          'INSERT INTO tab_groups (id, user_id, title, parent_id, is_folder, is_deleted, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 0, ?, ?)'\r\n        ).bind(groupId, userId, title, parentId, isFolder ? 1 : 0, timestamp, timestamp),\r\n      ]\r\n\r\n      if (!isFolder && body.items && body.items.length > 0) {\r\n        for (let i = 0; i < body.items.length; i++) {\r\n          const item = body.items[i]\r\n          const itemId = generateUUID()\r\n          const itemTitle = sanitizeString(item.title, 500)\r\n          const itemUrl = sanitizeString(item.url, 2000)\r\n          const favicon = item.favicon ? sanitizeString(item.favicon, 2000) : null\r\n\r\n          stmts.push(\r\n            context.env.DB.prepare(\r\n              'INSERT INTO tab_group_items (id, group_id, title, url, favicon, position, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)'\r\n            ).bind(itemId, groupId, itemTitle, itemUrl, favicon, i, timestamp)\r\n          )\r\n        }\r\n      }\r\n\r\n      await context.env.DB.batch(stmts)\r\n\r\n      // Fetch the created group with items\r\n      const groupRow = await context.env.DB.prepare('SELECT * FROM tab_groups WHERE id = ?')\r\n        .bind(groupId)\r\n        .first<TabGroupRow>()\r\n\r\n      const { results: items } = await context.env.DB.prepare(\r\n        `SELECT tgi.*\r\n         FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.group_id = ? AND tg.user_id = ?\r\n         ORDER BY tgi.position ASC`\r\n      )\r\n        .bind(groupId, userId)\r\n        .all<TabGroupItemRow>()\r\n\r\n      if (!groupRow) {\r\n        return internalError('Failed to load tab group after creation')\r\n      }\r\n\r\n      return created({\r\n        tab_group: {\r\n          ...groupRow,\r\n          items: items || [],\r\n          item_count: items?.length || 0,\r\n        },\r\n      })\r\n    } catch (error) {\r\n      console.error('Create tab group error:', error)\r\n      return internalError('Failed to create tab group')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/items/[id]/move.ts",
    "content": "/**\r\n *  API - \r\n * : /api/v1/tab-groups/items/:id/move\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../../middleware/auth'\r\n\r\ninterface TabGroupItemRow {\r\n  id: string\r\n  group_id: string\r\n  title: string\r\n  url: string\r\n  favicon: string | null\r\n  position: number\r\n  created_at: string\r\n  is_pinned?: number\r\n  is_todo?: number\r\n}\r\n\r\ninterface MoveItemRequest {\r\n  target_group_id: string\r\n  position?: number\r\n}\r\n\r\n// POST /api/v1/tab-groups/items/:id/move - \r\nexport const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const itemId = context.params.id\r\n\r\n    try {\r\n      const body = (await context.request.json()) as MoveItemRequest\r\n\r\n      if (!body.target_group_id) {\r\n        return badRequest('target_group_id is required')\r\n      }\r\n\r\n      // 1. \r\n      const item = await context.env.DB.prepare(\r\n        `SELECT tgi.*, tg.user_id \r\n         FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.id = ?`\r\n      )\r\n        .bind(itemId)\r\n        .first<TabGroupItemRow & { user_id: string }>()\r\n\r\n      if (!item) {\r\n        return notFound('Tab group item not found')\r\n      }\r\n\r\n      if (item.user_id !== userId) {\r\n        return notFound('Tab group item not found')\r\n      }\r\n\r\n      // 2. \r\n      const targetGroup = await context.env.DB.prepare(\r\n        'SELECT id, user_id FROM tab_groups WHERE id = ? AND user_id = ?'\r\n      )\r\n        .bind(body.target_group_id, userId)\r\n        .first<{ id: string; user_id: string }>()\r\n\r\n      if (!targetGroup) {\r\n        return notFound('Target group not found')\r\n      }\r\n\r\n      // 3. ，\r\n      if (item.group_id === body.target_group_id) {\r\n        if (body.position !== undefined) {\r\n          // \r\n          await context.env.DB.prepare(\r\n            'UPDATE tab_group_items SET position = ? WHERE id = ?'\r\n          )\r\n            .bind(body.position, itemId)\r\n            .run()\r\n\r\n          // \r\n          await context.env.DB.prepare(\r\n            `UPDATE tab_group_items \r\n             SET position = position + 1 \r\n             WHERE group_id = ? AND id != ? AND position >= ?`\r\n          )\r\n            .bind(item.group_id, itemId, body.position)\r\n            .run()\r\n        }\r\n      } else {\r\n        // 4. \r\n\r\n        // 4.1 \r\n        const maxPositionResult = await context.env.DB.prepare(\r\n          'SELECT MAX(position) as max_position FROM tab_group_items WHERE group_id = ?'\r\n        )\r\n          .bind(body.target_group_id)\r\n          .first<{ max_position: number | null }>()\r\n\r\n        const targetPosition =\r\n          body.position !== undefined\r\n            ? body.position\r\n            : (maxPositionResult?.max_position ?? -1) + 1\r\n\r\n        // 4.2 \r\n        await context.env.DB.prepare(\r\n          `UPDATE tab_group_items \r\n           SET group_id = ?, position = ? \r\n           WHERE id = ?`\r\n        )\r\n          .bind(body.target_group_id, targetPosition, itemId)\r\n          .run()\r\n\r\n        // 4.3 （）\r\n        await context.env.DB.prepare(\r\n          `UPDATE tab_group_items \r\n           SET position = position - 1 \r\n           WHERE group_id = ? AND position > ?`\r\n        )\r\n          .bind(item.group_id, item.position)\r\n          .run()\r\n\r\n        // 4.4 （）\r\n        if (body.position !== undefined) {\r\n          await context.env.DB.prepare(\r\n            `UPDATE tab_group_items \r\n             SET position = position + 1 \r\n             WHERE group_id = ? AND id != ? AND position >= ?`\r\n          )\r\n            .bind(body.target_group_id, itemId, targetPosition)\r\n            .run()\r\n        }\r\n      }\r\n\r\n      // 5. \r\n      const updatedItem = await context.env.DB.prepare(\r\n        'SELECT * FROM tab_group_items WHERE id = ?'\r\n      )\r\n        .bind(itemId)\r\n        .first<TabGroupItemRow>()\r\n\r\n      if (!updatedItem) {\r\n        return internalError('Failed to load item after move')\r\n      }\r\n\r\n      return success({\r\n        item: updatedItem,\r\n        message: 'Item moved successfully',\r\n      })\r\n    } catch (error) {\r\n      console.error('Move tab group item error:', error)\r\n      return internalError('Failed to move tab group item')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/items/[id].ts",
    "content": "/**\r\n *  API - \r\n * : /api/v1/tab-groups/items/:id\r\n * : JWT Token (Bearer)\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../../../../lib/types'\r\nimport { success, badRequest, notFound, internalError } from '../../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../../middleware/auth'\r\nimport { sanitizeString } from '../../../../lib/validation'\r\n\r\ninterface TabGroupItemRow {\r\n  id: string\r\n  group_id: string\r\n  title: string\r\n  url: string\r\n  favicon: string | null\r\n  position: number\r\n  created_at: string\r\n  is_pinned?: number\r\n  is_todo?: number\r\n  is_archived?: number\r\n}\r\n\r\ninterface UpdateTabGroupItemRequest {\r\n  title?: string\r\n  is_pinned?: boolean\r\n  is_todo?: boolean\r\n  is_archived?: boolean\r\n  position?: number\r\n}\r\n\r\n// PATCH /api/v1/tab-groups/items/:id - \r\nexport const onRequestPatch: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const itemId = context.params.id\r\n\r\n    try {\r\n      const body = (await context.request.json()) as UpdateTabGroupItemRequest\r\n\r\n      // Check if item exists and user has permission\r\n      const item = await context.env.DB.prepare(\r\n        `SELECT tgi.*, tg.user_id \r\n         FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.id = ?`\r\n      )\r\n        .bind(itemId)\r\n        .first<TabGroupItemRow & { user_id: string }>()\r\n\r\n      if (!item) {\r\n        return notFound('Tab group item not found')\r\n      }\r\n\r\n      if (item.user_id !== userId) {\r\n        return notFound('Tab group item not found')\r\n      }\r\n\r\n      // Build update query\r\n      const updates: string[] = []\r\n      const params: (string | number)[] = []\r\n\r\n      if (body.title !== undefined) {\r\n        updates.push('title = ?')\r\n        params.push(sanitizeString(body.title, 500))\r\n      }\r\n\r\n      if (body.is_pinned !== undefined) {\r\n        updates.push('is_pinned = ?')\r\n        params.push(body.is_pinned ? 1 : 0)\r\n        \r\n        // If pinning, set position to 0 and shift others\r\n        if (body.is_pinned) {\r\n          await context.env.DB.prepare(\r\n            'UPDATE tab_group_items SET position = position + 1 WHERE group_id = ? AND id != ?'\r\n          )\r\n            .bind(item.group_id, itemId)\r\n            .run()\r\n          \r\n          updates.push('position = ?')\r\n          params.push(0)\r\n        }\r\n      }\r\n\r\n      if (body.is_todo !== undefined) {\r\n        updates.push('is_todo = ?')\r\n        params.push(body.is_todo ? 1 : 0)\r\n      }\r\n\r\n      if (body.is_archived !== undefined) {\r\n        updates.push('is_archived = ?')\r\n        params.push(body.is_archived ? 1 : 0)\r\n      }\r\n\r\n      if (body.position !== undefined) {\r\n        updates.push('position = ?')\r\n        params.push(body.position)\r\n      }\r\n\r\n      if (updates.length === 0) {\r\n        return badRequest('No fields to update')\r\n      }\r\n\r\n      // Add item ID to params — use subquery to enforce ownership\r\n      params.push(itemId, item.group_id, userId)\r\n\r\n      // Execute update with ownership check via group\r\n      await context.env.DB.prepare(\r\n        `UPDATE tab_group_items SET ${updates.join(', ')} WHERE id = ? AND group_id IN (SELECT id FROM tab_groups WHERE id = ? AND user_id = ?)`\r\n      )\r\n        .bind(...params)\r\n        .run()\r\n\r\n      // Get updated item\r\n      const updatedItem = await context.env.DB.prepare(\r\n        `SELECT tgi.* FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.id = ? AND tg.user_id = ?`\r\n      )\r\n        .bind(itemId, userId)\r\n        .first<TabGroupItemRow>()\r\n\r\n      if (!updatedItem) {\r\n        return internalError('Failed to load item after update')\r\n      }\r\n\r\n      return success({\r\n        item: updatedItem,\r\n        message: 'Item updated successfully',\r\n      })\r\n    } catch (error) {\r\n      console.error('Update tab group item error:', error)\r\n      return internalError('Failed to update tab group item')\r\n    }\r\n  },\r\n]\r\n\r\n// DELETE /api/v1/tab-groups/items/:id - \r\nexport const onRequestDelete: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n    const itemId = context.params.id\r\n\r\n    try {\r\n      // Check if item exists and user has permission\r\n      const item = await context.env.DB.prepare(\r\n        `SELECT tgi.*, tg.user_id \r\n         FROM tab_group_items tgi\r\n         JOIN tab_groups tg ON tgi.group_id = tg.id\r\n         WHERE tgi.id = ?`\r\n      )\r\n        .bind(itemId)\r\n        .first<TabGroupItemRow & { user_id: string }>()\r\n\r\n      if (!item) {\r\n        return notFound('Tab group item not found')\r\n      }\r\n\r\n      if (item.user_id !== userId) {\r\n        return notFound('Tab group item not found')\r\n      }\r\n\r\n      // Delete item with ownership check\r\n      await context.env.DB.prepare(\r\n        'DELETE FROM tab_group_items WHERE id = ? AND group_id IN (SELECT id FROM tab_groups WHERE id = ? AND user_id = ?)'\r\n      )\r\n        .bind(itemId, item.group_id, userId)\r\n        .run()\r\n\r\n      // Reorder remaining items\r\n      await context.env.DB.prepare(\r\n        'UPDATE tab_group_items SET position = position - 1 WHERE group_id = ? AND position > ?'\r\n      )\r\n        .bind(item.group_id, item.position)\r\n        .run()\r\n\r\n      return success({\r\n        message: 'Item deleted successfully',\r\n      })\r\n    } catch (error) {\r\n      console.error('Delete tab group item error:', error)\r\n      return internalError('Failed to delete tab group item')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/tab-groups/trash.ts",
    "content": "\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env } from '../../../lib/types'\r\nimport { success, internalError } from '../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\r\n\r\ninterface TabGroupRow {\r\n  id: string\r\n  user_id: string\r\n  title: string\r\n  color: string | null\r\n  tags: string | null\r\n  parent_id: string | null\r\n  is_folder: number\r\n  is_deleted: number\r\n  deleted_at: string | null\r\n  position: number\r\n  created_at: string\r\n  updated_at: string\r\n}\r\n\r\n// GET /api/v1/tab-groups/trash - Retrieve trashed tab groups\r\nexport const onRequestGet: PagesFunction<Env, string, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    const userId = context.data.user_id\r\n\r\n    try {\r\n      \r\n      const { results: groups } = await context.env.DB.prepare(\r\n        'SELECT * FROM tab_groups WHERE user_id = ? AND is_deleted = 1 ORDER BY deleted_at DESC'\r\n      )\r\n        .bind(userId)\r\n        .all<TabGroupRow>()\r\n\r\n      const groupsWithCounts = await Promise.all(\r\n        (groups || []).map(async (group) => {\r\n          const { results: items } = await context.env.DB.prepare(\r\n            'SELECT COUNT(*) as count FROM tab_group_items WHERE group_id = ?'\r\n          )\r\n            .bind(group.id)\r\n            .all<{ count: number }>()\r\n\r\n          let tags: string[] | null = null\r\n          if (group.tags) {\r\n            try {\r\n              tags = JSON.parse(group.tags)\r\n            } catch {\r\n              tags = null\r\n            }\r\n          }\r\n\r\n          return {\r\n            ...group,\r\n            tags,\r\n            item_count: items?.[0]?.count || 0,\r\n          }\r\n        })\r\n      )\r\n\r\n      return success({\r\n        tab_groups: groupsWithCounts,\r\n        total: groupsWithCounts.length,\r\n      })\r\n    } catch (error) {\r\n      console.error('Get trash error:', error)\r\n      return internalError('Failed to load trash')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/api/v1/tags/[id].ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env, Tag, RouteParams, SQLParam } from '../../../lib/types'\nimport { success, badRequest, notFound, noContent, conflict, internalError } from '../../../lib/response'\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\nimport { sanitizeString } from '../../../lib/validation'\n\ninterface UpdateTagRequest {\n  name?: string\n  color?: string\n}\n\n// PATCH /api/v1/tags/:id - \nexport const onRequestPatch: PagesFunction<Env, RouteParams, AuthContext>[] = [\n  requireAuth,\n  async (context) => {\n    try {\n      const userId = context.data.user_id\n      const tagId = context.params.id\n      const body = await context.request.json() as UpdateTagRequest\n\n      // \n      const tag = await context.env.DB.prepare(\n        'SELECT * FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\n      )\n        .bind(tagId, userId)\n        .first<Tag>()\n\n      if (!tag) {\n        return notFound('Tag not found')\n      }\n\n      const updates: string[] = []\n      const values: SQLParam[] = []\n\n      if (body.name !== undefined) {\n        const name = sanitizeString(body.name, 50)\n\n        // \n        const existing = await context.env.DB.prepare(\n          'SELECT id FROM tags WHERE user_id = ? AND LOWER(name) = LOWER(?) AND id != ? AND deleted_at IS NULL'\n        )\n          .bind(userId, name, tagId)\n          .first()\n\n        if (existing) {\n          return conflict('Tag with this name already exists')\n        }\n\n        updates.push('name = ?')\n        values.push(name)\n      }\n\n      if (body.color !== undefined) {\n        updates.push('color = ?')\n        values.push(body.color ? sanitizeString(body.color, 20) : null)\n      }\n\n      if (updates.length === 0) {\n        return badRequest('No valid fields to update')\n      }\n\n      const now = new Date().toISOString()\n      updates.push('updated_at = ?')\n      values.push(now)\n      values.push(tagId, userId)\n\n      await context.env.DB.prepare(`UPDATE tags SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`)\n        .bind(...values)\n        .run()\n\n      // \n      const updatedTag = await context.env.DB.prepare('SELECT * FROM tags WHERE id = ? AND user_id = ?')\n        .bind(tagId, userId)\n        .first<Tag>()\n\n      return success({ tag: updatedTag })\n    } catch (error) {\n      console.error('Update tag error:', error)\n      return internalError('Failed to update tag')\n    }\n  },\n]\n\n// DELETE /api/v1/tags/:id - （）\nexport const onRequestDelete: PagesFunction<Env, RouteParams, AuthContext>[] = [\n  requireAuth,\n  async (context) => {\n    try {\n      const userId = context.data.user_id\n      const tagId = context.params.id\n\n      // \n      const tag = await context.env.DB.prepare(\n        'SELECT id FROM tags WHERE id = ? AND user_id = ? AND deleted_at IS NULL'\n      )\n        .bind(tagId, userId)\n        .first()\n\n      if (!tag) {\n        return notFound('Tag not found')\n      }\n\n      const now = new Date().toISOString()\n\n      // \n      await context.env.DB.prepare('UPDATE tags SET deleted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?')\n        .bind(now, now, tagId, userId)\n        .run()\n\n      // -\n      await context.env.DB.prepare('DELETE FROM bookmark_tags WHERE tag_id = ? AND user_id = ?')\n        .bind(tagId, userId)\n        .run()\n\n      return noContent()\n    } catch (error) {\n      console.error('Delete tag error:', error)\n      return internalError('Failed to delete tag')\n    }\n  },\n]\n"
  },
  {
    "path": "tmarks/functions/api/v1/tags/index.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, Tag, RouteParams } from '../../../lib/types'\r\nimport { success, badRequest, created, conflict, internalError } from '../../../lib/response'\r\nimport { requireAuth, AuthContext } from '../../../middleware/auth'\r\nimport { sanitizeString } from '../../../lib/validation'\r\nimport { generateUUID } from '../../../lib/crypto'\r\n\r\ninterface CreateTagRequest {\r\n  name: string\r\n  color?: string\r\n}\r\n\r\ninterface TagWithCount extends Tag {\r\n  bookmark_count: number\r\n}\r\n\r\n// GET /api/v1/tags - Retrieve all tags\r\nexport const onRequestGet: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    try {\r\n      const userId = context.data.user_id\r\n      const url = new URL(context.request.url)\r\n\r\n      const sortBy = url.searchParams.get('sort') || 'usage' \r\n\r\n      let query = `\r\n        SELECT\r\n          t.*,\r\n          COUNT(bt.bookmark_id) as bookmark_count\r\n        FROM tags t\r\n        LEFT JOIN bookmark_tags bt ON t.id = bt.tag_id\r\n        WHERE t.user_id = ? AND t.deleted_at IS NULL\r\n        GROUP BY t.id\r\n      `\r\n\r\n      if (sortBy === 'name') {\r\n        query += ' ORDER BY LOWER(t.name) ASC'\r\n      } else if (sortBy === 'clicks') {\r\n        query += ' ORDER BY t.click_count DESC, LOWER(t.name) ASC'\r\n      } else {\r\n        \r\n        query += ' ORDER BY bookmark_count DESC, LOWER(t.name) ASC'\r\n      }\r\n\r\n      const { results } = await context.env.DB.prepare(query)\r\n        .bind(userId)\r\n        .all<TagWithCount>()\r\n\r\n      return success({\r\n        tags: results || [],\r\n      })\r\n    } catch (error) {\r\n      console.error('Get tags error:', error)\r\n      return internalError('Failed to get tags')\r\n    }\r\n  },\r\n]\r\n\r\n// POST /api/v1/tags - Create a new tag\r\nexport const onRequestPost: PagesFunction<Env, RouteParams, AuthContext>[] = [\r\n  requireAuth,\r\n  async (context) => {\r\n    try {\r\n      const userId = context.data.user_id\r\n      const body = await context.request.json() as CreateTagRequest\r\n\r\n      if (!body.name) {\r\n        return badRequest('Tag name is required')\r\n      }\r\n\r\n      const name = sanitizeString(body.name, 50)\r\n      const color = body.color ? sanitizeString(body.color, 20) : null\r\n\r\n      const existing = await context.env.DB.prepare(\r\n        'SELECT id FROM tags WHERE user_id = ? AND LOWER(name) = LOWER(?) AND deleted_at IS NULL'\r\n      )\r\n        .bind(userId, name)\r\n        .first()\r\n\r\n      if (existing) {\r\n        return conflict('Tag with this name already exists')\r\n      }\r\n\r\n      const now = new Date().toISOString()\r\n      const tagUuid = generateUUID()\r\n\r\n      await context.env.DB.prepare(\r\n        `INSERT INTO tags (id, user_id, name, color, created_at, updated_at)\r\n         VALUES (?, ?, ?, ?, ?, ?)`\r\n      )\r\n        .bind(tagUuid, userId, name, color, now, now)\r\n        .run()\r\n\r\n      const tag = await context.env.DB.prepare('SELECT * FROM tags WHERE id = ?')\r\n        .bind(tagUuid)\r\n        .first<Tag>()\r\n\r\n      return created({ tag })\r\n    } catch (error) {\r\n      console.error('Create tag error:', error)\r\n      return internalError('Failed to create tag')\r\n    }\r\n  },\r\n]\r\n"
  },
  {
    "path": "tmarks/functions/lib/api-key/generator.ts",
    "content": "/**\n\n * : tmk_live_[20]\n */\n\n/**\n *  API Key\n * @param env  ('live' | 'test')\n * @returns API Key, , SHA256 \n */\nexport async function generateApiKey(env: 'live' | 'test' = 'live'): Promise<{\n  key: string\n  prefix: string\n  hash: string\n}> {\n\n  const base62Chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'\n\n  const randomBytes = new Uint8Array(20)\n  crypto.getRandomValues(randomBytes)\n\n  const randomStr = Array.from(randomBytes)\n    .map(byte => base62Chars[byte % base62Chars.length])\n    .join('')\n\n  const key = `tmk_${env}_${randomStr}`\n\n  const prefix = key.substring(0, 13) // tmk_live_1a2b\n\n  //  SHA256 \n  const hash = await hashApiKey(key)\n\n  return { key, prefix, hash }\n}\n\n/**\n *  SHA256 \n * @param key API Key \n * @returns SHA256  (hex)\n */\nexport async function hashApiKey(key: string): Promise<string> {\n  const encoder = new TextEncoder()\n  const data = encoder.encode(key)\n  const hashBuffer = await crypto.subtle.digest('SHA-256', data)\n\n  const hashArray = new Uint8Array(hashBuffer)\n  const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')\n\n  return hashHex\n}\n\n/**\n *  API Key \n\n */\nexport function isValidApiKeyFormat(key: string): boolean {\n  // : tmk_(live|test)_[20base62]\n  const pattern = /^tmk_(live|test)_[a-zA-Z0-9]{20}$/\n  return pattern.test(key)\n}\n"
  },
  {
    "path": "tmarks/functions/lib/api-key/logger.ts",
    "content": "/**\n * API Key Logger - Records API key usage and provides statistics\n * Automatically cleans up old logs, keeping only the latest 100 entries per key\n */\n\ninterface LogEntry {\n  api_key_id: string\n  user_id: string\n  endpoint: string\n  method: string\n  status: number\n  ip: string | null\n}\n\n/**\n * Log API Key usage\n * @param entry Log entry data\n * @param db D1 Database\n */\nexport async function logApiKeyUsage(entry: LogEntry, db: D1Database): Promise<void> {\n  try {\n    await db\n      .prepare(\n        `INSERT INTO api_key_logs (api_key_id, user_id, endpoint, method, status, ip)\n         VALUES (?, ?, ?, ?, ?, ?)`\n      )\n      .bind(\n        entry.api_key_id,\n        entry.user_id,\n        entry.endpoint,\n        entry.method,\n        entry.status,\n        entry.ip\n      )\n      .run()\n\n    // Async cleanup (keep only latest 100 logs per key)\n    await cleanupOldLogs(entry.api_key_id, db)\n  } catch (error) {\n    // Don't throw error, just log it\n    console.error('Failed to log API key usage:', error)\n  }\n}\n\n/**\n * Cleanup old logs, keep only the latest 100 entries\n * @param apiKeyId API Key ID\n * @param db D1 Database\n */\nasync function cleanupOldLogs(apiKeyId: string, db: D1Database): Promise<void> {\n  try {\n    // Keep only the latest 100 logs\n    await db\n      .prepare(\n        `DELETE FROM api_key_logs\n         WHERE api_key_id = ?\n         AND id NOT IN (\n           SELECT id FROM api_key_logs\n           WHERE api_key_id = ?\n           ORDER BY created_at DESC\n           LIMIT 100\n         )`\n      )\n      .bind(apiKeyId, apiKeyId)\n      .run()\n  } catch (error) {\n    console.error('Failed to cleanup old logs:', error)\n  }\n}\n\n/**\n * Get API Key usage logs\n * @param apiKeyId API Key ID\n * @param limit Maximum number of logs to return (default: 10)\n * @param db D1 Database\n * @returns Array of log entries\n */\nexport async function getApiKeyLogs(\n  apiKeyId: string,\n  db: D1Database,\n  limit: number = 10\n): Promise<LogEntry[]> {\n  const result = await db\n    .prepare(\n      `SELECT api_key_id, user_id, endpoint, method, status, ip, created_at\n       FROM api_key_logs\n       WHERE api_key_id = ?\n       ORDER BY created_at DESC\n       LIMIT ?`\n    )\n    .bind(apiKeyId, limit)\n    .all()\n\n  return result.results as unknown as LogEntry[]\n}\n\n/**\n * Get API Key usage statistics\n * @param apiKeyId API Key ID\n * @param db D1 Database\n * @returns Statistics object with total requests, last used time and IP\n */\nexport async function getApiKeyStats(\n  apiKeyId: string,\n  db: D1Database\n): Promise<{\n  total_requests: number\n  last_used_at: string | null\n  last_used_ip: string | null\n}> {\n  const result = await db\n    .prepare(\n      `SELECT\n         COUNT(*) as total_requests,\n         MAX(created_at) as last_used_at,\n         (SELECT ip FROM api_key_logs\n          WHERE api_key_id = ?\n          ORDER BY created_at DESC\n          LIMIT 1) as last_used_ip\n       FROM api_key_logs\n       WHERE api_key_id = ?`\n    )\n    .bind(apiKeyId, apiKeyId)\n    .first()\n\n  return result as unknown as {\n    total_requests: number\n    last_used_at: string | null\n    last_used_ip: string | null\n  }\n}\n"
  },
  {
    "path": "tmarks/functions/lib/api-key/rate-limiter-types.ts",
    "content": "/**\n * API Key Rate Limiter Types\n */\n\nexport type RateLimitWindow = 'minute' | 'hour' | 'day';\n\nexport interface RateLimitConfig {\n  per_minute: number;\n  per_hour: number;\n  per_day: number;\n}\n\n// Default limits: reasonable for normal use, low enough to deter abuse.\nexport const DEFAULT_LIMITS: RateLimitConfig = {\n  per_minute: 60,\n  per_hour: 1000,\n  per_day: 10000,\n};\n\nexport interface RateLimitResult {\n  allowed: boolean;\n  window: RateLimitWindow;\n  limit: number;\n  remaining: number;\n  reset: number; // unix ms\n  retryAfter?: number; // seconds\n}\n"
  },
  {
    "path": "tmarks/functions/lib/api-key/rate-limiter.ts",
    "content": "/**\r\n * API Key Rate Limiter (D1-backed)\r\n */\r\n\r\nimport {\r\n  RateLimitWindow,\r\n  RateLimitConfig,\r\n  RateLimitResult,\r\n  DEFAULT_LIMITS\r\n} from './rate-limiter-types';\r\n\r\nlet ensureTablePromise: Promise<void> | null = null;\r\n\r\nasync function ensureTable(db: D1Database): Promise<void> {\r\n  if (ensureTablePromise) return ensureTablePromise;\r\n\r\n  ensureTablePromise = db\r\n    .prepare(\r\n      `CREATE TABLE IF NOT EXISTS api_key_rate_limits (\r\n        api_key_id TEXT NOT NULL,\r\n        window TEXT NOT NULL,\r\n        window_start INTEGER NOT NULL,\r\n        count INTEGER NOT NULL DEFAULT 0,\r\n        updated_at INTEGER NOT NULL,\r\n        PRIMARY KEY (api_key_id, window, window_start)\r\n      )`\r\n    )\r\n    .run()\r\n    .then(() => undefined)\r\n    .catch(() => undefined);\r\n\r\n  return ensureTablePromise;\r\n}\r\n\r\nfunction getWindowMs(window: RateLimitWindow): number {\r\n  if (window === 'minute') return 60_000;\r\n  if (window === 'hour') return 3_600_000;\r\n  return 86_400_000;\r\n}\r\n\r\nfunction getLimit(limits: RateLimitConfig, window: RateLimitWindow): number {\r\n  if (window === 'minute') return limits.per_minute;\r\n  if (window === 'hour') return limits.per_hour;\r\n  return limits.per_day;\r\n}\r\n\r\nfunction getWindowStart(now: number, windowMs: number): number {\r\n  return Math.floor(now / windowMs) * windowMs;\r\n}\r\n\r\nasync function maybeCleanup(db: D1Database, now: number): Promise<void> {\r\n  // Use a consistent 1% probability for cleanup\r\n  if (Math.random() < 0.01) {\r\n    const cutoff = now - 7 * 86_400_000;\r\n    await db.prepare(`DELETE FROM api_key_rate_limits WHERE updated_at < ?`).bind(cutoff).run();\r\n  }\r\n}\r\n\r\nasync function getCounts(\r\n  db: D1Database,\r\n  apiKeyId: string,\r\n  windows: Array<{ window: RateLimitWindow; windowStart: number }>\r\n): Promise<Map<RateLimitWindow, number>> {\r\n  const counts = new Map<RateLimitWindow, number>();\r\n  windows.forEach((w) => counts.set(w.window, 0));\r\n\r\n  const minute = windows.find((w) => w.window === 'minute')!;\r\n  const hour = windows.find((w) => w.window === 'hour')!;\r\n  const day = windows.find((w) => w.window === 'day')!;\r\n\r\n  const result = await db\r\n    .prepare(\r\n      `SELECT window, count\r\n       FROM api_key_rate_limits\r\n       WHERE api_key_id = ?\r\n         AND (\r\n           (window = 'minute' AND window_start = ?)\r\n           OR (window = 'hour' AND window_start = ?)\r\n           OR (window = 'day' AND window_start = ?)\r\n         )`\r\n    )\r\n    .bind(apiKeyId, minute.windowStart, hour.windowStart, day.windowStart)\r\n    .all<{ window: RateLimitWindow; count: number }>();\r\n\r\n  (result.results || []).forEach((row) => {\r\n    counts.set(row.window, Number(row.count) || 0);\r\n  });\r\n\r\n  return counts;\r\n}\r\n\r\n/**\r\n * Check rate limit without incrementing counters.\r\n */\r\nexport async function checkRateLimit(\r\n  apiKeyId: string,\r\n  db: D1Database,\r\n  limits: RateLimitConfig = DEFAULT_LIMITS\r\n): Promise<RateLimitResult> {\r\n  const now = Date.now();\r\n\r\n  try {\r\n    await ensureTable(db);\r\n\r\n    const windows: Array<{ window: RateLimitWindow; windowStart: number }> = (['minute', 'hour', 'day'] as const).map((w) => {\r\n      const windowMs = getWindowMs(w);\r\n      return { window: w, windowStart: getWindowStart(now, windowMs) };\r\n    });\r\n\r\n    const counts = await getCounts(db, apiKeyId, windows);\r\n\r\n    let minuteAllowedResult: RateLimitResult | null = null;\r\n\r\n    for (const w of ['minute', 'hour', 'day'] as const) {\r\n      const limit = getLimit(limits, w);\r\n      const current = counts.get(w) || 0;\r\n      const windowMs = getWindowMs(w);\r\n      const windowStart = windows.find((x) => x.window === w)!.windowStart;\r\n      const reset = windowStart + windowMs;\r\n      const remaining = Math.max(0, limit - current);\r\n\r\n      if (current >= limit) {\r\n        return {\r\n          allowed: false,\r\n          window: w,\r\n          limit,\r\n          remaining: 0,\r\n          reset,\r\n          retryAfter: Math.max(0, Math.ceil((reset - now) / 1000)),\r\n        };\r\n      }\r\n\r\n      if (w === 'minute') {\r\n        minuteAllowedResult = {\r\n          allowed: true,\r\n          window: w,\r\n          limit,\r\n          remaining,\r\n          reset,\r\n        };\r\n      }\r\n    }\r\n\r\n    return (\r\n      minuteAllowedResult || {\r\n        allowed: true,\r\n        window: 'minute',\r\n        limit: limits.per_minute,\r\n        remaining: limits.per_minute,\r\n        reset: now + 60_000,\r\n      }\r\n    );\r\n  } catch {\r\n    // Fail-open to avoid accidental outage.\r\n    return {\r\n      allowed: true,\r\n      window: 'minute',\r\n      limit: limits.per_minute,\r\n      remaining: limits.per_minute,\r\n      reset: now + 60_000,\r\n    };\r\n  }\r\n}\r\n\r\n/**\r\n * Atomically consume one request from all rate-limit windows if allowed.\r\n */\r\nexport async function consumeRateLimit(\r\n  apiKeyId: string,\r\n  db: D1Database,\r\n  limits: RateLimitConfig = DEFAULT_LIMITS\r\n): Promise<RateLimitResult> {\r\n  // 1. Check current limits\r\n  const result = await checkRateLimit(apiKeyId, db, limits);\r\n\r\n  // 2. Only increment counters AFTER confirming the request is allowed\r\n  if (result.allowed) {\r\n    try {\r\n      await recordRequest(apiKeyId, db);\r\n      // Return the result with decremented remaining count\r\n      return {\r\n        ...result,\r\n        remaining: Math.max(0, result.remaining - 1),\r\n      };\r\n    } catch {\r\n      // If recording fails, still allow the request (fail-open)\r\n      return result;\r\n    }\r\n  }\r\n\r\n  return result;\r\n}\r\n\r\n/**\r\n * Record a request by incrementing all counters.\r\n */\r\nexport async function recordRequest(\r\n  apiKeyId: string,\r\n  db: D1Database\r\n): Promise<void> {\r\n  const now = Date.now();\r\n  await ensureTable(db);\r\n\r\n  const windows: RateLimitWindow[] = ['minute', 'hour', 'day'];\r\n  const statements = windows.map((w) => {\r\n    const windowMs = getWindowMs(w);\r\n    const windowStart = getWindowStart(now, windowMs);\r\n    return db\r\n      .prepare(\r\n        `INSERT INTO api_key_rate_limits (api_key_id, window, window_start, count, updated_at)\r\n         VALUES (?, ?, ?, 1, ?)\r\n         ON CONFLICT(api_key_id, window, window_start)\r\n         DO UPDATE SET count = count + 1, updated_at = excluded.updated_at`\r\n      )\r\n      .bind(apiKeyId, w, windowStart, now);\r\n  });\r\n\r\n  // D1 batch is best-effort here\r\n  const anyDb = db as unknown as { batch?: (stmts: unknown[]) => Promise<unknown> };\r\n  if (typeof anyDb.batch === 'function') {\r\n    await anyDb.batch(statements);\r\n  } else {\r\n    for (const stmt of statements) {\r\n      await stmt.run();\r\n    }\r\n  }\r\n\r\n  // Opportunistic cleanup\r\n  await maybeCleanup(db, now);\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/api-key/validator.ts",
    "content": "/**\n * API Key Validator\n */\n\nimport { hashApiKey } from './generator'\nimport { hasPermission } from '../../../shared/permissions'\n\ninterface ApiKeyData {\n  id: string\n  user_id: string\n  permissions: string // JSON string\n  status: 'active' | 'revoked' | 'expired'\n  expires_at: string | null\n  last_used_at: string | null\n  last_used_ip: string | null\n}\n\ninterface ValidationResult {\n  valid: boolean\n  error?: string\n  data?: ApiKeyData\n  permissions?: string[]\n}\n\n/**\n * Validate API Key\n * @param apiKey API Key string\n * @param db D1 Database instance\n * @returns Validation result\n */\nexport async function validateApiKey(\n  apiKey: string,\n  db: D1Database\n): Promise<ValidationResult> {\n  // 1. Validate format\n  if (!apiKey || !apiKey.startsWith('tmk_')) {\n    return { valid: false, error: 'Invalid API Key format' }\n  }\n\n  try {\n    // 2. Hash and query database\n    const keyHash = await hashApiKey(apiKey)\n\n    const keyData = await db\n      .prepare(\n        `SELECT id, user_id, permissions, status, expires_at, last_used_at, last_used_ip\n         FROM api_keys\n         WHERE key_hash = ?`\n      )\n      .bind(keyHash)\n      .first<ApiKeyData>()\n\n    if (!keyData) {\n      return { valid: false, error: 'API Key not found' }\n    }\n\n    // 3. Check if revoked\n    if (keyData.status === 'revoked') {\n      return { valid: false, error: 'API Key has been revoked' }\n    }\n\n    // 4. Check if expired\n    if (keyData.status === 'expired') {\n      return { valid: false, error: 'API Key has expired' }\n    }\n\n    // 5. Check expiration date\n    if (keyData.expires_at) {\n      const expiresAt = new Date(keyData.expires_at)\n      if (expiresAt < new Date()) {\n        await markAsExpired(keyData.id, db)\n        return { valid: false, error: 'API Key has expired' }\n      }\n    }\n\n    // 6. Parse permissions\n    const permissions = JSON.parse(keyData.permissions) as string[]\n\n    return {\n      valid: true,\n      data: keyData,\n      permissions,\n    }\n  } catch (error) {\n    console.error('API Key validation error:', error)\n    return { valid: false, error: 'Internal validation error' }\n  }\n}\n\n/**\n * Check if permissions include required permission\n * @param permissions Array of permission strings\n * @param requiredPermission Required permission to check\n * @returns True if permission is granted\n */\nexport function checkPermission(permissions: string[], requiredPermission: string): boolean {\n  return hasPermission(permissions, requiredPermission)\n}\n\n/**\n * Mark API Key as expired\n * @param keyId API Key ID\n * @param db D1 Database instance\n */\nasync function markAsExpired(keyId: string, db: D1Database): Promise<void> {\n  await db\n    .prepare(\n      `UPDATE api_keys\n       SET status = 'expired', updated_at = datetime('now')\n       WHERE id = ?`\n    )\n    .bind(keyId)\n    .run()\n}\n\n/**\n * Update last used timestamp and IP address\n * @param keyId API Key ID\n * @param ip Client IP address\n * @param db D1 Database instance\n */\nexport async function updateLastUsed(\n  keyId: string,\n  ip: string | null,\n  db: D1Database\n): Promise<void> {\n  await db\n    .prepare(\n      `UPDATE api_keys\n       SET last_used_at = datetime('now'),\n           last_used_ip = ?,\n           updated_at = datetime('now')\n       WHERE id = ?`\n    )\n    .bind(ip, keyId)\n    .run()\n}\n"
  },
  {
    "path": "tmarks/functions/lib/bookmark-utils.ts",
    "content": "import type { Bookmark, BookmarkRow } from './types'\r\n\r\n/**\r\n *  Bookmark \r\n\n */\r\nexport function normalizeBookmark(row: BookmarkRow): Bookmark {\r\n  return {\r\n    ...row,\r\n    is_pinned: Boolean(row.is_pinned),\r\n    is_archived: Boolean(row.is_archived),\r\n    is_public: Boolean(row.is_public),\r\n    click_count: Number(row.click_count || 0),\r\n    has_snapshot: Boolean(row.has_snapshot),\r\n    snapshot_count: Number(row.snapshot_count || 0),\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/cache/README.md",
    "content": "# TMarks 缓存系统\r\n\r\n## 📖 概述\r\n\r\nTMarks 缓存系统提供灵活、强健、成本可控的多层缓存解决方案。\r\n\r\n### 核心特性\r\n\r\n- ✅ **4 级配置** - 从无缓存到激进缓存\r\n- ✅ **批量操作零成本** - 批量导入不写缓存\r\n- ✅ **优雅降级** - KV 故障自动降级到 D1\r\n- ✅ **多层缓存** - 内存 + KV + D1\r\n- ✅ **模块化设计** - 易于维护和扩展\r\n\r\n## 🏗️ 架构\r\n\r\n```\r\n用户请求\r\n  ↓\r\nL1: Worker 内存缓存 (<1ms)\r\n  ↓ 未命中\r\nL2: KV 边缘缓存 (<10ms)\r\n  ↓ 未命中\r\nL3: D1 数据库 (50-200ms)\r\n```\r\n\r\n## 📁 文件结构\r\n\r\n```\r\ncache/\r\n├── types.ts           # 类型定义\r\n├── config.ts          # 配置管理 (4 级预设)\r\n├── strategies.ts      # 缓存策略 (键生成、判断)\r\n├── service.ts         # 核心服务 (多层缓存、降级)\r\n├── bookmark-cache.ts  # 书签缓存封装\r\n├── index.ts           # 导出接口\r\n└── README.md          # 本文档\r\n```\r\n\r\n## 🚀 快速开始\r\n\r\n### 1. 配置\r\n\r\n```toml\r\n# wrangler.toml\r\n[vars]\r\nCACHE_LEVEL = \"1\"          # 0-3\r\nENABLE_KV_CACHE = \"true\"\r\n```\r\n\r\n### 2. 使用\r\n\r\n```typescript\r\nimport { CacheService } from './lib/cache'\r\nimport { createBookmarkCacheManager } from './lib/cache/bookmark-cache'\r\n\r\n// 初始化\r\nconst cache = new CacheService(env)\r\nconst bookmarkCache = createBookmarkCacheManager(cache)\r\n\r\n// 获取缓存\r\nconst cached = await bookmarkCache.getBookmarkList(userId, params)\r\nif (cached) return success(cached)\r\n\r\n// 查询数据库\r\nconst data = await queryDB(...)\r\n\r\n// 写入缓存 (异步)\r\nawait bookmarkCache.setBookmarkList(userId, params, data, { async: true })\r\n```\r\n\r\n## ⚙️ 配置级别\r\n\r\n| 级别 | 说明 | 月成本 | 响应时间 | 命中率 |\r\n|------|------|--------|----------|--------|\r\n| 0 | 无缓存 | ~$5 | 100-300ms | 0% |\r\n| 1 | 最小缓存 ⭐ | ~$8 | 50-100ms | 60-70% |\r\n| 2 | 标准缓存 | ~$12 | 30-50ms | 80-85% |\r\n| 3 | 激进缓存 | ~$20 | 20-30ms | 90-95% |\r\n\r\n### Level 0: 无缓存\r\n\r\n```typescript\r\nstrategies: {\r\n  rateLimit: true,      // 仅速率限制\r\n  publicShare: false,\r\n  defaultList: false,\r\n  tagFilter: false,\r\n  search: false,\r\n  complexQuery: false,\r\n}\r\n```\r\n\r\n### Level 1: 最小缓存 (推荐默认)\r\n\r\n```typescript\r\nstrategies: {\r\n  rateLimit: true,\r\n  publicShare: true,\r\n  defaultList: true,    // 缓存默认列表\r\n  tagFilter: false,\r\n  search: false,\r\n  complexQuery: false,\r\n}\r\n```\r\n\r\n### Level 2: 标准缓存 (推荐生产)\r\n\r\n```typescript\r\nstrategies: {\r\n  rateLimit: true,\r\n  publicShare: true,\r\n  defaultList: true,\r\n  tagFilter: true,      // 缓存标签筛选\r\n  search: false,\r\n  complexQuery: false,\r\n}\r\nmemoryCache: {\r\n  enabled: true,        // 启用内存缓存\r\n  maxAge: 60,\r\n}\r\n```\r\n\r\n### Level 3: 激进缓存\r\n\r\n```typescript\r\nstrategies: {\r\n  rateLimit: true,\r\n  publicShare: true,\r\n  defaultList: true,\r\n  tagFilter: true,\r\n  search: true,         // 缓存搜索\r\n  complexQuery: true,   // 缓存复杂查询\r\n}\r\n```\r\n\r\n## 🔧 API 参考\r\n\r\n### CacheService\r\n\r\n核心缓存服务类。\r\n\r\n```typescript\r\nclass CacheService {\r\n  // 获取缓存\r\n  async get<T>(type: CacheStrategyType, key: string): Promise<T | null>\r\n  \r\n  // 设置缓存\r\n  async set<T>(type: CacheStrategyType, key: string, data: T, options?: CacheSetOptions): Promise<void>\r\n  \r\n  // 删除缓存\r\n  async delete(key: string): Promise<void>\r\n  \r\n  // 批量删除 (按前缀)\r\n  async invalidate(prefix: string): Promise<void>\r\n  \r\n  // 判断是否应该缓存\r\n  shouldCache(type: CacheStrategyType, params?: any): boolean\r\n  \r\n  // 获取统计信息\r\n  getStats(): CacheStats\r\n  \r\n  // 获取配置\r\n  getConfig(): CacheConfig\r\n}\r\n```\r\n\r\n### BookmarkCacheManager\r\n\r\n书签缓存管理器。\r\n\r\n```typescript\r\nclass BookmarkCacheManager {\r\n  // 获取书签列表缓存\r\n  async getBookmarkList<T>(userId: string, params?: QueryParams): Promise<T | null>\r\n  \r\n  // 设置书签列表缓存\r\n  async setBookmarkList<T>(userId: string, params: QueryParams | undefined, data: T, options?: { async?: boolean }): Promise<void>\r\n  \r\n  // 失效用户的所有书签缓存\r\n  async invalidateUserBookmarks(userId: string): Promise<void>\r\n  \r\n  // 失效特定查询的缓存\r\n  async invalidateQuery(userId: string, params?: QueryParams): Promise<void>\r\n  \r\n  // 批量操作后的缓存处理\r\n  async handleBatchOperation(userId: string): Promise<void>\r\n}\r\n```\r\n\r\n### 工具函数\r\n\r\n```typescript\r\n// 生成缓存键\r\ngenerateCacheKey(type: CacheStrategyType, userId: string, params?: QueryParams): string\r\n\r\n// 判断查询类型\r\ngetQueryType(params?: QueryParams): CacheStrategyType\r\n\r\n// 判断是否应该缓存\r\nshouldCacheQuery(type: CacheStrategyType, params?: QueryParams): boolean\r\n\r\n// 获取失效前缀\r\ngetCacheInvalidationPrefix(userId: string, type?: CacheStrategyType): string\r\n```\r\n\r\n## 💡 最佳实践\r\n\r\n### 1. 使用异步写入\r\n\r\n```typescript\r\n// ✅ 推荐：异步写入，不阻塞主流程\r\nawait cache.set('defaultList', key, data, { async: true })\r\n\r\n// ❌ 避免：同步写入，阻塞响应\r\nawait cache.set('defaultList', key, data)\r\n```\r\n\r\n### 2. 批量操作不写缓存\r\n\r\n```typescript\r\n// ✅ 推荐：批量导入后只失效缓存\r\nawait bookmarkCache.handleBatchOperation(userId)\r\n\r\n// ❌ 避免：批量导入时逐个写缓存\r\nfor (const bookmark of bookmarks) {\r\n  await cache.set(...)  // 不要这样做\r\n}\r\n```\r\n\r\n### 3. 使用缓存管理器\r\n\r\n```typescript\r\n// ✅ 推荐：使用封装好的管理器\r\nconst bookmarkCache = createBookmarkCacheManager(cache)\r\nawait bookmarkCache.getBookmarkList(userId, params)\r\n\r\n// ❌ 避免：直接操作缓存服务\r\nawait cache.get('defaultList', `bookmarks:${userId}:...`)\r\n```\r\n\r\n### 4. 检查缓存命中率\r\n\r\n```typescript\r\nconst stats = cache.getStats()\r\nconsole.log(`Hit rate: ${(stats.hitRate * 100).toFixed(2)}%`)\r\n\r\n// 如果命中率 < 60%，考虑调整策略\r\n```\r\n\r\n## 🛡️ 容错机制\r\n\r\n### 1. 自动降级\r\n\r\n```typescript\r\n// KV 不可用时自动降级到 D1\r\nconst cached = await cache.get('defaultList', key)\r\n// 如果 KV 失败，返回 null，触发 D1 查询\r\n```\r\n\r\n### 2. 超时保护\r\n\r\n```typescript\r\n// 100ms 超时，避免缓存拖慢响应\r\nprivate readonly CACHE_TIMEOUT = 100\r\n```\r\n\r\n### 3. 错误计数\r\n\r\n```typescript\r\n// 错误过多时自动禁用缓存\r\nprivate readonly MAX_ERRORS = 10\r\n```\r\n\r\n## 📊 监控\r\n\r\n### 获取统计信息\r\n\r\n```typescript\r\nconst stats = cache.getStats()\r\n\r\nconsole.log({\r\n  level: stats.level,           // 缓存级别\r\n  enabled: stats.enabled,       // 是否启用\r\n  hits: stats.hits,             // 命中次数\r\n  misses: stats.misses,         // 未命中次数\r\n  hitRate: stats.hitRate,       // 命中率\r\n  memCacheSize: stats.memCacheSize,  // 内存缓存大小\r\n})\r\n```\r\n\r\n### 调试模式\r\n\r\n```toml\r\n# wrangler.toml\r\n[vars]\r\nCACHE_DEBUG = \"true\"\r\n```\r\n\r\n## 🔄 迁移\r\n\r\n参见 [迁移指南](../../../docs/cache-migration-guide.md)\r\n\r\n## 📚 相关文档\r\n\r\n- [强健缓存策略](../../../docs/robust-cache-strategy.md)\r\n- [KV 优化分析](../../../docs/kv-optimization-analysis.md)\r\n- [存储架构分析](../../../docs/storage-cache-cloudflare-analysis.md)\r\n\r\n## 🤝 贡献\r\n\r\n欢迎提交 Issue 和 Pull Request！\r\n\r\n## 📄 许可\r\n\r\nMIT License\r\n"
  },
  {
    "path": "tmarks/functions/lib/cache/bookmark-cache.ts",
    "content": "/**\r\n * \r\n * \r\n\n */\r\nimport { CacheService } from './service'\r\nimport { generateCacheKey, getQueryType, getCacheInvalidationPrefix } from './strategies'\r\nimport type { QueryParams } from './types'\r\n/**\r\n\n */\r\nexport class BookmarkCacheManager {\r\n  constructor(private cache: CacheService) {}\r\n  /**\r\n   * \r\n   */\r\n  async getBookmarkList<T>(userId: string, params?: QueryParams): Promise<T | null> {\r\n    const queryType = getQueryType(params)\r\n    if (!this.cache.shouldCache(queryType, params)) {\r\n      return null\r\n    }\r\n    const cacheKey = generateCacheKey(queryType, userId, params)\r\n    return await this.cache.get<T>(queryType, cacheKey)\r\n  }\r\n  /**\r\n   * \r\n   */\r\n  async setBookmarkList<T>(\r\n    userId: string,\r\n    params: QueryParams | undefined,\r\n    data: T,\r\n    options?: { async?: boolean }\r\n  ): Promise<void> {\r\n    const queryType = getQueryType(params)\r\n    if (!this.cache.shouldCache(queryType, params)) {\r\n      return\r\n    }\r\n    const cacheKey = generateCacheKey(queryType, userId, params)\r\n    await this.cache.set(queryType, cacheKey, data, options)\r\n  }\r\n  /**\r\n\n   */\r\n  async invalidateUserBookmarks(userId: string): Promise<void> {\r\n    const prefix = getCacheInvalidationPrefix(userId)\r\n    await this.cache.invalidate(prefix)\r\n  }\r\n  /**\r\n\n   */\r\n  async invalidateQuery(userId: string, params?: QueryParams): Promise<void> {\r\n    const queryType = getQueryType(params)\r\n    const cacheKey = generateCacheKey(queryType, userId, params)\r\n    await this.cache.delete(cacheKey)\r\n  }\r\n  /**\r\n   * \r\n   */\r\n  async handleBatchOperation(userId: string): Promise<void> {\r\n    const config = this.cache.getConfig()\r\n    if (config.batchOperations.writeCache && config.batchOperations.asyncWrite) {\r\n      // Level 3: \r\n      this.refreshCommonQueries(userId)\r\n    } else {\r\n\n      await this.invalidateUserBookmarks(userId)\r\n    }\r\n  }\r\n  /**\r\n   * （）\r\n   */\r\n  private refreshCommonQueries(userId: string): void {\r\n    // ，\r\n    Promise.resolve().then(async () => {\r\n      try {\r\n        await this.invalidateUserBookmarks(userId)\r\n        // ：\r\n\n      } catch (error) {\r\n        console.warn('Refresh common queries error:', error)\r\n      }\r\n    })\r\n  }\r\n}\r\n/**\r\n\n */\r\nexport function createBookmarkCacheManager(cache: CacheService): BookmarkCacheManager {\r\n  return new BookmarkCacheManager(cache)\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/cache/config.ts",
    "content": "/**\r\n * \r\n * \r\n\n */\r\nimport type { CacheConfig, CacheLevel } from './types'\r\nimport type { Env } from '../types'\r\n/**\r\n * \r\n */\r\nexport const CACHE_PRESETS: Record<CacheLevel, CacheConfig> = {\r\n  /**\r\n   * Level 0:  ()\r\n   * - : \r\n\n   * - : 30-100ms\r\n   * - : ，\r\n\n   */\r\n  0: {\r\n    level: 0,\r\n    enabled: true,          \n    strategies: {\r\n      rateLimit: false,     //  KV  ()\r\n      publicShare: true,    //  ()\r\n      defaultList: true,    //  ()\r\n      tagFilter: false,     // \n      search: false,        // \n      complexQuery: false,\r\n    },\r\n    ttl: {\r\n      rateLimit: 0,\r\n      publicShare: 1800,    // 30 ()\r\n      defaultList: 1800,    // 30 ()\r\n      tagFilter: 0,\r\n      search: 0,\r\n      complexQuery: 0,\r\n    },\r\n    memoryCache: {\r\n      enabled: true,        //  ()\r\n      maxAge: 60,           // 1\r\n    },\r\n    batchOperations: {\r\n      writeCache: false,    //  ()\r\n      asyncWrite: false,\r\n    },\r\n  },\r\n  /**\r\n   * Level 1:  KV \r\n\n   */\r\n  1: {\r\n    level: 1,\r\n    enabled: true,\r\n    strategies: {\r\n      rateLimit: false,     //  KV  ()\r\n      publicShare: true,    //  ()\r\n      defaultList: false,   // \n      tagFilter: false,\r\n      search: false,\r\n      complexQuery: false,\r\n    },\r\n    ttl: {\r\n      rateLimit: 0,\r\n      publicShare: 1800,    // 30 ()\r\n      defaultList: 0,\r\n      tagFilter: 0,\r\n      search: 0,\r\n      complexQuery: 0,\r\n    },\r\n    memoryCache: {\r\n      enabled: true,        \n      maxAge: 60,\r\n    },\r\n    batchOperations: {\r\n      writeCache: false,    \n      asyncWrite: false,\r\n    },\r\n  },\r\n  /**\r\n   * Level 2:  KV  ()\r\n   * - :  (1000-10000 )\r\n\n   */\r\n  2: {\r\n    level: 2,\r\n    enabled: true,\r\n    strategies: {\r\n      rateLimit: true,      \n      publicShare: true,\r\n      defaultList: true,    \n      tagFilter: false,     // \n      search: false,\r\n      complexQuery: false,\r\n    },\r\n    ttl: {\r\n      rateLimit: 60,\r\n      publicShare: 1800,    // 30\r\n      defaultList: 600,     // 10\r\n      tagFilter: 0,\r\n      search: 0,\r\n      complexQuery: 0,\r\n    },\r\n    memoryCache: {\r\n      enabled: true,        \n      maxAge: 60,           // 1\r\n    },\r\n    batchOperations: {\r\n      writeCache: false,    \n      asyncWrite: false,\r\n    },\r\n  },\r\n  /**\r\n\n   * - :  (>10000 )\r\n\n   * - :  KV，\r\n   */\r\n  3: {\r\n    level: 3,\r\n    enabled: true,\r\n    strategies: {\r\n      rateLimit: true,\r\n      publicShare: true,\r\n      defaultList: true,\r\n      tagFilter: true,      // \n      search: false,        // \n      complexQuery: false,\r\n    },\r\n    ttl: {\r\n      rateLimit: 60,\r\n      publicShare: 1800,    // 30\r\n      defaultList: 600,     // 10\r\n      tagFilter: 600,       // 10\r\n      search: 0,\r\n      complexQuery: 0,\r\n    },\r\n    memoryCache: {\r\n      enabled: true,\r\n      maxAge: 60,\r\n    },\r\n    batchOperations: {\r\n      writeCache: false,    //  ()\r\n      asyncWrite: false,\r\n    },\r\n  },\r\n}\r\n/**\r\n\n */\r\nexport function loadCacheConfig(env: Env): CacheConfig {\r\n\n  if (env.ENABLE_KV_CACHE === 'false') {\r\n    return CACHE_PRESETS[0]\r\n  }\r\n  const levelStr = env.CACHE_LEVEL || '1'\r\n  let level: CacheLevel = 1\r\n  if (levelStr === 'none' || levelStr === '0') {\r\n    level = 0\r\n  } else if (levelStr === 'minimal' || levelStr === '1') {\r\n    level = 1\r\n  } else if (levelStr === 'standard' || levelStr === '2') {\r\n    level = 2\r\n  } else if (levelStr === 'aggressive' || levelStr === '3') {\r\n    level = 3\r\n  } else {\r\n    const parsed = parseInt(levelStr, 10)\r\n    if (parsed >= 0 && parsed <= 3) {\r\n      level = parsed as CacheLevel\r\n    }\r\n  }\r\n  const config = { ...CACHE_PRESETS[level] }\r\n  //  TTL\r\n  if (env.CACHE_TTL_DEFAULT_LIST) {\r\n    config.ttl.defaultList = parseInt(env.CACHE_TTL_DEFAULT_LIST, 10)\r\n  }\r\n  if (env.CACHE_TTL_TAG_FILTER) {\r\n    config.ttl.tagFilter = parseInt(env.CACHE_TTL_TAG_FILTER, 10)\r\n  }\r\n  if (env.CACHE_TTL_SEARCH) {\r\n    config.ttl.search = parseInt(env.CACHE_TTL_SEARCH, 10)\r\n  }\r\n  if (env.CACHE_TTL_PUBLIC_SHARE) {\r\n    config.ttl.publicShare = parseInt(env.CACHE_TTL_PUBLIC_SHARE, 10)\r\n  }\r\n  if (env.ENABLE_MEMORY_CACHE === 'false') {\r\n    config.memoryCache.enabled = false\r\n  }\r\n  if (env.MEMORY_CACHE_MAX_AGE) {\r\n    config.memoryCache.maxAge = parseInt(env.MEMORY_CACHE_MAX_AGE, 10)\r\n  }\r\n  return config\r\n}\r\n/**\r\n * \r\n */\r\nexport function validateCacheConfig(config: CacheConfig): boolean {\r\n\n  if (config.level < 0 || config.level > 3) {\r\n    return false\r\n  }\r\n\n  for (const ttl of Object.values(config.ttl)) {\r\n    if (ttl < 0 || ttl > 86400) {  // \n      return false\r\n    }\r\n  }\r\n\n  if (config.memoryCache.maxAge < 0 || config.memoryCache.maxAge > 3600) {\r\n    return false\r\n  }\r\n  return true\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/cache/index.ts",
    "content": "/**\r\n * \r\n * \r\n\n */\r\nexport type {\r\n  CacheLevel,\r\n  CacheStrategyType,\r\n  CacheConfig,\r\n  CacheEntry,\r\n  CacheSetOptions,\r\n  CacheStats,\r\n  QueryParams,\r\n} from './types'\r\nexport {\r\n  CACHE_PRESETS,\r\n  loadCacheConfig,\r\n  validateCacheConfig,\r\n} from './config'\r\nexport {\r\n  generateCacheKey,\r\n  getQueryType,\r\n  shouldCacheQuery,\r\n  getCacheInvalidationPrefix,\r\n  hashQueryParams,\r\n} from './strategies'\r\nexport { CacheService } from './service'\r\n"
  },
  {
    "path": "tmarks/functions/lib/cache/service.ts",
    "content": "/**\r\n * \r\n * \r\n\n */\r\nimport type { Env } from '../types'\r\nimport type {\r\n  CacheConfig,\r\n  CacheStrategyType,\r\n  CacheEntry,\r\n  CacheSetOptions,\r\n  CacheStats,\r\n} from './types'\r\nimport { loadCacheConfig } from './config'\r\nimport { shouldCacheQuery } from './strategies'\r\n/**\r\n\n */\r\nexport class CacheService {\r\n  private config: CacheConfig\r\n  private env: Env\r\n  private memCache: Map<string, CacheEntry> = new Map()\r\n  private hits = 0\r\n  private misses = 0\r\n  private errorCount = 0\r\n  private readonly MAX_ERRORS = 10\r\n  constructor(env: Env) {\r\n    this.env = env\r\n    this.config = loadCacheConfig(env)\r\n  }\r\n  /**\r\n   * \r\n   */\r\n  async get<T>(\r\n    type: CacheStrategyType,\r\n    key: string\r\n  ): Promise<T | null> {\r\n    if (!this.isEnabled(type)) {\r\n      return null\r\n    }\r\n    try {\r\n      if (this.config.memoryCache.enabled) {\r\n        const memCached = this.getFromMemory<T>(key)\r\n        if (memCached !== null) {\r\n          this.hits++\r\n          return memCached\r\n        }\r\n      }\r\n      this.misses++\r\n      return null\r\n    } catch (error) {\r\n      this.handleError('get', error)\r\n      this.misses++\r\n      return null\r\n    }\r\n  }\r\n  /**\r\n   * \r\n   */\r\n  async set<T>(\r\n    type: CacheStrategyType,\r\n    key: string,\r\n    data: T,\r\n    options?: CacheSetOptions\r\n  ): Promise<void> {\r\n    if (!this.isEnabled(type)) {\r\n      return\r\n    }\r\n    try {\r\n      if (this.config.memoryCache.enabled) {\r\n        this.setToMemory(key, data, options?.ttl)\r\n      }\r\n    } catch (error) {\r\n      this.handleError('set', error)\r\n    }\r\n  }\r\n  /**\r\n   * \r\n   */\r\n  async delete(key: string): Promise<void> {\r\n    try {\r\n      this.memCache.delete(key)\r\n    } catch (error) {\r\n      this.handleError('delete', error)\r\n    }\r\n  }\r\n  /**\r\n\n   */\r\n  async invalidate(prefix: string): Promise<void> {\r\n    try {\r\n      const keysToDelete: string[] = []\r\n      this.memCache.forEach((_, key) => {\r\n        if (key.startsWith(prefix)) {\r\n          keysToDelete.push(key)\r\n        }\r\n      })\r\n      keysToDelete.forEach(key => this.memCache.delete(key))\r\n    } catch (error) {\r\n      this.handleError('invalidate', error)\r\n    }\r\n  }\r\n  /**\r\n   * \r\n   */\r\n  shouldCache(type: CacheStrategyType, params?: Record<string, unknown>): boolean {\r\n    if (!this.isEnabled(type)) {\r\n      return false\r\n    }\r\n    return shouldCacheQuery(type, params)\r\n  }\r\n  /**\r\n   * \r\n   */\r\n  getStats(): CacheStats {\r\n    const total = this.hits + this.misses\r\n    return {\r\n      level: this.config.level,\r\n      enabled: this.config.enabled,\r\n      hits: this.hits,\r\n      misses: this.misses,\r\n      hitRate: total > 0 ? this.hits / total : 0,\r\n      memCacheSize: this.memCache.size,\r\n      strategies: this.config.strategies,\r\n    }\r\n  }\r\n  /**\r\n   * \r\n   */\r\n  getConfig(): CacheConfig {\r\n    return { ...this.config }\r\n  }\r\n  // ====================  ====================\r\n  /**\r\n   * \r\n   */\r\n  private isEnabled(type: CacheStrategyType): boolean {\r\n    return this.config.enabled && this.config.strategies[type]\r\n  }\r\n  /**\r\n\n   */\r\n  private getFromMemory<T>(key: string): T | null {\r\n    const entry = this.memCache.get(key)\r\n    if (entry && entry.expires > Date.now()) {\r\n      return entry.data as T\r\n    }\r\n    if (entry) {\r\n      this.memCache.delete(key)\r\n    }\r\n    return null\r\n  }\r\n  /**\r\n   * \r\n   */\r\n  private setToMemory<T>(key: string, data: T, ttlSeconds?: number): void {\r\n\n    if (this.memCache.size > 500) {\r\n      const now = Date.now()\r\n      for (const [k, entry] of this.memCache.entries()) {\r\n        if (entry.expires <= now) {\r\n          this.memCache.delete(k)\r\n        }\r\n      }\r\n    }\r\n    const maxAge = (ttlSeconds ?? this.config.memoryCache.maxAge) * 1000\r\n    this.memCache.set(key, {\r\n      data,\r\n      expires: Date.now() + maxAge,\r\n    })\r\n  }\r\n  /**\r\n   * \r\n   */\r\n  private handleError(operation: string, error: unknown): void {\r\n    this.errorCount++\r\n    if (this.errorCount >= this.MAX_ERRORS) {\r\n      console.error(`Too many cache errors (${this.errorCount}), disabling cache`)\r\n      this.config.enabled = false\r\n    }\r\n    const message = error instanceof Error ? error.message : String(error)\r\n    console.warn(`Cache ${operation} error:`, message)\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/cache/strategies.ts",
    "content": "/**\r\n * \r\n * \r\n * \r\n */\r\nimport type { CacheStrategyType, QueryParams } from './types'\r\n/**\r\n\n */\r\nexport function generateCacheKey(\r\n  type: CacheStrategyType,\r\n  userId: string,\r\n  params?: QueryParams | Record<string, unknown>\r\n): string {\r\n  const parts: string[] = []\r\n  switch (type) {\r\n    case 'rateLimit':\r\n      parts.push('ratelimit')\r\n      break\r\n    case 'publicShare':\r\n      parts.push('public-share')\r\n      break\r\n    case 'defaultList':\r\n    case 'tagFilter':\r\n    case 'search':\r\n    case 'complexQuery':\r\n      parts.push('bookmarks')\r\n      break\r\n  }\r\n  //  ID\r\n  if (userId) {\r\n    parts.push(userId)\r\n  }\r\n  if (params) {\r\n    if (type === 'search' && params.keyword) {\r\n      parts.push('search', params.keyword)\r\n    }\r\n    if (type === 'tagFilter' && params.tags) {\r\n      const tags = Array.isArray(params.tags) ? params.tags : [params.tags]\r\n      parts.push('tags', tags.sort().join(','))\r\n    }\r\n    if (params.archived) {\r\n      parts.push('archived')\r\n    }\r\n    if (params.pinned) {\r\n      parts.push('pinned')\r\n    }\r\n    if (params.sort) {\r\n      parts.push('sort', params.sort)\r\n    }\r\n    if (params.page_size) {\r\n      parts.push('size', String(params.page_size))\r\n    }\r\n    if (params.page_cursor) {\r\n      parts.push('cursor', String(params.page_cursor))\r\n    }\r\n  }\r\n  return parts.join(':')\r\n}\r\n/**\r\n * \r\n */\r\nexport function getQueryType(params?: QueryParams): CacheStrategyType {\r\n  if (!params) {\r\n    return 'defaultList'\r\n  }\r\n  if (params.keyword) {\r\n    return 'search'\r\n  }\r\n\n  if (params.tags && params.tags.length > 0) {\r\n    return 'tagFilter'\r\n  }\r\n\n  if (!params.archived && !params.pinned && !params.sort) {\r\n    return 'defaultList'\r\n  }\r\n  return 'complexQuery'\r\n}\r\n/**\r\n\n */\r\nexport function shouldCacheQuery(\r\n  type: CacheStrategyType,\r\n  params?: QueryParams\r\n): boolean {\r\n  if (type === 'rateLimit' || type === 'publicShare') {\r\n    return true\r\n  }\r\n  if (type === 'defaultList') {\r\n    return true\r\n  }\r\n\n  if (type === 'tagFilter' && params?.tags) {\r\n    return params.tags.length <= 3\r\n  }\r\n\n  if (type === 'search' && params?.keyword) {\r\n    return params.keyword.length <= 50\r\n  }\r\n\n  return false\r\n}\r\n/**\r\n * \r\n */\r\nexport function getCacheInvalidationPrefix(\r\n  userId: string,\r\n  type?: CacheStrategyType\r\n): string {\r\n  if (type === 'publicShare') {\r\n    return 'public-share:'\r\n  }\r\n  if (type === 'rateLimit') {\r\n    return `ratelimit:${userId}:`\r\n  }\r\n\n  return `bookmarks:${userId}:`\r\n}\r\n/**\r\n\n */\r\nexport function hashQueryParams(params: QueryParams): string {\r\n  const sorted = Object.keys(params)\r\n    .sort()\r\n    .map(key => {\r\n      const value = params[key as keyof QueryParams]\r\n      if (Array.isArray(value)) {\r\n        return `${key}=${value.sort().join(',')}`\r\n      }\r\n      return `${key}=${value}`\r\n    })\r\n    .join('&')\r\n  return sorted\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/cache/types.ts",
    "content": "export type CacheLevel = 0 | 1 | 2 | 3\n\nexport type CacheStrategyType =\n  | 'rateLimit'\n  | 'publicShare'\n  | 'defaultList'\n  | 'tagFilter'\n  | 'search'\n  | 'complexQuery'\n\nexport interface CacheConfig {\n  level: CacheLevel\n  enabled: boolean\n  strategies: Record<CacheStrategyType, boolean>\n  ttl: Record<CacheStrategyType, number>\n  memoryCache: {\n    enabled: boolean\n    maxAge: number\n  }\n  batchOperations: {\n    writeCache: boolean\n    asyncWrite: boolean\n  }\n}\n\nexport interface CacheEntry<T = unknown> {\n  data: T\n  expires: number\n}\n\nexport interface CacheSetOptions {\n  async?: boolean\n  ttl?: number\n}\n\nexport interface CacheStats {\n  level: CacheLevel\n  enabled: boolean\n  hits: number\n  misses: number\n  hitRate: number\n  memCacheSize: number\n  strategies: Record<CacheStrategyType, boolean>\n}\n\nexport interface QueryParams {\n  keyword?: string\n  tags?: string[]\n  archived?: boolean\n  pinned?: boolean\n  sort?: string\n  page_size?: number\n  page_cursor?: string\n}\n"
  },
  {
    "path": "tmarks/functions/lib/config.ts",
    "content": "/**\r\n * \r\n\n */\r\n\r\nimport type { Env } from './types'\r\n\r\n/**\r\n\n */\r\nexport const DEFAULT_CONFIG = {\r\n  JWT_ACCESS_TOKEN_EXPIRES_IN: '365d',\r\n  JWT_REFRESH_TOKEN_EXPIRES_IN: '365d',\r\n} as const\r\n\r\n/**\r\n\n */\r\nexport function getJwtAccessTokenExpiresIn(env?: Env): string {\r\n  return env?.JWT_ACCESS_TOKEN_EXPIRES_IN || DEFAULT_CONFIG.JWT_ACCESS_TOKEN_EXPIRES_IN\r\n}\r\n\r\n/**\r\n\n */\r\nexport function getJwtRefreshTokenExpiresIn(env?: Env): string {\r\n  return env?.JWT_REFRESH_TOKEN_EXPIRES_IN || DEFAULT_CONFIG.JWT_REFRESH_TOKEN_EXPIRES_IN\r\n}\r\n\r\n/**\r\n\n * @param env - Cloudflare \r\n * @returns \r\n */\r\nexport function isRegistrationAllowed(env: Env): boolean {\r\n  return env.ALLOW_REGISTRATION === 'true'\r\n}\r\n\r\n/**\r\n * \r\n * @param env - Cloudflare \r\n * @returns \r\n */\r\nexport function getEnvironment(env: Env): 'development' | 'production' {\r\n  return env.ENVIRONMENT || 'development'\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/crypto.ts",
    "content": "/**\r\n\n */\r\nconst PBKDF2_ITERATIONS = 100000 // OWASP \nconst SALT_LENGTH = 16\r\nconst HASH_LENGTH = 32\r\n/**\r\n * \r\n */\r\nexport async function hashPassword(password: string): Promise<string> {\r\n  const encoder = new TextEncoder()\r\n  const passwordBuffer = encoder.encode(password)\r\n\n  const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH))\r\n\n  const keyMaterial = await crypto.subtle.importKey(\r\n    'raw',\r\n    passwordBuffer,\r\n    'PBKDF2',\r\n    false,\r\n    ['deriveBits']\r\n  )\r\n  //  PBKDF2 \r\n  const hashBuffer = await crypto.subtle.deriveBits(\r\n    {\r\n      name: 'PBKDF2',\r\n      salt,\r\n      iterations: PBKDF2_ITERATIONS,\r\n      hash: 'SHA-256',\r\n    },\r\n    keyMaterial,\r\n    HASH_LENGTH * 8\r\n  )\r\n\n  const hash = new Uint8Array(hashBuffer)\r\n  const result = new Uint8Array(salt.length + hash.length)\r\n  result.set(salt, 0)\r\n  result.set(hash, salt.length)\r\n\n  return `pbkdf2_sha256:${PBKDF2_ITERATIONS}:${arrayBufferToBase64(result)}`\r\n}\r\n/**\r\n * \r\n */\r\nexport async function verifyPassword(password: string, hash: string): Promise<boolean> {\r\n  try {\r\n    const parts = hash.split(':')\r\n    if (parts.length !== 3 || parts[0] !== 'pbkdf2_sha256') {\r\n      return false\r\n    }\r\n    const iterations = parseInt(parts[1], 10)\r\n    const storedHash = base64ToArrayBuffer(parts[2])\r\n    const salt = storedHash.slice(0, SALT_LENGTH)\r\n    const originalHash = storedHash.slice(SALT_LENGTH)\r\n    const encoder = new TextEncoder()\r\n    const passwordBuffer = encoder.encode(password)\r\n    const keyMaterial = await crypto.subtle.importKey(\r\n      'raw',\r\n      passwordBuffer,\r\n      'PBKDF2',\r\n      false,\r\n      ['deriveBits']\r\n    )\r\n    const hashBuffer = await crypto.subtle.deriveBits(\r\n      {\r\n        name: 'PBKDF2',\r\n        salt,\r\n        iterations,\r\n        hash: 'SHA-256',\r\n      },\r\n      keyMaterial,\r\n      HASH_LENGTH * 8\r\n    )\r\n    const computedHash = new Uint8Array(hashBuffer)\r\n    return timingSafeEqual(originalHash, computedHash)\r\n  } catch {\r\n    return false\r\n  }\r\n}\r\n/**\r\n * \r\n */\r\nexport function generateToken(length: number = 32): string {\r\n  const array = crypto.getRandomValues(new Uint8Array(length))\r\n  return arrayBufferToBase64(array)\r\n}\r\n/**\r\n * （）\r\n */\r\nexport async function hashRefreshToken(token: string): Promise<string> {\r\n  const encoder = new TextEncoder()\r\n  const data = encoder.encode(token)\r\n  const hashBuffer = await crypto.subtle.digest('SHA-256', data)\r\n  return arrayBufferToBase64(new Uint8Array(hashBuffer))\r\n}\r\n/**\r\n\n */\r\nexport function generateUUID(): string {\r\n  const bytes = crypto.getRandomValues(new Uint8Array(16))\r\n  bytes[6] = (bytes[6] & 0x0f) | 0x40 // Version 4\r\n  bytes[8] = (bytes[8] & 0x3f) | 0x80 // Variant 10\r\n  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')\r\n  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`\r\n}\r\n/**\r\n\n */\r\nexport function generateShortUUID(): string {\r\n  const bytes = crypto.getRandomValues(new Uint8Array(16))\r\n  bytes[6] = (bytes[6] & 0x0f) | 0x40 // Version 4\r\n  bytes[8] = (bytes[8] & 0x3f) | 0x80 // Variant 10\r\n\n  const binary = Array.from(bytes, b => String.fromCharCode(b)).join('')\r\n  const base64 = btoa(binary)\r\n  return base64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '')\r\n}\r\n/**\r\n\n */\r\nexport function generateNanoId(length: number = 21): string {\r\n\n  const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_-'\r\n  const randomValues = new Uint8Array(length)\r\n  crypto.getRandomValues(randomValues)\r\n  let id = ''\r\n  for (let i = 0; i < length; i++) {\r\n    id += alphabet[randomValues[i] % alphabet.length]\r\n  }\r\n  return id\r\n}\r\n/**\r\n * （）\r\n */\r\nfunction timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {\r\n  if (a.length !== b.length) {\r\n    return false\r\n  }\r\n  let result = 0\r\n  for (let i = 0; i < a.length; i++) {\r\n    result |= a[i] ^ b[i]\r\n  }\r\n  return result === 0\r\n}\r\n/**\r\n\n */\r\nfunction arrayBufferToBase64(buffer: Uint8Array): string {\r\n  const binary = Array.from(buffer, (byte) => String.fromCharCode(byte)).join('')\r\n  return btoa(binary)\r\n}\r\n/**\r\n\n */\r\nfunction base64ToArrayBuffer(base64: string): Uint8Array {\r\n  const binary = atob(base64)\r\n  const bytes = new Uint8Array(binary.length)\r\n  for (let i = 0; i < binary.length; i++) {\r\n    bytes[i] = binary.charCodeAt(i)\r\n  }\r\n  return bytes\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/data-fetchers.ts",
    "content": "import type { D1Database } from '@cloudflare/workers-types'\nimport type { Bookmark, BookmarkRow } from '../lib/types'\n\nexport async function fetchFullBookmarks(\n  db: D1Database,\n  rows: BookmarkRow[],\n  userId: string\n): Promise<Bookmark[]> {\n  const bookmarkIds = rows.map(r => r.id)\n  if (bookmarkIds.length === 0) return []\n\n  // Fetch all tag relations for these bookmarks\n  const { results: tagRelations } = await db\n    .prepare(\n      `SELECT bt.bookmark_id, t.id, t.name, t.color\n       FROM bookmark_tags bt\n       JOIN tags t ON bt.tag_id = t.id\n       WHERE bt.bookmark_id IN (${bookmarkIds.map(() => '?').join(',')}) AND bt.user_id = ?`\n    )\n    .bind(...bookmarkIds, userId)\n    .all<{ bookmark_id: string; id: string; name: string; color: string }>()\n\n  // Assemble final results\n  return rows.map(row => {\n    const tags = tagRelations\n      .filter(tr => tr.bookmark_id === row.id)\n      .map(tr => ({\n        id: tr.id,\n        name: tr.name,\n        color: tr.color,\n      }))\n\n    return {\n      id: row.id,\n      user_id: row.user_id,\n      title: row.title,\n      url: row.url,\n      description: row.description,\n      cover_image: row.cover_image,\n      favicon: row.favicon,\n      is_pinned: Boolean(row.is_pinned),\n      is_archived: Boolean(row.is_archived),\n      is_public: Boolean(row.is_public),\n      click_count: row.click_count,\n      last_clicked_at: row.last_clicked_at,\n      has_snapshot: row.has_snapshot,\n      latest_snapshot_at: row.latest_snapshot_at,\n      snapshot_count: row.snapshot_count,\n      created_at: row.created_at,\n      updated_at: row.updated_at,\n      deleted_at: row.deleted_at,\n      tags,\n    }\n  })\n}\n"
  },
  {
    "path": "tmarks/functions/lib/error-handler.ts",
    "content": "/**\r\n * \r\n\n */\r\nimport { internalError, badRequest, unauthorized, forbidden, notFound, conflict } from './response'\r\nexport interface ErrorContext {\r\n  userId?: string\r\n  endpoint?: string\r\n  method?: string\r\n  ip?: string\r\n  userAgent?: string\r\n  requestId?: string\r\n}\r\nexport interface ErrorDetails {\r\n  code: string\r\n  message: string\r\n  details?: string\r\n  field?: string\r\n  context?: ErrorContext\r\n}\r\n/**\r\n\n */\r\nexport function handleError(error: unknown, context?: ErrorContext): Response {\r\n  const errorDetails = normalizeError(error, context)\r\n  logError(errorDetails)\r\n\n  switch (errorDetails.code) {\r\n    case 'VALIDATION_ERROR':\r\n    case 'INVALID_INPUT':\r\n    case 'MISSING_FIELD':\r\n      return badRequest(errorDetails.message, errorDetails.code)\r\n    case 'UNAUTHORIZED':\r\n    case 'INVALID_TOKEN':\r\n    case 'TOKEN_EXPIRED':\r\n      return unauthorized(errorDetails.message, errorDetails.code)\r\n    case 'FORBIDDEN':\r\n    case 'INSUFFICIENT_PERMISSIONS':\r\n      return forbidden(errorDetails.message, errorDetails.code)\r\n    case 'NOT_FOUND':\r\n    case 'RESOURCE_NOT_FOUND':\r\n      return notFound(errorDetails.message, errorDetails.code)\r\n    case 'CONFLICT':\r\n    case 'DUPLICATE_RESOURCE':\r\n      return conflict(errorDetails.message, errorDetails.code)\r\n    default:\r\n      return internalError(errorDetails.message, errorDetails.code)\r\n  }\r\n}\r\n/**\r\n\n */\r\nfunction normalizeError(error: unknown, context?: ErrorContext): ErrorDetails {\r\n  if (error instanceof Error) {\r\n\n    if (error.message.includes('UNIQUE constraint failed')) {\r\n      return {\r\n        code: 'DUPLICATE_RESOURCE',\r\n        message: 'Resource already exists',\r\n        details: error.message,\r\n        context\r\n      }\r\n    }\r\n    if (error.message.includes('FOREIGN KEY constraint failed')) {\r\n      return {\r\n        code: 'INVALID_REFERENCE',\r\n        message: 'Referenced resource does not exist',\r\n        details: error.message,\r\n        context\r\n      }\r\n    }\r\n    if (error.message.includes('no such column')) {\r\n      return {\r\n        code: 'DATABASE_SCHEMA_ERROR',\r\n        message: 'Database schema mismatch',\r\n        details: error.message,\r\n        context\r\n      }\r\n    }\r\n    return {\r\n      code: 'INTERNAL_ERROR',\r\n      message: error.message,\r\n      details: error.stack,\r\n      context\r\n    }\r\n  }\r\n  if (typeof error === 'string') {\r\n    return {\r\n      code: 'INTERNAL_ERROR',\r\n      message: error,\r\n      context\r\n    }\r\n  }\r\n  return {\r\n    code: 'UNKNOWN_ERROR',\r\n    message: 'An unknown error occurred',\r\n    details: String(error),\r\n    context\r\n  }\r\n}\r\n/**\r\n * \r\n */\r\nfunction logError(errorDetails: ErrorDetails): void {\r\n  const logData = {\r\n    timestamp: new Date().toISOString(),\r\n    code: errorDetails.code,\r\n    message: errorDetails.message,\r\n    details: errorDetails.details,\r\n    context: errorDetails.context\r\n  }\r\n  // ，\r\n  console.error('Error occurred:', JSON.stringify(logData, null, 2))\r\n}\r\n/**\r\n\n */\r\nexport function createErrorContext(request: Request, userId?: string): ErrorContext {\r\n  return {\r\n    userId,\r\n    endpoint: new URL(request.url).pathname,\r\n    method: request.method,\r\n    ip: request.headers.get('CF-Connecting-IP') || \r\n        request.headers.get('X-Forwarded-For') || \r\n        'unknown',\r\n    userAgent: request.headers.get('User-Agent') || 'unknown',\r\n    requestId: request.headers.get('X-Request-ID') || crypto.randomUUID()\r\n  }\r\n}\r\n/**\r\n\n */\r\nexport function withErrorHandling<T extends unknown[], R>(\r\n  fn: (...args: T) => Promise<R>,\r\n  context?: ErrorContext\r\n) {\r\n  return async (...args: T): Promise<R | Response> => {\r\n    try {\r\n      return await fn(...args)\r\n    } catch (error) {\r\n      return handleError(error, context)\r\n    }\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/image-sig.ts",
    "content": "/**\n * Generate HMAC signature for snapshot image URLs\n */\nexport async function generateImageSig(\n  hash: string,\n  userId: string,\n  bookmarkId: string,\n  secret: string\n): Promise<string> {\n  const data = `img:${hash}:${userId}:${bookmarkId}`\n  const encoder = new TextEncoder()\n  const key = await crypto.subtle.importKey(\n    'raw',\n    encoder.encode(secret),\n    { name: 'HMAC', hash: 'SHA-256' },\n    false,\n    ['sign']\n  )\n  const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data))\n  const bytes = new Uint8Array(signature)\n  const CHUNK = 8192\n  const chunks: string[] = []\n  for (let i = 0; i < bytes.length; i += CHUNK) {\n    chunks.push(String.fromCharCode(...bytes.subarray(i, i + CHUNK)))\n  }\n  return btoa(chunks.join(''))\n    .replace(/\\+/g, '-')\n    .replace(/\\//g, '_')\n    .replace(/=/g, '')\n}\n"
  },
  {
    "path": "tmarks/functions/lib/image-upload.ts",
    "content": "/**\r\n\n */\r\nimport type { R2Bucket, D1Database } from '@cloudflare/workers-types'\r\nimport type { Env } from './types'\r\nimport { generateUUID } from './crypto'\r\nimport { checkR2Quota } from './storage-quota'\r\ninterface UploadImageResult {\r\n  success: boolean\r\n  imageId?: string\r\n  r2Url?: string\r\n  originalUrl: string\r\n  imageHash?: string\r\n  fileSize?: number\r\n  mimeType?: string\r\n  isReused?: boolean // \n  error?: string\r\n}\r\ninterface ExistingImage {\r\n  id: string\r\n  r2_key: string\r\n  file_size: number\r\n  mime_type: string\r\n}\r\n/**\r\n\n * @param imageUrl  URL\r\n * @param userId  ID\r\n * @param bookmarkId  ID\r\n * @param bucket R2 Bucket\r\n * @param db D1 Database\r\n\n * @param env Cloudflare （）\r\n * @returns \r\n */\r\nexport async function uploadCoverImageToR2(\r\n  imageUrl: string,\r\n  userId: string,\r\n  bookmarkId: string,\r\n  bucket: R2Bucket,\r\n  db: D1Database,\r\n  r2PublicUrl: string,\r\n  env: Env\r\n): Promise<UploadImageResult> {\r\n  try {\r\n    // 1. \r\n    const response = await fetch(imageUrl, {\r\n      headers: {\r\n        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',\r\n      },\r\n      signal: AbortSignal.timeout(10000), // 10\n    })\r\n    if (!response.ok) {\r\n      return {\r\n        success: false,\r\n        originalUrl: imageUrl,\r\n        error: `Failed to download image: ${response.status}`,\r\n      }\r\n    }\r\n    // 2. \r\n    const imageData = await response.arrayBuffer()\r\n    const contentType = response.headers.get('content-type') || 'image/jpeg'\r\n    const fileSize = imageData.byteLength\r\n\n    if (fileSize > 10 * 1024 * 1024) {\r\n      return {\r\n        success: false,\r\n        originalUrl: imageUrl,\r\n        error: 'Image too large (max 10MB)',\r\n      }\r\n    }\r\n\n    if (!contentType.startsWith('image/')) {\r\n      return {\r\n        success: false,\r\n        originalUrl: imageUrl,\r\n        error: 'Not a valid image',\r\n      }\r\n    }\r\n\n    const imageHash = await calculateHash(imageData)\r\n\n    const existing = await db\r\n      .prepare('SELECT id, r2_key, file_size, mime_type FROM bookmark_images WHERE image_hash = ? LIMIT 1')\r\n      .bind(imageHash)\r\n      .first<ExistingImage>()\r\n    let imageId: string\r\n    let r2Key: string\r\n    if (existing) {\r\n      imageId = existing.id\r\n      r2Key = existing.r2_key\r\n\n      const r2Url = `${r2PublicUrl.replace(/\\/$/, '')}/${r2Key}`\r\n      return {\r\n        success: true,\r\n        imageId: imageId,\r\n        r2Url: r2Url,\r\n        originalUrl: imageUrl,\r\n        imageHash: imageHash,\r\n        fileSize: existing.file_size,\r\n        mimeType: existing.mime_type,\r\n        isReused: true,\r\n      }\r\n    }\r\n    // 7.  R2 key（，）\r\n    const ext = getExtensionFromContentType(contentType)\r\n    r2Key = `images/${imageHash}${ext}`\r\n    // 8. （）\r\n    const quota = await checkR2Quota(db, env, fileSize)\r\n    if (!quota.allowed) {\r\n      const usedGB = quota.usedBytes / (1024 * 1024 * 1024)\r\n      const limitGB = quota.limitBytes / (1024 * 1024 * 1024)\r\n      return {\r\n        success: false,\r\n        originalUrl: imageUrl,\r\n        error: `Image storage limit exceeded: used ${usedGB.toFixed(2)}GB / ${limitGB.toFixed(2)}GB`,\r\n      }\r\n    }\r\n\n    await bucket.put(r2Key, imageData, {\r\n      httpMetadata: {\r\n        contentType: contentType,\r\n      },\r\n      customMetadata: {\r\n        imageHash: imageHash,\r\n        originalUrl: imageUrl,\r\n        uploadedAt: new Date().toISOString(),\r\n      },\r\n    })\r\n    // 10. \r\n    imageId = generateUUID()\r\n    const now = new Date().toISOString()\r\n    await db\r\n      .prepare(\r\n        `INSERT INTO bookmark_images\r\n         (id, bookmark_id, user_id, image_hash, r2_key, r2_bucket, file_size, mime_type, original_url, created_at, updated_at)\r\n         VALUES (?, ?, ?, ?, ?, 'tmarks-snapshots', ?, ?, ?, ?, ?)`\r\n      )\r\n      .bind(imageId, bookmarkId, userId, imageHash, r2Key, fileSize, contentType, imageUrl, now, now)\r\n      .run()\r\n\n    const r2Url = `${r2PublicUrl.replace(/\\/$/, '')}/${r2Key}`\r\n    return {\r\n      success: true,\r\n      imageId: imageId,\r\n      r2Url: r2Url,\r\n      originalUrl: imageUrl,\r\n      imageHash: imageHash,\r\n      fileSize: fileSize,\r\n      mimeType: contentType,\r\n      isReused: false,\r\n    }\r\n  } catch (error) {\r\n    return {\r\n      success: false,\r\n      originalUrl: imageUrl,\r\n      error: error instanceof Error ? error.message : 'Unknown error',\r\n    }\r\n  }\r\n}\r\n/**\r\n\n */\r\nasync function calculateHash(data: ArrayBuffer): Promise<string> {\r\n  const hashBuffer = await crypto.subtle.digest('SHA-256', data)\r\n  const hashArray = Array.from(new Uint8Array(hashBuffer))\r\n  const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')\r\n  return hashHex\r\n}\r\n/**\r\n\n */\r\nfunction getExtensionFromContentType(contentType: string): string {\r\n  const typeMap: Record<string, string> = {\r\n    'image/jpeg': '.jpg',\r\n    'image/jpg': '.jpg',\r\n    'image/png': '.png',\r\n    'image/gif': '.gif',\r\n    'image/webp': '.webp',\r\n    'image/svg+xml': '.svg',\r\n  }\r\n  return typeMap[contentType.toLowerCase()] || '.jpg'\r\n}\r\n/**\r\n\n */\r\nexport async function deleteBookmarkImage(\r\n  bookmarkId: string,\r\n  db: D1Database,\r\n  bucket: R2Bucket\r\n): Promise<void> {\r\n  try {\r\n    // 1. \r\n    const image = await db\r\n      .prepare('SELECT id, r2_key, image_hash FROM bookmark_images WHERE bookmark_id = ?')\r\n      .bind(bookmarkId)\r\n      .first<{ id: string; r2_key: string; image_hash: string }>()\r\n    if (!image) {\r\n      return\r\n    }\r\n\n    const { count } = await db\r\n      .prepare('SELECT COUNT(*) as count FROM bookmark_images WHERE image_hash = ?')\r\n      .bind(image.image_hash)\r\n      .first<{ count: number }>() || { count: 0 }\r\n\n    await db.prepare('DELETE FROM bookmark_images WHERE id = ?').bind(image.id).run()\r\n\n    if (count <= 1) {\r\n      await bucket.delete(image.r2_key)\r\n    }\r\n  } catch (error) {\r\n    console.error('Failed to delete bookmark image:', error)\r\n  }\r\n}\r\n/**\r\n * （）\r\n */\r\nexport async function cleanupOrphanedImages(db: D1Database, bucket: R2Bucket): Promise<number> {\r\n  try {\r\n\n    const { results: orphaned } = await db\r\n      .prepare(\r\n        `SELECT bi.id, bi.r2_key, bi.image_hash\r\n         FROM bookmark_images bi\r\n         LEFT JOIN bookmarks b ON bi.bookmark_id = b.id\r\n         WHERE b.id IS NULL`\r\n      )\r\n      .all<{ id: string; r2_key: string; image_hash: string }>()\r\n    if (!orphaned || orphaned.length === 0) {\r\n      return 0\r\n    }\r\n    let deletedCount = 0\r\n    for (const image of orphaned) {\r\n\n      const { count } = await db\r\n        .prepare('SELECT COUNT(*) as count FROM bookmark_images WHERE image_hash = ?')\r\n        .bind(image.image_hash)\r\n        .first<{ count: number }>() || { count: 0 }\r\n\n      await db.prepare('DELETE FROM bookmark_images WHERE id = ?').bind(image.id).run()\r\n\n      if (count <= 1) {\r\n        await bucket.delete(image.r2_key)\r\n      }\r\n      deletedCount++\r\n    }\r\n    return deletedCount\r\n  } catch (error) {\r\n    console.error('Failed to cleanup orphaned images:', error)\r\n    return 0\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/import-export/collect-export-data.ts",
    "content": "import type {\n  TMarksExportData,\n  ExportBookmark,\n  ExportTag,\n  ExportUser,\n  ExportTabGroup,\n  ExportTabGroupItem,\n} from '../../../shared/import-export-types'\nimport { EXPORT_VERSION } from '../../../shared/import-export-types'\nimport type { ExportScope } from './export-scope'\n\nconst DEFAULT_TAG_COLOR = '#3b82f6'\n\nfunction parseMaybeJsonStringArray(raw: unknown): string[] | undefined {\n  if (raw == null) return undefined\n  if (Array.isArray(raw)) return raw.map(String)\n  if (typeof raw !== 'string') return undefined\n  try {\n    const parsed = JSON.parse(raw)\n    if (Array.isArray(parsed)) return parsed.map(String)\n    return undefined\n  } catch {\n    return undefined\n  }\n}\n\nasync function collectUser(db: D1Database, userId: string): Promise<ExportUser> {\n  interface UserRow {\n    id: string\n    email: string | null\n    username: string\n    created_at: string\n  }\n\n  if (userId === 'default-user') {\n    return {\n      id: 'default-user',\n      email: 'default@tmarks.local',\n      name: 'Default User',\n      created_at: new Date().toISOString(),\n    }\n  }\n\n  const { results: users } = await db\n    .prepare('SELECT id, email, username, created_at FROM users WHERE id = ?')\n    .bind(userId)\n    .all<UserRow>()\n\n  const foundUser = users?.[0]\n  if (!foundUser) throw new Error('User not found')\n\n  return {\n    id: foundUser.id,\n    email: foundUser.email ?? '',\n    name: foundUser.username,\n    created_at: foundUser.created_at,\n  }\n}\n\nasync function collectBookmarksAndTags(\n  db: D1Database,\n  userId: string,\n  includeDeleted: boolean\n): Promise<{ bookmarks: ExportBookmark[]; tags: ExportTag[] }> {\n  const bookmarkWhere = includeDeleted ? 'user_id = ?' : 'user_id = ? AND deleted_at IS NULL'\n  const tagWhere = includeDeleted ? 'user_id = ?' : 'user_id = ? AND deleted_at IS NULL'\n\n  const { results: bookmarks } = await db\n    .prepare(\n      `\n      SELECT\n        id, title, url, description, cover_image, cover_image_id, favicon,\n        is_pinned, is_archived, is_public,\n        click_count, last_clicked_at,\n        has_snapshot, latest_snapshot_at, snapshot_count,\n        created_at, updated_at, deleted_at\n      FROM bookmarks\n      WHERE ${bookmarkWhere}\n      ORDER BY created_at DESC\n    `\n    )\n    .bind(userId)\n    .all()\n\n  const { results: tags } = await db\n    .prepare(\n      `\n      SELECT\n        id, name, color, click_count, last_clicked_at, created_at, updated_at, deleted_at\n      FROM tags\n      WHERE ${tagWhere}\n      ORDER BY name ASC\n    `\n    )\n    .bind(userId)\n    .all()\n\n  const bookmarkTagSql = includeDeleted\n    ? `\n      SELECT bt.bookmark_id, bt.tag_id, t.name as tag_name\n      FROM bookmark_tags bt\n      JOIN tags t ON bt.tag_id = t.id\n      WHERE bt.user_id = ?\n    `\n    : `\n      SELECT bt.bookmark_id, bt.tag_id, t.name as tag_name\n      FROM bookmark_tags bt\n      JOIN tags t ON bt.tag_id = t.id\n      JOIN bookmarks b ON bt.bookmark_id = b.id\n      WHERE bt.user_id = ? AND t.deleted_at IS NULL AND b.deleted_at IS NULL\n    `\n\n  const { results: bookmarkTags } = await db.prepare(bookmarkTagSql).bind(userId).all()\n\n  const bookmarkTagMap = new Map<string, string[]>()\n  const tagCountMap = new Map<string, number>()\n\n  bookmarkTags?.forEach((bt: Record<string, unknown>) => {\n    const bookmarkId = String(bt.bookmark_id)\n    const tagName = String(bt.tag_name)\n    const list = bookmarkTagMap.get(bookmarkId) ?? []\n    list.push(tagName)\n    bookmarkTagMap.set(bookmarkId, list)\n  })\n\n  // Compute bookmark_count per tag name\n  for (const tagList of bookmarkTagMap.values()) {\n    for (const tagName of tagList) {\n      tagCountMap.set(tagName, (tagCountMap.get(tagName) ?? 0) + 1)\n    }\n  }\n\n  const exportBookmarks: ExportBookmark[] = (bookmarks || []).map((bookmark: Record<string, unknown>) => ({\n    id: String(bookmark.id),\n    title: String(bookmark.title),\n    url: String(bookmark.url),\n    description: (bookmark.description ?? null) as string | null,\n    cover_image: (bookmark.cover_image ?? null) as string | null,\n    cover_image_id: (bookmark.cover_image_id ?? null) as string | null,\n    favicon: (bookmark.favicon ?? null) as string | null,\n    tags: bookmarkTagMap.get(String(bookmark.id)) || [],\n    is_pinned: Boolean(bookmark.is_pinned),\n    is_archived: Boolean(bookmark.is_archived),\n    is_public: Boolean(bookmark.is_public),\n    click_count: Number(bookmark.click_count ?? 0),\n    last_clicked_at: (bookmark.last_clicked_at ?? null) as string | null,\n    has_snapshot: Boolean(bookmark.has_snapshot),\n    latest_snapshot_at: (bookmark.latest_snapshot_at ?? null) as string | null,\n    snapshot_count: Number(bookmark.snapshot_count ?? 0),\n    created_at: String(bookmark.created_at),\n    updated_at: String(bookmark.updated_at),\n    deleted_at: (bookmark.deleted_at ?? null) as string | null,\n  }))\n\n  const exportTags: ExportTag[] = (tags || []).map((tag: Record<string, unknown>) => ({\n    id: String(tag.id),\n    name: String(tag.name),\n    color: (tag.color == null || tag.color === '') ? DEFAULT_TAG_COLOR : String(tag.color),\n    click_count: Number(tag.click_count ?? 0),\n    last_clicked_at: (tag.last_clicked_at ?? null) as string | null,\n    created_at: String(tag.created_at),\n    updated_at: String(tag.updated_at),\n    deleted_at: (tag.deleted_at ?? null) as string | null,\n    bookmark_count: tagCountMap.get(String(tag.name)) ?? 0,\n  }))\n\n  return { bookmarks: exportBookmarks, tags: exportTags }\n}\n\nasync function collectTabGroups(\n  db: D1Database,\n  userId: string,\n  includeDeleted: boolean\n): Promise<ExportTabGroup[]> {\n  const where = includeDeleted ? 'user_id = ?' : 'user_id = ? AND is_deleted = 0'\n\n  const { results: tabGroups } = await db\n    .prepare(\n      `\n      SELECT\n        id, title, parent_id, is_folder, position, color, tags,\n        is_deleted, deleted_at, created_at, updated_at\n      FROM tab_groups\n      WHERE ${where}\n      ORDER BY position ASC\n    `\n    )\n    .bind(userId)\n    .all()\n\n  const { results: tabGroupItems } = await db\n    .prepare(\n      `\n      SELECT tgi.id, tgi.group_id, tgi.title, tgi.url, tgi.favicon, tgi.position,\n             tgi.is_pinned, tgi.is_todo, tgi.is_archived, tgi.created_at\n      FROM tab_group_items tgi\n      JOIN tab_groups tg ON tgi.group_id = tg.id\n      WHERE tg.user_id = ? ${includeDeleted ? '' : 'AND tg.is_deleted = 0'}\n      ORDER BY tgi.position ASC\n    `\n    )\n    .bind(userId)\n    .all()\n\n  const groupItemsMap = new Map<string, ExportTabGroupItem[]>()\n  tabGroupItems?.forEach((item: Record<string, unknown>) => {\n    const groupId = String(item.group_id)\n    const list = groupItemsMap.get(groupId) ?? []\n    list.push({\n      id: String(item.id),\n      title: String(item.title),\n      url: String(item.url),\n      favicon: item.favicon ? String(item.favicon) : undefined,\n      position: Number(item.position),\n      is_pinned: Boolean(item.is_pinned),\n      is_todo: Boolean(item.is_todo),\n      is_archived: Boolean(item.is_archived),\n      created_at: String(item.created_at),\n    })\n    groupItemsMap.set(groupId, list)\n  })\n\n  return (tabGroups || []).map((group: Record<string, unknown>) => ({\n    id: String(group.id),\n    title: String(group.title),\n    parent_id: group.parent_id ? String(group.parent_id) : undefined,\n    is_folder: Boolean(group.is_folder),\n    position: Number(group.position),\n    color: group.color ? String(group.color) : undefined,\n    tags: parseMaybeJsonStringArray(group.tags),\n    is_deleted: Boolean(group.is_deleted),\n    deleted_at: group.deleted_at ? String(group.deleted_at) : undefined,\n    created_at: String(group.created_at),\n    updated_at: String(group.updated_at),\n    items: groupItemsMap.get(String(group.id)) || [],\n  }))\n}\n\nexport async function collectExportData(\n  db: D1Database,\n  userId: string,\n  scope: ExportScope,\n  includeDeleted: boolean\n): Promise<TMarksExportData> {\n  const exportedAt = new Date().toISOString()\n  const user = await collectUser(db, userId)\n\n  const shouldBookmarks = scope === 'all' || scope === 'bookmarks'\n  const shouldTabGroups = scope === 'all' || scope === 'tab_groups'\n\n  const [{ bookmarks, tags }, tab_groups] = await Promise.all([\n    shouldBookmarks ? collectBookmarksAndTags(db, userId, includeDeleted) : Promise.resolve({ bookmarks: [], tags: [] }),\n    shouldTabGroups ? collectTabGroups(db, userId, includeDeleted) : Promise.resolve([] as ExportTabGroup[]),\n  ])\n\n  return {\n    version: EXPORT_VERSION,\n    format: 'tmarks' as const,\n    exported_at: exportedAt,\n    user,\n    bookmarks,\n    tags,\n    tab_groups,\n    metadata: {\n      total_bookmarks: bookmarks.length,\n      total_tags: tags.length,\n      total_tab_groups: tab_groups.length,\n      export_format: 'json',\n      source: 'tmarks',\n    },\n  }\n}\n"
  },
  {
    "path": "tmarks/functions/lib/import-export/export-scope.ts",
    "content": "export type ExportScope = 'all' | 'bookmarks' | 'tab_groups'\n\nexport function parseExportScope(raw: string | null | undefined): ExportScope {\n  if (raw === 'bookmarks' || raw === 'tab_groups' || raw === 'all') return raw\n  return 'all'\n}\n\nexport function getExportFilename(exportedAtIso: string, scope: ExportScope): string {\n  const date = new Date(exportedAtIso)\n  const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD\n  const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-') // HH-MM-SS\n  const prefix =\n    scope === 'bookmarks' ? 'tmarks-bookmarks-export' :\n    scope === 'tab_groups' ? 'tmarks-tab-groups-export' :\n    'tmarks-export'\n  return `${prefix}-${dateStr}-${timeStr}.json`\n}\n\n"
  },
  {
    "path": "tmarks/functions/lib/import-export/export-stats.ts",
    "content": "import type { ExportScope } from './export-scope'\n\nexport interface ExportStats {\n  total_bookmarks: number\n  total_tags: number\n  pinned_bookmarks: number\n  total_tab_groups: number\n}\n\nexport async function getExportStats(\n  db: D1Database,\n  userId: string,\n  scope: ExportScope,\n  includeDeleted: boolean\n): Promise<ExportStats> {\n  interface CountRow {\n    count: number\n  }\n\n  const bookmarkWhere = includeDeleted ? 'user_id = ?' : 'user_id = ? AND deleted_at IS NULL'\n  const tagWhere = includeDeleted ? 'user_id = ?' : 'user_id = ? AND deleted_at IS NULL'\n  const tabGroupWhere = includeDeleted ? 'user_id = ?' : 'user_id = ? AND is_deleted = 0'\n\n  const shouldBookmarks = scope === 'all' || scope === 'bookmarks'\n  const shouldTabGroups = scope === 'all' || scope === 'tab_groups'\n\n  const [bookmarkCount, tagCount, pinnedCount, tabGroupCount] = await Promise.all([\n    shouldBookmarks\n      ? db.prepare(`SELECT COUNT(*) as count FROM bookmarks WHERE ${bookmarkWhere}`).bind(userId).first<CountRow>()\n      : Promise.resolve({ count: 0 } as CountRow),\n    shouldBookmarks\n      ? db.prepare(`SELECT COUNT(*) as count FROM tags WHERE ${tagWhere}`).bind(userId).first<CountRow>()\n      : Promise.resolve({ count: 0 } as CountRow),\n    shouldBookmarks\n      ? db.prepare(\n          `SELECT COUNT(*) as count FROM bookmarks WHERE ${bookmarkWhere} AND is_pinned = 1`\n        ).bind(userId).first<CountRow>()\n      : Promise.resolve({ count: 0 } as CountRow),\n    shouldTabGroups\n      ? db.prepare(`SELECT COUNT(*) as count FROM tab_groups WHERE ${tabGroupWhere}`).bind(userId).first<CountRow>()\n      : Promise.resolve({ count: 0 } as CountRow),\n  ])\n\n  return {\n    total_bookmarks: bookmarkCount?.count || 0,\n    total_tags: tagCount?.count || 0,\n    pinned_bookmarks: pinnedCount?.count || 0,\n    total_tab_groups: tabGroupCount?.count || 0,\n  }\n}\n\nexport function estimateExportSize(stats: ExportStats): number {\n  // Heuristic. Export is JSON; real size depends on text lengths and tab-group items.\n  const avgBookmarkSize = 350\n  const avgTagSize = 90\n  const avgTabGroupSize = 700\n  return (\n    stats.total_bookmarks * avgBookmarkSize +\n    stats.total_tags * avgTagSize +\n    stats.total_tab_groups * avgTabGroupSize\n  )\n}\n\n"
  },
  {
    "path": "tmarks/functions/lib/import-export/exporters/html-exporter.ts",
    "content": "/**\n\n *  Netscape ，\n */\n\nimport type { \n  Exporter, \n  TMarksExportData, \n  ExportOptions, \n  ExportOutput\n} from '../../../../shared/import-export-types'\n\nimport { generateTabGroupsNetscapeSection } from './tab-groups-netscape'\n\nexport class HtmlExporter implements Exporter {\n  readonly format = 'html' as const\n\n  async export(data: TMarksExportData, options?: ExportOptions): Promise<ExportOutput> {\n    try {\n      //  HTML \n      const htmlContent = this.generateHtml(data, options)\n\n      const filename = this.generateFilename(data.exported_at)\n      \n      return {\n        content: htmlContent,\n        filename,\n        mimeType: 'text/html',\n        size: new TextEncoder().encode(htmlContent).length\n      }\n    } catch (error) {\n      throw new Error(`HTML export failed: ${error instanceof Error ? error.message : 'Unknown error'}`)\n    }\n  }\n\n  private generateHtml(data: TMarksExportData, options?: ExportOptions): string {\n    const includeMetadata = options?.include_metadata ?? true\n    const includeTags = options?.include_tags ?? true\n    const bookmarksByFolder = this.organizeBookmarksByFolder(\n      data.bookmarks as Array<Record<string, unknown>>,\n      includeTags\n    )\n\n    const tabGroupsSection = generateTabGroupsNetscapeSection({\n      tabGroups: data.tab_groups,\n      exportedAt: data.exported_at,\n      escapeHtml: (text) => this.escapeHtml(text),\n      toUnixTimestamp: (iso) => this.toUnixTimestamp(iso),\n    })\n    \n    const html = `<!DOCTYPE NETSCAPE-Bookmark-file-1>\n<!-- This is an automatically generated file.\n     It will be read and overwritten.\n     DO NOT EDIT! -->\n<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n<TITLE>Bookmarks</TITLE>\n<H1>Bookmarks</H1>\n\n<DL><p>\n${tabGroupsSection}\n${this.generateBookmarkFolders(bookmarksByFolder, data.exported_at)}\n${includeMetadata ? this.generateMetadataComment(data) : ''}\n</DL><p>`\n\n    return html\n  }\n\n  private organizeBookmarksByFolder(bookmarks: Array<Record<string, unknown>>, includeTags: boolean): Map<string, Array<Record<string, unknown>>> {\n    const folderMap = new Map<string, Array<Record<string, unknown>>>()\n    folderMap.set('Uncategorized', [])\n\n    bookmarks.forEach(bookmark => {\n      const tags = bookmark.tags as string[] | undefined\n      if (!includeTags || !tags || tags.length === 0) {\n        //\n        const uncategorized = folderMap.get('Uncategorized')\n        if (uncategorized) {\n          uncategorized.push(bookmark)\n        }\n      } else {\n\n        tags.forEach((tag: string) => {\n          if (!folderMap.has(tag)) {\n            folderMap.set(tag, [])\n          }\n          const folder = folderMap.get(tag)\n          if (folder) {\n            folder.push(bookmark)\n          }\n        })\n      }\n    })\n    \n    // \n    const uncategorized = folderMap.get('Uncategorized')\n    if (uncategorized && uncategorized.length === 0) {\n      folderMap.delete('Uncategorized')\n    }\n    \n    return folderMap\n  }\n\n  private generateBookmarkFolders(folderMap: Map<string, Array<Record<string, unknown>>>, exportedAt: string): string {\n    let html = ''\n    \n    folderMap.forEach((bookmarks, folderName) => {\n      if (bookmarks.length === 0) return\n      \n      html += `    <DT><H3 ADD_DATE=\"${this.toUnixTimestamp(exportedAt)}\" LAST_MODIFIED=\"${this.toUnixTimestamp(exportedAt)}\">${this.escapeHtml(folderName)}</H3>\\n`\n      html += `    <DL><p>\\n`\n      \n      bookmarks.forEach(bookmark => {\n        html += this.generateBookmarkEntry(bookmark)\n      })\n      \n      html += `    </DL><p>\\n`\n    })\n    \n    return html\n  }\n\n  private generateBookmarkEntry(bookmark: Record<string, unknown>): string {\n    const addDate = bookmark.created_at ? this.toUnixTimestamp(bookmark.created_at) : this.toUnixTimestamp(new Date().toISOString())\n    const lastModified = bookmark.updated_at ? this.toUnixTimestamp(bookmark.updated_at) : addDate\n\n    const attributes = [\n      `HREF=\"${this.escapeHtml(bookmark.url)}\"`,\n      `ADD_DATE=\"${addDate}\"`,\n      `LAST_MODIFIED=\"${lastModified}\"`\n    ]\n\n    if (bookmark.is_pinned) {\n      attributes.push('PERSONAL_TOOLBAR_FOLDER=\"true\"')\n    }\n    \n    if (bookmark.tags && bookmark.tags.length > 0) {\n      attributes.push(`TAGS=\"${this.escapeHtml(bookmark.tags.join(','))}\"`)\n    }\n    \n    // \n    let entry = `        <DT><A ${attributes.join(' ')}>${this.escapeHtml(bookmark.title)}</A>\\n`\n    \n    // \n    if (bookmark.description) {\n      entry += `        <DD>${this.escapeHtml(bookmark.description)}\\n`\n    }\n    \n    return entry\n  }\n\n  private generateMetadataComment(data: TMarksExportData): string {\n    const stats = {\n      totalBookmarks: data.bookmarks.length,\n      totalTags: data.tags.length,\n      totalTabGroups: data.tab_groups?.length || 0,\n      totalTabGroupItems: data.tab_groups?.reduce((sum, g) => sum + (g.items?.length || 0), 0) || 0,\n      exportedAt: data.exported_at,\n      version: data.version\n    }\n    \n    return `\n<!-- TMarks Export Metadata\n     Total Bookmarks: ${stats.totalBookmarks}\n     Total Tags: ${stats.totalTags}\n     Total Tab Groups: ${stats.totalTabGroups}\n     Total Tab Group Items: ${stats.totalTabGroupItems}\n     Exported At: ${stats.exportedAt}\n     Export Version: ${stats.version}\n-->`\n  }\n\n  private generateFilename(exportedAt: string): string {\n    const date = new Date(exportedAt)\n    const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD\n    \n    return `tmarks-bookmarks-${dateStr}.html`\n  }\n\n  private toUnixTimestamp(isoString: string): string {\n    return Math.floor(new Date(isoString).getTime() / 1000).toString()\n  }\n\n  private escapeHtml(text: string): string {\n\n    return text\n      .replace(/&/g, '&amp;')\n      .replace(/</g, '&lt;')\n      .replace(/>/g, '&gt;')\n      .replace(/\"/g, '&quot;')\n      .replace(/'/g, '&#39;')\n  }\n\n  /**\n   *  HTML \n   */\n  validateData(data: TMarksExportData): { valid: boolean; errors: string[] } {\n    const errors: string[] = []\n\n    // \n    if (!Array.isArray(data.bookmarks)) {\n      errors.push('Bookmarks must be an array')\n    }\n\n    // \n    data.bookmarks.forEach((bookmark, index) => {\n      if (!bookmark.title) {\n        errors.push(`Bookmark ${index}: missing title`)\n      }\n      if (!bookmark.url) {\n        errors.push(`Bookmark ${index}: missing url`)\n      }\n      if (!this.isValidUrl(bookmark.url)) {\n        errors.push(`Bookmark ${index}: invalid URL format`)\n      }\n    })\n\n    return {\n      valid: errors.length === 0,\n      errors\n    }\n  }\n\n  private isValidUrl(url: string): boolean {\n    try {\n      new URL(url)\n      return true\n    } catch {\n      return false\n    }\n  }\n\n  /**\n   *  HTML \n   */\n  getPreview(data: TMarksExportData, maxItems: number = 5): string {\n    const previewData = {\n      ...data,\n      bookmarks: data.bookmarks.slice(0, maxItems)\n    }\n    \n    return this.generateHtml(previewData, { \n      include_metadata: false, \n      include_tags: true,\n      format_options: {}\n    })\n  }\n}\n\n/**\n\n */\nexport function createHtmlExporter(): HtmlExporter {\n  return new HtmlExporter()\n}\n\n/**\n\n */\nexport async function exportToHtml(\n  data: TMarksExportData, \n  options?: ExportOptions\n): Promise<string> {\n  const exporter = createHtmlExporter()\n  const result = await exporter.export(data, options)\n  return result.content as string\n}\n"
  },
  {
    "path": "tmarks/functions/lib/import-export/exporters/json-exporter.ts",
    "content": "/**\r\n\n *  TMarks  JSON \r\n */\r\n\r\nimport type { \r\n  Exporter, \r\n  TMarksExportData, \r\n  ExportOptions, \r\n  ExportOutput \r\n} from '../../../../shared/import-export-types'\r\n\r\nexport class JsonExporter implements Exporter {\r\n  readonly format = 'json' as const\r\n\r\n  async export(data: TMarksExportData, options?: ExportOptions): Promise<ExportOutput> {\r\n    try {\r\n      // \r\n      const filteredData = this.filterData(data, options)\r\n\n      const jsonContent = this.formatJson(filteredData, options)\r\n\n      const filename = this.generateFilename(data.exported_at)\r\n      \r\n      return {\r\n        content: jsonContent,\r\n        filename,\r\n        mimeType: 'application/json',\r\n        size: new TextEncoder().encode(jsonContent).length\r\n      }\r\n    } catch (error) {\r\n      throw new Error(`JSON export failed: ${error instanceof Error ? error.message : 'Unknown error'}`)\r\n    }\r\n  }\r\n\r\n  private filterData(data: TMarksExportData, options?: ExportOptions): TMarksExportData {\r\n    const filtered = { ...data }\r\n\r\n    // \r\n    if (!options?.include_tags) {\r\n      filtered.tags = []\r\n      filtered.bookmarks = filtered.bookmarks.map(bookmark => ({\r\n        ...bookmark,\r\n        tags: []\r\n      }))\r\n    }\r\n\n    if (!options?.include_metadata) {\r\n      delete filtered.metadata\r\n    }\r\n\r\n    // \r\n    if (!options?.format_options?.include_user_info) {\r\n      filtered.user = {\r\n        id: filtered.user.id,\r\n        email: '',\r\n        created_at: filtered.user.created_at\r\n      }\r\n    }\r\n\r\n    // \r\n    if (!options?.format_options?.include_click_stats) {\r\n      filtered.bookmarks = filtered.bookmarks.map(bookmark => {\r\n        // eslint-disable-next-line @typescript-eslint/no-unused-vars\r\n        const { click_count, last_clicked_at, ...rest } = bookmark\r\n        return rest\r\n      })\r\n    }\r\n\r\n    return filtered\r\n  }\r\n\r\n  private formatJson(data: TMarksExportData, options?: ExportOptions): string {\r\n    const prettyPrint = options?.format_options?.pretty_print ?? true\r\n    \r\n    if (prettyPrint) {\r\n      return JSON.stringify(data, null, 2)\r\n    } else {\r\n      return JSON.stringify(data)\r\n    }\r\n  }\r\n\r\n  private generateFilename(exportedAt: string): string {\r\n    const date = new Date(exportedAt)\r\n    const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD\r\n    const timeStr = date.toTimeString().split(' ')[0].replace(/:/g, '-') // HH-MM-SS\r\n    \r\n    return `tmarks-export-${dateStr}-${timeStr}.json`\r\n  }\r\n\r\n  /**\r\n\n   */\r\n  validateData(data: TMarksExportData): { valid: boolean; errors: string[] } {\r\n    const errors: string[] = []\r\n\r\n    // \r\n    if (!data.version) errors.push('Missing version field')\r\n    if (!data.exported_at) errors.push('Missing exported_at field')\r\n    if (!data.user?.id) errors.push('Missing user.id field')\r\n    if (!Array.isArray(data.bookmarks)) errors.push('Bookmarks must be an array')\r\n    if (!Array.isArray(data.tags)) errors.push('Tags must be an array')\r\n\n    data.bookmarks.forEach((bookmark, index) => {\r\n      if (!bookmark.id) errors.push(`Bookmark ${index}: missing id`)\r\n      if (!bookmark.title) errors.push(`Bookmark ${index}: missing title`)\r\n      if (!bookmark.url) errors.push(`Bookmark ${index}: missing url`)\r\n      if (!this.isValidUrl(bookmark.url)) errors.push(`Bookmark ${index}: invalid URL`)\r\n      if (!Array.isArray(bookmark.tags)) errors.push(`Bookmark ${index}: tags must be an array`)\r\n    })\r\n\n    data.tags.forEach((tag, index) => {\r\n      if (!tag.id) errors.push(`Tag ${index}: missing id`)\r\n      if (!tag.name) errors.push(`Tag ${index}: missing name`)\r\n      if (!tag.color) errors.push(`Tag ${index}: missing color`)\r\n      if (!this.isValidColor(tag.color)) errors.push(`Tag ${index}: invalid color format`)\r\n    })\r\n\r\n    return {\r\n      valid: errors.length === 0,\r\n      errors\r\n    }\r\n  }\r\n\r\n  private isValidUrl(url: string): boolean {\r\n    try {\r\n      new URL(url)\r\n      return true\r\n    } catch {\r\n      return false\r\n    }\r\n  }\r\n\r\n  private isValidColor(color: string): boolean {\r\n\n    return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color)\r\n  }\r\n\r\n  /**\r\n   * \r\n   */\r\n  getExportStats(data: TMarksExportData): {\r\n    totalBookmarks: number\r\n    totalTags: number\r\n    pinnedBookmarks: number\r\n    taggedBookmarks: number\r\n    estimatedSize: number\r\n  } {\r\n    const pinnedBookmarks = data.bookmarks.filter(b => b.is_pinned).length\r\n    const taggedBookmarks = data.bookmarks.filter(b => b.tags.length > 0).length\r\n    const estimatedSize = new TextEncoder().encode(JSON.stringify(data)).length\r\n\r\n    return {\r\n      totalBookmarks: data.bookmarks.length,\r\n      totalTags: data.tags.length,\r\n      pinnedBookmarks,\r\n      taggedBookmarks,\r\n      estimatedSize\r\n    }\r\n  }\r\n}\r\n\r\n/**\r\n\n */\r\nexport function createJsonExporter(): JsonExporter {\r\n  return new JsonExporter()\r\n}\r\n\r\n/**\r\n\n */\r\nexport async function exportToJson(\r\n  data: TMarksExportData, \r\n  options?: ExportOptions\r\n): Promise<string> {\r\n  const exporter = createJsonExporter()\r\n  const result = await exporter.export(data, options)\r\n  return result.content as string\r\n}\r\n\r\n/**\r\n *  JSON\r\n */\r\nexport async function exportToCompactJson(data: TMarksExportData): Promise<string> {\r\n  const options: ExportOptions = {\r\n    include_tags: true,\r\n    include_metadata: true,\r\n    format_options: {\r\n      pretty_print: false,\r\n      include_click_stats: false,\r\n      include_user_info: false\r\n    }\r\n  }\r\n  \r\n  return exportToJson(data, options)\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/import-export/exporters/tab-groups-netscape.ts",
    "content": "import type { ExportTabGroup, ExportTabGroupItem } from '../../../../shared/import-export-types'\n\ntype EscapeHtml = (text: string) => string\ntype ToUnixTimestamp = (isoString: string) => string\n\nexport function generateTabGroupsNetscapeSection(params: {\n  tabGroups: ExportTabGroup[] | undefined\n  exportedAt: string\n  escapeHtml: EscapeHtml\n  toUnixTimestamp: ToUnixTimestamp\n}): string {\n  const { tabGroups, exportedAt, escapeHtml, toUnixTimestamp } = params\n  if (!tabGroups || tabGroups.length === 0) return ''\n\n  type TabGroupNode = ExportTabGroup & { children: TabGroupNode[] }\n\n  const nodeById = new Map<string, TabGroupNode>()\n  for (const group of tabGroups) {\n    nodeById.set(group.id, { ...group, children: [] })\n  }\n\n  const roots: TabGroupNode[] = []\n  for (const node of nodeById.values()) {\n    const parentId = node.parent_id\n    const parent = parentId && parentId !== node.id ? nodeById.get(parentId) : undefined\n    if (parent) parent.children.push(node)\n    else roots.push(node)\n  }\n\n  const sortTree = (nodes: TabGroupNode[]) => {\n    nodes.sort((a, b) => a.position - b.position)\n    nodes.forEach((n) => sortTree(n.children))\n  }\n  sortTree(roots)\n\n  const generateTabGroupItemEntry = (item: ExportTabGroupItem, depth: number): string => {\n    const indent = '    '.repeat(Math.max(depth, 0))\n    const addDate = item.created_at ? toUnixTimestamp(item.created_at) : toUnixTimestamp(new Date().toISOString())\n\n    const tags: string[] = ['tmarks_tab_group']\n    if (item.is_pinned) tags.push('pinned')\n    if (item.is_todo) tags.push('todo')\n    if (item.is_archived) tags.push('archived')\n\n    const attributes = [\n      `HREF=\"${escapeHtml(item.url)}\"`,\n      `ADD_DATE=\"${addDate}\"`,\n      `LAST_MODIFIED=\"${addDate}\"`,\n      `TAGS=\"${escapeHtml(tags.join(','))}\"`\n    ]\n\n    let entry = `${indent}    <DT><A ${attributes.join(' ')}>${escapeHtml(item.title)}</A>\\n`\n    if (item.is_todo || item.is_archived || item.is_pinned) {\n      const flags = [\n        item.is_pinned ? 'pinned' : null,\n        item.is_todo ? 'todo' : null,\n        item.is_archived ? 'archived' : null\n      ].filter(Boolean).join(', ')\n      entry += `${indent}    <DD>Status: ${escapeHtml(flags)}\\n`\n    }\n    return entry\n  }\n\n  const renderNode = (node: TabGroupNode, depth: number, visited: Set<string>): string => {\n    if (visited.has(node.id)) return ''\n    visited.add(node.id)\n\n    const indent = '    '.repeat(Math.max(depth, 0))\n    const addDate = toUnixTimestamp(node.created_at || exportedAt)\n    const lastModified = toUnixTimestamp(node.updated_at || node.created_at || exportedAt)\n\n    let html = ''\n    html += `${indent}<DT><H3 ADD_DATE=\"${addDate}\" LAST_MODIFIED=\"${lastModified}\">${escapeHtml(node.title)}</H3>\\n`\n    html += `${indent}<DL><p>\\n`\n\n    const items = [...(node.items || [])].sort((a, b) => a.position - b.position)\n    for (const item of items) {\n      html += generateTabGroupItemEntry(item, depth + 1)\n    }\n\n    for (const child of node.children) {\n      html += renderNode(child, depth + 1, visited)\n    }\n\n    html += `${indent}</DL><p>\\n`\n    return html\n  }\n\n  const headerAddDate = toUnixTimestamp(exportedAt)\n  let html = ''\n  html += `    <DT><H3 ADD_DATE=\"${headerAddDate}\" LAST_MODIFIED=\"${headerAddDate}\">Tab Groups (TMarks)</H3>\\n`\n  html += `    <DL><p>\\n`\n  const visited = new Set<string>()\n  for (const root of roots) {\n    html += renderNode(root, 2, visited)\n  }\n  html += `    </DL><p>\\n`\n  return html\n}\n"
  },
  {
    "path": "tmarks/functions/lib/index.ts",
    "content": "// ============ Functions Library Exports ============\r\n\r\nexport * from './bookmark-utils';\r\nexport * from './config';\r\nexport * from './crypto';\r\nexport * from './error-handler';\r\nexport * from './image-upload';\r\nexport * from './input-sanitizer';\r\nexport * from './jwt';\r\nexport * from './rate-limit';\r\nexport * from './response';\r\nexport * from './signed-url';\r\nexport * from './storage-quota';\r\nexport * from './tags';\r\nexport * from './types';\r\nexport * from './utils';\r\nexport * from './validation';\r\n"
  },
  {
    "path": "tmarks/functions/lib/input-sanitizer.ts",
    "content": "/**\r\n\r\n */\r\n/**\r\n * HTML \r\n */\r\nexport function escapeHtml(text: string): string {\r\n  const map: Record<string, string> = {\r\n    '&': '&amp;',\r\n    '<': '&lt;',\r\n    '>': '&gt;',\r\n    '\"': '&quot;',\r\n    \"'\": '&#39;',\r\n    '/': '&#x2F;',\r\n  }\r\n  return text.replace(/[&<>\"'/]/g, (s) => map[s])\r\n}\r\n/**\r\n * \r\n */\r\nexport function sanitizeString(input: string, options: {\r\n  maxLength?: number\r\n  allowHtml?: boolean\r\n  trimWhitespace?: boolean\r\n} = {}): string {\r\n  const {\r\n    maxLength = 1000,\r\n    allowHtml = false,\r\n    trimWhitespace = true\r\n  } = options\r\n  let result = input\r\n  if (trimWhitespace) {\r\n    result = result.trim()\r\n  }\r\n  if (result.length > maxLength) {\r\n    result = result.substring(0, maxLength)\r\n  }\r\n\r\n  if (!allowHtml) {\r\n    result = escapeHtml(result)\r\n  }\r\n  return result\r\n}\r\n/**\r\n\r\n */\r\nexport function sanitizeUrl(url: string): string | null {\r\n  try {\r\n    const parsed = new URL(url.trim())\r\n\r\n    if (!['http:', 'https:'].includes(parsed.protocol)) {\r\n      return null\r\n    }\r\n    //  JavaScript \r\n    if (parsed.protocol === 'javascript:') {\r\n      return null\r\n    }\r\n    return parsed.toString()\r\n  } catch {\r\n    return null\r\n  }\r\n}\r\n/**\r\n * \r\n */\r\nexport function sanitizeEmail(email: string): string | null {\r\n  const trimmed = email.trim().toLowerCase()\r\n  const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\r\n  if (!emailRegex.test(trimmed)) {\r\n    return null\r\n  }\r\n  if (trimmed.length > 254) {\r\n    return null\r\n  }\r\n  return trimmed\r\n}\r\n/**\r\n * \r\n */\r\nexport function sanitizeUsername(username: string): string | null {\r\n  const trimmed = username.trim()\r\n  // ：3-20，、、\r\n  const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/\r\n  if (!usernameRegex.test(trimmed)) {\r\n    return null\r\n  }\r\n  return trimmed\r\n}\r\n/**\r\n * \r\n */\r\nexport function sanitizeTagName(tagName: string): string | null {\r\n  const trimmed = tagName.trim()\r\n\r\n  if (trimmed.length === 0 || trimmed.length > 50) {\r\n    return null\r\n  }\r\n  const cleaned = trimmed.replace(/[<>\"'&]/g, '')\r\n  if (cleaned.length === 0) {\r\n    return null\r\n  }\r\n  return cleaned\r\n}\r\n/**\r\n\r\n */\r\nexport function sanitizeFileName(fileName: string): string {\r\n  return fileName\r\n    .replace(/[/\\\\:*?\"<>|]/g, '')\r\n    .replace(/\\.\\./g, '')\r\n    .trim()\r\n    .substring(0, 255)\r\n}\r\n/**\r\n\r\n */\r\nexport function sanitizeColor(color: string): string | null {\r\n  const trimmed = color.trim()\r\n  //  hex \r\n  const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/\r\n  if (hexRegex.test(trimmed)) {\r\n    return trimmed.toLowerCase()\r\n  }\r\n\r\n  const allowedColors = [\r\n    'red', 'blue', 'green', 'yellow', 'orange', 'purple', \r\n    'pink', 'cyan', 'gray', 'black', 'white'\r\n  ]\r\n  if (allowedColors.includes(trimmed.toLowerCase())) {\r\n    return trimmed.toLowerCase()\r\n  }\r\n  return null\r\n}\r\n/**\r\n * \r\n */\r\nexport function sanitizeSearchQuery(query: string): string {\r\n  return query\r\n    .trim()\r\n    .replace(/[<>\"'&]/g, '') \r\n    .substring(0, 100) \r\n}\r\n/**\r\n * \r\n */\r\nexport function sanitizePaginationParams(params: {\r\n  page?: string | number\r\n  pageSize?: string | number\r\n  cursor?: string\r\n}): {\r\n  page: number\r\n  pageSize: number\r\n  cursor?: string\r\n} {\r\n  const page = Math.max(1, parseInt(String(params.page || 1), 10) || 1)\r\n  const pageSize = Math.min(100, Math.max(1, parseInt(String(params.pageSize || 30), 10) || 30))\r\n  const result: { page: number; pageSize: number; cursor?: string } = { page, pageSize }\r\n  if (params.cursor && typeof params.cursor === 'string') {\r\n\r\n    if (/^[a-zA-Z0-9-]+$/.test(params.cursor)) {\r\n      result.cursor = params.cursor\r\n    }\r\n  }\r\n  return result\r\n}\r\n/**\r\n\r\n */\r\nexport function sanitizeObject<T extends Record<string, unknown>>(\r\n  obj: T,\r\n  rules: Partial<Record<keyof T, (value: unknown) => unknown>>\r\n): Partial<T> {\r\n  const result: Partial<T> = {}\r\n  for (const [key, value] of Object.entries(obj)) {\r\n    const rule = rules[key as keyof T]\r\n    if (rule && value !== undefined && value !== null) {\r\n      const sanitized = rule(value)\r\n      if (sanitized !== null && sanitized !== undefined) {\r\n        result[key as keyof T] = sanitized\r\n      }\r\n    }\r\n  }\r\n  return result\r\n}\r\n/**\r\n *  JSON \r\n */\r\nexport function validateJsonStructure(\r\n  data: unknown,\r\n  schema: Record<string, 'string' | 'number' | 'boolean' | 'array' | 'object'>\r\n): boolean {\r\n  if (typeof data !== 'object' || data === null) {\r\n    return false\r\n  }\r\n  const obj = data as Record<string, unknown>\r\n  for (const [key, expectedType] of Object.entries(schema)) {\r\n    const value = obj[key]\r\n    switch (expectedType) {\r\n      case 'string':\r\n        if (typeof value !== 'string') return false\r\n        break\r\n      case 'number':\r\n        if (typeof value !== 'number') return false\r\n        break\r\n      case 'boolean':\r\n        if (typeof value !== 'boolean') return false\r\n        break\r\n      case 'array':\r\n        if (!Array.isArray(value)) return false\r\n        break\r\n      case 'object':\r\n        if (typeof value !== 'object' || value === null) return false\r\n        break\r\n    }\r\n  }\r\n  return true\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/jwt.ts",
    "content": "export interface JWTPayload {\r\n  sub: string // user_id\r\n  exp: number\r\n  iat: number\r\n  session_id?: string\r\n}\r\n/**\r\n * Generate JWT token\r\n */\r\nexport async function generateJWT(\r\n  payload: Omit<JWTPayload, 'exp' | 'iat'>,\r\n  secret: string,\r\n  expiresIn: string = '30d'\r\n): Promise<string> {\r\n  const now = Math.floor(Date.now() / 1000)\r\n  const exp = now + parseExpiry(expiresIn)\r\n  const fullPayload: JWTPayload = {\r\n    ...payload,\r\n    iat: now,\r\n    exp,\r\n  }\r\n  const header = {\r\n    alg: 'HS256',\r\n    typ: 'JWT',\r\n  }\r\n  const encodedHeader = base64UrlEncode(JSON.stringify(header))\r\n  const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload))\r\n  const signature = await sign(`${encodedHeader}.${encodedPayload}`, secret)\r\n  return `${encodedHeader}.${encodedPayload}.${signature}`\r\n}\r\n/**\r\n * Verify JWT token\r\n */\r\nexport async function verifyJWT(token: string, secret: string): Promise<JWTPayload> {\r\n  const parts = token.split('.')\r\n  if (parts.length !== 3) {\r\n    throw new Error('Invalid token format')\r\n  }\r\n  const [encodedHeader, encodedPayload, signature] = parts\r\n  const expectedSignature = await sign(`${encodedHeader}.${encodedPayload}`, secret)\r\n  if (signature !== expectedSignature) {\r\n    throw new Error('Invalid signature')\r\n  }\r\n  // Decode payload\r\n  const payload: JWTPayload = JSON.parse(base64UrlDecode(encodedPayload))\r\n  // Check expiration\r\n  const now = Math.floor(Date.now() / 1000)\r\n  if (payload.exp < now) {\r\n    throw new Error('Token expired')\r\n  }\r\n  return payload\r\n}\r\n/**\r\n * Extract JWT from request\r\n */\r\nexport function extractJWT(request: Request): string | null {\r\n  const authHeader = request.headers.get('Authorization')\r\n  if (!authHeader || !authHeader.startsWith('Bearer ')) {\r\n    return null\r\n  }\r\n  return authHeader.substring(7)\r\n}\r\n/**\r\n * Sign data using Web Crypto API\r\n */\r\nasync function sign(data: string, secret: string): Promise<string> {\r\n  const encoder = new TextEncoder()\r\n  const keyData = encoder.encode(secret)\r\n  const dataBuffer = encoder.encode(data)\r\n  const key = await crypto.subtle.importKey(\r\n    'raw',\r\n    keyData,\r\n    { name: 'HMAC', hash: 'SHA-256' },\r\n    false,\r\n    ['sign']\r\n  )\r\n  const signature = await crypto.subtle.sign('HMAC', key, dataBuffer)\r\n  return base64UrlEncode(signature)\r\n}\r\n/**\r\n * Base64 URL encode\r\n */\r\nfunction base64UrlEncode(data: string | ArrayBuffer): string {\r\n  let base64: string\r\n  if (typeof data === 'string') {\r\n    base64 = btoa(data)\r\n  } else {\r\n    const bytes = new Uint8Array(data)\r\n    const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')\r\n    base64 = btoa(binary)\r\n  }\r\n  return base64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '')\r\n}\r\n/**\r\n * Base64 URL decode\r\n */\r\nfunction base64UrlDecode(data: string): string {\r\n  let base64 = data.replace(/-/g, '+').replace(/_/g, '/')\r\n  while (base64.length % 4) {\r\n    base64 += '='\r\n  }\r\n  return atob(base64)\r\n}\r\n/**\r\n * Parse expiry string (e.g. \"15m\", \"7d\")\r\n */\r\nexport function parseExpiry(expiry: string): number {\r\n  const match = expiry.match(/^(\\d+)([smhd])$/)\r\n  if (!match) {\r\n    throw new Error('Invalid expiry format')\r\n  }\r\n  const value = parseInt(match[1], 10)\r\n  const unit = match[2]\r\n  switch (unit) {\r\n    case 's':\r\n      return value\r\n    case 'm':\r\n      return value * 60\r\n    case 'h':\r\n      return value * 60 * 60\r\n    case 'd':\r\n      return value * 24 * 60 * 60\r\n    default:\r\n      throw new Error('Invalid expiry unit')\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/rate-limit.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\nimport type { Env } from './types'\n\ntype RateLimiterContext = Parameters<PagesFunction<Env>>[0]\n\ninterface RateLimitResult {\n  allowed: boolean\n  remaining: number\n  resetAt: number\n  retryAfter?: number\n}\n\nlet ensureTablePromise: Promise<void> | null = null\n\nasync function ensureTable(db: D1Database): Promise<void> {\n  if (ensureTablePromise) return ensureTablePromise\n\n  ensureTablePromise = db\n    .prepare(\n      `CREATE TABLE IF NOT EXISTS rate_limits (\n        key TEXT NOT NULL,\n        window_seconds INTEGER NOT NULL,\n        window_start INTEGER NOT NULL,\n        count INTEGER NOT NULL DEFAULT 0,\n        updated_at INTEGER NOT NULL,\n        PRIMARY KEY (key, window_seconds, window_start)\n      )`\n    )\n    .run()\n    .then(() => undefined)\n    .catch(() => undefined)\n\n  return ensureTablePromise\n}\n\nfunction getWindowStart(now: number, windowSeconds: number): number {\n  const windowMs = windowSeconds * 1000\n  return Math.floor(now / windowMs) * windowMs\n}\n\nasync function checkAndRecord(\n  db: D1Database,\n  key: string,\n  limit: number,\n  windowSeconds: number\n): Promise<RateLimitResult> {\n  const now = Date.now()\n  const windowStart = getWindowStart(now, windowSeconds)\n  const resetAt = windowStart + windowSeconds * 1000\n\n  try {\n    await ensureTable(db)\n\n    // Atomic upsert + increment; count is the *new* count after this request.\n    const row = await db\n      .prepare(\n        `INSERT INTO rate_limits (key, window_seconds, window_start, count, updated_at)\n         VALUES (?, ?, ?, 1, ?)\n         ON CONFLICT(key, window_seconds, window_start)\n         DO UPDATE SET count = count + 1, updated_at = excluded.updated_at\n         RETURNING count`\n      )\n      .bind(key, windowSeconds, windowStart, now)\n      .first<{ count: number }>()\n\n    const count = Number(row?.count || 0)\n    const allowed = count <= limit\n    const remaining = Math.max(0, limit - count)\n\n    return {\n      allowed,\n      remaining,\n      resetAt,\n      retryAfter: allowed ? undefined : Math.max(0, Math.ceil((resetAt - now) / 1000)),\n    }\n  } catch {\n    // Fail-open to avoid accidental outage.\n    return { allowed: true, remaining: limit, resetAt }\n  }\n}\n\nexport function getClientIP(request: Request): string {\n  const cfIP = request.headers.get('CF-Connecting-IP')\n  if (cfIP) return cfIP\n\n  const xForwardedFor = request.headers.get('X-Forwarded-For')\n  if (xForwardedFor) {\n    return xForwardedFor.split(',')[0].trim()\n  }\n\n  const xRealIP = request.headers.get('X-Real-IP')\n  if (xRealIP) return xRealIP\n\n  return 'unknown'\n}\n\nexport function createRateLimiter(\n  getKey: (context: RateLimiterContext) => string,\n  limit: number,\n  windowSeconds: number\n): PagesFunction<Env> {\n  return async (context) => {\n    const key = getKey(context)\n    const result = await checkAndRecord(context.env.DB, key, limit, windowSeconds)\n\n    const headers: Record<string, string> = {\n      'X-RateLimit-Limit': String(limit),\n      'X-RateLimit-Remaining': String(result.remaining),\n      'X-RateLimit-Reset': String(Math.ceil(result.resetAt / 1000)),\n    }\n\n    if (!result.allowed) {\n      const retryAfter = result.retryAfter ?? 0\n      return new Response(\n        JSON.stringify({\n          code: 'RATE_LIMIT_EXCEEDED',\n          message: 'Too many requests. Please try again later.',\n          retry_after: retryAfter,\n        }),\n        {\n          status: 429,\n          headers: {\n            'Content-Type': 'application/json',\n            'Retry-After': String(retryAfter),\n            ...headers,\n          },\n        }\n      )\n    }\n\n    const response = await context.next()\n    Object.entries(headers).forEach(([k, v]) => response.headers.set(k, v))\n    return response\n  }\n}\n\nexport const loginRateLimiter = createRateLimiter(\n  (context) => {\n    const ip = getClientIP(context.request)\n    return `login:${ip}`\n  },\n  30,\n  60\n)\n\nexport const filterRateLimiter = createRateLimiter(\n  (context) => {\n    const userId = context.data?.user_id || getClientIP(context.request)\n    return `filter:${userId}`\n  },\n  600,\n  60\n)\n\n"
  },
  {
    "path": "tmarks/functions/lib/response.ts",
    "content": "import type { ApiResponse, ApiError } from './types'\r\n\r\nexport function success<T>(data: T, meta?: ApiResponse['meta']): Response {\r\n  const body: ApiResponse<T> = { data }\r\n  if (meta) {\r\n    body.meta = meta\r\n  }\r\n  return Response.json(body, { status: 200 })\r\n}\r\n\r\nexport function created<T>(data: T): Response {\r\n  return Response.json({ data } as ApiResponse<T>, { status: 201 })\r\n}\r\n\r\nexport function noContent(): Response {\r\n  return new Response(null, { status: 204 })\r\n}\r\n\r\nexport function badRequest(error: string | ApiError | Partial<ApiError>, code = 'BAD_REQUEST'): Response {\r\n  const errorObj: ApiError = typeof error === 'string'\r\n    ? { code, message: error }\r\n    : { code: error.code || code, message: error.message || 'Bad request', ...error }\r\n  return Response.json({ error: errorObj } as ApiResponse, { status: 400 })\r\n}\r\n\r\nexport function unauthorized(error: string | ApiError | Partial<ApiError>, code?: string): Response {\r\n  const errorObj: ApiError = typeof error === 'string'\r\n    ? { code: code || 'UNAUTHORIZED', message: error }\r\n    : { code: error.code || code || 'UNAUTHORIZED', message: error.message || 'Unauthorized', ...error }\r\n  return Response.json({ error: errorObj } as ApiResponse, { status: 401 })\r\n}\r\n\r\nexport function forbidden(error: string | ApiError | Partial<ApiError>, code?: string): Response {\r\n  const errorObj: ApiError = typeof error === 'string'\r\n    ? { code: code || 'FORBIDDEN', message: error }\r\n    : { code: error.code || code || 'FORBIDDEN', message: error.message || 'Forbidden', ...error }\r\n  return Response.json({ error: errorObj } as ApiResponse, { status: 403 })\r\n}\r\n\r\nexport function notFound(message = 'Not found', code = 'NOT_FOUND'): Response {\r\n  const error: ApiError = { code, message }\r\n  return Response.json({ error } as ApiResponse, { status: 404 })\r\n}\r\n\r\nexport function conflict(message: string, code = 'CONFLICT'): Response {\r\n  const error: ApiError = { code, message }\r\n  return Response.json({ error } as ApiResponse, { status: 409 })\r\n}\r\n\r\nexport function tooManyRequests(error: string | ApiError | Partial<ApiError>, headers?: Record<string, string>): Response {\r\n  const errorObj: ApiError = typeof error === 'string'\r\n    ? { code: 'RATE_LIMIT_EXCEEDED', message: error }\r\n    : { code: error.code || 'RATE_LIMIT_EXCEEDED', message: error.message || 'Too many requests', ...error }\r\n\r\n  const responseHeaders = new Headers({ 'Content-Type': 'application/json' })\r\n  if (headers) {\r\n    Object.entries(headers).forEach(([key, value]) => responseHeaders.set(key, value))\r\n  }\r\n\r\n  return new Response(JSON.stringify({ error: errorObj } as ApiResponse), {\r\n    status: 429,\r\n    headers: responseHeaders,\r\n  })\r\n}\r\n\r\nexport function internalError(message = 'Internal server error', code = 'INTERNAL_ERROR'): Response {\r\n  const error: ApiError = { code, message }\r\n  return Response.json({ error } as ApiResponse, { status: 500 })\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/signed-url.ts",
    "content": "/**\r\n * Signed URL Generator\r\n * Generates secure signed URLs for temporary resource access\r\n * Similar to AWS S3 Presigned URLs\r\n */\r\n\r\nexport interface SignedUrlParams {\r\n  userId: string\r\n  resourceId: string // Resource ID (e.g. snapshot ID)\r\n  expiresIn?: number // Expiration time (seconds), default 1 hour\r\n  action?: string // Action type (e.g. 'view', 'download')\r\n}\r\n\r\nexport interface SignedUrlData {\r\n  userId: string\r\n  resourceId: string\r\n  expires: number // Unix timestamp\r\n  action?: string\r\n}\r\n\r\n/**\r\n * Generate signed URL\r\n * @param params URL parameters\r\n * @param secret Secret key for signing\r\n * @returns Signature and expiration timestamp\r\n */\r\nexport async function generateSignedUrl(\r\n  params: SignedUrlParams,\r\n  secret: string\r\n): Promise<{ signature: string; expires: number }> {\r\n  const now = Math.floor(Date.now() / 1000)\r\n  const expires = now + (params.expiresIn || 3600) // Default 1 hour\r\n\r\n  const data: SignedUrlData = {\r\n    userId: params.userId,\r\n    resourceId: params.resourceId,\r\n    expires,\r\n    action: params.action,\r\n  }\r\n\r\n  // Generate signature\r\n  const message = `${data.userId}:${data.resourceId}:${data.expires}:${data.action || ''}`\r\n  const signature = await sign(message, secret)\r\n\r\n  return { signature, expires }\r\n}\r\n\r\n/**\r\n * Verify signed URL\r\n * @param signature Signature string\r\n * @param expires Expiration timestamp\r\n * @param userId User ID\r\n * @param resourceId Resource ID\r\n * @param action Action type\r\n * @param secret Secret key for verification\r\n * @returns Validation result\r\n */\r\nexport async function verifySignedUrl(\r\n  signature: string,\r\n  expires: number,\r\n  userId: string,\r\n  resourceId: string,\r\n  secret: string,\r\n  action?: string\r\n): Promise<{ valid: boolean; error?: string }> {\r\n  // Check expiration\r\n  const now = Math.floor(Date.now() / 1000)\r\n  if (expires < now) {\r\n    return { valid: false, error: 'URL has expired' }\r\n  }\r\n\r\n  // Verify signature\r\n  const message = `${userId}:${resourceId}:${expires}:${action || ''}`\r\n  const expectedSignature = await sign(message, secret)\r\n\r\n  if (!timingSafeEqual(signature, expectedSignature)) {\r\n    return { valid: false, error: 'Invalid signature' }\r\n  }\r\n\r\n  return { valid: true }\r\n}\r\n\r\n/**\r\n * Extract signed URL parameters from request\r\n */\r\nexport function extractSignedParams(request: Request): {\r\n  signature: string | null\r\n  expires: number | null\r\n  userId: string | null\r\n  action: string | null\r\n} {\r\n  try {\r\n    const url = new URL(request.url)\r\n    const signature = url.searchParams.get('sig') || url.searchParams.get('signature')\r\n    const expiresStr = url.searchParams.get('exp') || url.searchParams.get('expires')\r\n    const userId = url.searchParams.get('u') || url.searchParams.get('user')\r\n    const action = url.searchParams.get('a') || url.searchParams.get('action')\r\n\r\n    return {\r\n      signature,\r\n      expires: expiresStr ? parseInt(expiresStr, 10) : null,\r\n      userId,\r\n      action,\r\n    }\r\n  } catch {\r\n    return {\r\n      signature: null,\r\n      expires: null,\r\n      userId: null,\r\n      action: null,\r\n    }\r\n  }\r\n}\r\n\r\n/**\r\n * Generate HMAC-SHA256 signature\r\n */\r\nfunction timingSafeEqual(a: string, b: string): boolean {\r\n  if (a.length !== b.length) return false\r\n  const encoder = new TextEncoder()\r\n  const ab = encoder.encode(a)\r\n  const bb = encoder.encode(b)\r\n  let result = 0\r\n  for (let i = 0; i < ab.length; i++) {\r\n    result |= ab[i] ^ bb[i]\r\n  }\r\n  return result === 0\r\n}\r\n\r\nasync function sign(message: string, secret: string): Promise<string> {\r\n  const encoder = new TextEncoder()\r\n  const keyData = encoder.encode(secret)\r\n  const messageData = encoder.encode(message)\r\n\r\n  const key = await crypto.subtle.importKey(\r\n    'raw',\r\n    keyData,\r\n    { name: 'HMAC', hash: 'SHA-256' },\r\n    false,\r\n    ['sign']\r\n  )\r\n\r\n  const signature = await crypto.subtle.sign('HMAC', key, messageData)\r\n  \r\n  // Convert to hex string (lowercase)\r\n  return Array.from(new Uint8Array(signature))\r\n    .map(b => b.toString(16).padStart(2, '0'))\r\n    .join('')\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/storage-quota.ts",
    "content": "import type { Env } from './types'\r\nimport type { D1Database } from '@cloudflare/workers-types'\r\n\r\n/**\r\n * R2 Storage Quota Management\r\n *\r\n * Calculation method: R2 total storage (snapshots + images)\r\n * Data source: Query D1 database bookmark_snapshots.file_size and bookmark_images.file_size\r\n */\r\n\r\n// Note: If not configured or <= 0, means \"unlimited\"\r\ntype UsageRow = {\r\n  total: number | null\r\n}\r\n\r\n/**\r\n * Get R2 storage quota limit (bytes)\r\n *\r\n * Rules:\r\n * - Not configured/empty: \"unlimited\"\r\n * - Invalid format: \"unlimited\"\r\n * - <= 0: \"unlimited\"\r\n * - > 0: Use configured value\r\n */\r\nexport function getR2MaxTotalBytes(env: Env): number {\r\n  const raw = env.R2_MAX_TOTAL_BYTES\r\n\r\n  if (!raw || raw.trim() === '') {\r\n    return Number.POSITIVE_INFINITY\r\n  }\r\n\r\n  const parsed = Number(raw)\r\n\r\n  if (!Number.isFinite(parsed)) {\r\n    console.warn('[StorageQuota] Invalid R2_MAX_TOTAL_BYTES, treating as unlimited', raw)\r\n    return Number.POSITIVE_INFINITY\r\n  }\r\n\r\n  if (parsed <= 0) {\r\n    return Number.POSITIVE_INFINITY\r\n  }\r\n\r\n  return parsed\r\n}\r\n\r\n/**\r\n * Get current R2 storage usage (bytes)\r\n *\r\n * Data sources:\r\n * - bookmark_snapshots.file_size: Snapshot HTML + images (V2 format)\r\n * - bookmark_images.file_size: Cover images (deduplicated by image_hash)\r\n */\r\nexport async function getCurrentR2UsageBytes(db: D1Database): Promise<number> {\r\n  const snapshotRow = await db\r\n    .prepare('SELECT COALESCE(SUM(file_size), 0) AS total FROM bookmark_snapshots')\r\n    .first<UsageRow>()\r\n\r\n  const snapshotsTotal = snapshotRow?.total ?? 0\r\n\r\n  let imagesTotal = 0\r\n  try {\r\n    const imageRow = await db\r\n      .prepare('SELECT COALESCE(SUM(file_size), 0) AS total FROM bookmark_images')\r\n      .first<UsageRow>()\r\n\r\n    imagesTotal = imageRow?.total ?? 0\r\n  } catch (error) {\r\n    console.warn('[StorageQuota] Failed to query bookmark_images usage', error)\r\n  }\r\n\r\n  return snapshotsTotal + imagesTotal\r\n}\r\n\r\nexport interface R2QuotaCheckResult {\r\n  allowed: boolean\r\n  limitBytes: number\r\n  usedBytes: number\r\n}\r\n\r\n/**\r\n * Check if adding additionalBytes would exceed quota\r\n */\r\nexport async function checkR2Quota(\r\n  db: D1Database,\r\n  env: Env,\r\n  additionalBytes: number\r\n): Promise<R2QuotaCheckResult> {\r\n  const limitBytes = getR2MaxTotalBytes(env)\r\n\r\n  // Optimization: If unlimited, skip D1 query\r\n  if (!Number.isFinite(limitBytes)) {\r\n    return { allowed: true, limitBytes, usedBytes: 0 }\r\n  }\r\n\r\n  const usedBytes = await getCurrentR2UsageBytes(db)\r\n  const willUse = usedBytes + Math.max(0, additionalBytes)\r\n\r\n  if (willUse > limitBytes) {\r\n    return { allowed: false, limitBytes, usedBytes }\r\n  }\r\n\r\n  return { allowed: true, limitBytes, usedBytes }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/lib/tags.ts",
    "content": "import { generateUUID } from './crypto'\n\nfunction normalizeTagNames(tagNames: string[]): string[] {\n  const seen = new Set<string>()\n  const normalized: string[] = []\n\n  for (const rawName of tagNames) {\n    const name = rawName.trim()\n    if (!name) continue\n\n    const key = name.toLowerCase()\n    if (seen.has(key)) continue\n\n    seen.add(key)\n    normalized.push(name)\n  }\n\n  return normalized\n}\n\nfunction uniqueTagIds(tagIds: string[]): string[] {\n  const seen = new Set<string>()\n  const uniqueIds: string[] = []\n\n  for (const rawId of tagIds) {\n    const tagId = rawId.trim()\n    if (!tagId || seen.has(tagId)) continue\n\n    seen.add(tagId)\n    uniqueIds.push(tagId)\n  }\n\n  return uniqueIds\n}\n\nexport async function getValidTagIds(\n  db: D1Database,\n  userId: string,\n  tagIds: string[]\n): Promise<string[]> {\n  const requestedIds = uniqueTagIds(tagIds)\n  if (requestedIds.length === 0) return []\n\n  const placeholders = requestedIds.map(() => '?').join(',')\n  const { results } = await db.prepare(\n    `SELECT id\n     FROM tags\n     WHERE id IN (${placeholders}) AND user_id = ? AND deleted_at IS NULL`\n  )\n    .bind(...requestedIds, userId)\n    .all<{ id: string }>()\n\n  const validIds = new Set((results || []).map((row) => row.id))\n  return requestedIds.filter((tagId) => validIds.has(tagId))\n}\n\nexport async function resolveOrCreateTagIds(\n  db: D1Database,\n  userId: string,\n  tagNames: string[],\n  now: string = new Date().toISOString()\n): Promise<string[]> {\n  const normalizedNames = normalizeTagNames(tagNames)\n  if (normalizedNames.length === 0) return []\n\n  const placeholders = normalizedNames.map(() => '?').join(',')\n  const { results: existingTags } = await db.prepare(\n    `SELECT id, name\n     FROM tags\n     WHERE user_id = ? AND LOWER(name) IN (${placeholders}) AND deleted_at IS NULL`\n  )\n    .bind(userId, ...normalizedNames.map((name) => name.toLowerCase()))\n    .all<{ id: string; name: string }>()\n\n  const tagMap = new Map<string, string>()\n  for (const tag of existingTags || []) {\n    tagMap.set(tag.name.toLowerCase(), tag.id)\n  }\n\n  const tagsToCreate = normalizedNames.filter((name) => !tagMap.has(name.toLowerCase()))\n  if (tagsToCreate.length > 0) {\n    const insertStatements = tagsToCreate.map((name) => {\n      const tagId = generateUUID()\n      tagMap.set(name.toLowerCase(), tagId)\n      return db\n        .prepare('INSERT INTO tags (id, user_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')\n        .bind(tagId, userId, name, now, now)\n    })\n    await db.batch(insertStatements)\n  }\n\n  return normalizedNames\n    .map((name) => tagMap.get(name.toLowerCase()))\n    .filter((tagId): tagId is string => Boolean(tagId))\n}\n\nexport async function createOrLinkTags(\n  db: D1Database,\n  bookmarkId: string,\n  tagNames: string[],\n  userId: string\n): Promise<void> {\n  const now = new Date().toISOString()\n  const tagIds = await resolveOrCreateTagIds(db, userId, tagNames, now)\n  if (tagIds.length === 0) return\n\n  const linkStatements = tagIds.map((tagId) =>\n    db\n      .prepare('INSERT OR IGNORE INTO bookmark_tags (bookmark_id, tag_id, user_id, created_at) VALUES (?, ?, ?, ?)')\n      .bind(bookmarkId, tagId, userId, now)\n  )\n  await db.batch(linkStatements)\n}\n\nexport async function replaceBookmarkTags(\n  db: D1Database,\n  bookmarkId: string,\n  userId: string,\n  tagIds: string[],\n  now: string = new Date().toISOString()\n): Promise<void> {\n  const normalizedIds = uniqueTagIds(tagIds)\n  const statements: D1PreparedStatement[] = [\n    db.prepare('DELETE FROM bookmark_tags WHERE bookmark_id = ? AND user_id = ?')\n      .bind(bookmarkId, userId),\n  ]\n\n  for (const tagId of normalizedIds) {\n    statements.push(\n      db\n        .prepare('INSERT OR IGNORE INTO bookmark_tags (bookmark_id, tag_id, user_id, created_at) VALUES (?, ?, ?, ?)')\n        .bind(bookmarkId, tagId, userId, now)\n    )\n  }\n\n  await db.batch(statements)\n}\n\nexport async function replaceBookmarkTagsByNames(\n  db: D1Database,\n  bookmarkId: string,\n  tagNames: string[],\n  userId: string,\n  now: string = new Date().toISOString()\n): Promise<void> {\n  const tagIds = await resolveOrCreateTagIds(db, userId, tagNames, now)\n  await replaceBookmarkTags(db, bookmarkId, userId, tagIds, now)\n}\n"
  },
  {
    "path": "tmarks/functions/lib/types.ts",
    "content": "export interface Env {\r\n  DB: D1Database\r\n  // TMARKS_KV?: KVNamespace // Unified cache (public sharing, rate limiting, etc.) - Removed\r\n  SNAPSHOTS_BUCKET?: R2Bucket // R2 bucket for bookmark snapshots\r\n  R2_PUBLIC_URL?: string // (Optional) Public URL for R2 storage for cover images (e.g. https://r2.example.com)\r\n  R2_MAX_TOTAL_BYTES?: string // R2 total storage quota (bytes), optional; not configured or <= 0 means unlimited\r\n  CORS_ALLOWED_ORIGINS?: string // CORS allowed origins list (comma-separated, e.g. https://example.com,https://app.example.com)\r\n  ALLOW_REGISTRATION?: string\r\n  JWT_SECRET: string\r\n  ENCRYPTION_KEY: string\r\n  ENVIRONMENT?: string // 'development' | 'production'\r\n  JWT_ACCESS_TOKEN_EXPIRES_IN?: string\r\n  JWT_REFRESH_TOKEN_EXPIRES_IN?: string\r\n  CACHE_LEVEL?: string // '0' | '1' | '2' | '3' | 'none' | 'minimal' | 'standard' | 'aggressive'\r\n  ENABLE_KV_CACHE?: string // 'true' | 'false'\r\n  CACHE_TTL_DEFAULT_LIST?: string\r\n  CACHE_TTL_TAG_FILTER?: string\r\n  CACHE_TTL_SEARCH?: string\r\n  CACHE_TTL_PUBLIC_SHARE?: string\r\n  ENABLE_MEMORY_CACHE?: string // 'true' | 'false'\r\n  MEMORY_CACHE_MAX_AGE?: string\r\n  CACHE_DEBUG?: string // 'true' | 'false'\r\n}\r\nexport interface User {\r\n  id: string\r\n  username: string\r\n  email: string | null\r\n  password_hash: string\r\n  created_at: string\r\n  updated_at: string\r\n}\r\nexport interface Bookmark {\r\n  id: string\r\n  user_id: string\r\n  title: string\r\n  url: string\r\n  description: string | null\r\n  cover_image: string | null\r\n  favicon: string | null\r\n  is_pinned: boolean\r\n  is_archived: boolean\r\n  is_public: boolean\r\n  click_count: number\r\n  last_clicked_at: string | null\r\n  has_snapshot?: boolean\r\n  latest_snapshot_at?: string | null\r\n  snapshot_count?: number\r\n  created_at: string\r\n  updated_at: string\r\n  deleted_at: string | null\r\n}\r\nexport interface BookmarkRow extends Omit<Bookmark, 'is_pinned' | 'is_archived' | 'is_public'> {\r\n  is_pinned: number | boolean\r\n  is_archived: number | boolean\r\n  is_public: number | boolean\r\n}\r\nexport interface PublicProfile {\r\n  user_id: string\r\n  public_share_enabled: boolean\r\n  public_slug: string | null\r\n  public_page_title: string | null\r\n  public_page_description: string | null\r\n  username: string\r\n}\r\nexport interface Tag {\r\n  id: string\r\n  user_id: string\r\n  name: string\r\n  color: string | null\r\n  click_count: number\r\n  last_clicked_at: string | null\r\n  created_at: string\r\n  updated_at: string\r\n  deleted_at: string | null\r\n}\r\nexport interface ApiError {\r\n  code: string\r\n  message: string\r\n  details?: unknown\r\n}\r\nexport interface ApiResponse<T = unknown> {\r\n  data?: T\r\n  error?: ApiError\r\n  meta?: {\r\n    page?: number\r\n    page_size?: number\r\n    total?: number\r\n    next_cursor?: string\r\n  }\r\n}\r\nexport type RouteParams = Record<string, string>\r\nexport type SQLParam = string | number | boolean | null\r\n"
  },
  {
    "path": "tmarks/functions/lib/utils.ts",
    "content": "export function generateSlug(): string {\n  const uuid = crypto.randomUUID().replace(/-/g, '')\n  return uuid.slice(0, 10)\n}\n"
  },
  {
    "path": "tmarks/functions/lib/validation.ts",
    "content": "export function isValidEmail(email: string): boolean {\r\n  if (email.length > 254) return false\r\n  const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/\r\n  return emailRegex.test(email)\r\n}\r\n\r\nexport function isValidUrl(url: string): boolean {\r\n  try {\r\n    const parsed = new URL(url)\r\n    return parsed.protocol === 'http:' || parsed.protocol === 'https:'\r\n  } catch {\r\n    return false\r\n  }\r\n}\r\n\r\nexport function isValidUsername(username: string): boolean {\r\n  // 3-20 ，、、\r\n  const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/\r\n  return usernameRegex.test(username)\r\n}\r\n\r\nexport function isValidPassword(password: string): boolean {\r\n  //  8 ，\r\n  return password.length >= 8 && /[a-zA-Z]/.test(password) && /[0-9]/.test(password)\r\n}\r\n\r\nexport function sanitizeString(str: string, maxLength = 1000): string {\r\n  return str.trim().slice(0, maxLength)\r\n}\r\n\r\n/**\r\n * Escape HTML special characters to prevent XSS\r\n */\r\nexport function escapeHtml(str: string): string {\r\n  return str\r\n    .replace(/&/g, '&amp;')\r\n    .replace(/</g, '&lt;')\r\n    .replace(/>/g, '&gt;')\r\n    .replace(/\"/g, '&quot;')\r\n    .replace(/'/g, '&#39;')\r\n}\r\n\r\n/**\r\n * Sanitize and escape a string for safe HTML output\r\n */\r\nexport function sanitizeForHtml(str: string, maxLength = 1000): string {\r\n  return escapeHtml(str.trim().slice(0, maxLength))\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/middleware/api-key-auth-pages.ts",
    "content": "/**\r\n * API Key Authentication Middleware for Cloudflare Pages Functions\r\n * Validates API Key and checks permissions for Pages Functions routes\r\n */\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../lib/types'\r\nimport { validateApiKey } from '../lib/api-key/validator'\r\nimport { consumeRateLimit } from '../lib/api-key/rate-limiter'\r\nimport { logApiKeyUsage } from '../lib/api-key/logger'\r\nimport { unauthorized, forbidden, tooManyRequests } from '../lib/response'\r\nimport { hasPermission } from '../../shared/permissions'\r\nexport interface ApiKeyAuthContext extends Record<string, unknown> {\r\n  user_id: string\r\n  api_key_id: string\r\n  api_key_permissions: string[]\r\n}\r\n/**\r\n * Create API Key authentication middleware\r\n * @param requiredPermission Required permission string\r\n */\r\nexport function requireApiKeyAuth(\r\n  requiredPermission: string\r\n): PagesFunction<Env, RouteParams, ApiKeyAuthContext> {\r\n  return async (context) => {\r\n    const request = context.request\r\n    try {\r\n      // 1. Extract API Key\r\n      const apiKey = request.headers.get('X-API-Key')\r\n      if (!apiKey) {\r\n        return unauthorized({\r\n          code: 'MISSING_API_KEY',\r\n          message: 'API Key is required. Please provide X-API-Key header.',\r\n        })\r\n      }\r\n      // 2. Validate API Key\r\n      const validation = await validateApiKey(apiKey, context.env.DB)\r\n      if (!validation.valid || !validation.data || !validation.permissions) {\r\n        return unauthorized({\r\n          code: 'INVALID_API_KEY',\r\n          message: validation.error || 'Invalid API Key',\r\n        })\r\n      }\r\n      const { data: keyData, permissions } = validation\r\n      // 3. Check permissions\r\n      if (!hasPermission(permissions, requiredPermission)) {\r\n        return forbidden({\r\n          code: 'INSUFFICIENT_PERMISSIONS',\r\n          message: `Missing required permission: ${requiredPermission}`,\r\n          required: requiredPermission,\r\n          available: permissions,\r\n        })\r\n      }\r\n      // 4. Rate limiting (D1 based)\r\n      const rateLimitResult = await consumeRateLimit(keyData.id, context.env.DB)\r\n      const rateLimitHeaders: Record<string, string> = {\r\n        'X-RateLimit-Limit': String(rateLimitResult.limit),\r\n        'X-RateLimit-Remaining': String(rateLimitResult.remaining),\r\n        'X-RateLimit-Reset': String(Math.ceil(rateLimitResult.reset / 1000)),\r\n      }\r\n      if (!rateLimitResult.allowed) {\r\n        const retryAfter = rateLimitResult.retryAfter || 0\r\n        return tooManyRequests(\r\n          {\r\n            code: 'RATE_LIMIT_EXCEEDED',\r\n            message: 'Too many requests. Please try again later.',\r\n          },\r\n          {\r\n            'Retry-After': String(retryAfter),\r\n            ...rateLimitHeaders,\r\n          }\r\n        )\r\n      }\r\n      // 5. Get request IP\r\n      const ip =\r\n        request.headers.get('CF-Connecting-IP') ||\r\n        request.headers.get('X-Forwarded-For') ||\r\n        null\r\n      // 6. Pass user info to context.data (for downstream handlers)\r\n      context.data.user_id = keyData.user_id\r\n      context.data.api_key_id = keyData.id\r\n      context.data.api_key_permissions = permissions\r\n      // 8. Update last used info (async, non-blocking)\r\n      context.waitUntil(\r\n        (async () => {\r\n          try {\r\n            await context.env.DB.prepare(\r\n              `UPDATE api_keys SET last_used_at = datetime('now'), last_used_ip = ? WHERE id = ?`\r\n            )\r\n              .bind(ip, keyData.id)\r\n              .run()\r\n          } catch (error) {\r\n            console.error('Failed to update last_used:', error)\r\n          }\r\n        })()\r\n      )\r\n      // 9. Log API usage (async, non-blocking)\r\n      context.waitUntil(\r\n        (async () => {\r\n          try {\r\n            await logApiKeyUsage(\r\n              {\r\n                api_key_id: keyData.id,\r\n                user_id: keyData.user_id,\r\n                endpoint: new URL(request.url).pathname,\r\n                method: request.method,\r\n                status: 200, // Default status, actual status logged after response\r\n                ip,\r\n              },\r\n              context.env.DB\r\n            )\r\n          } catch (error) {\r\n            console.error('Failed to log API usage:', error)\r\n          }\r\n        })()\r\n      )\r\n      // 10. Continue to next handler (may return undefined, next() handles it)\r\n      // Note: Pages Functions middleware can return undefined\r\n      const response = await context.next()\r\n      const headers = new Headers(response.headers)\r\n      Object.entries(rateLimitHeaders).forEach(([k, v]) => headers.set(k, v))\r\n      return new Response(response.body, {\r\n        status: response.status,\r\n        statusText: response.statusText,\r\n        headers,\r\n      })\r\n    } catch (error) {\r\n      console.error('API Key auth middleware error:', error)\r\n      return unauthorized({\r\n        code: 'AUTH_ERROR',\r\n        message: 'Authentication failed',\r\n      })\r\n    }\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/middleware/api-key-auth.ts",
    "content": "/**\r\n * API Key Authentication Middleware\r\n * Validates API Key and checks permissions\r\n */\r\n\r\nimport { Context } from 'hono'\r\nimport { validateApiKey, checkPermission, updateLastUsed } from '../lib/api-key/validator'\r\nimport { consumeRateLimit } from '../lib/api-key/rate-limiter'\r\nimport { logApiKeyUsage } from '../lib/api-key/logger'\r\n\r\ninterface ApiKeyAuthOptions {\r\n  requiredPermission: string\r\n}\r\n\r\n/**\r\n * Create API Key authentication middleware\r\n * @param options Configuration options\r\n * @returns Middleware function\r\n */\r\nexport function requireApiKey(options: ApiKeyAuthOptions) {\r\n  return async (c: Context, next: () => Promise<void>) => {\r\n    const { requiredPermission } = options\r\n\r\n    // 1. Extract API Key\r\n    const apiKey = c.req.header('X-API-Key')\r\n\r\n    if (!apiKey) {\r\n      return c.json(\r\n        {\r\n          error: {\r\n            code: 'MISSING_API_KEY',\r\n            message: 'API Key is required. Please provide X-API-Key header.',\r\n          },\r\n        },\r\n        401\r\n      )\r\n    }\r\n\r\n    // 2. Validate API Key\r\n    const validation = await validateApiKey(apiKey, c.env.DB)\r\n\r\n    if (!validation.valid || !validation.data || !validation.permissions) {\r\n      return c.json(\r\n        {\r\n          error: {\r\n            code: 'INVALID_API_KEY',\r\n            message: validation.error || 'Invalid API Key',\r\n          },\r\n        },\r\n        401\r\n      )\r\n    }\r\n\r\n    const { data: keyData, permissions } = validation\r\n\r\n    // 3. Check permissions\r\n    if (!checkPermission(permissions, requiredPermission)) {\r\n      return c.json(\r\n        {\r\n          error: {\r\n            code: 'INSUFFICIENT_PERMISSIONS',\r\n            message: `Missing required permission: ${requiredPermission}`,\r\n            required: requiredPermission,\r\n            available: permissions,\r\n          },\r\n        },\r\n        403\r\n      )\r\n    }\r\n\r\n    // 4. Rate limiting (D1 based)\r\n    const rateLimitResult = await consumeRateLimit(keyData.id, c.env.DB)\r\n    if (!rateLimitResult.allowed) {\r\n      return c.json(\r\n        {\r\n          error: {\r\n            code: 'RATE_LIMIT_EXCEEDED',\r\n            message: 'Too many requests. Please try again later.',\r\n            retry_after: rateLimitResult.retryAfter || 0,\r\n          },\r\n        },\r\n        429,\r\n        {\r\n          'Retry-After': String(rateLimitResult.retryAfter || 0),\r\n          'X-RateLimit-Limit': String(rateLimitResult.limit),\r\n          'X-RateLimit-Remaining': String(rateLimitResult.remaining),\r\n          'X-RateLimit-Reset': String(Math.ceil(rateLimitResult.reset / 1000)),\r\n        }\r\n      )\r\n    }\r\n\r\n    // 5. Get request IP\r\n    const ip = c.req.header('CF-Connecting-IP') || c.req.header('X-Forwarded-For') || null\r\n\r\n    // 6. Update last used timestamp\r\n    await updateLastUsed(keyData.id, ip, c.env.DB)\r\n\r\n    // 7. Set context variables\r\n    c.set('user_id', keyData.user_id)\r\n    c.set('api_key_id', keyData.id)\r\n    c.set('api_key_permissions', permissions)\r\n\r\n    // 8. Continue to next handler\r\n    await next()\r\n\r\n    // 9. Log API usage\r\n    const status = c.res.status\r\n    const endpoint = c.req.path\r\n    const method = c.req.method\r\n\r\n    await logApiKeyUsage(\r\n      {\r\n        api_key_id: keyData.id,\r\n        user_id: keyData.user_id,\r\n        endpoint,\r\n        method,\r\n        status,\r\n        ip,\r\n      },\r\n      c.env.DB\r\n    )\r\n  }\r\n}\r\n\r\n/**\r\n * Optional API Key authentication middleware\r\n * Validates API Key if present, continues without auth if not provided\r\n */\r\nexport function optionalApiKey() {\r\n  return async (c: Context, next: () => Promise<void>) => {\r\n    const apiKey = c.req.header('X-API-Key')\r\n\r\n    if (apiKey) {\r\n      const validation = await validateApiKey(apiKey, c.env.DB)\r\n\r\n      if (validation.valid && validation.data && validation.permissions) {\r\n        c.set('user_id', validation.data.user_id)\r\n        c.set('api_key_id', validation.data.id)\r\n        c.set('api_key_permissions', validation.permissions)\r\n      }\r\n    }\r\n\r\n    await next()\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/middleware/auth.ts",
    "content": "import type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../lib/types'\r\nimport { extractJWT, verifyJWT } from '../lib/jwt'\r\nimport { unauthorized } from '../lib/response'\r\n\r\nexport interface AuthContext extends Record<string, unknown> {\r\n  user_id: string\r\n  session_id?: string\r\n}\r\n\r\n/**\r\n * Authentication Middleware - Requires valid JWT token\r\n */\r\nexport const requireAuth: PagesFunction<Env, RouteParams, AuthContext> = async (context) => {\r\n  const token = extractJWT(context.request)\r\n\r\n  if (!token) {\r\n    return unauthorized('Missing authorization token')\r\n  }\r\n\r\n  try {\r\n    const payload = await verifyJWT(token, context.env.JWT_SECRET)\r\n\r\n    // Pass user info to context.data\r\n    context.data.user_id = payload.sub\r\n    context.data.session_id = payload.session_id\r\n\r\n    return context.next()\r\n  } catch (error) {\r\n    const message = error instanceof Error ? error.message : 'Invalid token'\r\n    return unauthorized(message)\r\n  }\r\n}\r\n\r\n/**\r\n * Optional Authentication Middleware - Validates token if present, continues if not\r\n */\r\nexport const optionalAuth: PagesFunction<Env, RouteParams, Partial<AuthContext>> = async (context) => {\r\n  const token = extractJWT(context.request)\r\n\r\n  if (token) {\r\n    try {\r\n      const payload = await verifyJWT(token, context.env.JWT_SECRET)\r\n      context.data.user_id = payload.sub\r\n      context.data.session_id = payload.session_id\r\n    } catch {\r\n      // Ignore invalid token, continue without auth\r\n    }\r\n  }\r\n\r\n  return context.next()\r\n}\r\n"
  },
  {
    "path": "tmarks/functions/middleware/dual-auth.ts",
    "content": "/**\r\n * Dual Authentication Middleware\r\n * Supports both JWT Token and API Key authentication methods\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\nimport type { Env, RouteParams } from '../lib/types'\r\nimport { validateApiKey } from '../lib/api-key/validator'\r\nimport { consumeRateLimit } from '../lib/api-key/rate-limiter'\r\nimport { hasPermission } from '../../shared/permissions'\r\nimport { unauthorized, forbidden, tooManyRequests } from '../lib/response'\r\nimport { verifyJWT } from '../lib/jwt'\r\n\r\nexport interface DualAuthContext {\r\n  user_id: string\r\n  auth_type: 'jwt' | 'api_key'\r\n  api_key_id?: string\r\n  api_key_permissions?: string[]\r\n}\r\n\r\n/**\r\n * Create dual authentication middleware\r\n * @param requiredPermission Required permission (only for API Key authentication)\r\n */\r\nexport function requireDualAuth(\r\n  requiredPermission: string\r\n): PagesFunction<Env, RouteParams, DualAuthContext> {\r\n  return async (context) => {\r\n    const request = context.request\r\n\r\n    try {\r\n      // 1. Check for API Key\r\n      const apiKey = request.headers.get('X-API-Key')\r\n      \r\n      if (apiKey) {\r\n        // API Key authentication flow\r\n        const validation = await validateApiKey(apiKey, context.env.DB)\r\n\r\n        if (!validation.valid || !validation.data || !validation.permissions) {\r\n          return unauthorized({\r\n            code: 'INVALID_API_KEY',\r\n            message: validation.error || 'Invalid API Key',\r\n          })\r\n        }\r\n\r\n        const { data: keyData, permissions } = validation\r\n\r\n        // Check permissions\r\n        if (!hasPermission(permissions, requiredPermission)) {\r\n          return forbidden({\r\n            code: 'INSUFFICIENT_PERMISSIONS',\r\n            message: `Missing required permission: ${requiredPermission}`,\r\n            required: requiredPermission,\r\n            available: permissions,\r\n          })\r\n        }\r\n\r\n        // Rate limiting (D1)\r\n        const rateLimitResult = await consumeRateLimit(keyData.id, context.env.DB)\r\n        if (!rateLimitResult.allowed) {\r\n          const headers: Record<string, string> = {\r\n            'Retry-After': String(rateLimitResult.retryAfter || 0),\r\n            'X-RateLimit-Limit': String(rateLimitResult.limit),\r\n            'X-RateLimit-Remaining': String(rateLimitResult.remaining),\r\n            'X-RateLimit-Reset': String(Math.ceil(rateLimitResult.reset / 1000)),\r\n          }\r\n          return tooManyRequests(\r\n            {\r\n              code: 'RATE_LIMIT_EXCEEDED',\r\n              message: 'Too many requests. Please try again later.',\r\n            },\r\n            headers\r\n          )\r\n        }\r\n\r\n        // Get request IP\r\n        const ip =\r\n          request.headers.get('CF-Connecting-IP') ||\r\n          request.headers.get('X-Forwarded-For') ||\r\n          null\r\n\r\n        // Pass user info to context.data\r\n        context.data.user_id = keyData.user_id\r\n        context.data.auth_type = 'api_key'\r\n        context.data.api_key_id = keyData.id\r\n        context.data.api_key_permissions = permissions\r\n\r\n        // Update last used info (async)\r\n        context.waitUntil(\r\n          (async () => {\r\n            try {\r\n              await context.env.DB.prepare(\r\n                `UPDATE api_keys SET last_used_at = datetime('now'), last_used_ip = ? WHERE id = ?`\r\n              )\r\n                .bind(ip, keyData.id)\r\n                .run()\r\n            } catch (error) {\r\n              console.error('Failed to update last_used:', error)\r\n            }\r\n          })()\r\n        )\r\n\r\n        return context.next()\r\n      }\r\n\r\n      // 2. Check for JWT Token\r\n      const authHeader = request.headers.get('Authorization')\r\n      \r\n      if (authHeader && authHeader.startsWith('Bearer ')) {\r\n        const token = authHeader.substring(7)\r\n\r\n        try {\r\n          const payload = await verifyJWT(token, context.env.JWT_SECRET)\r\n\r\n          if (!payload || !payload.sub) {\r\n            return unauthorized({\r\n              code: 'INVALID_TOKEN',\r\n              message: 'Invalid or expired token',\r\n            })\r\n          }\r\n\r\n          // Pass user info to context.data\r\n          context.data.user_id = payload.sub\r\n          context.data.auth_type = 'jwt'\r\n\r\n          return context.next()\r\n        } catch {\r\n          return unauthorized({\r\n            code: 'INVALID_TOKEN',\r\n            message: 'Invalid or expired token',\r\n          })\r\n        }\r\n      }\r\n\r\n      // 3. No authentication provided\r\n      return unauthorized({\r\n        code: 'MISSING_AUTH',\r\n        message: 'Authentication required. Provide either X-API-Key header or Bearer token.',\r\n      })\r\n    } catch (error) {\r\n      console.error('Dual auth middleware error:', error)\r\n      return unauthorized({\r\n        code: 'AUTH_ERROR',\r\n        message: 'Authentication failed',\r\n      })\r\n    }\r\n  }\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/functions/middleware/index.ts",
    "content": "// ============ Middleware Exports ============\r\n\r\nexport * from './api-key-auth';\r\nexport * from './api-key-auth-pages';\r\nexport * from './auth';\r\nexport * from './dual-auth';\r\nexport * from './security';\r\n"
  },
  {
    "path": "tmarks/functions/middleware/security.ts",
    "content": "/**\r\n * Security Middleware\r\n * Provides security headers, CSP policies, input validation, and other security features\r\n */\r\n\r\nimport type { PagesFunction } from '@cloudflare/workers-types'\r\n\r\n/**\r\n * Security Headers Middleware\r\n */\r\nexport const securityHeaders: PagesFunction = async (context) => {\r\n  const response = await context.next()\r\n  \r\n  // Create new response headers\r\n  const newHeaders = new Headers(response.headers)\r\n  \r\n  // Check if this is a snapshot view path (these paths need relaxed CSP)\r\n  const url = new URL(context.request.url)\r\n  const isSnapshotView = url.pathname.includes('/snapshots/') && \r\n                         (url.pathname.includes('/view') || url.searchParams.has('sig'))\r\n  \r\n  const standardCsp = [\r\n    \"default-src 'self'\",\r\n    \"script-src 'self' 'unsafe-inline'\",\r\n    \"style-src 'self' 'unsafe-inline'\",\r\n    \"img-src 'self' data: https:\",\r\n    \"font-src 'self' data:\",\r\n    \"connect-src 'self' https:\",\r\n    \"object-src 'none'\",\r\n    \"frame-ancestors 'none'\",\r\n    \"base-uri 'self'\",\r\n    \"form-action 'self'\",\r\n  ].join('; ')\r\n\r\n  // Snapshot view should not execute scripts from captured HTML.\r\n  const snapshotCsp = [\r\n    \"default-src 'none'\",\r\n    \"script-src 'none'\",\r\n    \"style-src 'self' 'unsafe-inline'\",\r\n    \"img-src 'self' data: https:\",\r\n    \"font-src 'self' data:\",\r\n    \"connect-src 'none'\",\r\n    \"media-src 'self' data: https:\",\r\n    \"object-src 'none'\",\r\n    \"frame-ancestors 'none'\",\r\n    \"base-uri 'none'\",\r\n    \"form-action 'none'\",\r\n  ].join('; ')\r\n\r\n  // Security headers configuration\r\n  const securityHeaders = {\r\n    // Prevent clickjacking (except for snapshot views)\r\n    ...(!isSnapshotView && { 'X-Frame-Options': 'DENY' }),\r\n    \r\n    // Prevent MIME type sniffing\r\n    'X-Content-Type-Options': 'nosniff',\r\n    \r\n    // XSS protection\r\n    'X-XSS-Protection': '1; mode=block',\r\n    \r\n    // Referrer policy\r\n    'Referrer-Policy': 'strict-origin-when-cross-origin',\r\n    \r\n    // Permissions policy\r\n    'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()',\r\n    \r\n    // Content Security Policy\r\n    'Content-Security-Policy': isSnapshotView ? snapshotCsp : standardCsp,\r\n    \r\n    // HSTS (only in HTTPS environment)\r\n    ...(context.request.url.startsWith('https://') && {\r\n      'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload'\r\n    })\r\n  }\r\n  \r\n  // Add security headers (skip undefined values)\r\n  Object.entries(securityHeaders).forEach(([key, value]) => {\r\n    if (value) {\r\n      newHeaders.set(key, value)\r\n    }\r\n  })\r\n  \r\n  return new Response(response.body, {\r\n    status: response.status,\r\n    statusText: response.statusText,\r\n    headers: newHeaders,\r\n  })\r\n}\r\n\r\n/**\r\n * CORS Configuration Middleware\r\n */\r\nexport const corsHeaders: PagesFunction = async (context) => {\r\n  // Get allowed origins from environment variables\r\n  const allowedOriginsEnv = (context.env as { CORS_ALLOWED_ORIGINS?: string })?.CORS_ALLOWED_ORIGINS\r\n\r\n  const cors = getCorsPolicy(context.request, allowedOriginsEnv)\r\n\r\n  // Handle preflight requests\r\n  if (context.request.method === 'OPTIONS') {\r\n    const headers: Record<string, string> = {\r\n      'Access-Control-Allow-Origin': cors.origin,\r\n      'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',\r\n      'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key',\r\n      'Access-Control-Max-Age': '86400',\r\n      'Vary': 'Origin',\r\n    }\r\n\r\n    if (cors.allowCredentials) {\r\n      headers['Access-Control-Allow-Credentials'] = 'true'\r\n    }\r\n\r\n    return new Response(null, {\r\n      headers,\r\n    })\r\n  }\r\n\r\n  const response = await context.next()\r\n  const newHeaders = new Headers(response.headers)\r\n\r\n  // Add CORS headers\r\n  newHeaders.set('Access-Control-Allow-Origin', cors.origin)\r\n  if (cors.allowCredentials) {\r\n    newHeaders.set('Access-Control-Allow-Credentials', 'true')\r\n  } else {\r\n    newHeaders.delete('Access-Control-Allow-Credentials')\r\n  }\r\n  newHeaders.set('Vary', 'Origin')\r\n  \r\n  return new Response(response.body, {\r\n    status: response.status,\r\n    statusText: response.statusText,\r\n    headers: newHeaders,\r\n  })\r\n}\r\n\r\n/**\r\n * Get allowed origins\r\n * @param request Request object\r\n * @param allowedOriginsEnv Allowed origins list from environment variables (comma-separated)\r\n */\r\nfunction getCorsPolicy(request: Request, allowedOriginsEnv?: string): { origin: string; allowCredentials: boolean } {\r\n  const origin = request.headers.get('Origin')\r\n\r\n  const defaultOrigins = [\r\n    'http://localhost:5173',\r\n    'http://localhost:3000',\r\n  ]\r\n\r\n  const envOrigins = allowedOriginsEnv\r\n    ? allowedOriginsEnv.split(',').map(o => o.trim()).filter(Boolean)\r\n    : []\r\n\r\n  const allowedOrigins = [...defaultOrigins, ...envOrigins]\r\n\r\n  // Browser extensions must be explicitly listed in CORS_ALLOWED_ORIGINS\r\n  // Example: chrome-extension://abcdef123456,extension://abcdef123456\r\n  if (origin && (origin.startsWith('chrome-extension://') || origin.startsWith('extension://'))) {\r\n    if (allowedOrigins.includes(origin)) {\r\n      return { origin, allowCredentials: true }\r\n    }\r\n    // Development fallback: allow unconfigured extensions only when localhost origins exist\r\n    const hasExtensionWhitelist = allowedOrigins.some(\r\n      o => o.startsWith('chrome-extension://') || o.startsWith('extension://')\r\n    )\r\n    if (!hasExtensionWhitelist && allowedOrigins.some(o => o.includes('localhost'))) {\r\n      return { origin, allowCredentials: true }\r\n    }\r\n    return { origin: 'null', allowCredentials: false }\r\n  }\r\n\r\n  if (origin && allowedOrigins.includes(origin)) {\r\n    return { origin, allowCredentials: true }\r\n  }\r\n\r\n  if (!origin) {\r\n    return { origin: '*', allowCredentials: false }\r\n  }\r\n\r\n  return { origin: 'null', allowCredentials: false }\r\n}\r\n\r\n/**\r\n * Input Validation Middleware\r\n */\r\nexport function validateInput<T>(validator: (data: unknown) => data is T) {\r\n  return async (context: { request: Request; next: () => Promise<Response>; validatedData?: T }) => {\r\n    if (context.request.method === 'POST' || context.request.method === 'PUT' || context.request.method === 'PATCH') {\r\n      try {\r\n        const body = await context.request.json()\r\n\r\n        if (!validator(body)) {\r\n          return new Response(\r\n            JSON.stringify({\r\n              error: {\r\n                code: 'INVALID_INPUT',\r\n                message: 'Invalid request body format'\r\n              }\r\n            }),\r\n            {\r\n              status: 400,\r\n              headers: { 'Content-Type': 'application/json' }\r\n            }\r\n          )\r\n        }\r\n\r\n        // Attach validated data to context\r\n        context.validatedData = body\r\n      } catch {\r\n        return new Response(\r\n          JSON.stringify({\r\n            error: {\r\n              code: 'INVALID_JSON',\r\n              message: 'Invalid JSON format'\r\n            }\r\n          }),\r\n          {\r\n            status: 400,\r\n            headers: { 'Content-Type': 'application/json' }\r\n          }\r\n        )\r\n      }\r\n    }\r\n\r\n    return context.next()\r\n  }\r\n}\r\n\r\n/**\r\n * Rate Limiting Middleware (IP-based)\r\n * Note: This function currently serves as a placeholder, actual rate limiting logic is implemented in rate-limit.ts\r\n */\r\n// eslint-disable-next-line @typescript-eslint/no-unused-vars\r\nexport function rateLimitByIP(_limit: number, _windowSeconds: number) {\r\n  return async (context: { request: Request; next: () => Promise<Response> }) => {\r\n    // Get IP address for future rate limiting implementation\r\n    // const ip = context.request.headers.get('CF-Connecting-IP') ||\r\n    //            context.request.headers.get('X-Forwarded-For') ||\r\n    //            'unknown'\r\n\r\n    // This can be integrated into the existing rate limiting system\r\n    // For now, continue execution\r\n    return context.next()\r\n  }\r\n}\r\n\r\n/**\r\n * Request Logging Middleware\r\n */\r\nexport const requestLogger: PagesFunction = async (context) => {\r\n  const start = Date.now()\r\n  const ip = context.request.headers.get('CF-Connecting-IP') || 'unknown'\r\n  const userAgent = context.request.headers.get('User-Agent') || 'unknown'\r\n\r\n  // Remove sensitive query parameters from logged URL\r\n  const logUrl = new URL(context.request.url)\r\n  for (const param of ['sig', 'token', 'api_key', 'key']) {\r\n    if (logUrl.searchParams.has(param)) {\r\n      logUrl.searchParams.set(param, '***')\r\n    }\r\n  }\r\n  const sanitizedUrl = logUrl.toString()\r\n\r\n  try {\r\n    const response = await context.next()\r\n    const duration = Date.now() - start\r\n\r\n    // Log request\r\n    console.log(JSON.stringify({\r\n      timestamp: new Date().toISOString(),\r\n      method: context.request.method,\r\n      url: sanitizedUrl,\r\n      status: response.status,\r\n      duration,\r\n      ip,\r\n      userAgent: userAgent.substring(0, 100),\r\n    }))\r\n\r\n    return response\r\n  } catch (error) {\r\n    const duration = Date.now() - start\r\n\r\n    // Log error\r\n    console.error(JSON.stringify({\r\n      timestamp: new Date().toISOString(),\r\n      method: context.request.method,\r\n      url: sanitizedUrl,\r\n      error: error instanceof Error ? error.message : 'Unknown error',\r\n      duration,\r\n      ip,\r\n      userAgent: userAgent.substring(0, 100),\r\n    }))\r\n\r\n    throw error\r\n  }\r\n}\r\n\r\n/**\r\n * Combined Security Middleware\r\n */\r\nexport const securityMiddleware: PagesFunction = async (context) => {\r\n  // Apply security middleware in sequence\r\n  return securityHeaders(context)\r\n}\r\n"
  },
  {
    "path": "tmarks/index.html",
    "content": "<!doctype html>\n<html lang=\"zh-CN\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/vite.svg\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>TMarks - 书签管理</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.tsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "tmarks/migrations/0001_d1_console.sql",
    "content": "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, email TEXT UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'user', public_share_enabled INTEGER NOT NULL DEFAULT 0, public_slug TEXT, public_page_title TEXT, public_page_description TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')));\r\nCREATE INDEX IF NOT EXISTS idx_users_username_lower ON users(LOWER(username));\r\nCREATE INDEX IF NOT EXISTS idx_users_email_lower ON users(LOWER(email));\r\nCREATE INDEX IF NOT EXISTS idx_users_role ON users(role);\r\nCREATE UNIQUE INDEX IF NOT EXISTS idx_users_public_slug ON users(public_slug) WHERE public_slug IS NOT NULL;\r\nCREATE TABLE IF NOT EXISTS auth_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, refresh_token_hash TEXT NOT NULL, expires_at TEXT NOT NULL, revoked_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\r\nCREATE INDEX IF NOT EXISTS idx_auth_tokens_user_id ON auth_tokens(user_id);\r\nCREATE INDEX IF NOT EXISTS idx_auth_tokens_hash ON auth_tokens(refresh_token_hash);\r\nCREATE INDEX IF NOT EXISTS idx_auth_tokens_expires ON auth_tokens(expires_at);\r\nCREATE TABLE IF NOT EXISTS bookmarks (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, description TEXT, cover_image TEXT, cover_image_id TEXT, favicon TEXT, is_pinned INTEGER NOT NULL DEFAULT 0, is_archived INTEGER NOT NULL DEFAULT 0, is_public INTEGER NOT NULL DEFAULT 0, click_count INTEGER NOT NULL DEFAULT 0, last_clicked_at TEXT, has_snapshot INTEGER NOT NULL DEFAULT 0, latest_snapshot_at TEXT, snapshot_count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE(user_id, url));\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_user_created ON bookmarks(user_id, created_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_user_url ON bookmarks(user_id, url);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_url ON bookmarks(url);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_user_deleted ON bookmarks(user_id, deleted_at);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_pinned ON bookmarks(user_id, is_pinned, created_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_click_count ON bookmarks(user_id, click_count DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_last_clicked ON bookmarks(user_id, last_clicked_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_created ON bookmarks(user_id, is_archived, created_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_updated ON bookmarks(user_id, is_archived, updated_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_pinned_created ON bookmarks(user_id, is_archived, is_pinned DESC, created_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_pinned_updated ON bookmarks(user_id, is_archived, is_pinned DESC, updated_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_user_archived_pinned_clicks ON bookmarks(user_id, is_archived, is_pinned DESC, click_count DESC, last_clicked_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_user_deleted_created ON bookmarks(user_id, deleted_at, created_at DESC) WHERE deleted_at IS NULL;\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_has_snapshot ON bookmarks(user_id, has_snapshot, created_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmarks_cover_image_id ON bookmarks(cover_image_id);\r\nCREATE TABLE IF NOT EXISTS tags (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, color TEXT, click_count INTEGER NOT NULL DEFAULT 0, last_clicked_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), deleted_at TEXT, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, UNIQUE(user_id, name));\r\nCREATE INDEX IF NOT EXISTS idx_tags_user_name ON tags(user_id, LOWER(name));\r\nCREATE INDEX IF NOT EXISTS idx_tags_user_deleted ON tags(user_id, deleted_at);\r\nCREATE INDEX IF NOT EXISTS idx_tags_click_count ON tags(user_id, click_count DESC);\r\nCREATE INDEX IF NOT EXISTS idx_tags_last_clicked ON tags(user_id, last_clicked_at DESC);\r\nCREATE TABLE IF NOT EXISTS bookmark_tags (bookmark_id TEXT NOT NULL, tag_id TEXT NOT NULL, user_id TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (bookmark_id, tag_id), FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_tags_tag_user ON bookmark_tags(tag_id, user_id);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_tags_bookmark ON bookmark_tags(bookmark_id);\r\nCREATE TABLE IF NOT EXISTS bookmark_snapshots (id TEXT PRIMARY KEY, bookmark_id TEXT NOT NULL, user_id TEXT NOT NULL, version INTEGER NOT NULL, is_latest INTEGER NOT NULL DEFAULT 0, content_hash TEXT NOT NULL, r2_key TEXT NOT NULL, r2_bucket TEXT NOT NULL DEFAULT 'tmarks-snapshots', file_size INTEGER NOT NULL, mime_type TEXT NOT NULL DEFAULT 'text/html', snapshot_url TEXT NOT NULL, snapshot_title TEXT NOT NULL, snapshot_status TEXT NOT NULL DEFAULT 'completed', created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_bookmark_id ON bookmark_snapshots(bookmark_id);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_user_id ON bookmark_snapshots(user_id);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_created_at ON bookmark_snapshots(created_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_content_hash ON bookmark_snapshots(content_hash);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_bookmark_latest ON bookmark_snapshots(bookmark_id, is_latest DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_snapshots_bookmark_version ON bookmark_snapshots(bookmark_id, version DESC);\r\nCREATE TABLE IF NOT EXISTS bookmark_images (id TEXT PRIMARY KEY, bookmark_id TEXT NOT NULL, user_id TEXT NOT NULL, image_hash TEXT NOT NULL, r2_key TEXT NOT NULL, r2_bucket TEXT NOT NULL DEFAULT 'tmarks-snapshots', file_size INTEGER NOT NULL, mime_type TEXT NOT NULL, original_url TEXT NOT NULL, width INTEGER, height INTEGER, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_images_bookmark_id ON bookmark_images(bookmark_id);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_images_user_id ON bookmark_images(user_id);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_images_hash ON bookmark_images(image_hash);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_images_created_at ON bookmark_images(created_at DESC);\r\nCREATE TABLE IF NOT EXISTS user_preferences (user_id TEXT PRIMARY KEY, theme TEXT NOT NULL DEFAULT 'light', page_size INTEGER NOT NULL DEFAULT 30, view_mode TEXT NOT NULL DEFAULT 'list', density TEXT NOT NULL DEFAULT 'normal', tag_layout TEXT NOT NULL DEFAULT 'grid', sort_by TEXT NOT NULL DEFAULT 'popular', search_auto_clear_seconds INTEGER NOT NULL DEFAULT 15, tag_selection_auto_clear_seconds INTEGER NOT NULL DEFAULT 30, enable_search_auto_clear INTEGER NOT NULL DEFAULT 1, enable_tag_selection_auto_clear INTEGER NOT NULL DEFAULT 0, default_bookmark_icon TEXT NOT NULL DEFAULT 'gradient-glow', snapshot_retention_count INTEGER NOT NULL DEFAULT 5, snapshot_auto_create INTEGER NOT NULL DEFAULT 0, snapshot_auto_dedupe INTEGER NOT NULL DEFAULT 1, snapshot_auto_cleanup_days INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\r\nCREATE TABLE IF NOT EXISTS api_keys (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, key_hash TEXT NOT NULL UNIQUE, key_prefix TEXT NOT NULL, name TEXT NOT NULL, description TEXT, permissions TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'active', expires_at TEXT, last_used_at TEXT, last_used_ip TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\r\nCREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);\r\nCREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);\r\nCREATE INDEX IF NOT EXISTS idx_api_keys_status ON api_keys(user_id, status);\r\nCREATE TABLE IF NOT EXISTS api_key_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, api_key_id TEXT NOT NULL, user_id TEXT NOT NULL, endpoint TEXT NOT NULL, method TEXT NOT NULL, status INTEGER NOT NULL, ip TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (api_key_id) REFERENCES api_keys(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\r\nCREATE INDEX IF NOT EXISTS idx_api_logs_key ON api_key_logs(api_key_id, created_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_api_logs_user ON api_key_logs(user_id, created_at DESC);\r\nCREATE TABLE IF NOT EXISTS tab_groups (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, parent_id TEXT DEFAULT NULL, is_folder INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0, color TEXT DEFAULT NULL, tags TEXT DEFAULT NULL, is_deleted INTEGER NOT NULL DEFAULT 0, deleted_at TEXT DEFAULT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\r\nCREATE INDEX IF NOT EXISTS idx_tab_groups_user_created ON tab_groups(user_id, created_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_tab_groups_user_id ON tab_groups(user_id);\r\nCREATE INDEX IF NOT EXISTS idx_tab_groups_parent_id ON tab_groups(parent_id);\r\nCREATE INDEX IF NOT EXISTS idx_tab_groups_is_folder ON tab_groups(is_folder);\r\nCREATE INDEX IF NOT EXISTS idx_tab_groups_user_parent ON tab_groups(user_id, parent_id);\r\nCREATE INDEX IF NOT EXISTS idx_tab_groups_parent_position ON tab_groups(parent_id, position ASC);\r\nCREATE INDEX IF NOT EXISTS idx_tab_groups_user_parent_position ON tab_groups(user_id, parent_id, position ASC);\r\nCREATE INDEX IF NOT EXISTS idx_tab_groups_deleted ON tab_groups(user_id, is_deleted);\r\nCREATE TABLE IF NOT EXISTS tab_group_items (id TEXT PRIMARY KEY, group_id TEXT NOT NULL, title TEXT NOT NULL, url TEXT NOT NULL, favicon TEXT, position INTEGER NOT NULL, is_pinned INTEGER NOT NULL DEFAULT 0, is_todo INTEGER NOT NULL DEFAULT 0, is_archived INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (group_id) REFERENCES tab_groups(id) ON DELETE CASCADE);\r\nCREATE INDEX IF NOT EXISTS idx_tab_group_items_group_id ON tab_group_items(group_id, position ASC);\r\nCREATE INDEX IF NOT EXISTS idx_tab_group_items_group_created ON tab_group_items(group_id, created_at ASC);\r\nCREATE INDEX IF NOT EXISTS idx_tab_group_items_pinned ON tab_group_items(group_id, is_pinned DESC, position ASC);\r\nCREATE INDEX IF NOT EXISTS idx_tab_group_items_archived ON tab_group_items(group_id, is_archived, position ASC);\r\nCREATE INDEX IF NOT EXISTS idx_tab_group_items_not_archived ON tab_group_items(group_id, is_archived) WHERE is_archived = 0;\r\nCREATE TABLE IF NOT EXISTS shares (id TEXT PRIMARY KEY, group_id TEXT NOT NULL, user_id TEXT NOT NULL, share_token TEXT NOT NULL UNIQUE, is_public INTEGER DEFAULT 1, view_count INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at TEXT DEFAULT NULL, FOREIGN KEY (group_id) REFERENCES tab_groups(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\r\nCREATE INDEX IF NOT EXISTS idx_shares_token ON shares(share_token);\r\nCREATE INDEX IF NOT EXISTS idx_shares_group_id ON shares(group_id);\r\nCREATE INDEX IF NOT EXISTS idx_shares_user_id ON shares(user_id);\r\nCREATE TABLE IF NOT EXISTS statistics (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, stat_date TEXT NOT NULL, groups_created INTEGER DEFAULT 0, groups_deleted INTEGER DEFAULT 0, items_added INTEGER DEFAULT 0, items_deleted INTEGER DEFAULT 0, shares_created INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\r\nCREATE UNIQUE INDEX IF NOT EXISTS idx_statistics_user_date ON statistics(user_id, stat_date);\r\nCREATE INDEX IF NOT EXISTS idx_statistics_user_id ON statistics(user_id);\r\nCREATE INDEX IF NOT EXISTS idx_statistics_date ON statistics(stat_date);\r\nCREATE TABLE IF NOT EXISTS audit_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, event_type TEXT NOT NULL, payload TEXT, ip TEXT, user_agent TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL);\r\nCREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id, created_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_audit_logs_event ON audit_logs(event_type, created_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at DESC);\r\nCREATE TABLE IF NOT EXISTS registration_limits (date TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL DEFAULT (datetime('now')));\r\nCREATE TABLE IF NOT EXISTS bookmark_click_events (id INTEGER PRIMARY KEY AUTOINCREMENT, bookmark_id TEXT NOT NULL, user_id TEXT NOT NULL, clicked_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (bookmark_id) REFERENCES bookmarks(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_click_events_user_clicked_at ON bookmark_click_events(user_id, clicked_at DESC);\r\nCREATE INDEX IF NOT EXISTS idx_bookmark_click_events_bookmark_clicked_at ON bookmark_click_events(bookmark_id, clicked_at DESC);\r\n\r\nCREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')));\r\nINSERT OR IGNORE INTO schema_migrations (version) VALUES ('0001');\r\n"
  },
  {
    "path": "tmarks/migrations/0002_d1_console_ai_settings.sql",
    "content": "CREATE TABLE IF NOT EXISTS ai_settings (\r\n    id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\r\n    user_id TEXT NOT NULL UNIQUE,\r\n    provider TEXT NOT NULL DEFAULT 'openai',\r\n    api_keys_encrypted TEXT,\r\n    api_urls TEXT,\r\n    model TEXT,\r\n    custom_prompt TEXT,\r\n    enable_custom_prompt INTEGER NOT NULL DEFAULT 0,\r\n    enabled INTEGER NOT NULL DEFAULT 0,\r\n    created_at TEXT NOT NULL DEFAULT (datetime('now')),\r\n    updated_at TEXT NOT NULL DEFAULT (datetime('now')),\r\n    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE\r\n);\r\n\r\nCREATE INDEX IF NOT EXISTS idx_ai_settings_user_id ON ai_settings(user_id);\r\n\r\nINSERT OR IGNORE INTO schema_migrations (version) VALUES ('0002');\r\n"
  },
  {
    "path": "tmarks/migrations/0103_api_key_rate_limits.sql",
    "content": "CREATE TABLE IF NOT EXISTS api_key_rate_limits (\n  api_key_id TEXT NOT NULL,\n  window TEXT NOT NULL, -- minute|hour|day\n  window_start INTEGER NOT NULL, -- unix ms aligned to window\n  count INTEGER NOT NULL DEFAULT 0,\n  updated_at INTEGER NOT NULL,\n  PRIMARY KEY (api_key_id, window, window_start)\n);\n\nCREATE INDEX IF NOT EXISTS idx_api_key_rate_limits_updated_at ON api_key_rate_limits(updated_at);\n\nINSERT OR IGNORE INTO schema_migrations (version) VALUES ('0103');\n\n"
  },
  {
    "path": "tmarks/migrations/0104_rate_limits.sql",
    "content": "CREATE TABLE IF NOT EXISTS rate_limits (\n  key TEXT NOT NULL,\n  window_seconds INTEGER NOT NULL,\n  window_start INTEGER NOT NULL,\n  count INTEGER NOT NULL DEFAULT 0,\n  updated_at INTEGER NOT NULL,\n  PRIMARY KEY (key, window_seconds, window_start)\n);\n\nCREATE INDEX IF NOT EXISTS idx_rate_limits_updated_at ON rate_limits(updated_at);\n\nINSERT OR IGNORE INTO schema_migrations (version) VALUES ('0104');\n\n"
  },
  {
    "path": "tmarks/package.json",
    "content": "{\r\n  \"name\": \"tmarks\",\r\n  \"version\": \"0.1.2\",\r\n  \"private\": true,\r\n  \"license\": \"CC-BY-NC-4.0\",\r\n  \"type\": \"module\",\r\n  \"scripts\": {\r\n    \"dev\": \"vite --host\",\r\n    \"build\": \"tsc && vite build\",\r\n    \"build:deploy\": \"pnpm build && node scripts/prepare-deploy.js\",\r\n    \"preview\": \"vite preview\",\r\n    \"lint\": \"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0\",\r\n    \"format\": \"prettier --write \\\"src/**/*.{ts,tsx,css}\\\"\",\r\n    \"type-check\": \"tsc --noEmit\",\r\n    \"cf:dev\": \"wrangler dev\",\r\n    \"cf:deploy\": \"wrangler deploy\",\r\n    \"cf:tail\": \"wrangler tail\",\r\n    \"db:migrate\": \"wrangler d1 migrations apply tmarks-prod-db\",\r\n    \"db:migrate:local\": \"wrangler d1 migrations apply tmarks-prod-db --local\",\r\n    \"db:auto-migrate\": \"node scripts/auto-migrate.js\",\r\n    \"db:auto-migrate:local\": \"node scripts/auto-migrate.js --local\",\r\n    \"db:check\": \"node scripts/check-db-schema.js\",\r\n    \"db:check:local\": \"node scripts/check-db-schema.js --local\",\r\n    \"deploy\": \"pwsh scripts/deploy.ps1\",\r\n    \"prepare\": \"husky\"\r\n  },\r\n  \"dependencies\": {\r\n    \"@dnd-kit/core\": \"^6.3.1\",\r\n    \"@dnd-kit/sortable\": \"^10.0.0\",\r\n    \"@dnd-kit/utilities\": \"^3.2.2\",\r\n    \"@tanstack/query-sync-storage-persister\": \"^5.90.12\",\r\n    \"@tanstack/react-query\": \"^5.90.10\",\r\n    \"@tanstack/react-query-persist-client\": \"^5.90.12\",\r\n    \"@tanstack/react-virtual\": \"^3.10.8\",\r\n    \"date-fns\": \"^4.1.0\",\r\n    \"i18next\": \"^25.7.3\",\r\n    \"i18next-browser-languagedetector\": \"^8.2.0\",\r\n    \"lodash-es\": \"^4.17.21\",\r\n    \"lucide-react\": \"^0.545.0\",\r\n    \"react\": \"^18.3.1\",\r\n    \"react-dom\": \"^18.3.1\",\r\n    \"react-i18next\": \"^16.5.0\",\r\n    \"react-router-dom\": \"^7.0.1\",\r\n    \"simple-icons\": \"^15.22.0\",\r\n    \"zustand\": \"^5.0.1\"\r\n  },\r\n  \"devDependencies\": {\r\n    \"@cloudflare/workers-types\": \"^4.20251008.0\",\r\n    \"@eslint/js\": \"^9.15.0\",\r\n    \"@tailwindcss/postcss\": \"^4.1.14\",\r\n    \"@types/lodash-es\": \"^4.17.12\",\r\n    \"@types/node\": \"^22.10.1\",\r\n    \"@types/react\": \"^18.3.12\",\r\n    \"@types/react-dom\": \"^18.3.1\",\r\n    \"@typescript-eslint/eslint-plugin\": \"^8.15.0\",\r\n    \"@typescript-eslint/parser\": \"^8.15.0\",\r\n    \"@vitejs/plugin-react-swc\": \"^3.7.1\",\r\n    \"autoprefixer\": \"^10.4.20\",\r\n    \"eslint\": \"^9.15.0\",\r\n    \"eslint-plugin-react-hooks\": \"^5.0.0\",\r\n    \"eslint-plugin-react-refresh\": \"^0.4.14\",\r\n    \"fast-check\": \"^4.3.0\",\r\n    \"globals\": \"^15.12.0\",\r\n    \"husky\": \"^9.1.7\",\r\n    \"javascript-obfuscator\": \"^4.1.1\",\r\n    \"lint-staged\": \"^15.2.10\",\r\n    \"postcss\": \"^8.4.49\",\r\n    \"prettier\": \"^3.3.3\",\r\n    \"rollup-plugin-visualizer\": \"^6.0.4\",\r\n    \"tailwindcss\": \"^4.0.0\",\r\n    \"terser\": \"^5.44.0\",\r\n    \"typescript\": \"^5.6.3\",\r\n    \"typescript-eslint\": \"^8.15.0\",\r\n    \"vite\": \"^6.0.1\",\r\n    \"vite-plugin-compression\": \"^0.5.1\",\r\n    \"vite-plugin-obfuscator\": \"^1.0.5\",\r\n    \"vitest\": \"^2.1.5\",\r\n    \"wrangler\": \"^4.42.1\"\r\n  },\r\n  \"lint-staged\": {\r\n    \"*.{ts,tsx}\": [\r\n      \"eslint --fix\",\r\n      \"prettier --write\"\r\n    ],\r\n    \"*.{css,md}\": [\r\n      \"prettier --write\"\r\n    ]\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/postcss.config.js",
    "content": "export default {\n  plugins: {\n    '@tailwindcss/postcss': {},\n    autoprefixer: {},\n  },\n}\n"
  },
  {
    "path": "tmarks/public/_headers",
    "content": "/*\n  X-Content-Type-Options: nosniff\n  X-Frame-Options: DENY\n  Referrer-Policy: strict-origin-when-cross-origin\n  Permissions-Policy: camera=(), microphone=(), geolocation=()\n  Strict-Transport-Security: max-age=63072000; includeSubDomains; preload\n\n/assets/*\n  Cache-Control: public, max-age=31536000, immutable\n"
  },
  {
    "path": "tmarks/public/_routes.json",
    "content": "{\n  \"version\": 1,\n  \"include\": [\n    \"/api/*\"\n  ],\n  \"exclude\": []\n}\n\n"
  },
  {
    "path": "tmarks/scripts/auto-migrate.js",
    "content": "#!/usr/bin/env node\r\n\r\n/**\r\n * 自动数据库迁移脚本\r\n * \r\n * 功能：\r\n * 1. 检测新的迁移文件\r\n * 2. 自动执行未应用的迁移\r\n * 3. 记录迁移历史\r\n * \r\n * 使用方式：\r\n * - 本地开发: pnpm db:auto-migrate:local\r\n * - 生产环境: pnpm db:auto-migrate\r\n */\r\n\r\nimport { execSync } from 'child_process'\r\nimport { readFileSync, existsSync, writeFileSync, readdirSync } from 'fs'\r\nimport { join, dirname } from 'path'\r\nimport { fileURLToPath } from 'url'\r\n\r\nconst __filename = fileURLToPath(import.meta.url)\r\nconst __dirname = dirname(__filename)\r\n\r\nconst MIGRATIONS_DIR = join(__dirname, '../migrations')\r\nconst MIGRATION_HISTORY_FILE = join(__dirname, '../.migration-history.json')\r\n\r\n// 颜色输出\r\nconst colors = {\r\n  reset: '\\x1b[0m',\r\n  green: '\\x1b[32m',\r\n  yellow: '\\x1b[33m',\r\n  red: '\\x1b[31m',\r\n  blue: '\\x1b[34m',\r\n  gray: '\\x1b[90m',\r\n}\r\n\r\nfunction log(message, color = 'reset') {\r\n  console.log(`${colors[color]}${message}${colors.reset}`)\r\n}\r\n\r\n// 读取迁移历史\r\nfunction getMigrationHistory() {\r\n  if (!existsSync(MIGRATION_HISTORY_FILE)) {\r\n    return { migrations: [] }\r\n  }\r\n  try {\r\n    return JSON.parse(readFileSync(MIGRATION_HISTORY_FILE, 'utf-8'))\r\n  } catch (error) {\r\n    log(`⚠️  无法读取迁移历史: ${error.message}`, 'yellow')\r\n    return { migrations: [] }\r\n  }\r\n}\r\n\r\n// 保存迁移历史\r\nfunction saveMigrationHistory(history) {\r\n  writeFileSync(MIGRATION_HISTORY_FILE, JSON.stringify(history, null, 2))\r\n}\r\n\r\n// 获取所有迁移文件（按编号排序）\r\nfunction getMigrationFiles() {\r\n  const files = readdirSync(MIGRATIONS_DIR)\r\n    .filter(file => {\r\n      // 只处理编号开头的 SQL 文件\r\n      return /^\\d{4}_.*\\.sql$/.test(file)\r\n    })\r\n    .sort()\r\n  \r\n  return files\r\n}\r\n\r\n// 执行迁移\r\nfunction executeMigration(filename, isLocal = false) {\r\n  const filepath = join(MIGRATIONS_DIR, filename)\r\n  const sql = readFileSync(filepath, 'utf-8')\r\n  \r\n  // 跳过空文件或只有注释的文件\r\n  const hasContent = sql.split('\\n').some(line => {\r\n    const trimmed = line.trim()\r\n    return trimmed && !trimmed.startsWith('--')\r\n  })\r\n  \r\n  if (!hasContent) {\r\n    log(`  ⏭️  跳过空文件: ${filename}`, 'gray')\r\n    return true\r\n  }\r\n  \r\n  try {\r\n    const dbName = 'tmarks-prod-db'\r\n    const localFlag = isLocal ? '--local' : ''\r\n    \r\n    log(`  📝 执行迁移: ${filename}`, 'blue')\r\n    \r\n    // 使用 wrangler d1 execute 执行 SQL\r\n    const command = `wrangler d1 execute ${dbName} --file=\"${filepath}\" ${localFlag}`.trim()\r\n    \r\n    execSync(command, {\r\n      stdio: 'inherit',\r\n      cwd: join(__dirname, '..')\r\n    })\r\n    \r\n    log(`  ✅ 成功: ${filename}`, 'green')\r\n    return true\r\n  } catch (error) {\r\n    log(`  ❌ 失败: ${filename}`, 'red')\r\n    log(`     ${error.message}`, 'red')\r\n    return false\r\n  }\r\n}\r\n\r\n// 主函数\r\nfunction main() {\r\n  const isLocal = process.argv.includes('--local')\r\n  const force = process.argv.includes('--force')\r\n  \r\n  log('\\n🚀 开始数据库迁移检查...\\n', 'blue')\r\n  log(`环境: ${isLocal ? '本地开发' : '生产环境'}`, 'gray')\r\n  \r\n  // 读取迁移历史\r\n  const history = getMigrationHistory()\r\n  const appliedMigrations = new Set(history.migrations || [])\r\n  \r\n  // 获取所有迁移文件\r\n  const migrationFiles = getMigrationFiles()\r\n  \r\n  if (migrationFiles.length === 0) {\r\n    log('\\n⚠️  未找到迁移文件（格式: 0001_xxx.sql）', 'yellow')\r\n    log('   迁移文件应该以 4 位数字开头，例如: 0003_add_general_settings.sql\\n', 'gray')\r\n    return\r\n  }\r\n  \r\n  log(`\\n找到 ${migrationFiles.length} 个迁移文件:\\n`, 'gray')\r\n  \r\n  // 找出未应用的迁移\r\n  const pendingMigrations = migrationFiles.filter(file => {\r\n    const isApplied = appliedMigrations.has(file)\r\n    const status = isApplied ? '✓' : '○'\r\n    const color = isApplied ? 'green' : 'yellow'\r\n    log(`  ${status} ${file}`, color)\r\n    return !isApplied || force\r\n  })\r\n  \r\n  if (pendingMigrations.length === 0) {\r\n    log('\\n✨ 所有迁移已应用，无需操作\\n', 'green')\r\n    return\r\n  }\r\n  \r\n  log(`\\n📦 需要应用 ${pendingMigrations.length} 个迁移:\\n`, 'yellow')\r\n  \r\n  // 执行迁移\r\n  let successCount = 0\r\n  let failCount = 0\r\n  \r\n  for (const file of pendingMigrations) {\r\n    const success = executeMigration(file, isLocal)\r\n    if (success) {\r\n      successCount++\r\n      // 记录到历史\r\n      if (!appliedMigrations.has(file)) {\r\n        history.migrations = history.migrations || []\r\n        history.migrations.push(file)\r\n        history.migrations.sort()\r\n      }\r\n    } else {\r\n      failCount++\r\n      // 失败则停止\r\n      break\r\n    }\r\n  }\r\n  \r\n  // 保存迁移历史\r\n  if (successCount > 0) {\r\n    history.lastUpdated = new Date().toISOString()\r\n    saveMigrationHistory(history)\r\n  }\r\n  \r\n  // 输出结果\r\n  log('\\n' + '='.repeat(50), 'gray')\r\n  if (failCount === 0) {\r\n    log(`\\n✅ 迁移完成！成功: ${successCount}`, 'green')\r\n  } else {\r\n    log(`\\n⚠️  迁移部分完成。成功: ${successCount}, 失败: ${failCount}`, 'yellow')\r\n    log('   请检查错误信息并手动修复', 'yellow')\r\n  }\r\n  log('')\r\n}\r\n\r\n// 运行\r\ntry {\r\n  main()\r\n} catch (error) {\r\n  log(`\\n❌ 迁移失败: ${error.message}\\n`, 'red')\r\n  process.exit(1)\r\n}\r\n"
  },
  {
    "path": "tmarks/scripts/check-db-schema.js",
    "content": "#!/usr/bin/env node\r\n\r\n/**\r\n * 检查数据库结构是否完整\r\n * 用法: node scripts/check-db-schema.js [--local]\r\n */\r\n\r\nimport { execSync } from 'child_process';\r\n\r\nconst isLocal = process.argv.includes('--local');\r\nconst localFlag = isLocal ? '--local' : '';\r\n\r\nconsole.log(`🔍 检查数据库结构 (${isLocal ? '本地' : '生产'}环境)...\\n`);\r\n\r\n// 必需的表\r\nconst requiredTables = [\r\n  'users',\r\n  'bookmarks',\r\n  'tags',\r\n  'bookmark_tags',\r\n  'user_preferences',\r\n  'bookmark_snapshots',\r\n  'bookmark_images',\r\n  'api_keys',\r\n];\r\n\r\n// bookmarks表必需的字段\r\nconst requiredBookmarkFields = [\r\n  'id',\r\n  'user_id',\r\n  'title',\r\n  'url',\r\n  'description',\r\n  'cover_image',\r\n  'cover_image_id',\r\n  'favicon',\r\n  'has_snapshot',\r\n  'latest_snapshot_at',\r\n  'snapshot_count',\r\n  'is_pinned',\r\n  'is_archived',\r\n  'is_public',\r\n  'click_count',\r\n  'last_clicked_at',\r\n  'created_at',\r\n  'updated_at',\r\n  'deleted_at',\r\n];\r\n\r\n// user_preferences表必需的字段\r\nconst requiredPreferenceFields = [\r\n  'user_id',\r\n  'theme',\r\n  'page_size',\r\n  'view_mode',\r\n  'density',\r\n  'tag_layout',\r\n  'sort_by',\r\n  'search_auto_clear_seconds',\r\n  'tag_selection_auto_clear_seconds',\r\n  'enable_search_auto_clear',\r\n  'enable_tag_selection_auto_clear',\r\n  'default_bookmark_icon',\r\n  'snapshot_retention_count',\r\n  'snapshot_auto_create',\r\n  'snapshot_auto_dedupe',\r\n  'snapshot_auto_cleanup_days',\r\n  'updated_at',\r\n];\r\n\r\nfunction executeQuery(query) {\r\n  try {\r\n    const command = `pnpm wrangler d1 execute tmarks-prod-db ${localFlag} --command=\"${query}\"`;\r\n    const result = execSync(command, { encoding: 'utf-8' });\r\n    return result;\r\n  } catch (error) {\r\n    return null;\r\n  }\r\n}\r\n\r\nfunction checkTable(tableName) {\r\n  console.log(`📋 检查表: ${tableName}`);\r\n  const result = executeQuery(`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`);\r\n  \r\n  if (result && result.includes(tableName)) {\r\n    console.log(`   ✅ 表存在\\n`);\r\n    return true;\r\n  } else {\r\n    console.log(`   ❌ 表不存在\\n`);\r\n    return false;\r\n  }\r\n}\r\n\r\nfunction checkTableFields(tableName, requiredFields) {\r\n  console.log(`🔍 检查表字段: ${tableName}`);\r\n  const result = executeQuery(`PRAGMA table_info(${tableName})`);\r\n  \r\n  if (!result) {\r\n    console.log(`   ❌ 无法获取表结构\\n`);\r\n    return false;\r\n  }\r\n\r\n  const missingFields = [];\r\n  \r\n  for (const field of requiredFields) {\r\n    if (result.includes(field)) {\r\n      console.log(`   ✅ ${field}`);\r\n    } else {\r\n      console.log(`   ❌ ${field} (缺失)`);\r\n      missingFields.push(field);\r\n    }\r\n  }\r\n  \r\n  console.log();\r\n  \r\n  if (missingFields.length > 0) {\r\n    console.log(`⚠️  缺失字段: ${missingFields.join(', ')}\\n`);\r\n    return false;\r\n  }\r\n  \r\n  return true;\r\n}\r\n\r\nfunction checkMigrations() {\r\n  console.log(`📜 检查迁移记录`);\r\n  const result = executeQuery(`SELECT version FROM schema_migrations ORDER BY version`);\r\n  \r\n  if (result) {\r\n    console.log(result);\r\n  } else {\r\n    console.log(`   ❌ 无法获取迁移记录\\n`);\r\n  }\r\n}\r\n\r\n// 主检查流程\r\nlet allGood = true;\r\n\r\nconsole.log('='.repeat(60));\r\nconsole.log('检查必需的表');\r\nconsole.log('='.repeat(60) + '\\n');\r\n\r\nfor (const table of requiredTables) {\r\n  if (!checkTable(table)) {\r\n    allGood = false;\r\n  }\r\n}\r\n\r\nconsole.log('='.repeat(60));\r\nconsole.log('检查bookmarks表字段');\r\nconsole.log('='.repeat(60) + '\\n');\r\n\r\nif (!checkTableFields('bookmarks', requiredBookmarkFields)) {\r\n  allGood = false;\r\n}\r\n\r\nconsole.log('='.repeat(60));\r\nconsole.log('检查user_preferences表字段');\r\nconsole.log('='.repeat(60) + '\\n');\r\n\r\nif (!checkTableFields('user_preferences', requiredPreferenceFields)) {\r\n  allGood = false;\r\n}\r\n\r\nconsole.log('='.repeat(60));\r\ncheckMigrations();\r\nconsole.log('='.repeat(60) + '\\n');\r\n\r\nif (allGood) {\r\n  console.log('✅ 数据库结构完整！\\n');\r\n  process.exit(0);\r\n} else {\r\n  console.log('❌ 数据库结构不完整，请查看上面的错误信息\\n');\r\n  console.log('💡 修复建议：');\r\n  console.log('   1. 查看 SQL_ANALYSIS.md 了解详细信息');\r\n  console.log('   2. 手动执行缺失的ALTER TABLE语句');\r\n  console.log('   3. 或者重新执行数据库迁移\\n');\r\n  process.exit(1);\r\n}\r\n"
  },
  {
    "path": "tmarks/scripts/check-migrations.js",
    "content": "#!/usr/bin/env node\r\n\r\n/**\r\n * 检查是否有待执行的迁移\r\n * 在 pnpm install 后自动运行\r\n */\r\n\r\nimport { readFileSync, existsSync, readdirSync } from 'fs'\r\nimport { join, dirname } from 'path'\r\nimport { fileURLToPath } from 'url'\r\n\r\nconst __filename = fileURLToPath(import.meta.url)\r\nconst __dirname = dirname(__filename)\r\n\r\nconst MIGRATIONS_DIR = join(__dirname, '../migrations')\r\nconst MIGRATION_HISTORY_FILE = join(__dirname, '../.migration-history.json')\r\n\r\n// 颜色输出\r\nconst colors = {\r\n  reset: '\\x1b[0m',\r\n  yellow: '\\x1b[33m',\r\n  blue: '\\x1b[34m',\r\n  gray: '\\x1b[90m',\r\n}\r\n\r\nfunction log(message, color = 'reset') {\r\n  console.log(`${colors[color]}${message}${colors.reset}`)\r\n}\r\n\r\n// 读取迁移历史\r\nfunction getMigrationHistory() {\r\n  if (!existsSync(MIGRATION_HISTORY_FILE)) {\r\n    return { migrations: [] }\r\n  }\r\n  try {\r\n    return JSON.parse(readFileSync(MIGRATION_HISTORY_FILE, 'utf-8'))\r\n  } catch (error) {\r\n    return { migrations: [] }\r\n  }\r\n}\r\n\r\n// 获取所有迁移文件\r\nfunction getMigrationFiles() {\r\n  if (!existsSync(MIGRATIONS_DIR)) {\r\n    return []\r\n  }\r\n  \r\n  return readdirSync(MIGRATIONS_DIR)\r\n    .filter(file => /^\\d{4}_.*\\.sql$/.test(file))\r\n    .sort()\r\n}\r\n\r\n// 主函数\r\nfunction main() {\r\n  const history = getMigrationHistory()\r\n  const appliedMigrations = new Set(history.migrations || [])\r\n  const migrationFiles = getMigrationFiles()\r\n  \r\n  if (migrationFiles.length === 0) {\r\n    return\r\n  }\r\n  \r\n  const pendingMigrations = migrationFiles.filter(file => !appliedMigrations.has(file))\r\n  \r\n  if (pendingMigrations.length > 0) {\r\n    log('\\n' + '='.repeat(60), 'yellow')\r\n    log('⚠️  检测到待执行的数据库迁移', 'yellow')\r\n    log('='.repeat(60), 'yellow')\r\n    log('', 'reset')\r\n    log(`发现 ${pendingMigrations.length} 个新的迁移文件:`, 'blue')\r\n    log('', 'reset')\r\n    \r\n    pendingMigrations.forEach(file => {\r\n      log(`  • ${file}`, 'gray')\r\n    })\r\n    \r\n    log('', 'reset')\r\n    log('请执行以下命令应用迁移:', 'blue')\r\n    log('', 'reset')\r\n    log('  本地开发环境:', 'gray')\r\n    log('    pnpm db:auto-migrate:local', 'yellow')\r\n    log('', 'reset')\r\n    log('  生产环境:', 'gray')\r\n    log('    pnpm db:auto-migrate', 'yellow')\r\n    log('', 'reset')\r\n    log('='.repeat(60), 'yellow')\r\n    log('', 'reset')\r\n  }\r\n}\r\n\r\n// 运行\r\ntry {\r\n  main()\r\n} catch (error) {\r\n  // 静默失败，不影响 install\r\n}\r\n"
  },
  {
    "path": "tmarks/scripts/prepare-deploy.js",
    "content": "#!/usr/bin/env node\r\n\r\n/**\r\n * 准备Cloudflare Pages部署\r\n * 将dist内容和functions目录合并到同一层级\r\n */\r\n\r\nimport fs from 'fs';\r\nimport path from 'path';\r\nimport { fileURLToPath } from 'url';\r\n\r\nconst __filename = fileURLToPath(import.meta.url);\r\nconst __dirname = path.dirname(__filename);\r\n\r\nconst distDir = path.join(__dirname, '../dist');\r\nconst functionsDir = path.join(__dirname, '../functions');\r\nconst deployDir = path.join(__dirname, '../.deploy');\r\n\r\nconsole.log('🚀 准备Cloudflare Pages部署...');\r\n\r\n// 清理旧的部署目录（尝试删除，失败则跳过）\r\nif (fs.existsSync(deployDir)) {\r\n  try {\r\n    fs.rmSync(deployDir, { recursive: true, force: true });\r\n    console.log('✓ 清理旧部署目录');\r\n  } catch (error) {\r\n    console.log('⚠ 无法删除旧目录，将覆盖文件');\r\n  }\r\n}\r\n\r\n// 创建部署目录\r\nfs.mkdirSync(deployDir, { recursive: true });\r\n\r\n// 复制dist内容到部署目录\r\nconsole.log('📦 复制静态文件...');\r\ncopyDir(distDir, deployDir);\r\n\r\n// 复制functions目录到部署目录\r\nconsole.log('⚡ 复制Functions...');\r\nconst targetFunctionsDir = path.join(deployDir, 'functions');\r\ncopyDir(functionsDir, targetFunctionsDir);\r\n\r\nconsole.log('✅ 部署准备完成!');\r\nconsole.log(`� 部署目录库: ${deployDir}`);\r\n\r\n/**\r\n * 递归复制目录\r\n */\r\nfunction copyDir(src, dest) {\r\n  if (!fs.existsSync(dest)) {\r\n    fs.mkdirSync(dest, { recursive: true });\r\n  }\r\n\r\n  const entries = fs.readdirSync(src, { withFileTypes: true });\r\n\r\n  for (const entry of entries) {\r\n    // 跳过废弃的备份目录\r\n    if (entry.name.startsWith('_deprecated')) {\r\n      console.log(`⏭ 跳过废弃目录: ${entry.name}`);\r\n      continue;\r\n    }\r\n\r\n    const srcPath = path.join(src, entry.name);\r\n    const destPath = path.join(dest, entry.name);\r\n\r\n    if (entry.isDirectory()) {\r\n      copyDir(srcPath, destPath);\r\n    } else {\r\n      fs.copyFileSync(srcPath, destPath);\r\n    }\r\n  }\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/shared/import-export-types.ts",
    "content": "/**\r\n * 导入导出功能的共享类型定义\r\n * 提供类型安全的接口定义，支持多种格式的数据交换\r\n */\r\n\r\n// ============ 基础数据类型 ============\r\n\r\nexport interface ExportBookmark {\n  id: string\n  title: string\n  url: string\n  description?: string | null\n  cover_image?: string | null\n  cover_image_id?: string | null\n  favicon?: string | null\n  tags: string[]\n  is_pinned: boolean\n  is_archived?: boolean\n  is_public?: boolean\n  created_at: string\n  updated_at: string\n  click_count?: number\n  last_clicked_at?: string | null\n  has_snapshot?: boolean\n  latest_snapshot_at?: string | null\n  snapshot_count?: number\n  deleted_at?: string | null\n}\n\r\nexport interface ExportTag {\n  id: string\n  name: string\n  color: string\n  click_count?: number\n  last_clicked_at?: string | null\n  created_at: string\n  updated_at: string\n  deleted_at?: string | null\n  bookmark_count?: number\n}\n\r\nexport interface ExportUser {\r\n  id: string\r\n  email: string\r\n  name?: string\r\n  created_at: string\r\n}\r\n\r\nexport interface ExportTabGroupItem {\r\n  id: string\r\n  title: string\r\n  url: string\r\n  favicon?: string\r\n  position: number\r\n  is_pinned: boolean\r\n  is_todo: boolean\r\n  is_archived: boolean\r\n  created_at: string\r\n}\r\n\r\nexport interface ExportTabGroup {\n  id: string\n  title: string\n  parent_id?: string\n  is_folder: boolean\n  position: number\n  color?: string\n  tags?: string[]\n  is_deleted?: boolean\n  deleted_at?: string\n  created_at: string\n  updated_at: string\n  items: ExportTabGroupItem[]\n}\n\r\n// ============ 导出格式 ============\n\nexport type ExportFormat = 'json'\n\r\nexport interface TMarksExportData {\r\n  version: string\r\n  format: 'tmarks' // TMarks 专属标识\r\n  exported_at: string\r\n  user: ExportUser\r\n  bookmarks: ExportBookmark[]\r\n  tags: ExportTag[]\r\n  tab_groups?: ExportTabGroup[]\r\n  metadata: {\r\n    total_bookmarks: number\r\n    total_tags: number\r\n    total_tab_groups?: number\r\n    export_format: ExportFormat\r\n    source: 'tmarks' // 明确标识来源\r\n  }\r\n}\r\n\r\n// ============ 导入格式（用于浏览器扩展）============\r\n\r\nexport type ImportFormat = 'html' | 'json' | 'tmarks'\r\n\r\nexport interface ParsedBookmark {\r\n  title: string\r\n  url: string\r\n  description?: string\r\n  cover_image?: string\r\n  tags: string[]\r\n  created_at?: string\r\n  folder?: string\r\n}\r\n\r\nexport interface ParsedTag {\r\n  name: string\r\n  color?: string\r\n}\r\n\r\nexport interface ParsedTabGroupItem {\r\n  id?: string\r\n  title: string\r\n  url: string\r\n  favicon?: string\r\n  position: number\r\n  is_pinned: boolean\r\n  is_todo: boolean\r\n  is_archived: boolean\r\n  created_at?: string\r\n}\r\n\r\nexport interface ParsedTabGroup {\r\n  id?: string\r\n  title: string\r\n  parent_id?: string\r\n  is_folder: boolean\r\n  position: number\r\n  color?: string\r\n  tags?: string\r\n  created_at?: string\r\n  updated_at?: string\r\n  items: ParsedTabGroupItem[]\r\n}\r\n\r\nexport interface ImportData {\r\n  bookmarks: ParsedBookmark[]\r\n  tags: ParsedTag[]\r\n  tab_groups?: ParsedTabGroup[]\r\n  metadata?: {\r\n    source: ImportFormat\r\n    total_items: number\r\n    total_tab_groups?: number\r\n    parsed_at: string\r\n  }\r\n}\r\n\r\n// ============ 操作结果 ============\r\n\r\nexport interface ImportResult {\r\n  success: number\r\n  failed: number\r\n  skipped: number\r\n  total: number\r\n  errors: ImportError[]\r\n  created_bookmarks: string[]\r\n  created_tags: string[]\r\n  created_tab_groups: string[]\r\n  tab_groups_success: number\r\n  tab_groups_failed: number\r\n}\r\n\r\nexport interface ExportResult {\r\n  success: boolean\r\n  format: ExportFormat\r\n  filename: string\r\n  size: number\r\n  exported_at: string\r\n  error?: string\r\n}\r\n\r\nexport interface ImportError {\r\n  index: number\r\n  item: ParsedBookmark\r\n  error: string\r\n  code: ImportErrorCode\r\n}\r\n\r\nexport type ImportErrorCode = \r\n  | 'INVALID_URL'\r\n  | 'DUPLICATE_URL'\r\n  | 'MISSING_TITLE'\r\n  | 'TAG_CREATION_FAILED'\r\n  | 'BOOKMARK_CREATION_FAILED'\r\n  | 'VALIDATION_ERROR'\r\n  | 'UNKNOWN_ERROR'\r\n\r\n// ============ 进度跟踪 ============\r\n\r\nexport interface ProgressInfo {\r\n  current: number\r\n  total: number\r\n  percentage: number\r\n  status: ProgressStatus\r\n  message: string\r\n  estimated_remaining?: number\r\n}\r\n\r\nexport type ProgressStatus = \r\n  | 'preparing'\r\n  | 'parsing'\r\n  | 'validating'\r\n  | 'importing'\r\n  | 'exporting'\r\n  | 'completed'\r\n  | 'failed'\r\n  | 'cancelled'\r\n\r\n// ============ 配置选项 ============\r\n\r\nexport interface ImportOptions {\r\n  skip_duplicates: boolean\r\n  create_missing_tags: boolean\r\n  preserve_timestamps: boolean\r\n  batch_size: number\r\n  max_concurrent: number\r\n  default_tag_color: string\r\n  folder_as_tag: boolean\r\n}\r\n\r\nexport interface ExportOptions {\r\n  include_tags: boolean\r\n  include_metadata: boolean\r\n  format_options: {\r\n    pretty_print?: boolean\r\n    include_click_stats?: boolean\r\n    include_user_info?: boolean\r\n  }\r\n}\r\n\r\n// ============ API 请求/响应 ============\r\n\r\nexport interface ExportRequest {\r\n  format: ExportFormat\r\n  options?: ExportOptions\r\n}\r\n\r\nexport interface ImportRequest {\r\n  format: ImportFormat\r\n  data: string | File\r\n  options?: ImportOptions\r\n}\r\n\r\nexport interface ImportProgressResponse {\r\n  progress: ProgressInfo\r\n  result?: ImportResult\r\n}\r\n\r\nexport interface ExportProgressResponse {\r\n  progress: ProgressInfo\r\n  result?: ExportResult\r\n}\r\n\r\n// ============ 解析器接口 ============\r\n\r\nexport interface ImportParser {\r\n  format: ImportFormat\r\n  parse(content: string): Promise<ImportData>\r\n  validate(data: ImportData): Promise<ValidationResult>\r\n}\r\n\r\nexport interface ValidationResult {\r\n  valid: boolean\r\n  errors: ValidationError[]\r\n  warnings: ValidationWarning[]\r\n}\r\n\r\nexport interface ValidationError {\r\n  field: string\r\n  message: string\r\n  value?: unknown\r\n}\r\n\r\nexport interface ValidationWarning {\r\n  field: string\r\n  message: string\r\n  value?: unknown\r\n}\r\n\r\n// ============ 导出器接口 ============\r\n\r\nexport interface Exporter {\r\n  format: ExportFormat\r\n  export(data: TMarksExportData, options?: ExportOptions): Promise<ExportOutput>\r\n}\r\n\r\nexport interface ExportOutput {\r\n  content: string | Buffer\r\n  filename: string\r\n  mimeType: string\r\n  size: number\r\n}\r\n\r\n// ============ 工具函数类型 ============\r\n\r\nexport type DateParser = (dateString: string) => Date | null\r\nexport type URLValidator = (url: string) => boolean\r\nexport type TagNormalizer = (tagName: string) => string\r\n\r\n// ============ 常量 ============\r\n\r\nexport const SUPPORTED_IMPORT_FORMATS: ImportFormat[] = ['html', 'json', 'tmarks']\nexport const SUPPORTED_EXPORT_FORMATS: ExportFormat[] = ['json']\n\r\nexport const DEFAULT_IMPORT_OPTIONS: ImportOptions = {\r\n  skip_duplicates: true,\r\n  create_missing_tags: true,\r\n  preserve_timestamps: true,\r\n  batch_size: 50,\r\n  max_concurrent: 5,\r\n  default_tag_color: '#3b82f6',\r\n  folder_as_tag: true\r\n}\r\n\r\nexport const DEFAULT_EXPORT_OPTIONS: ExportOptions = {\r\n  include_tags: true,\r\n  include_metadata: true,\r\n  format_options: {\r\n    pretty_print: true,\r\n    include_click_stats: false,\r\n    include_user_info: false\r\n  }\r\n}\r\n\r\nexport const EXPORT_VERSION = '1.0.0'\r\n"
  },
  {
    "path": "tmarks/shared/permissions.ts",
    "content": "/**\r\n * 权限系统 - 前后端共享\r\n * 定义所有 API Key 权限常量和工具函数\r\n */\r\n\r\n/**\r\n * 权限常量\r\n */\r\nexport const PERMISSIONS = {\r\n  // 书签权限\r\n  BOOKMARKS_CREATE: 'bookmarks.create',\r\n  BOOKMARKS_READ: 'bookmarks.read',\r\n  BOOKMARKS_UPDATE: 'bookmarks.update',\r\n  BOOKMARKS_DELETE: 'bookmarks.delete',\r\n  BOOKMARKS_ALL: 'bookmarks.*',\r\n\r\n  // 标签权限\r\n  TAGS_CREATE: 'tags.create',\r\n  TAGS_READ: 'tags.read',\r\n  TAGS_UPDATE: 'tags.update',\r\n  TAGS_DELETE: 'tags.delete',\r\n  TAGS_ASSIGN: 'tags.assign',\r\n  TAGS_ALL: 'tags.*',\r\n\r\n  // 收纳（标签页组）权限\r\n  TAB_GROUPS_CREATE: 'tab_groups.create',\r\n  TAB_GROUPS_READ: 'tab_groups.read',\r\n  TAB_GROUPS_UPDATE: 'tab_groups.update',\r\n  TAB_GROUPS_DELETE: 'tab_groups.delete',\r\n  TAB_GROUPS_ALL: 'tab_groups.*',\r\n\r\n  // AI 权限\r\n  AI_SUGGEST: 'ai.suggest',\r\n\r\n  // 用户权限\r\n  USER_READ: 'user.read',\r\n  USER_PREFERENCES_READ: 'user.preferences.read',\r\n} as const\r\n\r\nexport type Permission = typeof PERMISSIONS[keyof typeof PERMISSIONS]\r\n\r\n/**\r\n * 权限模板 - 使用 i18n key\r\n */\r\nexport const PERMISSION_TEMPLATES = {\r\n  READ_ONLY: {\r\n    nameKey: 'settings:permissions.templates.readOnly',\r\n    descriptionKey: 'settings:permissions.templates.readOnlyDesc',\r\n    permissions: [\r\n      PERMISSIONS.BOOKMARKS_READ,\r\n      PERMISSIONS.TAGS_READ,\r\n      PERMISSIONS.USER_READ,\r\n    ] as string[],\r\n  },\r\n\r\n  BASIC: {\r\n    nameKey: 'settings:permissions.templates.basic',\r\n    descriptionKey: 'settings:permissions.templates.basicDesc',\r\n    permissions: [\r\n      PERMISSIONS.BOOKMARKS_CREATE,\r\n      PERMISSIONS.BOOKMARKS_READ,\r\n      PERMISSIONS.TAGS_CREATE,\r\n      PERMISSIONS.TAGS_READ,\r\n      PERMISSIONS.TAGS_ASSIGN,\r\n      PERMISSIONS.USER_READ,\r\n    ] as string[],\r\n  },\r\n\r\n  FULL: {\r\n    nameKey: 'settings:permissions.templates.full',\r\n    descriptionKey: 'settings:permissions.templates.fullDesc',\r\n    permissions: [\r\n      PERMISSIONS.BOOKMARKS_ALL,\r\n      PERMISSIONS.TAGS_ALL,\r\n      PERMISSIONS.TAB_GROUPS_ALL,\r\n      PERMISSIONS.AI_SUGGEST,\r\n      PERMISSIONS.USER_READ,\r\n    ] as string[],\r\n  },\r\n} as const\r\n\r\nexport type PermissionTemplate = keyof typeof PERMISSION_TEMPLATES\r\n\r\n/**\r\n * 检查是否有权限\r\n * @param userPermissions 用户拥有的权限列表\r\n * @param requiredPermission 需要的权限\r\n * @returns 是否有权限\r\n */\r\nexport function hasPermission(\r\n  userPermissions: string[],\r\n  requiredPermission: string\r\n): boolean {\r\n  return userPermissions.some(p => {\r\n    // 完全匹配\r\n    if (p === requiredPermission) return true\r\n\r\n    // 通配符匹配：bookmarks.* 匹配 bookmarks.create\r\n    if (p.endsWith('.*')) {\r\n      const prefix = p.slice(0, -2)\r\n      return requiredPermission.startsWith(prefix + '.')\r\n    }\r\n\r\n    return false\r\n  })\r\n}\r\n\r\n/**\r\n * 权限到 i18n key 的映射\r\n */\r\nconst PERMISSION_I18N_KEYS: Record<string, string> = {\r\n  'bookmarks.create': 'settings:permissions.bookmarksCreate',\r\n  'bookmarks.read': 'settings:permissions.bookmarksRead',\r\n  'bookmarks.update': 'settings:permissions.bookmarksUpdate',\r\n  'bookmarks.delete': 'settings:permissions.bookmarksDelete',\r\n  'bookmarks.*': 'settings:permissions.bookmarksAll',\r\n  'tags.create': 'settings:permissions.tagsCreate',\r\n  'tags.read': 'settings:permissions.tagsRead',\r\n  'tags.update': 'settings:permissions.tagsUpdate',\r\n  'tags.delete': 'settings:permissions.tagsDelete',\r\n  'tags.assign': 'settings:permissions.tagsAssign',\r\n  'tags.*': 'settings:permissions.tagsAll',\r\n  'tab_groups.create': 'settings:permissions.tabGroupsCreate',\r\n  'tab_groups.read': 'settings:permissions.tabGroupsRead',\r\n  'tab_groups.update': 'settings:permissions.tabGroupsUpdate',\r\n  'tab_groups.delete': 'settings:permissions.tabGroupsDelete',\r\n  'tab_groups.*': 'settings:permissions.tabGroupsAll',\r\n  'ai.suggest': 'settings:permissions.aiSuggest',\r\n  'user.read': 'settings:permissions.userRead',\r\n  'user.preferences.read': 'settings:permissions.userPreferencesRead',\r\n}\r\n\r\n/**\r\n * 获取权限的 i18n key\r\n * @param permission 权限字符串\r\n * @returns i18n key\r\n */\r\nexport function getPermissionI18nKey(permission: string): string {\r\n  return PERMISSION_I18N_KEYS[permission] || permission\r\n}\r\n\r\n/**\r\n * 获取权限的显示名称（需要传入翻译函数）\r\n * @param permission 权限字符串\r\n * @param t 翻译函数\r\n * @returns 显示名称\r\n */\r\nexport function getPermissionLabel(permission: string, t?: (key: string) => string): string {\r\n  const key = PERMISSION_I18N_KEYS[permission]\r\n  if (t && key) {\r\n    return t(key)\r\n  }\r\n  // 后备：返回权限字符串本身\r\n  return permission\r\n}\r\n\r\n/**\r\n * 权限分组的 i18n key\r\n */\r\nconst PERMISSION_GROUP_I18N_KEYS = {\r\n  bookmarks: 'settings:permissions.bookmarks',\r\n  tags: 'settings:permissions.tags',\r\n  tabGroups: 'settings:permissions.tabGroups',\r\n  other: 'settings:permissions.other',\r\n}\r\n\r\n/**\r\n * 获取权限的分组（需要传入翻译函数）\r\n */\r\nexport function getPermissionGroups(t?: (key: string) => string): Array<{\r\n  name: string\r\n  nameKey: string\r\n  permissions: Array<{ value: string; label: string; labelKey: string }>\r\n}> {\r\n  const getName = (key: string) => t ? t(key) : key\r\n  const getLabel = (permission: string) => {\r\n    const labelKey = getPermissionI18nKey(permission)\r\n    return t ? t(labelKey) : permission\r\n  }\r\n\r\n  return [\r\n    {\r\n      name: getName(PERMISSION_GROUP_I18N_KEYS.bookmarks),\r\n      nameKey: PERMISSION_GROUP_I18N_KEYS.bookmarks,\r\n      permissions: [\r\n        { value: PERMISSIONS.BOOKMARKS_CREATE, label: getLabel(PERMISSIONS.BOOKMARKS_CREATE), labelKey: getPermissionI18nKey(PERMISSIONS.BOOKMARKS_CREATE) },\r\n        { value: PERMISSIONS.BOOKMARKS_READ, label: getLabel(PERMISSIONS.BOOKMARKS_READ), labelKey: getPermissionI18nKey(PERMISSIONS.BOOKMARKS_READ) },\r\n        { value: PERMISSIONS.BOOKMARKS_UPDATE, label: getLabel(PERMISSIONS.BOOKMARKS_UPDATE), labelKey: getPermissionI18nKey(PERMISSIONS.BOOKMARKS_UPDATE) },\r\n        { value: PERMISSIONS.BOOKMARKS_DELETE, label: getLabel(PERMISSIONS.BOOKMARKS_DELETE), labelKey: getPermissionI18nKey(PERMISSIONS.BOOKMARKS_DELETE) },\r\n      ],\r\n    },\r\n    {\r\n      name: getName(PERMISSION_GROUP_I18N_KEYS.tags),\r\n      nameKey: PERMISSION_GROUP_I18N_KEYS.tags,\r\n      permissions: [\r\n        { value: PERMISSIONS.TAGS_CREATE, label: getLabel(PERMISSIONS.TAGS_CREATE), labelKey: getPermissionI18nKey(PERMISSIONS.TAGS_CREATE) },\r\n        { value: PERMISSIONS.TAGS_READ, label: getLabel(PERMISSIONS.TAGS_READ), labelKey: getPermissionI18nKey(PERMISSIONS.TAGS_READ) },\r\n        { value: PERMISSIONS.TAGS_UPDATE, label: getLabel(PERMISSIONS.TAGS_UPDATE), labelKey: getPermissionI18nKey(PERMISSIONS.TAGS_UPDATE) },\r\n        { value: PERMISSIONS.TAGS_DELETE, label: getLabel(PERMISSIONS.TAGS_DELETE), labelKey: getPermissionI18nKey(PERMISSIONS.TAGS_DELETE) },\r\n        { value: PERMISSIONS.TAGS_ASSIGN, label: getLabel(PERMISSIONS.TAGS_ASSIGN), labelKey: getPermissionI18nKey(PERMISSIONS.TAGS_ASSIGN) },\r\n      ],\r\n    },\r\n    {\r\n      name: getName(PERMISSION_GROUP_I18N_KEYS.tabGroups),\r\n      nameKey: PERMISSION_GROUP_I18N_KEYS.tabGroups,\r\n      permissions: [\r\n        { value: PERMISSIONS.TAB_GROUPS_CREATE, label: getLabel(PERMISSIONS.TAB_GROUPS_CREATE), labelKey: getPermissionI18nKey(PERMISSIONS.TAB_GROUPS_CREATE) },\r\n        { value: PERMISSIONS.TAB_GROUPS_READ, label: getLabel(PERMISSIONS.TAB_GROUPS_READ), labelKey: getPermissionI18nKey(PERMISSIONS.TAB_GROUPS_READ) },\r\n        { value: PERMISSIONS.TAB_GROUPS_UPDATE, label: getLabel(PERMISSIONS.TAB_GROUPS_UPDATE), labelKey: getPermissionI18nKey(PERMISSIONS.TAB_GROUPS_UPDATE) },\r\n        { value: PERMISSIONS.TAB_GROUPS_DELETE, label: getLabel(PERMISSIONS.TAB_GROUPS_DELETE), labelKey: getPermissionI18nKey(PERMISSIONS.TAB_GROUPS_DELETE) },\r\n      ],\r\n    },\r\n    {\r\n      name: getName(PERMISSION_GROUP_I18N_KEYS.other),\r\n      nameKey: PERMISSION_GROUP_I18N_KEYS.other,\r\n      permissions: [\r\n        { value: PERMISSIONS.AI_SUGGEST, label: getLabel(PERMISSIONS.AI_SUGGEST), labelKey: getPermissionI18nKey(PERMISSIONS.AI_SUGGEST) },\r\n        { value: PERMISSIONS.USER_READ, label: getLabel(PERMISSIONS.USER_READ), labelKey: getPermissionI18nKey(PERMISSIONS.USER_READ) },\r\n      ],\r\n    },\r\n  ]\r\n}\r\n"
  },
  {
    "path": "tmarks/src/App.tsx",
    "content": "import { QueryClientProvider } from '@tanstack/react-query'\r\nimport { BrowserRouter } from 'react-router-dom'\r\nimport { useEffect, useRef } from 'react'\r\nimport { AppRouter } from '@/routes'\r\nimport { useAuthStore } from '@/stores/authStore'\r\nimport { ToastContainer } from '@/components/common/Toast'\r\nimport { DialogHost } from '@/components/common/DialogHost'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { useThemeStore } from '@/stores/themeStore'\r\nimport { queryClient } from '@/lib/query-client'\r\nimport { ErrorBoundary } from '@/components/common/ErrorBoundary'\r\n\r\nfunction App() {\r\n  const { user, isAuthenticated, accessToken, refreshToken, clearAuth, refreshAccessToken } = useAuthStore()\r\n  const { toasts, removeToast } = useToastStore()\r\n  const { theme, colorTheme } = useThemeStore()\r\n  const previousUserId = useRef<string | null | undefined>(undefined)\r\n\r\n  // 应用主题到 document.documentElement\r\n  useEffect(() => {\r\n    const root = document.documentElement\r\n    root.setAttribute('data-theme', theme)\r\n    root.setAttribute('data-color-theme', colorTheme)\r\n  }, [theme, colorTheme])\r\n\r\n  useEffect(() => {\r\n    if (isAuthenticated && !accessToken) {\r\n      if (refreshToken) {\r\n        refreshAccessToken().catch((err) => {\r\n          console.error('Failed to refresh access token:', err)\r\n          clearAuth()\r\n        })\r\n      } else {\r\n        clearAuth()\r\n      }\r\n    }\r\n  }, [isAuthenticated, accessToken, refreshToken, refreshAccessToken, clearAuth])\r\n\r\n  const userId = user?.id ?? null\r\n\r\n  useEffect(() => {\r\n    if (previousUserId.current === undefined) {\r\n      previousUserId.current = userId\r\n      return\r\n    }\r\n\r\n    if (previousUserId.current !== userId) {\r\n      queryClient.clear()\r\n      previousUserId.current = userId\r\n    }\r\n  }, [userId])\r\n\r\n  useEffect(() => {\r\n    let debounceTimer: ReturnType<typeof setTimeout>\r\n    const handler = () => {\r\n      clearTimeout(debounceTimer)\r\n      debounceTimer = setTimeout(() => {\r\n        queryClient.invalidateQueries({ queryKey: ['bookmarks'] }).catch((err) => console.error('Failed to invalidate bookmarks:', err))\r\n        queryClient.invalidateQueries({ queryKey: ['tags'] }).catch((err) => console.error('Failed to invalidate tags:', err))\r\n      }, 300)\r\n    }\r\n\r\n    window.addEventListener('tmarks:data-changed', handler)\r\n    return () => {\r\n      window.removeEventListener('tmarks:data-changed', handler)\r\n      clearTimeout(debounceTimer)\r\n    }\r\n  }, [])\r\n\r\n  return (\r\n    <ErrorBoundary>\r\n      <QueryClientProvider client={queryClient}>\r\n        <BrowserRouter>\r\n          <AppRouter />\r\n          <ToastContainer toasts={toasts} onClose={removeToast} />\r\n          <DialogHost />\r\n        </BrowserRouter>\r\n      </QueryClientProvider>\r\n    </ErrorBoundary>\r\n  )\r\n}\r\n\r\nexport default App\r\n"
  },
  {
    "path": "tmarks/src/components/api-keys/ApiKeyCard.tsx",
    "content": "/**\r\n * API Key 卡片组件\r\n * 显示单个 API Key 的摘要信息\r\n */\r\n\r\nimport { useTranslation } from 'react-i18next'\r\nimport type { ApiKey } from '@/services/api-keys'\r\nimport { formatDistanceToNow } from 'date-fns'\r\nimport { zhCN, enUS } from 'date-fns/locale'\r\n\r\ninterface ApiKeyCardProps {\r\n  apiKey: ApiKey\r\n  onViewDetails: () => void\r\n  onRevoke: () => void\r\n  onDelete?: () => void\r\n}\r\n\r\nexport function ApiKeyCard({ apiKey, onViewDetails, onRevoke, onDelete }: ApiKeyCardProps) {\r\n  const { t, i18n } = useTranslation('settings')\r\n  const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS\r\n\r\n  const statusIcon = {\r\n    active: <span className=\"inline-block w-2 h-2 rounded-full bg-green-500\" />,\r\n    revoked: <span className=\"inline-block w-2 h-2 rounded-full bg-red-500\" />,\r\n    expired: <span className=\"inline-block w-2 h-2 rounded-full bg-orange-500\" />,\r\n  }[apiKey.status]\r\n\r\n  const statusText = t(`apiKey.status.${apiKey.status}`)\r\n\r\n  const lastUsedText = apiKey.last_used_at\r\n    ? formatDistanceToNow(new Date(apiKey.last_used_at), {\r\n        addSuffix: true,\r\n        locale: dateLocale,\r\n      })\r\n    : t('apiKey.neverUsed')\r\n\r\n  return (\r\n    <div className=\"p-3 sm:p-4 md:p-5 bg-gradient-to-br from-primary/5 to-secondary/5 border border-primary/20 rounded-xl\">\r\n      <div className=\"flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4\">\r\n        <div className=\"flex-1 min-w-0\">\r\n          {/* 名称和前缀 */}\r\n          <div className=\"mb-3\">\r\n            <h3 className=\"text-base sm:text-lg font-semibold text-foreground mb-1 truncate\">\r\n              {apiKey.name}\r\n            </h3>\r\n            <code className=\"text-xs sm:text-sm text-muted-foreground font-mono break-all\">\r\n              {apiKey.key_prefix}...\r\n            </code>\r\n          </div>\r\n\r\n          {/* 描述 */}\r\n          {apiKey.description && (\r\n            <p className=\"text-xs sm:text-sm text-muted-foreground mb-3 leading-relaxed\">\r\n              {apiKey.description}\r\n            </p>\r\n          )}\r\n\r\n          {/* 元信息 */}\r\n          <div className=\"grid grid-cols-1 sm:grid-cols-2 gap-2 sm:gap-3 text-xs text-muted-foreground\">\r\n            <div className=\"flex items-center space-x-1\">\r\n              <span>{t('apiKey.status.label')}:</span>\r\n              <span>{statusIcon}</span>\r\n              <strong>{statusText}</strong>\r\n            </div>\r\n            <div>\r\n              {t('apiKey.permissions')}: <strong>{t('apiKey.permissionsCount', { count: apiKey.permissions.length })}</strong>\r\n            </div>\r\n            <div>\r\n              {t('apiKey.lastUsed')}: <strong className=\"break-words\">{lastUsedText}</strong>\r\n            </div>\r\n            <div>\r\n              {t('apiKey.createdAt')}:{' '}\r\n              <strong>\r\n                {new Date(apiKey.created_at).toLocaleDateString(i18n.language)}\r\n              </strong>\r\n            </div>\r\n            {apiKey.expires_at && (\r\n              <div className=\"sm:col-span-2\">\r\n                {t('apiKey.expiresAt')}:{' '}\r\n                <strong>\r\n                  {new Date(apiKey.expires_at).toLocaleDateString(i18n.language)}\r\n                </strong>\r\n              </div>\r\n            )}\r\n          </div>\r\n        </div>\r\n\r\n        {/* 操作按钮 */}\r\n        <div className=\"flex flex-col sm:flex-row gap-2 w-full sm:w-auto sm:ml-4\">\r\n          <button className=\"btn btn-sm w-full sm:w-auto touch-manipulation\" onClick={onViewDetails}>\r\n            {t('apiKey.viewDetails')}\r\n          </button>\r\n          {apiKey.status === 'active' && (\r\n            <button className=\"btn btn-sm btn-error w-full sm:w-auto touch-manipulation\" onClick={onRevoke}>\r\n              {t('apiKey.revoke')}\r\n            </button>\r\n          )}\r\n          {onDelete && (\r\n            <button\r\n              className={`btn btn-sm w-full sm:w-auto touch-manipulation ${apiKey.status === 'active' ? 'btn-outline btn-error' : 'btn-error'}`}\r\n              onClick={onDelete}\r\n            >\r\n              {t('apiKey.delete')}\r\n            </button>\r\n          )}\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/api-keys/ApiKeyDetailModal.tsx",
    "content": "/**\r\n * API Key 详情模态框\r\n * 显示 API Key 的详细信息和使用日志\r\n */\r\n\r\nimport { useTranslation } from 'react-i18next'\r\nimport { useApiKey, useApiKeyLogs } from '@/hooks/useApiKeys'\r\nimport { getPermissionLabel } from '@shared/permissions'\r\nimport type { ApiKey } from '@/services/api-keys'\r\nimport { formatDistanceToNow } from 'date-fns'\r\nimport { zhCN, enUS } from 'date-fns/locale'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\n\r\ninterface ApiKeyDetailModalProps {\r\n  apiKey: ApiKey\r\n  onClose: () => void\r\n}\r\n\r\nexport function ApiKeyDetailModal({ apiKey, onClose }: ApiKeyDetailModalProps) {\r\n  const { t, i18n } = useTranslation('settings')\r\n  const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS\r\n  const { data: keyData } = useApiKey(apiKey.id)\r\n  const { data: logsData } = useApiKeyLogs(apiKey.id, 10)\r\n\r\n  const key = keyData || apiKey\r\n  const logs = logsData?.logs || []\r\n  const stats = keyData?.stats\r\n\r\n  const statusIcon = {\r\n    active: '🟢',\r\n    revoked: '🔴',\r\n    expired: '🟠',\r\n  }[key.status]\r\n\r\n  const statusText = t(`apiKey.status.${key.status}`)\r\n\r\n  return (\r\n    <div className=\"fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center\" style={{ zIndex: Z_INDEX.API_KEY_MODAL }}>\r\n      <div className=\"card rounded-2xl shadow-2xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto\" style={{ backgroundColor: 'var(--card)' }}>\r\n        <div className=\"p-6\">\r\n          {/* 标题 */}\r\n          <div className=\"flex items-center justify-between mb-6\">\r\n            <h2 className=\"text-xl font-bold text-foreground\">{key.name}</h2>\r\n            <button className=\"btn btn-sm\" onClick={onClose}>\r\n              {t('apiKey.detail.close')}\r\n            </button>\r\n          </div>\r\n\r\n          {/* 基本信息 */}\r\n          <div className=\"mb-6\">\r\n            <h3 className=\"text-sm font-medium text-foreground mb-3\">\r\n              {t('apiKey.detail.basicInfo')}\r\n            </h3>\r\n            <div className=\"space-y-2 text-sm\">\r\n              <div className=\"flex\">\r\n                <span className=\"text-muted-foreground w-28\">{t('apiKey.detail.keyPrefix')}</span>\r\n                <code className=\"font-mono\">{key.key_prefix}...</code>\r\n              </div>\r\n              <div className=\"flex\">\r\n                <span className=\"text-muted-foreground w-28\">{t('apiKey.status.label')}:</span>\r\n                <span>\r\n                  {statusIcon} {statusText}\r\n                </span>\r\n              </div>\r\n              <div className=\"flex\">\r\n                <span className=\"text-muted-foreground w-28\">{t('apiKey.detail.createdAt')}</span>\r\n                <span>{new Date(key.created_at).toLocaleString(i18n.language)}</span>\r\n              </div>\r\n              {key.expires_at && (\r\n                <div className=\"flex\">\r\n                  <span className=\"text-muted-foreground w-28\">{t('apiKey.detail.expiresAt')}</span>\r\n                  <span>\r\n                    {new Date(key.expires_at).toLocaleString(i18n.language)}\r\n                  </span>\r\n                </div>\r\n              )}\r\n              {!key.expires_at && (\r\n                <div className=\"flex\">\r\n                  <span className=\"text-muted-foreground w-28\">{t('apiKey.detail.expiresAt')}</span>\r\n                  <span>{t('apiKey.detail.neverExpire')}</span>\r\n                </div>\r\n              )}\r\n              {key.description && (\r\n                <div className=\"flex\">\r\n                  <span className=\"text-muted-foreground w-28\">{t('apiKey.detail.description')}</span>\r\n                  <span>{key.description}</span>\r\n                </div>\r\n              )}\r\n            </div>\r\n          </div>\r\n\r\n          {/* 权限列表 */}\r\n          <div className=\"mb-6\">\r\n            <h3 className=\"text-sm font-medium text-foreground mb-3\">{t('apiKey.detail.permissions')}</h3>\r\n            <div className=\"grid grid-cols-1 gap-2\">\r\n              {key.permissions.map((perm) => (\r\n                <div\r\n                  key={perm}\r\n                  className=\"text-xs bg-primary/10 text-primary px-3 py-2 rounded flex items-center gap-2\"\r\n                >\r\n                  <span>✓</span>\r\n                  <span className=\"font-medium\">{getPermissionLabel(perm)}</span>\r\n                  <span className=\"text-primary/60\">({perm})</span>\r\n                </div>\r\n              ))}\r\n            </div>\r\n          </div>\r\n\r\n          {/* 使用情况 */}\r\n          {stats && (\r\n            <div className=\"mb-6\">\r\n              <h3 className=\"text-sm font-medium text-foreground mb-3\">\r\n                {t('apiKey.detail.usage')}\r\n              </h3>\r\n              <div className=\"space-y-2 text-sm\">\r\n                <div className=\"flex\">\r\n                  <span className=\"text-muted-foreground w-28\">{t('apiKey.detail.lastUsed')}</span>\r\n                  <span>\r\n                    {stats.last_used_at\r\n                      ? formatDistanceToNow(new Date(stats.last_used_at), {\r\n                          addSuffix: true,\r\n                          locale: dateLocale,\r\n                        })\r\n                      : t('apiKey.detail.neverUsed')}\r\n                  </span>\r\n                </div>\r\n                <div className=\"flex\">\r\n                  <span className=\"text-muted-foreground w-28\">{t('apiKey.detail.totalRequests')}</span>\r\n                  <span>{t('apiKey.detail.requestCount', { count: stats.total_requests })}</span>\r\n                </div>\r\n                {stats.last_used_ip && (\r\n                  <div className=\"flex\">\r\n                    <span className=\"text-muted-foreground w-28\">{t('apiKey.detail.lastIp')}</span>\r\n                    <span>{stats.last_used_ip}</span>\r\n                  </div>\r\n                )}\r\n              </div>\r\n            </div>\r\n          )}\r\n\r\n          {/* 最近活动 */}\r\n          {logs.length > 0 && (\r\n            <div>\r\n              <h3 className=\"text-sm font-medium text-foreground mb-3\">\r\n                {t('apiKey.detail.recentActivity')} {t('apiKey.detail.recentActivityLimit', { count: 10 })}\r\n              </h3>\r\n              <div className=\"bg-muted/30 border border-border rounded-lg overflow-hidden\">\r\n                <table className=\"w-full text-xs\">\r\n                  <thead className=\"bg-muted\">\r\n                    <tr>\r\n                      <th className=\"text-left px-3 py-2 font-medium\">{t('apiKey.detail.tableTime')}</th>\r\n                      <th className=\"text-left px-3 py-2 font-medium\">{t('apiKey.detail.tableMethod')}</th>\r\n                      <th className=\"text-left px-3 py-2 font-medium\">{t('apiKey.detail.tableEndpoint')}</th>\r\n                      <th className=\"text-left px-3 py-2 font-medium\">{t('apiKey.detail.tableStatus')}</th>\r\n                    </tr>\r\n                  </thead>\r\n                  <tbody>\r\n                    {logs.map((log, index) => (\r\n                      <tr\r\n                        key={index}\r\n                        className=\"border-t border-border hover:bg-muted/50\"\r\n                      >\r\n                        <td className=\"px-3 py-2 text-muted-foreground\">\r\n                          {new Date(log.created_at).toLocaleString(i18n.language, {\r\n                            month: '2-digit',\r\n                            day: '2-digit',\r\n                            hour: '2-digit',\r\n                            minute: '2-digit',\r\n                          })}\r\n                        </td>\r\n                        <td className=\"px-3 py-2\">\r\n                          <code className=\"text-xs\">{log.method}</code>\r\n                        </td>\r\n                        <td className=\"px-3 py-2 font-mono\">{log.endpoint}</td>\r\n                        <td className=\"px-3 py-2\">\r\n                          <span\r\n                            className={\r\n                              log.status < 300\r\n                                ? 'text-success'\r\n                                : log.status < 400\r\n                                  ? 'text-warning'\r\n                                  : 'text-error'\r\n                            }\r\n                          >\r\n                            {log.status}\r\n                          </span>\r\n                        </td>\r\n                      </tr>\r\n                    ))}\r\n                  </tbody>\r\n                </table>\r\n              </div>\r\n            </div>\r\n          )}\r\n\r\n          {logs.length === 0 && (\r\n            <div className=\"text-center text-sm text-muted-foreground py-6\">\r\n              {t('apiKey.detail.noLogs')}\r\n            </div>\r\n          )}\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/api-keys/CreateApiKeyModal.tsx",
    "content": "/**\r\n * 创建 API Key 模态框\r\n * 多步骤流程：基本信息 → 权限设置 → 过期设置 → 显示 Key\r\n */\r\n\r\nimport { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { useCreateApiKey } from '@/hooks/useApiKeys'\r\nimport { AlertDialog } from '@/components/common/AlertDialog'\r\nimport { PERMISSION_TEMPLATES } from '@shared/permissions'\r\nimport type { ApiKeyWithKey, CreateApiKeyRequest } from '@/services/api-keys'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\nimport { StepBasicInfo } from './StepBasicInfo'\r\nimport { StepPermissions } from './StepPermissions'\r\nimport { StepSuccess } from './StepSuccess'\r\n\r\ninterface CreateApiKeyModalProps {\r\n  onClose: () => void\r\n}\r\n\r\ntype Step = 'basic' | 'permissions' | 'expiration' | 'success'\r\n\r\nexport function CreateApiKeyModal({ onClose }: CreateApiKeyModalProps) {\r\n  const { t } = useTranslation('settings')\r\n  const createApiKey = useCreateApiKey()\r\n\r\n  const [step, setStep] = useState<Step>('basic')\r\n  const [formData, setFormData] = useState<CreateApiKeyRequest>({\r\n    name: '',\r\n    description: '',\r\n    template: 'BASIC',\r\n    permissions: [],\r\n    expires_at: null,\r\n  })\r\n  const [createdKey, setCreatedKey] = useState<ApiKeyWithKey | null>(null)\r\n  const [showErrorAlert, setShowErrorAlert] = useState(false)\r\n\r\n  const updateFormData = (data: Partial<CreateApiKeyRequest>) => {\r\n    setFormData((prev) => ({ ...prev, ...data }))\r\n  }\r\n\r\n  const handleNext = () => {\r\n    if (step === 'basic') setStep('permissions')\r\n    else if (step === 'permissions') setStep('expiration')\r\n    else if (step === 'expiration') handleCreate()\r\n  }\r\n\r\n  const handleBack = () => {\r\n    if (step === 'permissions') setStep('basic')\r\n    else if (step === 'expiration') setStep('permissions')\r\n  }\r\n\r\n  const handleCreate = async () => {\r\n    try {\r\n      const result = await createApiKey.mutateAsync(formData)\r\n      setCreatedKey(result)\r\n      setStep('success')\r\n    } catch {\r\n      setShowErrorAlert(true)\r\n    }\r\n  }\r\n\r\n  const canProceed = () => {\r\n    if (step === 'basic') return formData.name.trim().length > 0\r\n    if (step === 'permissions') {\r\n      const perms =\r\n        formData.template\r\n          ? PERMISSION_TEMPLATES[formData.template].permissions\r\n          : formData.permissions\r\n      return !!(perms && perms.length > 0)\r\n    }\r\n    return true\r\n  }\r\n\r\n  return (\r\n    <div\r\n      className=\"fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center\"\r\n      style={{ zIndex: Z_INDEX.API_KEY_MODAL }}\r\n      onClick={step !== 'success' ? onClose : undefined}\r\n    >\r\n      <AlertDialog\r\n        isOpen={showErrorAlert}\r\n        title={t('apiKey.create.failed')}\r\n        message={t('apiKey.create.failedMessage')}\r\n        type=\"error\"\r\n        onConfirm={() => setShowErrorAlert(false)}\r\n      />\r\n\r\n      <div className=\"card rounded-2xl shadow-2xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto\" style={{ backgroundColor: 'var(--card)' }} onClick={(e) => e.stopPropagation()}>\r\n        {/* 步骤 1: 基本信息 */}\r\n        {step === 'basic' && (\r\n          <StepBasicInfo\r\n            formData={formData}\r\n            onChange={updateFormData}\r\n            onNext={handleNext}\r\n            onCancel={onClose}\r\n            canProceed={canProceed()}\r\n          />\r\n        )}\r\n\r\n        {/* 步骤 2: 权限设置 */}\r\n        {step === 'permissions' && (\r\n          <StepPermissions\r\n            formData={formData}\r\n            onChange={updateFormData}\r\n            onNext={handleNext}\r\n            onBack={handleBack}\r\n            canProceed={canProceed()}\r\n          />\r\n        )}\r\n\r\n        {/* 步骤 3: 过期设置 */}\r\n        {step === 'expiration' && (\r\n          <div className=\"p-6\">\r\n            <h2 className=\"text-xl font-bold text-foreground mb-4\">\r\n              {t('apiKey.create.title')} - {t('apiKey.create.step', { current: 3, total: 4 })}\r\n            </h2>\r\n\r\n            <div className=\"space-y-4\">\r\n              <div>\r\n                <label className=\"block text-sm font-medium text-foreground mb-3\">\r\n                  {t('apiKey.create.expiration')}\r\n                </label>\r\n                <div className=\"space-y-2\">\r\n                  <label className=\"flex items-center gap-3 p-3 border border-border rounded-lg cursor-pointer hover:bg-muted/30\">\r\n                    <input\r\n                      type=\"radio\"\r\n                      name=\"expires\"\r\n                      checked={formData.expires_at === null}\r\n                      onChange={() =>\r\n                        updateFormData({ expires_at: null })\r\n                      }\r\n                    />\r\n                    <span className=\"text-foreground\">{t('apiKey.create.neverExpire')}</span>\r\n                  </label>\r\n\r\n                  <label className=\"flex items-center gap-3 p-3 border border-border rounded-lg cursor-pointer hover:bg-muted/30\">\r\n                    <input\r\n                      type=\"radio\"\r\n                      name=\"expires\"\r\n                      checked={formData.expires_at === '30d'}\r\n                      onChange={() =>\r\n                        updateFormData({ expires_at: '30d' })\r\n                      }\r\n                    />\r\n                    <span className=\"text-foreground\">{t('apiKey.create.expireIn30Days')}</span>\r\n                  </label>\r\n\r\n                  <label className=\"flex items-center gap-3 p-3 border border-border rounded-lg cursor-pointer hover:bg-muted/30\">\r\n                    <input\r\n                      type=\"radio\"\r\n                      name=\"expires\"\r\n                      checked={formData.expires_at === '90d'}\r\n                      onChange={() =>\r\n                        updateFormData({ expires_at: '90d' })\r\n                      }\r\n                    />\r\n                    <span className=\"text-foreground\">{t('apiKey.create.expireIn90Days')}</span>\r\n                  </label>\r\n                </div>\r\n              </div>\r\n            </div>\r\n\r\n            <div className=\"flex justify-between mt-6\">\r\n              <button className=\"btn\" onClick={handleBack}>\r\n                {t('apiKey.create.prev')}\r\n              </button>\r\n              <button\r\n                className=\"btn btn-primary\"\r\n                onClick={handleNext}\r\n                disabled={createApiKey.isPending}\r\n              >\r\n                {createApiKey.isPending ? t('apiKey.create.creating') : t('apiKey.create.createButton')}\r\n              </button>\r\n            </div>\r\n          </div>\r\n        )}\r\n\r\n        {/* 步骤 4: 成功显示 Key */}\r\n        {step === 'success' && createdKey && (\r\n          <StepSuccess\r\n            createdKey={createdKey}\r\n            onClose={onClose}\r\n          />\r\n        )}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/api-keys/StepBasicInfo.tsx",
    "content": "import { useTranslation } from 'react-i18next'\nimport type { CreateApiKeyRequest } from '@/services/api-keys'\n\ninterface StepBasicInfoProps {\n  formData: CreateApiKeyRequest\n  onChange: (data: Partial<CreateApiKeyRequest>) => void\n  onNext: () => void\n  onCancel: () => void\n  canProceed: boolean\n}\n\nexport function StepBasicInfo({\n  formData,\n  onChange,\n  onNext,\n  onCancel,\n  canProceed,\n}: StepBasicInfoProps) {\n  const { t } = useTranslation('settings')\n\n  return (\n    <div className=\"p-6\">\n      <h2 className=\"text-xl font-bold text-foreground mb-4\">\n        {t('apiKey.create.title')} - {t('apiKey.create.step', { current: 1, total: 3 })}\n      </h2>\n\n      <div className=\"space-y-4\">\n        <div>\n          <label className=\"block text-sm font-medium text-foreground mb-2\">\n            {t('apiKey.create.nameRequired')}\n          </label>\n          <input\n            type=\"text\"\n            className=\"input w-full\"\n            placeholder={t('apiKey.create.namePlaceholder')}\n            value={formData.name}\n            onChange={(e) => onChange({ name: e.target.value })}\n          />\n          <p className=\"text-xs text-muted-foreground mt-1\">\n            {t('apiKey.create.nameHint')}\n          </p>\n        </div>\n\n        <div>\n          <label className=\"block text-sm font-medium text-foreground mb-2\">\n            {t('apiKey.create.description')}\n          </label>\n          <textarea\n            className=\"input w-full h-20 resize-none\"\n            placeholder={t('apiKey.create.descriptionPlaceholder')}\n            value={formData.description}\n            onChange={(e) => onChange({ description: e.target.value })}\n          />\n        </div>\n      </div>\n\n      <div className=\"flex justify-between mt-6\">\n        <button className=\"btn\" onClick={onCancel}>\n          {t('apiKey.create.cancel')}\n        </button>\n        <button\n          className=\"btn btn-primary\"\n          onClick={onNext}\n          disabled={!canProceed}\n        >\n          {t('apiKey.create.next')}\n        </button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/api-keys/StepPermissions.tsx",
    "content": "import { useTranslation } from 'react-i18next'\nimport {\n  PERMISSION_TEMPLATES,\n  getPermissionLabel,\n  type PermissionTemplate,\n} from '@shared/permissions'\nimport type { CreateApiKeyRequest } from '@/services/api-keys'\n\ninterface StepPermissionsProps {\n  formData: CreateApiKeyRequest\n  onChange: (data: Partial<CreateApiKeyRequest>) => void\n  onNext: () => void\n  onBack: () => void\n  canProceed: boolean\n}\n\nexport function StepPermissions({\n  formData,\n  onChange,\n  onNext,\n  onBack,\n  canProceed,\n}: StepPermissionsProps) {\n  const { t } = useTranslation('settings')\n\n  return (\n    <div className=\"p-6\">\n      <h2 className=\"text-xl font-bold text-foreground mb-4\">\n        {t('apiKey.create.title')} - {t('apiKey.create.step', { current: 2, total: 3 })}\n      </h2>\n\n      <div className=\"space-y-4\">\n        <div>\n          <label className=\"block text-sm font-medium text-foreground mb-3\">\n            {t('apiKey.create.quickSelect')}\n          </label>\n          <div className=\"space-y-2\">\n            {(Object.keys(PERMISSION_TEMPLATES) as PermissionTemplate[]).map(\n              (template) => (\n                <label\n                  key={template}\n                  className=\"flex items-start gap-3 p-3 border border-border rounded-lg cursor-pointer hover:bg-muted/30\"\n                >\n                  <input\n                    type=\"radio\"\n                    name=\"template\"\n                    checked={formData.template === template}\n                    onChange={() => onChange({ template })}\n                    className=\"mt-1\"\n                  />\n                  <div className=\"flex-1\">\n                    <div className=\"font-medium text-foreground\">\n                      {t(PERMISSION_TEMPLATES[template].nameKey)}\n                      {template === 'BASIC' && (\n                        <span className=\"ml-2 text-xs bg-primary text-primary-content px-2 py-0.5 rounded\">\n                          {t('apiKey.create.recommended')}\n                        </span>\n                      )}\n                    </div>\n                    <div className=\"text-xs text-muted-foreground\">\n                      {t(PERMISSION_TEMPLATES[template].descriptionKey)}\n                    </div>\n                  </div>\n                </label>\n              )\n            )}\n          </div>\n        </div>\n\n        {/* 预览权限 */}\n        <div className=\"p-4 bg-muted/30 border border-border rounded-lg\">\n          <div className=\"text-sm font-medium text-foreground mb-2\">\n            {t('apiKey.create.includedPermissions')}\n          </div>\n          <div className=\"space-y-1 text-xs text-muted-foreground\">\n            {(formData.template\n              ? PERMISSION_TEMPLATES[formData.template].permissions\n              : formData.permissions || []\n            ).map((perm: string) => (\n              <div key={perm} className=\"flex items-center gap-2\">\n                <span>✓</span>\n                <span className=\"font-medium\">{getPermissionLabel(perm)}</span>\n                <span className=\"text-muted-foreground/60\">({perm})</span>\n              </div>\n            ))}\n          </div>\n        </div>\n      </div>\n\n      <div className=\"flex justify-between mt-6\">\n        <button className=\"btn\" onClick={onBack}>\n          {t('apiKey.create.prev')}\n        </button>\n        <button\n          className=\"btn btn-primary\"\n          onClick={onNext}\n          disabled={!canProceed}\n        >\n          {t('apiKey.create.next')}\n        </button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/api-keys/StepSuccess.tsx",
    "content": "import { useState, useRef, useEffect } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport type { ApiKeyWithKey } from '@/services/api-keys'\n\ninterface StepSuccessProps {\n  createdKey: ApiKeyWithKey\n  onClose: () => void\n}\n\nexport function StepSuccess({ createdKey, onClose }: StepSuccessProps) {\n  const { t } = useTranslation('settings')\n  const [copied, setCopied] = useState(false)\n  const timerRef = useRef<ReturnType<typeof setTimeout>>()\n\n  useEffect(() => {\n    return () => {\n      if (timerRef.current) clearTimeout(timerRef.current)\n    }\n  }, [])\n\n  const handleCopy = () => {\n    navigator.clipboard.writeText(createdKey.key)\n    setCopied(true)\n    if (timerRef.current) clearTimeout(timerRef.current)\n    timerRef.current = setTimeout(() => setCopied(false), 2000)\n  }\n\n  return (\n    <div className=\"p-6\">\n      <div className=\"text-center mb-6\">\n        <div className=\"text-4xl mb-3\">⚠️</div>\n        <h2 className=\"text-xl font-bold text-foreground mb-2\">\n          {t('apiKey.success.title')}\n        </h2>\n      </div>\n\n      <div className=\"mb-6\">\n        <label className=\"block text-sm font-medium text-foreground mb-2\">\n          {t('apiKey.success.yourKey')}\n        </label>\n        <div className=\"flex gap-2\">\n          <input\n            type=\"text\"\n            className=\"input flex-1 font-mono text-sm\"\n            value={createdKey.key}\n            readOnly\n            onClick={(e) => e.currentTarget.select()}\n          />\n          <button className=\"btn\" onClick={handleCopy}>\n            {copied ? t('apiKey.success.copied') : t('apiKey.success.copy')}\n          </button>\n        </div>\n      </div>\n\n      <div className=\"p-4 bg-warning/10 border border-warning/30 rounded-lg mb-6\">\n        <h4 className=\"font-medium text-warning mb-2\">{t('apiKey.success.warning')}</h4>\n        <ul className=\"text-xs text-muted-foreground space-y-1 list-disc list-inside\">\n          <li>{t('apiKey.success.warningList.showOnce')}</li>\n          <li>{t('apiKey.success.warningList.saveNow')}</li>\n          <li>{t('apiKey.success.warningList.prefixOnly', { prefix: createdKey.key_prefix })}</li>\n        </ul>\n      </div>\n\n      <div className=\"flex justify-center\">\n        <button className=\"btn btn-primary\" onClick={onClose}>\n          {t('apiKey.success.close')}\n        </button>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/auth/ProtectedRoute.tsx",
    "content": "import { Navigate, Outlet } from 'react-router-dom'\r\nimport { useAuthStore } from '@/stores/authStore'\r\nimport { useState, useEffect } from 'react'\r\n\r\nexport function ProtectedRoute() {\r\n  const isAuthenticated = useAuthStore((state) => state.isAuthenticated)\r\n  const [hydrated, setHydrated] = useState(useAuthStore.persist.hasHydrated())\r\n\r\n  useEffect(() => {\r\n    if (hydrated) return\r\n    // Zustand persist 提供 onFinishHydration 回调\r\n    const unsub = useAuthStore.persist.onFinishHydration(() => {\r\n      setHydrated(true)\r\n    })\r\n    // 如果在注册回调期间已经 hydrated\r\n    if (useAuthStore.persist.hasHydrated()) {\r\n      setHydrated(true)\r\n    }\r\n    return unsub\r\n  }, [hydrated])\r\n\r\n  // 开发环境下可以跳过登录\r\n  const skipAuth = import.meta.env.DEV && import.meta.env.VITE_SKIP_AUTH === 'true'\r\n\r\n  if (!hydrated) {\r\n    return (\r\n      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>\r\n        <div style={{\r\n          width: 32, height: 32, border: '3px solid #e5e7eb',\r\n          borderTopColor: '#6366f1', borderRadius: '50%',\r\n          animation: 'spin 0.6s linear infinite',\r\n        }} />\r\n        <style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  if (!isAuthenticated && !skipAuth) {\r\n    return <Navigate to=\"/login\" replace />\r\n  }\r\n\r\n  return <Outlet />\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/BatchActionBar.tsx",
    "content": "import { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport type { BatchActionType } from '@/lib/types'\r\nimport { useBatchAction } from '@/hooks/useBookmarks'\r\nimport { useTags } from '@/hooks/useTags'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { AlertDialog } from '@/components/common/AlertDialog'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\n\r\ninterface BatchActionBarProps {\r\n  selectedIds: string[]\r\n  onClearSelection: () => void\r\n  onSuccess?: () => void\r\n}\r\n\r\nexport function BatchActionBar({\r\n  selectedIds,\r\n  onClearSelection,\r\n  onSuccess,\r\n}: BatchActionBarProps) {\r\n  const { t } = useTranslation('bookmarks')\r\n  const { t: tc } = useTranslation('common')\r\n  const [showTagMenu, setShowTagMenu] = useState(false)\r\n  const [selectedTagIds, setSelectedTagIds] = useState<string[]>([])\r\n  const [showConfirmDialog, setShowConfirmDialog] = useState(false)\r\n  const [showErrorAlert, setShowErrorAlert] = useState(false)\r\n  const [showSuccessAlert, setShowSuccessAlert] = useState(false)\r\n  const [successMessage, setSuccessMessage] = useState('')\r\n  const [pendingAction, setPendingAction] = useState<BatchActionType | null>(null)\r\n  const batchAction = useBatchAction()\r\n  const { data: tagsData } = useTags({ sort: 'name' })\r\n\r\n  const tags = tagsData?.tags || []\r\n\r\n  const handleAction = async (action: BatchActionType) => {\r\n    if (selectedIds.length === 0) return\r\n\r\n    if (action === 'delete' || action === 'pin' || action === 'archive') {\r\n      setPendingAction(action)\r\n      setShowConfirmDialog(true)\r\n      return\r\n    }\r\n\r\n    await executeAction(action)\r\n  }\r\n\r\n  const getSuccessMessage = (action: BatchActionType) => {\r\n    const count = selectedIds.length\r\n    switch (action) {\r\n      case 'delete':\r\n        return t('batch.deleteSuccess', { count })\r\n      case 'pin':\r\n        return t('batch.pinSuccess', { count })\r\n      case 'archive':\r\n        return t('batch.archiveSuccess', { count })\r\n      default:\r\n        return tc('message.operationSuccess')\r\n    }\r\n  }\r\n\r\n  const executeAction = async (action: BatchActionType) => {\r\n    try {\r\n      await batchAction.mutateAsync({\r\n        action,\r\n        bookmark_ids: selectedIds,\r\n      })\r\n      onClearSelection()\r\n      onSuccess?.()\r\n      setSuccessMessage(getSuccessMessage(action))\r\n      setShowSuccessAlert(true)\r\n    } catch (error) {\r\n      console.error('Batch action failed:', error)\r\n      setShowErrorAlert(true)\r\n    }\r\n  }\r\n\r\n  const handleConfirm = async () => {\r\n    setShowConfirmDialog(false)\r\n    if (pendingAction) {\r\n      await executeAction(pendingAction)\r\n      setPendingAction(null)\r\n    }\r\n  }\r\n\r\n  const handleCancel = () => {\r\n    setShowConfirmDialog(false)\r\n    setPendingAction(null)\r\n  }\r\n\r\n  const getConfirmDialogConfig = () => {\r\n    const count = selectedIds.length\r\n    switch (pendingAction) {\r\n      case 'delete':\r\n        return {\r\n          title: t('batch.deleteTitle'),\r\n          message: t('batch.deleteMessage', { count }),\r\n          type: 'warning' as const,\r\n        }\r\n      case 'pin':\r\n        return {\r\n          title: t('batch.pinTitle'),\r\n          message: t('batch.pinMessage', { count }),\r\n          type: 'info' as const,\r\n        }\r\n      case 'archive':\r\n        return {\r\n          title: t('batch.archiveTitle'),\r\n          message: t('batch.archiveMessage', { count }),\r\n          type: 'info' as const,\r\n        }\r\n      default:\r\n        return {\r\n          title: t('batch.confirmAction'),\r\n          message: t('batch.confirmMessage'),\r\n          type: 'info' as const,\r\n        }\r\n    }\r\n  }\r\n\r\n  const handleUpdateTags = async (mode: 'add' | 'remove') => {\r\n    if (selectedIds.length === 0 || selectedTagIds.length === 0) return\r\n\r\n    try {\r\n      await batchAction.mutateAsync({\r\n        action: 'update_tags',\r\n        bookmark_ids: selectedIds,\r\n        add_tag_ids: mode === 'add' ? selectedTagIds : undefined,\r\n        remove_tag_ids: mode === 'remove' ? selectedTagIds : undefined,\r\n      })\r\n      const message = mode === 'add'\r\n        ? t('batch.addTagsSuccess', { count: selectedIds.length })\r\n        : t('batch.removeTagsSuccess', { count: selectedIds.length })\r\n      setSuccessMessage(message)\r\n      setShowSuccessAlert(true)\r\n      setSelectedTagIds([])\r\n      setShowTagMenu(false)\r\n      onClearSelection()\r\n      onSuccess?.()\r\n    } catch (error) {\r\n      console.error('Batch update tags failed:', error)\r\n      setShowErrorAlert(true)\r\n    }\r\n  }\r\n\r\n  const toggleTag = (tagId: string) => {\r\n    if (selectedTagIds.includes(tagId)) {\r\n      setSelectedTagIds(selectedTagIds.filter((id) => id !== tagId))\r\n    } else {\r\n      setSelectedTagIds([...selectedTagIds, tagId])\r\n    }\r\n  }\r\n\r\n  return (\r\n    <div className=\"fixed bottom-6 left-1/2 -translate-x-1/2 animate-slide-up\" style={{ zIndex: Z_INDEX.BATCH_ACTION_BAR }}>\r\n      <div className=\"card bg-primary text-primary-content shadow-2xl\">\r\n        <div className=\"flex items-center gap-4\">\r\n          <div className=\"text-sm font-medium\">\r\n            {t('batch.selected', { count: selectedIds.length })}\r\n          </div>\r\n\r\n          <div className=\"w-px h-6 bg-primary-content/20\"></div>\r\n\r\n          <div className=\"flex gap-2\">\r\n            <button\r\n              onClick={() => handleAction('pin')}\r\n              className=\"btn btn-sm bg-primary-content/10 hover:bg-primary-content/20 border-none text-primary-content\"\r\n              disabled={batchAction.isPending}\r\n              title={t('batch.pin')}\r\n            >\r\n              {t('batch.pin')}\r\n            </button>\r\n\r\n            <button\r\n              onClick={() => handleAction('archive')}\r\n              className=\"btn btn-sm bg-primary-content/10 hover:bg-primary-content/20 border-none text-primary-content\"\r\n              disabled={batchAction.isPending}\r\n              title={t('batch.archive')}\r\n            >\r\n              {t('batch.archive')}\r\n            </button>\r\n\r\n            <div className=\"relative\">\r\n              <button\r\n                onClick={() => setShowTagMenu(!showTagMenu)}\r\n                className=\"btn btn-sm bg-primary-content/10 hover:bg-primary-content/20 border-none text-primary-content\"\r\n                disabled={batchAction.isPending}\r\n              >\r\n                {t('batch.tags')}\r\n              </button>\r\n\r\n              {showTagMenu && (\r\n                <div\r\n                  className=\"absolute bottom-full mb-2 left-0 w-64 text-base-content rounded-lg shadow-xl p-3 border\"\r\n                  style={{ backgroundColor: 'var(--card)', borderColor: 'var(--border)' }}\r\n                >\r\n                  <div className=\"text-sm font-medium mb-2\">{t('batch.selectTags')}</div>\r\n                  <div className=\"max-h-48 overflow-y-auto space-y-1 mb-3\">\r\n                    {tags.map((tag) => (\r\n                      <label\r\n                        key={tag.id}\r\n                        className=\"flex items-center gap-2 p-1.5 hover:bg-muted rounded cursor-pointer\"\r\n                      >\r\n                        <input\r\n                          type=\"checkbox\"\r\n                          checked={selectedTagIds.includes(tag.id)}\r\n                          onChange={() => toggleTag(tag.id)}\r\n                          className=\"checkbox checkbox-sm\"\r\n                        />\r\n                        <span className=\"text-sm\">{tag.name}</span>\r\n                      </label>\r\n                    ))}\r\n                  </div>\r\n                  <div className=\"flex gap-2 border-t pt-2\" style={{ borderColor: 'var(--border)' }}>\r\n                    <button\r\n                      onClick={() => handleUpdateTags('add')}\r\n                      className=\"btn btn-sm flex-1\"\r\n                      disabled={selectedTagIds.length === 0 || batchAction.isPending}\r\n                    >\r\n                      {t('batch.addTags')}\r\n                    </button>\r\n                    <button\r\n                      onClick={() => handleUpdateTags('remove')}\r\n                      className=\"btn btn-sm btn-outline flex-1\"\r\n                      disabled={selectedTagIds.length === 0 || batchAction.isPending}\r\n                    >\r\n                      {t('batch.removeTags')}\r\n                    </button>\r\n                  </div>\r\n                </div>\r\n              )}\r\n            </div>\r\n\r\n            <button\r\n              onClick={() => handleAction('delete')}\r\n              className=\"btn btn-sm bg-error/10 hover:bg-error/20 border-none text-primary-content\"\r\n              disabled={batchAction.isPending}\r\n              title={t('batch.delete')}\r\n            >\r\n              {t('batch.delete')}\r\n            </button>\r\n          </div>\r\n\r\n          <div className=\"w-px h-6 bg-primary-content/20\"></div>\r\n\r\n          <button\r\n            onClick={onClearSelection}\r\n            className=\"btn btn-sm btn-ghost text-primary-content\"\r\n            disabled={batchAction.isPending}\r\n          >\r\n            {t('batch.cancel')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n\r\n      {pendingAction && (\r\n        <ConfirmDialog\r\n          isOpen={showConfirmDialog}\r\n          title={getConfirmDialogConfig().title}\r\n          message={getConfirmDialogConfig().message}\r\n          type={getConfirmDialogConfig().type}\r\n          onConfirm={handleConfirm}\r\n          onCancel={handleCancel}\r\n        />\r\n      )}\r\n\r\n      <AlertDialog\r\n        isOpen={showSuccessAlert}\r\n        title={tc('dialog.successTitle')}\r\n        message={successMessage}\r\n        type=\"success\"\r\n        onConfirm={() => setShowSuccessAlert(false)}\r\n      />\r\n\r\n      <AlertDialog\r\n        isOpen={showErrorAlert}\r\n        title={tc('dialog.errorTitle')}\r\n        message={t('action.failed')}\r\n        type=\"error\"\r\n        onConfirm={() => setShowErrorAlert(false)}\r\n      />\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkCardView.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport type { Bookmark, DefaultBookmarkIcon } from '@/lib/types'\r\nimport { AdaptiveImage } from '@/components/common/AdaptiveImage'\r\nimport { useRecordClick } from '@/hooks/useBookmarks'\r\nimport { useState } from 'react'\r\nimport type { ImageType } from '@/lib/image-utils'\r\nimport { DefaultBookmarkIconComponent } from './DefaultBookmarkIcon'\r\nimport { usePreferences } from '@/hooks/usePreferences'\r\nimport { MasonryGrid } from './shared/MasonryGrid'\r\nimport { BatchCheckbox, EditButton } from './shared/BookmarkActions'\r\nimport { BookmarkTagList } from './shared/BookmarkTagList'\r\nimport { useFaviconFallback } from './shared/useFaviconFallback'\r\n\r\ninterface BookmarkCardViewProps {\r\n  bookmarks: Bookmark[]\r\n  onEdit?: (bookmark: Bookmark) => void\r\n  readOnly?: boolean\r\n  batchMode?: boolean\r\n  selectedIds?: string[]\r\n  onToggleSelect?: (id: string) => void\r\n}\r\n\r\nexport function BookmarkCardView({\r\n  bookmarks, onEdit, readOnly = false,\r\n  batchMode = false, selectedIds = [], onToggleSelect,\r\n}: BookmarkCardViewProps) {\r\n  return (\r\n    <MasonryGrid\r\n      bookmarks={bookmarks}\r\n      minColumnWidth={280}\r\n      gap={16}\r\n      renderItem={(bookmark, showEditHint) => (\r\n        <BookmarkCard\r\n          bookmark={bookmark}\r\n          onEdit={onEdit ? () => onEdit(bookmark) : undefined}\r\n          readOnly={readOnly}\r\n          batchMode={batchMode}\r\n          isSelected={selectedIds.includes(bookmark.id)}\r\n          onToggleSelect={onToggleSelect}\r\n          showEditHint={showEditHint}\r\n        />\r\n      )}\r\n    />\r\n  )\r\n}\r\n\r\ninterface BookmarkCardProps {\r\n  bookmark: Bookmark\r\n  onEdit?: () => void\r\n  readOnly?: boolean\r\n  batchMode?: boolean\r\n  isSelected?: boolean\r\n  onToggleSelect?: (id: string) => void\r\n  showEditHint?: boolean\r\n}\r\n\r\nfunction BookmarkCard({\r\n  bookmark, onEdit, readOnly = false,\r\n  batchMode = false, isSelected = false, onToggleSelect, showEditHint = false,\r\n}: BookmarkCardProps) {\r\n  const { t } = useTranslation('bookmarks')\r\n  const [imageType, setImageType] = useState<ImageType>('unknown')\r\n  const recordClick = useRecordClick()\r\n  const { data: preferences } = usePreferences()\r\n  const defaultIcon: DefaultBookmarkIcon = preferences?.default_bookmark_icon || 'orbital-spinner'\r\n  const {\r\n    googleFaviconUrl, hasCoverImage, hasFavicon, hasGoogleFavicon, hasAnyIcon,\r\n    setCoverImageError, setFaviconError, checkIfGoogleDefaultIcon,\r\n  } = useFaviconFallback(bookmark)\r\n\r\n  const handleVisit = () => {\r\n    if (!readOnly) recordClick.mutate(bookmark.id)\r\n    window.open(bookmark.url, '_blank', 'noopener,noreferrer')\r\n  }\r\n\r\n  const handleCardClick = (e: React.MouseEvent) => {\r\n    if (batchMode && onToggleSelect) { e.preventDefault(); onToggleSelect(bookmark.id) }\r\n    else handleVisit()\r\n  }\r\n\r\n  const radiusStyle = { borderTopLeftRadius: 'calc(var(--radius) * 1.5)', borderTopRightRadius: 'calc(var(--radius) * 1.5)' }\r\n\r\n  return (\r\n    <div\r\n      className={`card hover:shadow-xl transition-all relative group flex flex-col cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary/60 touch-manipulation w-full min-w-0 ${\r\n        batchMode && isSelected ? 'ring-2 ring-primary' : ''\r\n      }`}\r\n      role=\"link\" tabIndex={0}\r\n      onClick={handleCardClick}\r\n      onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (batchMode && onToggleSelect) { onToggleSelect(bookmark.id) } else { handleVisit() } } }}\r\n      aria-label={t('action.open', { title: bookmark.title })}\r\n    >\r\n      {batchMode && onToggleSelect && (\r\n        <BatchCheckbox bookmarkId={bookmark.id} isSelected={isSelected} onToggleSelect={onToggleSelect} />\r\n      )}\r\n      {!!onEdit && !readOnly && !batchMode && <EditButton onEdit={onEdit} showHint={showEditHint} />}\r\n\r\n      {/* 图片区域 */}\r\n      {hasAnyIcon ? (\r\n        <div\r\n          className={`relative overflow-hidden flex-shrink-0 flex items-center justify-center ${\r\n            imageType === 'favicon' || hasFavicon || hasGoogleFavicon\r\n              ? 'h-24 sm:h-20 bg-gradient-to-br from-primary/5 to-secondary/5'\r\n              : 'h-40 sm:h-32 bg-gradient-to-br from-primary/10 to-secondary/10'\r\n          }`}\r\n          style={radiusStyle}\r\n        >\r\n          {hasCoverImage ? (\r\n            <AdaptiveImage\r\n              src={bookmark.cover_image!} alt={bookmark.title}\r\n              className={imageType === 'favicon' ? 'w-14 h-14 sm:w-12 sm:h-12 object-contain' : 'w-full h-full object-cover'}\r\n              onTypeDetected={setImageType} onError={() => setCoverImageError(true)}\r\n            />\r\n          ) : hasFavicon ? (\r\n            <div className=\"relative w-14 h-14 sm:w-12 sm:h-12 flex items-center justify-center\">\r\n              <img src={bookmark.favicon!} alt={bookmark.title} className=\"w-full h-full object-contain\" onError={() => setFaviconError(true)} />\r\n            </div>\r\n          ) : hasGoogleFavicon ? (\r\n            <div className=\"relative w-14 h-14 sm:w-12 sm:h-12 flex items-center justify-center\">\r\n              <img src={googleFaviconUrl} alt={bookmark.title} className=\"w-full h-full object-contain\"\r\n                onLoad={(e) => checkIfGoogleDefaultIcon(e.target as HTMLImageElement)} onError={() => setFaviconError(true)} />\r\n            </div>\r\n          ) : null}\r\n        </div>\r\n      ) : (\r\n        <div className=\"relative h-24 sm:h-20 overflow-hidden flex-shrink-0 flex items-center justify-center bg-gradient-to-br from-primary/5 via-secondary/5 to-accent/5\" style={radiusStyle}>\r\n          <DefaultBookmarkIconComponent icon={defaultIcon} />\r\n        </div>\r\n      )}\r\n\r\n      {/* 内容区 */}\r\n      <div className=\"flex flex-col p-4 sm:p-3 gap-2.5 sm:gap-2 relative\">\r\n        {(bookmark.is_pinned || bookmark.is_archived) && (\r\n          <div className=\"flex gap-1.5 mb-1\">\r\n            {bookmark.is_pinned && <span className=\"bg-warning text-warning-content text-xs px-2 py-0.5 rounded-full font-medium\">{t('status.pinned')}</span>}\r\n            {bookmark.is_archived && <span className=\"bg-base-content/40 text-base-100 text-xs px-2 py-0.5 rounded-full font-medium\">{t('status.archived')}</span>}\r\n          </div>\r\n        )}\r\n\r\n        <h3 className=\"font-semibold text-base sm:text-sm line-clamp-2 hover:text-primary transition-colors leading-snug\" title={bookmark.title}>\r\n          {bookmark.title}\r\n        </h3>\r\n\r\n        {bookmark.description && (\r\n          <p className=\"text-sm sm:text-xs text-base-content/70 line-clamp-3 leading-relaxed\">{bookmark.description}</p>\r\n        )}\r\n\r\n        {bookmark.ai_summary && (\r\n          <div className=\"bg-primary/5 border border-primary/10 rounded-lg p-2.5 sm:p-2 mt-0.5\">\r\n            <div className=\"flex items-center gap-1.5 mb-1\">\r\n              <span className=\"text-[10px] font-bold text-primary uppercase tracking-wider bg-primary/10 px-1.5 py-0.5 rounded\">AI</span>\r\n              <span className=\"text-[11px] font-medium text-primary/80\">{t('view.aiSummaryTitle')}</span>\r\n            </div>\r\n            <p className=\"text-[11px] text-base-content/80 line-clamp-3 italic leading-relaxed\">\"{bookmark.ai_summary}\"</p>\r\n          </div>\r\n        )}\r\n\r\n        <div className=\"mt-1\">\r\n          <BookmarkTagList bookmark={bookmark} />\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkForm.tsx",
    "content": "import { useTags } from '@/hooks/useTags'\r\nimport type { Bookmark } from '@/lib/types'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\nimport { useBookmarkForm } from './useBookmarkForm'\r\nimport { TagSelector } from './TagSelector'\r\n\r\ninterface BookmarkFormProps {\r\n  bookmark?: Bookmark | null\r\n  onClose: () => void\r\n  onSuccess?: () => void\r\n}\r\n\r\nexport function BookmarkForm({ bookmark, onClose, onSuccess }: BookmarkFormProps) {\r\n  const { data: tagsData } = useTags()\r\n  const tags = tagsData?.tags || []\r\n\r\n  const {\r\n    title, setTitle,\r\n    url, setUrl,\r\n    description, setDescription,\r\n    coverImage, setCoverImage,\r\n    selectedTagIds, toggleTag,\r\n    isPinned, setIsPinned,\r\n    isArchived, setIsArchived,\r\n    isPublic, setIsPublic,\r\n    error,\r\n    tagInput, setTagInput,\r\n    showDeleteConfirm, setShowDeleteConfirm,\r\n    urlWarning, checkingUrl,\r\n    isPending,\r\n    handleSubmit,\r\n    processTagInput,\r\n    handleDeleteClick,\r\n    handleConfirmDelete,\r\n    isEditing,\r\n    t\r\n  } = useBookmarkForm({ bookmark, onClose, onSuccess, tags })\r\n\r\n  const handleTagInputKeyDown = async (e: React.KeyboardEvent<HTMLInputElement>) => {\r\n    if (e.key === 'Enter') {\r\n      e.preventDefault()\r\n      await processTagInput()\r\n    }\r\n  }\r\n\r\n  return (\r\n    <div className=\"fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4\" style={{ zIndex: Z_INDEX.BOOKMARK_FORM }}>\r\n      <div className=\"card w-full max-w-4xl max-h-[92vh] flex flex-col min-h-0\" style={{ backgroundColor: 'var(--card)' }}>\r\n        <div className=\"flex items-center justify-between mb-4 flex-shrink-0\">\r\n          <h2 className=\"text-xl font-bold text-foreground\">\r\n            {isEditing ? t('form.editTitle') : t('form.addTitle')}\r\n          </h2>\r\n          <button\r\n            onClick={onClose}\r\n            className=\"w-8 h-8 rounded-lg hover:bg-muted flex items-center justify-center text-foreground transition-colors\"\r\n            disabled={isPending}\r\n          >\r\n            <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\r\n            </svg>\r\n          </button>\r\n        </div>\r\n\r\n        {error && (\r\n          <div className=\"mb-3 p-2.5 bg-error/10 border border-error/30 text-error rounded-lg text-xs animate-fade-in flex items-center gap-2 flex-shrink-0\">\r\n            <svg className=\"w-4 h-4 flex-shrink-0\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\r\n              <path fillRule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z\" clipRule=\"evenodd\" />\r\n            </svg>\r\n            <span>{error}</span>\r\n          </div>\r\n        )}\r\n\r\n        <div className=\"flex-1 overflow-y-auto min-h-0 overscroll-contain\" style={{ scrollbarGutter: 'stable' }}>\r\n          <form onSubmit={handleSubmit} className=\"space-y-3\">\r\n            {/* 第一行：标题和URL */}\r\n            <div className=\"grid grid-cols-2 gap-3\">\r\n              <div>\r\n                <label htmlFor=\"title\" className=\"block text-xs font-medium mb-1.5 text-foreground\">\r\n                  {t('form.titleRequired')} <span className=\"text-error\">*</span>\r\n                </label>\r\n                <input\r\n                  id=\"title\"\r\n                  type=\"text\"\r\n                  className=\"input\"\r\n                  placeholder={t('form.titlePlaceholder')}\r\n                  value={title}\r\n                  onChange={(e) => setTitle(e.target.value)}\r\n                  disabled={isPending}\r\n                  autoFocus\r\n                />\r\n              </div>\r\n\r\n              <div>\r\n                <label htmlFor=\"url\" className=\"block text-xs font-medium mb-1.5 text-foreground\">\r\n                  {t('form.urlRequired')} <span className=\"text-error\">*</span>\r\n                </label>\r\n                <div className=\"relative\">\r\n                  <input\r\n                    id=\"url\"\r\n                    type=\"url\"\r\n                    className={`input ${urlWarning ? 'border-warning' : ''}`}\r\n                    placeholder={t('form.urlPlaceholder')}\r\n                    value={url}\r\n                    onChange={(e) => setUrl(e.target.value)}\r\n                    disabled={isPending}\r\n                  />\r\n                  {checkingUrl && (\r\n                    <div className=\"absolute right-3 top-1/2 -translate-y-1/2\">\r\n                      <svg className=\"animate-spin h-4 w-4 text-muted-foreground\" viewBox=\"0 0 24 24\" fill=\"none\">\r\n                        <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\"></circle>\r\n                        <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\r\n                      </svg>\r\n                    </div>\r\n                  )}\r\n                </div>\r\n                {urlWarning && (\r\n                  <div className=\"mt-1.5 p-2 bg-warning/10 border border-warning/30 rounded-lg text-xs text-warning animate-fade-in flex items-start gap-2\">\r\n                    <svg className=\"w-4 h-4 flex-shrink-0 mt-0.5\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\r\n                      <path fillRule=\"evenodd\" d=\"M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z\" clipRule=\"evenodd\" />\r\n                    </svg>\r\n                    <div className=\"flex-1\">\r\n                      <p className=\"font-medium\">{t('form.urlWarning.title')}</p>\r\n                      <p className=\"mt-0.5 text-muted-foreground\">\r\n                        {t('form.urlWarning.bookmark', { title: urlWarning.bookmark.title })}\r\n                      </p>\r\n                    </div>\r\n                  </div>\r\n                )}\r\n              </div>\r\n            </div>\r\n\r\n            {/* 第二行：描述和封面图 */}\r\n            <div className=\"grid grid-cols-2 gap-3\">\r\n              <div>\r\n                <label htmlFor=\"description\" className=\"block text-xs font-medium mb-1.5 text-foreground\">\r\n                  {t('form.description')}\r\n                </label>\r\n                <textarea\r\n                  id=\"description\"\r\n                  className=\"input min-h-[60px] resize-none text-sm\"\r\n                  placeholder={t('form.descriptionPlaceholder')}\r\n                  value={description}\r\n                  onChange={(e) => setDescription(e.target.value)}\r\n                  disabled={isPending}\r\n                />\r\n              </div>\r\n\r\n              <div>\r\n                <label htmlFor=\"coverImage\" className=\"block text-xs font-medium mb-1.5 text-foreground\">\r\n                  {t('form.coverImage')}\r\n                </label>\r\n                <div className=\"flex gap-2\">\r\n                  <input\r\n                    id=\"coverImage\"\r\n                    type=\"url\"\r\n                    className=\"input flex-1\"\r\n                    placeholder={t('form.coverImagePlaceholder')}\r\n                    value={coverImage}\r\n                    onChange={(e) => setCoverImage(e.target.value)}\r\n                    disabled={isPending}\r\n                  />\r\n                  {coverImage && (\r\n                    <img\r\n                      src={coverImage}\r\n                      alt={t('form.coverImage')}\r\n                      className=\"w-[60px] h-[60px] object-cover rounded-lg flex-shrink-0\"\r\n                      onError={(e) => {\r\n                        e.currentTarget.style.display = 'none'\r\n                      }}\r\n                    />\r\n                  )}\r\n                </div>\r\n              </div>\r\n            </div>\r\n\r\n            {/* 标签选择 */}\r\n            <TagSelector\r\n              tagInput={tagInput}\r\n              setTagInput={setTagInput}\r\n              onTagInputKeyDown={handleTagInputKeyDown}\r\n              selectedTagIds={selectedTagIds}\r\n              toggleTag={toggleTag}\r\n              tags={tags}\r\n              isPending={isPending}\r\n            />\r\n\r\n            {/* 选项和按钮 */}\r\n            <div className=\"flex items-center justify-between pt-2 border-t border-border\">\r\n              <div className=\"flex gap-4\">\r\n                <label className=\"flex items-center gap-1.5 cursor-pointer\">\r\n                  <input\r\n                    type=\"checkbox\"\r\n                    checked={isPinned}\r\n                    onChange={(e) => setIsPinned(e.target.checked)}\r\n                    disabled={isPending}\r\n                  />\r\n                  <span className=\"text-xs text-foreground\">{t('form.pinned')}</span>\r\n                </label>\r\n\r\n                <label className=\"flex items-center gap-1.5 cursor-pointer\">\r\n                  <input\r\n                    type=\"checkbox\"\r\n                    checked={isArchived}\r\n                    onChange={(e) => setIsArchived(e.target.checked)}\r\n                    disabled={isPending}\r\n                  />\r\n                  <span className=\"text-xs text-foreground\">{t('form.archived')}</span>\r\n                </label>\r\n\r\n                <label className=\"flex items-center gap-1.5 cursor-pointer\">\r\n                  <input\r\n                    type=\"checkbox\"\r\n                    checked={isPublic}\r\n                    onChange={(e) => setIsPublic(e.target.checked)}\r\n                    disabled={isPending}\r\n                  />\r\n                  <span className=\"text-xs text-foreground\">{t('form.public')}</span>\r\n                </label>\r\n              </div>\r\n\r\n              {/* 按钮 */}\r\n              <div className=\"flex gap-2\">\r\n                {isEditing && (\r\n                  <button\r\n                    type=\"button\"\r\n                    onClick={handleDeleteClick}\r\n                    className=\"btn btn-sm btn-outline border-2 border-destructive text-destructive hover:bg-destructive hover:text-destructive-foreground px-4\"\r\n                    disabled={isPending}\r\n                    title={t('form.delete')}\r\n                  >\r\n                    {isPending ? t('form.deleting') : t('form.delete')}\r\n                  </button>\r\n                )}\r\n                <button type=\"submit\" className=\"btn btn-sm px-6\" disabled={isPending}>\r\n                  {isPending\r\n                    ? t('form.saving')\r\n                    : isEditing\r\n                      ? t('form.save')\r\n                      : t('form.create')}\r\n                </button>\r\n                <button\r\n                  type=\"button\"\r\n                  onClick={onClose}\r\n                  className=\"btn btn-sm btn-outline px-4\"\r\n                  disabled={isPending}\r\n                >\r\n                  {t('form.cancel')}\r\n                </button>\r\n              </div>\r\n            </div>\r\n          </form>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 删除确认对话框 */}\r\n      <ConfirmDialog\r\n        isOpen={showDeleteConfirm}\r\n        title={t('form.deleteTitle')}\r\n        message={t('form.deleteMessage')}\r\n        type=\"error\"\r\n        confirmText={t('form.delete')}\r\n        cancelText={t('form.cancel')}\r\n        onConfirm={handleConfirmDelete}\r\n        onCancel={() => setShowDeleteConfirm(false)}\r\n      />\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkListContainer.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport type { Bookmark } from '@/lib/types'\r\nimport { BookmarkListView } from './BookmarkListView'\r\nimport { BookmarkCardView } from './BookmarkCardView'\r\nimport { BookmarkMinimalListView } from './BookmarkMinimalListView'\r\nimport { BookmarkTitleView } from './BookmarkTitleView'\r\n\r\ninterface BookmarkListContainerProps {\r\n  bookmarks: Bookmark[]\r\n  isLoading?: boolean\r\n  viewMode?: 'list' | 'card' | 'minimal' | 'title'\r\n  onEdit?: (bookmark: Bookmark) => void\r\n  readOnly?: boolean\r\n  previousCount?: number\r\n  batchMode?: boolean\r\n  selectedIds?: string[]\r\n  onToggleSelect?: (id: string) => void\r\n}\r\n\r\nexport function BookmarkListContainer({\r\n  bookmarks,\r\n  isLoading,\r\n  viewMode = 'list',\r\n  onEdit,\r\n  readOnly = false,\r\n  previousCount = 0,\r\n  batchMode = false,\r\n  selectedIds = [],\r\n  onToggleSelect,\r\n}: BookmarkListContainerProps) {\r\n  const { t } = useTranslation('bookmarks')\r\n\r\n  if (isLoading && bookmarks.length === 0) {\r\n    const skeletonCount = previousCount > 0 ? Math.min(previousCount, 10) : 3\r\n\r\n    return (\r\n      <div className=\"space-y-4\">\r\n        {[...Array(skeletonCount)].map((_, i) => (\r\n          <div key={i} className=\"card animate-pulse shadow-float\">\r\n            <div className=\"h-32 bg-gradient-to-r from-base-300 via-base-200 to-base-300 rounded-xl\"></div>\r\n          </div>\r\n        ))}\r\n      </div>\r\n    )\r\n  }\r\n\r\n  if (bookmarks.length === 0) {\r\n    return (\r\n      <div className=\"card text-center py-16 shadow-float\">\r\n        <div className=\"inline-flex items-center justify-center w-24 h-24 rounded-3xl bg-gradient-to-br from-primary/20 to-secondary/20 mb-6 mx-auto\">\r\n          <svg className=\"w-16 h-16 text-primary/60\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1.5} d=\"M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253\" />\r\n          </svg>\r\n        </div>\r\n        <h3 className=\"text-2xl font-bold mb-3 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent\">\r\n          {t('empty.title')}\r\n        </h3>\r\n        <p className=\"text-base-content/60 text-base\">\r\n          {readOnly ? t('empty.readOnlyDescription') : t('empty.description')}\r\n        </p>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <>\r\n      {/* 加载指示器 */}\r\n      {isLoading && bookmarks.length > 0 && (\r\n        <div className=\"mb-4 flex items-center justify-center gap-2 text-sm text-primary\">\r\n          <svg className=\"animate-spin h-4 w-4\" viewBox=\"0 0 24 24\" fill=\"none\">\r\n            <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\"></circle>\r\n            <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\r\n          </svg>\r\n          <span>{t('loading')}</span>\r\n        </div>\r\n      )}\r\n\r\n      {/* 书签列表/卡片 */}\r\n      {viewMode === 'list' ? (\r\n        <BookmarkListView\r\n          bookmarks={bookmarks}\r\n          onEdit={onEdit}\r\n          readOnly={readOnly}\r\n          batchMode={batchMode}\r\n          selectedIds={selectedIds}\r\n          onToggleSelect={onToggleSelect}\r\n        />\r\n      ) : viewMode === 'minimal' ? (\r\n        <BookmarkMinimalListView\r\n          bookmarks={bookmarks}\r\n          onEdit={onEdit}\r\n          readOnly={readOnly}\r\n          batchMode={batchMode}\r\n          selectedIds={selectedIds}\r\n          onToggleSelect={onToggleSelect}\r\n        />\r\n      ) : viewMode === 'title' ? (\r\n        <BookmarkTitleView\r\n          bookmarks={bookmarks}\r\n          onEdit={onEdit}\r\n          readOnly={readOnly}\r\n          batchMode={batchMode}\r\n          selectedIds={selectedIds}\r\n          onToggleSelect={onToggleSelect}\r\n        />\r\n      ) : (\r\n        <BookmarkCardView\r\n          bookmarks={bookmarks}\r\n          onEdit={onEdit}\r\n          readOnly={readOnly}\r\n          batchMode={batchMode}\r\n          selectedIds={selectedIds}\r\n          onToggleSelect={onToggleSelect}\r\n        />\r\n      )}\r\n    </>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkListItem.tsx",
    "content": "import { memo, useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport type { Bookmark } from '@/lib/types'\nimport { useRecordClick } from '@/hooks/useBookmarks'\nimport { usePreferences } from '@/hooks/usePreferences'\nimport { DefaultBookmarkIconComponent } from './DefaultBookmarkIcon'\nimport { SnapshotViewer } from './SnapshotViewer'\nimport { getFaviconUrl, isGoogleDefaultIcon } from './bookmark-utils'\n\nexport interface BookmarkListItemProps {\n  bookmark: Bookmark\n  onEdit?: () => void\n  readOnly?: boolean\n  batchMode?: boolean\n  isSelected?: boolean\n  onToggleSelect?: (id: string) => void\n  showEditHint?: boolean\n}\n\nexport const BookmarkListItem = memo(function BookmarkListItem({\n  bookmark,\n  onEdit,\n  readOnly = false,\n  batchMode = false,\n  isSelected = false,\n  onToggleSelect,\n  showEditHint = false,\n}: BookmarkListItemProps) {\n  const { t } = useTranslation('bookmarks')\n  const [coverImageError, setCoverImageError] = useState(false)\n  const [faviconError, setFaviconError] = useState(false)\n  const [googleFaviconIsDefault, setGoogleFaviconIsDefault] = useState(false)\n  const recordClick = useRecordClick()\n  const { data: preferences } = usePreferences()\n  const defaultIcon = preferences?.default_bookmark_icon || 'orbital-spinner'\n\n  const googleFaviconUrl = getFaviconUrl(bookmark.url)\n  \n  // 决定显示什么图片 - 四级回退策略\n  // 1. cover_image (封面图)\n  // 2. favicon (网站图标，从插件获取)\n  // 3. Google Favicon API (但跳过默认灰色地球)\n  // 4. 动画图标\n  const hasCoverImage = bookmark.cover_image && !coverImageError\n  const hasFavicon = !hasCoverImage && bookmark.favicon && !faviconError\n  const shouldShowGoogleFavicon = !hasCoverImage && !hasFavicon && googleFaviconUrl && !faviconError && !googleFaviconIsDefault\n  const shouldShowIcon = hasCoverImage || hasFavicon || shouldShowGoogleFavicon\n\n  const handleVisit = () => {\n    // 记录点击统计\n    if (!readOnly) {\n      recordClick.mutate(bookmark.id)\n    }\n    // 打开书签\n    window.open(bookmark.url, '_blank', 'noopener,noreferrer')\n  }\n\n  return (\n    <div className={`card hover:shadow-lg transition-all relative group touch-manipulation ${\n      batchMode && isSelected ? 'ring-2 ring-primary' : ''\n    }`}>\n      {/* 批量选择复选框 */}\n      {batchMode && onToggleSelect && (\n        <div className=\"absolute top-2 left-2 sm:top-3 sm:left-3 z-10\">\n          <button\n            onClick={(e) => {\n              e.preventDefault()\n              e.stopPropagation()\n              onToggleSelect(bookmark.id)\n            }}\n            className={`w-5 h-5 sm:w-6 sm:h-6 rounded flex items-center justify-center transition-all ${\n              isSelected\n                ? 'bg-primary text-primary-foreground'\n                : 'bg-card border-2 border-border hover:border-primary'\n            }`}\n            title={isSelected ? t('batch.deselect') : t('batch.select')}\n          >\n            {isSelected && (\n              <svg className=\"w-3 h-3 sm:w-4 sm:h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={3}>\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" />\n              </svg>\n            )}\n          </button>\n        </div>\n      )}\n\n      {/* 编辑按钮 - 初始显示10秒后隐藏 */}\n      {!!onEdit && !readOnly && !batchMode && (\n        <button\n          onClick={(event) => {\n            event.preventDefault()\n            event.stopPropagation()\n            onEdit()\n          }}\n          className={`absolute top-2 right-2 sm:top-3 sm:right-3 w-7 h-7 sm:w-8 sm:h-8 rounded-lg flex items-center justify-center transition-all hover:scale-110 z-10 touch-manipulation ${\n            showEditHint ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 active:opacity-100'\n          }`}\n          title={t('action.edit')}\n        >\n          <svg className=\"w-3.5 h-3.5 sm:w-4 sm:h-4 text-base-content drop-shadow-lg\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\" />\n          </svg>\n        </button>\n      )}\n\n      <div className=\"flex flex-col gap-2\">\n        {/* 第一行：图标 + 标题/URL/状态标签 */}\n        <div className=\"flex flex-row gap-3 sm:gap-4\">\n          {/* 封面图/图标 - 四级回退，始终显示 */}\n          <div className=\"flex-shrink-0 w-16 h-16 sm:w-20 sm:h-20 rounded-lg overflow-hidden bg-gradient-to-br from-primary/5 to-secondary/5 flex items-center justify-center\">\n            {shouldShowIcon ? (\n              hasCoverImage ? (\n                <img\n                  src={bookmark.cover_image!}\n                  alt={bookmark.title}\n                  className=\"w-full h-full object-cover\"\n                  onError={() => setCoverImageError(true)}\n                />\n              ) : hasFavicon ? (\n                <img\n                  src={bookmark.favicon!}\n                  alt={bookmark.title}\n                  className=\"w-8 h-8 sm:w-10 sm:h-10 object-contain\"\n                  onError={() => setFaviconError(true)}\n                />\n              ) : shouldShowGoogleFavicon ? (\n                <img\n                  src={googleFaviconUrl}\n                  alt={bookmark.title}\n                  className=\"w-8 h-8 sm:w-10 sm:h-10 object-contain\"\n                  onLoad={(e) => {\n                    const img = e.target as HTMLImageElement\n                    if (isGoogleDefaultIcon(img)) {\n                      setGoogleFaviconIsDefault(true)\n                    }\n                  }}\n                  onError={() => setFaviconError(true)}\n                />\n              ) : null\n            ) : (\n              <div className=\"w-10 h-10 sm:w-12 sm:h-12\">\n                <DefaultBookmarkIconComponent icon={defaultIcon} className=\"w-full h-full\" />\n              </div>\n            )}\n          </div>\n\n          {/* 标题和URL区域 */}\n          <div className=\"flex-1 min-w-0 flex flex-col justify-center\">\n            {/* 标题和状态标签 */}\n            <div className=\"flex items-center gap-2 mb-1\">\n              <button\n                onClick={handleVisit}\n                className=\"font-semibold text-sm sm:text-base hover:text-primary transition-colors text-left flex-1 min-w-0 truncate\"\n                title={bookmark.title}\n              >\n                {bookmark.title}\n              </button>\n              {bookmark.is_pinned && (\n                <span className=\"bg-warning text-warning-content text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0\" title={t('status.pinned')}>\n                  {t('status.pinned')}\n                </span>\n              )}\n              {bookmark.is_archived && (\n                <span className=\"bg-base-content/40 text-card text-xs px-2 py-0.5 rounded-full font-medium flex-shrink-0\" title={t('status.archived')}>\n                  {t('status.archived')}\n                </span>\n              )}\n            </div>\n\n            {/* URL */}\n            <a\n              href={bookmark.url}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className=\"text-xs text-primary hover:underline truncate\"\n              title={bookmark.url}\n            >\n              {bookmark.url}\n            </a>\n          </div>\n        </div>\n\n        {/* 第二行：描述（占据整个宽度） */}\n        {bookmark.description && (\n          <p className=\"text-sm text-base-content/70 line-clamp-2 leading-relaxed\">\n            {bookmark.description}\n          </p>\n        )}\n\n        {/* 第三行：标签和快照（占据整个宽度） */}\n        {((bookmark.tags && bookmark.tags.length > 0) || (bookmark.has_snapshot && (bookmark.snapshot_count ?? 0) > 0)) && (\n          <div className=\"flex flex-wrap items-center gap-1.5\">\n            {/* 快照图标 */}\n            {bookmark.has_snapshot && (bookmark.snapshot_count ?? 0) > 0 && (\n              <div onClick={(e) => e.stopPropagation()}>\n                <SnapshotViewer \n                  bookmarkId={bookmark.id} \n                  bookmarkTitle={bookmark.title}\n                  snapshotCount={bookmark.snapshot_count ?? 0}\n                />\n              </div>\n            )}\n            \n            {/* 标签 */}\n            {bookmark.tags && bookmark.tags.map((tag) => (\n              <span\n                key={tag.id}\n                className=\"text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary\"\n              >\n                {tag.name}\n              </span>\n            ))}\n          </div>\n        )}\n      </div>\n    </div>\n  )\n})\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkListLayout.tsx",
    "content": "/**\n * 统一书签列表布局\n * 公开分享页和私有书签页共享的骨架组件\n * 包含: TagSidebar + SearchToolbar + BookmarkListContainer + PaginationFooter + MobileTagDrawer\n */\n\nimport { ReactNode, useState } from 'react'\nimport { Search } from 'lucide-react'\nimport { useTranslation } from 'react-i18next'\nimport { TagSidebar } from '@/components/tags/TagSidebar'\nimport { BookmarkListContainer } from '@/components/bookmarks/BookmarkListContainer'\nimport { PaginationFooter } from '@/components/common/PaginationFooter'\nimport { SearchToolbar } from '@/components/common/SearchToolbar'\nimport { MobileTagDrawer } from '@/pages/bookmarks/components/MobileTagDrawer'\nimport type { Bookmark, Tag } from '@/lib/types'\nimport type { ViewMode, VisibilityFilter } from '@/lib/constants/bookmarks'\nimport type { SortOption } from '@/components/common/SortSelector'\n\nexport interface BookmarkListLayoutProps {\n  /* ---- 数据 ---- */\n  bookmarks: Bookmark[]\n  /** 传给 TagSidebar 的书签（可能是过滤后的子集） */\n  tagBookmarks?: Bookmark[]\n  isLoading: boolean\n  isError?: boolean\n  onRetry?: () => void\n\n  /* ---- 分页 ---- */\n  hasMore: boolean\n  isFetchingMore?: boolean\n  onLoadMore: () => void\n  totalLoaded?: number\n\n  /* ---- 搜索/过滤 ---- */\n  searchMode: 'bookmark' | 'tag'\n  onSearchModeToggle: () => void\n  searchKeyword: string\n  onSearchKeywordChange: (kw: string) => void\n  sortBy: SortOption\n  onSortByChange: () => void\n  visibilityFilter: VisibilityFilter\n  onVisibilityChange: () => void\n  viewMode: ViewMode\n  onViewModeChange: () => void\n\n  /* ---- 标签 ---- */\n  selectedTags: string[]\n  onTagsChange: (tags: string[]) => void\n  tagLayout: 'grid' | 'masonry'\n  onTagLayoutChange: (l: 'grid' | 'masonry') => void\n  debouncedSearchKeyword?: string\n\n  /* ---- 标签侧栏（公开模式的额外配置） ---- */\n  readOnly?: boolean\n  availableTags?: Tag[]\n  relatedTagIds?: string[]\n\n  /* ---- 批量操作（仅私有页面） ---- */\n  batchMode?: boolean\n  selectedIds?: string[]\n  onToggleSelect?: (id: string) => void\n  onEdit?: (bookmark: Bookmark) => void\n\n  /* ---- 插槽 ---- */\n  /** 搜索栏右侧额外按钮（批量/回收站/添加） */\n  extraActions?: ReactNode\n  /** 搜索栏上方的内容（分享头部卡片等） */\n  headerSlot?: ReactNode\n  /** 列表下方的内容（批量操作栏、表单弹窗等） */\n  footerSlot?: ReactNode\n  /** 批量选中提示栏 */\n  selectionPromptSlot?: ReactNode\n\n  /* ---- 布局 ---- */\n  /** 是否使用全屏固定高度布局（私有页面 true，公开页面 false） */\n  fullScreen?: boolean\n  /** i18n namespace */\n  i18nNs?: string\n}\n\nexport function BookmarkListLayout({\n  bookmarks, tagBookmarks, isLoading, isError, onRetry,\n  hasMore, isFetchingMore, onLoadMore, totalLoaded,\n  searchMode, onSearchModeToggle, searchKeyword, onSearchKeywordChange,\n  sortBy, onSortByChange, visibilityFilter, onVisibilityChange,\n  viewMode, onViewModeChange,\n  selectedTags, onTagsChange, tagLayout, onTagLayoutChange, debouncedSearchKeyword,\n  readOnly, availableTags, relatedTagIds,\n  batchMode, selectedIds, onToggleSelect, onEdit,\n  extraActions, headerSlot, footerSlot, selectionPromptSlot,\n  fullScreen = false, i18nNs = 'bookmarks',\n}: BookmarkListLayoutProps) {\n  const { t } = useTranslation(i18nNs)\n  const [isTagSidebarOpen, setIsTagSidebarOpen] = useState(false)\n  const sidebarBookmarks = tagBookmarks ?? bookmarks\n\n  const wrapperClass = fullScreen\n    ? 'w-full h-[calc(100dvh-4rem)] sm:h-[calc(100dvh-5rem)] flex flex-col overflow-hidden touch-none'\n    : 'w-full mx-auto py-3 sm:py-4 md:py-6 px-3 sm:px-4 md:px-6'\n\n  const mainClass = fullScreen\n    ? 'lg:col-span-9 lg:col-start-4 flex flex-col h-full overflow-hidden w-full min-w-0'\n    : 'lg:col-span-9 lg:col-start-4'\n\n  return (\n    <div className={wrapperClass}>\n      <div className={`grid grid-cols-1 lg:grid-cols-12 ${fullScreen ? 'gap-0 lg:gap-6 w-full h-full overflow-hidden' : 'gap-3 sm:gap-4 md:gap-6'}`}>\n        {/* 左侧标签栏 */}\n        <aside className=\"hidden lg:block lg:col-span-3 fixed top-[calc(5rem+0.75rem)] left-3 sm:left-4 md:left-6 bottom-3 w-[calc(25%-1.5rem)] z-40\">\n          <TagSidebar\n            selectedTags={selectedTags} onTagsChange={onTagsChange}\n            bookmarks={sidebarBookmarks} isLoadingBookmarks={isLoading}\n            tagLayout={tagLayout} onTagLayoutChange={onTagLayoutChange}\n            readOnly={readOnly} availableTags={availableTags}\n            relatedTagIds={relatedTagIds}\n            searchQuery={searchMode === 'tag' ? (debouncedSearchKeyword ?? searchKeyword) : ''}\n          />\n        </aside>\n\n        <main className={mainClass}>\n          {fullScreen ? (\n            <FullScreenContent\n              headerSlot={headerSlot} selectionPromptSlot={selectionPromptSlot}\n              searchMode={searchMode} onSearchModeToggle={onSearchModeToggle}\n              searchKeyword={searchKeyword} onSearchKeywordChange={onSearchKeywordChange}\n              sortBy={sortBy} onSortByChange={onSortByChange}\n              visibilityFilter={visibilityFilter} onVisibilityChange={onVisibilityChange}\n              viewMode={viewMode} onViewModeChange={onViewModeChange}\n              extraActions={extraActions} i18nNs={i18nNs}\n              onOpenTagDrawer={() => setIsTagSidebarOpen(true)}\n              bookmarks={bookmarks} isLoading={isLoading} isError={isError} onRetry={onRetry}\n              hasMore={hasMore} isFetchingMore={isFetchingMore} onLoadMore={onLoadMore} totalLoaded={totalLoaded}\n              batchMode={batchMode} selectedIds={selectedIds} onToggleSelect={onToggleSelect} onEdit={onEdit}\n              readOnly={readOnly} t={t}\n            />\n          ) : (\n            <FlowContent\n              headerSlot={headerSlot}\n              searchMode={searchMode} onSearchModeToggle={onSearchModeToggle}\n              searchKeyword={searchKeyword} onSearchKeywordChange={onSearchKeywordChange}\n              sortBy={sortBy} onSortByChange={onSortByChange}\n              visibilityFilter={visibilityFilter} onVisibilityChange={onVisibilityChange}\n              viewMode={viewMode} onViewModeChange={onViewModeChange}\n              extraActions={extraActions} i18nNs={i18nNs}\n              onOpenTagDrawer={() => setIsTagSidebarOpen(true)}\n              bookmarks={bookmarks} isLoading={isLoading}\n              hasMore={hasMore} onLoadMore={onLoadMore}\n              readOnly={readOnly} t={t}\n            />\n          )}\n        </main>\n      </div>\n\n      <MobileTagDrawer\n        isOpen={isTagSidebarOpen} onClose={() => setIsTagSidebarOpen(false)}\n        selectedTags={selectedTags} onTagsChange={onTagsChange}\n        tagLayout={tagLayout} onTagLayoutChange={onTagLayoutChange}\n        bookmarks={sidebarBookmarks} isLoading={isLoading}\n        searchMode={searchMode} searchKeyword={debouncedSearchKeyword ?? searchKeyword}\n        relatedTagIds={relatedTagIds}\n      />\n\n      {footerSlot}\n    </div>\n  )\n}\n\n/** 全屏固定高度模式（私有书签页面） */\nfunction FullScreenContent(props: {\n  headerSlot?: ReactNode; selectionPromptSlot?: ReactNode\n  searchMode: 'bookmark' | 'tag'; onSearchModeToggle: () => void\n  searchKeyword: string; onSearchKeywordChange: (kw: string) => void\n  sortBy: SortOption; onSortByChange: () => void\n  visibilityFilter: VisibilityFilter; onVisibilityChange: () => void\n  viewMode: ViewMode; onViewModeChange: () => void\n  extraActions?: ReactNode; i18nNs: string; onOpenTagDrawer: () => void\n  bookmarks: Bookmark[]; isLoading: boolean; isError?: boolean; onRetry?: () => void\n  hasMore: boolean; isFetchingMore?: boolean; onLoadMore: () => void; totalLoaded?: number\n  batchMode?: boolean; selectedIds?: string[]; onToggleSelect?: (id: string) => void\n  onEdit?: (b: Bookmark) => void; readOnly?: boolean\n  t: (key: string) => string\n}) {\n  return (\n    <>\n      <div className=\"flex-shrink-0 px-3 sm:px-4 md:px-6 pt-3 sm:pt-4 md:pt-6 pb-3 sm:pb-4 w-full\">\n        {props.headerSlot}\n        <div className=\"p-4 sm:p-5 w-full\">\n          <SearchToolbar\n            searchMode={props.searchMode} onSearchModeToggle={props.onSearchModeToggle}\n            searchKeyword={props.searchKeyword} onSearchKeywordChange={props.onSearchKeywordChange}\n            sortBy={props.sortBy} onSortByChange={props.onSortByChange}\n            visibilityFilter={props.visibilityFilter} onVisibilityChange={props.onVisibilityChange}\n            viewMode={props.viewMode} onViewModeChange={props.onViewModeChange}\n            onOpenTagDrawer={props.onOpenTagDrawer} extraActions={props.extraActions} i18nNs={props.i18nNs}\n          />\n        </div>\n      </div>\n      {props.selectionPromptSlot}\n      <div className=\"flex-1 overflow-y-auto px-3 sm:px-4 md:px-6 pb-20 sm:pb-4 md:pb-6 w-full overscroll-contain\">\n        {props.isError ? (\n          <div className=\"flex flex-col items-center justify-center py-16 text-center\">\n            <p className=\"text-red-600 dark:text-red-400 mb-4\">{props.t('error')}</p>\n            {props.onRetry && <button onClick={props.onRetry} className=\"px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 transition-colors\">{props.t('retry')}</button>}\n          </div>\n        ) : (\n          <>\n            <BookmarkListContainer\n              bookmarks={props.bookmarks} viewMode={props.viewMode}\n              onEdit={props.readOnly ? undefined : props.onEdit}\n              isLoading={props.isLoading} readOnly={props.readOnly}\n              batchMode={props.batchMode} selectedIds={props.selectedIds} onToggleSelect={props.onToggleSelect}\n            />\n            {!props.isLoading && props.bookmarks.length > 0 && (\n              <PaginationFooter\n                hasMore={props.hasMore} isLoading={props.isFetchingMore || false}\n                onLoadMore={props.onLoadMore}\n                currentCount={props.bookmarks.length} totalLoaded={props.totalLoaded ?? props.bookmarks.length}\n              />\n            )}\n          </>\n        )}\n      </div>\n    </>\n  )\n}\n\n/** 流式布局模式（公开分享页面） */\nfunction FlowContent(props: {\n  headerSlot?: ReactNode\n  searchMode: 'bookmark' | 'tag'; onSearchModeToggle: () => void\n  searchKeyword: string; onSearchKeywordChange: (kw: string) => void\n  sortBy: SortOption; onSortByChange: () => void\n  visibilityFilter: VisibilityFilter; onVisibilityChange: () => void\n  viewMode: ViewMode; onViewModeChange: () => void\n  extraActions?: ReactNode; i18nNs: string; onOpenTagDrawer: () => void\n  bookmarks: Bookmark[]; isLoading: boolean\n  hasMore: boolean; onLoadMore: () => void\n  readOnly?: boolean\n  t: (key: string) => string\n}) {\n  return (\n    <div className=\"space-y-3 sm:space-y-4 md:space-y-5\">\n      {props.headerSlot}\n      <div className=\"p-4 sm:p-5 card bg-card/50\">\n        <SearchToolbar\n          searchMode={props.searchMode} onSearchModeToggle={props.onSearchModeToggle}\n          searchKeyword={props.searchKeyword} onSearchKeywordChange={props.onSearchKeywordChange}\n          sortBy={props.sortBy} onSortByChange={props.onSortByChange}\n          visibilityFilter={props.visibilityFilter} onVisibilityChange={props.onVisibilityChange}\n          viewMode={props.viewMode} onViewModeChange={props.onViewModeChange}\n          onOpenTagDrawer={props.onOpenTagDrawer} extraActions={props.extraActions} i18nNs={props.i18nNs}\n        />\n      </div>\n      {props.bookmarks.length > 0 ? (\n        <>\n          <BookmarkListContainer bookmarks={props.bookmarks} viewMode={props.viewMode} readOnly={props.readOnly} />\n          <PaginationFooter hasMore={props.hasMore} isLoading={false} onLoadMore={props.onLoadMore} currentCount={props.bookmarks.length} totalLoaded={props.bookmarks.length} />\n        </>\n      ) : !props.isLoading ? (\n        <div className=\"card text-center py-12\">\n          <Search className=\"w-16 h-16 mx-auto mb-4 opacity-20 text-muted-foreground\" />\n          <p className=\"text-lg font-medium text-muted-foreground\">{props.t('empty.title')}</p>\n          <p className=\"text-sm mt-2 text-muted-foreground\">{props.t('empty.hint')}</p>\n        </div>\n      ) : null}\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkListView.tsx",
    "content": "import { useRef, useState, useEffect } from 'react'\r\nimport { useVirtualizer } from '@tanstack/react-virtual'\r\nimport type { Bookmark } from '@/lib/types'\r\nimport { BookmarkListItem } from './BookmarkListItem'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\n\r\ninterface BookmarkListViewProps {\r\n  bookmarks: Bookmark[]\r\n  onEdit?: (bookmark: Bookmark) => void\r\n  readOnly?: boolean\r\n  batchMode?: boolean\r\n  selectedIds?: string[]\r\n  onToggleSelect?: (id: string) => void\r\n}\r\n\r\n/**\r\n * 书签列表视图组件\r\n * 包含虚拟滚动支持，适用于大量数据的展示\r\n */\r\nexport function BookmarkListView({\r\n  bookmarks,\r\n  onEdit,\r\n  readOnly = false,\r\n  batchMode = false,\r\n  selectedIds = [],\r\n  onToggleSelect,\r\n}: BookmarkListViewProps) {\r\n  const parentRef = useRef<HTMLDivElement>(null)\r\n  const [showEditHint, setShowEditHint] = useState(true)\r\n  const isMobile = useIsMobile()\r\n\r\n  // 移动端10秒后隐藏编辑按钮提示\r\n  useEffect(() => {\r\n    if (isMobile) {\r\n      const timer = setTimeout(() => {\r\n        setShowEditHint(false)\r\n      }, 10000)\r\n      return () => clearTimeout(timer)\r\n    } else {\r\n      setShowEditHint(false)\r\n    }\r\n  }, [isMobile])\r\n\r\n  // 只有超过 100 个书签时才启用虚拟滚动\r\n  const enableVirtualization = bookmarks.length > 100\r\n\r\n  const virtualizer = useVirtualizer({\r\n    count: bookmarks.length,\r\n    getScrollElement: () => parentRef.current,\r\n    estimateSize: () => 150, // 估计每行高度\r\n    overscan: 5, // 预渲染额外的行\r\n    enabled: enableVirtualization,\r\n  })\r\n\r\n  return (\r\n    <div\r\n      ref={parentRef}\r\n      className=\"space-y-3 sm:space-y-4 scrollbar-hide\"\r\n      style={enableVirtualization ? { height: 'calc(100dvh - 16rem)', overflow: 'auto' } : undefined}\r\n    >\r\n      {enableVirtualization && (\r\n        <div\r\n          style={{\r\n            height: `${virtualizer.getTotalSize()}px`,\r\n            width: '100%',\r\n            position: 'relative',\r\n          }}\r\n        >\r\n          {virtualizer.getVirtualItems().map((virtualRow) => {\r\n            const bookmark = bookmarks[virtualRow.index]\r\n            if (!bookmark) return null\r\n            return (\r\n              <div\r\n                key={bookmark.id}\r\n                style={{\r\n                  position: 'absolute',\r\n                  top: 0,\r\n                  left: 0,\r\n                  width: '100%',\r\n                  transform: `translateY(${virtualRow.start}px)`,\r\n                }}\r\n              >\r\n                <BookmarkListItem\r\n                  bookmark={bookmark}\r\n                  onEdit={onEdit ? () => onEdit(bookmark) : undefined}\r\n                  readOnly={readOnly}\r\n                  batchMode={batchMode}\r\n                  isSelected={selectedIds.includes(bookmark.id)}\r\n                  onToggleSelect={onToggleSelect}\r\n                  showEditHint={showEditHint}\r\n                />\r\n              </div>\r\n            )\r\n          })}\r\n        </div>\r\n      )}\r\n\r\n      {!enableVirtualization &&\r\n        bookmarks.map((bookmark) => (\r\n          <BookmarkListItem\r\n            key={bookmark.id}\r\n            bookmark={bookmark}\r\n            onEdit={onEdit ? () => onEdit(bookmark) : undefined}\r\n            readOnly={readOnly}\r\n            batchMode={batchMode}\r\n            isSelected={selectedIds.includes(bookmark.id)}\r\n            onToggleSelect={onToggleSelect}\r\n            showEditHint={showEditHint}\r\n          />\r\n        ))}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkMinimalListView.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport type { Bookmark } from '@/lib/types'\r\nimport { useRecordClick } from '@/hooks/useBookmarks'\r\n\r\ninterface BookmarkMinimalListViewProps {\r\n  bookmarks: Bookmark[]\r\n  onEdit?: (bookmark: Bookmark) => void\r\n  readOnly?: boolean\r\n  batchMode?: boolean\r\n  selectedIds?: string[]\r\n  onToggleSelect?: (id: string) => void\r\n}\r\n\r\nexport function BookmarkMinimalListView({\r\n  bookmarks,\r\n  onEdit,\r\n  readOnly = false,\r\n  batchMode = false,\r\n  selectedIds = [],\r\n  onToggleSelect,\r\n}: BookmarkMinimalListViewProps) {\r\n  const { t } = useTranslation('bookmarks')\r\n  \r\n  return (\r\n    <div className=\"rounded-xl border border-base-300 overflow-hidden overflow-x-auto\">\r\n      {/* 表头 - 移动端只显示标题和操作 */}\r\n      <div className={`grid ${batchMode ? 'grid-cols-[auto_1fr_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(0,2fr)_minmax(0,2fr)_auto]' : 'grid-cols-[1fr_auto] sm:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,2fr)_auto]'} gap-2 sm:gap-4 px-3 sm:px-4 py-2 text-xs uppercase tracking-wide text-base-content/50 bg-base-200`}>\r\n        {batchMode && <span></span>}\r\n        <span>{t('view.header.title')}</span>\r\n        <span className=\"hidden sm:block\">{t('view.header.url')}</span>\r\n        <span className=\"hidden sm:block\">{t('view.header.note')}</span>\r\n        <span className=\"text-right sm:block\">{readOnly ? '' : ''}</span>\r\n      </div>\r\n      <div>\r\n        {bookmarks.map((bookmark) => (\r\n          <MinimalRow\r\n            key={bookmark.id}\r\n            bookmark={bookmark}\r\n            onEdit={onEdit ? () => onEdit(bookmark) : undefined}\r\n            readOnly={readOnly}\r\n            batchMode={batchMode}\r\n            isSelected={selectedIds.includes(bookmark.id)}\r\n            onToggleSelect={onToggleSelect}\r\n          />\r\n        ))}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n\r\ninterface MinimalRowProps {\r\n  bookmark: Bookmark\r\n  onEdit?: () => void\r\n  readOnly?: boolean\r\n  batchMode?: boolean\r\n  isSelected?: boolean\r\n  onToggleSelect?: (id: string) => void\r\n}\r\n\r\nfunction MinimalRow({\r\n  bookmark,\r\n  onEdit,\r\n  readOnly = false,\r\n  batchMode = false,\r\n  isSelected = false,\r\n  onToggleSelect,\r\n}: MinimalRowProps) {\r\n  const { t } = useTranslation('bookmarks')\r\n  const recordClick = useRecordClick()\r\n\r\n  const handleVisit = () => {\r\n    if (!readOnly) {\r\n      recordClick.mutate(bookmark.id)\r\n    }\r\n    window.open(bookmark.url, '_blank', 'noopener,noreferrer')\r\n  }\r\n\r\n  return (\r\n    <div className={`grid ${batchMode ? 'grid-cols-[auto_1fr_auto] sm:grid-cols-[auto_minmax(0,2fr)_minmax(0,2fr)_minmax(0,2fr)_auto]' : 'grid-cols-[1fr_auto] sm:grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,2fr)_auto]'} gap-2 sm:gap-4 px-3 sm:px-4 py-3 text-sm items-center border-t border-base-200 first:border-t-0 hover:bg-base-200/60 ${\r\n      batchMode && isSelected ? 'bg-primary/10' : ''\r\n    }`}>\r\n      {batchMode && onToggleSelect && (\r\n        <div className=\"flex items-center justify-center\">\r\n          <button\r\n            onClick={(e) => {\r\n              e.preventDefault()\r\n              e.stopPropagation()\r\n              onToggleSelect(bookmark.id)\r\n            }}\r\n            className={`w-5 h-5 rounded flex items-center justify-center transition-all ${\r\n              isSelected\r\n                ? 'bg-primary text-primary-foreground'\r\n                : 'bg-card border-2 border-border hover:border-primary'\r\n            }`}\r\n            title={isSelected ? t('batch.deselect') : t('batch.select')}\r\n          >\r\n            {isSelected && (\r\n              <svg className=\"w-3 h-3\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={3}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" />\r\n              </svg>\r\n            )}\r\n          </button>\r\n        </div>\r\n      )}\r\n      \r\n      {/* 标题 */}\r\n      <button\r\n        type=\"button\"\r\n        onClick={handleVisit}\r\n        className=\"text-left font-medium text-sm sm:text-base truncate hover:text-primary min-w-0\"\r\n        title={bookmark.title}\r\n      >\r\n        {bookmark.title || bookmark.url}\r\n      </button>\r\n      \r\n      {/* 网址 - 移动端隐藏 */}\r\n      <a\r\n        href={bookmark.url}\r\n        target=\"_blank\"\r\n        rel=\"noopener noreferrer\"\r\n        className=\"hidden sm:block text-xs text-primary truncate hover:underline min-w-0\"\r\n        title={bookmark.url}\r\n      >\r\n        {bookmark.url}\r\n      </a>\r\n      \r\n      {/* 备注 - 移动端隐藏 */}\r\n      <span className=\"hidden sm:block text-xs text-base-content/70 truncate min-w-0\" title={bookmark.description || undefined}>\r\n        {bookmark.description || t('view.noDescription')}\r\n      </span>\r\n      \r\n      {/* 操作按钮 */}\r\n      <div className=\"flex justify-end items-center\">\r\n        {!!onEdit && !readOnly && !batchMode ? (\r\n          <button\r\n            type=\"button\"\r\n            onClick={(event) => {\r\n              event.preventDefault()\r\n              event.stopPropagation()\r\n              onEdit()\r\n            }}\r\n            className=\"w-8 h-8 rounded-md flex items-center justify-center hover:bg-base-300 transition-colors text-base-content/70 hover:text-base-content\"\r\n            title={t('action.edit')}\r\n          >\r\n            <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\" />\r\n            </svg>\r\n          </button>\r\n        ) : null}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/BookmarkTitleView.tsx",
    "content": "import { useRef } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport type { Bookmark, DefaultBookmarkIcon } from '@/lib/types'\r\nimport { useRecordClick } from '@/hooks/useBookmarks'\r\nimport { usePreferences } from '@/hooks/usePreferences'\r\nimport { DefaultBookmarkIconComponent } from './DefaultBookmarkIcon'\r\nimport { MasonryGrid } from './shared/MasonryGrid'\r\nimport { BatchCheckbox, EditButton } from './shared/BookmarkActions'\r\nimport { BookmarkTagList } from './shared/BookmarkTagList'\r\nimport { useFaviconFallback } from './shared/useFaviconFallback'\r\n\r\ninterface BookmarkTitleViewProps {\r\n  bookmarks: Bookmark[]\r\n  onEdit?: (bookmark: Bookmark) => void\r\n  readOnly?: boolean\r\n  batchMode?: boolean\r\n  selectedIds?: string[]\r\n  onToggleSelect?: (id: string) => void\r\n}\r\n\r\nexport function BookmarkTitleView({\r\n  bookmarks,\r\n  onEdit,\r\n  readOnly = false,\r\n  batchMode = false,\r\n  selectedIds = [],\r\n  onToggleSelect,\r\n}: BookmarkTitleViewProps) {\r\n  return (\r\n    <MasonryGrid\r\n      bookmarks={bookmarks}\r\n      minColumnWidth={240}\r\n      gap={10}\r\n      minCols={2}\r\n      itemSpacing=\"mb-2.5 sm:mb-3\"\r\n      renderItem={(bookmark, showEditHint) => (\r\n        <TitleOnlyCard\r\n          bookmark={bookmark}\r\n          onEdit={onEdit ? () => onEdit(bookmark) : undefined}\r\n          readOnly={readOnly}\r\n          batchMode={batchMode}\r\n          isSelected={selectedIds.includes(bookmark.id)}\r\n          onToggleSelect={onToggleSelect}\r\n          showEditHint={showEditHint}\r\n        />\r\n      )}\r\n    />\r\n  )\r\n}\r\n\r\ninterface TitleOnlyCardProps {\r\n  bookmark: Bookmark\r\n  onEdit?: () => void\r\n  readOnly?: boolean\r\n  batchMode?: boolean\r\n  isSelected?: boolean\r\n  onToggleSelect?: (id: string) => void\r\n  showEditHint?: boolean\r\n}\r\n\r\nfunction TitleOnlyCard({\r\n  bookmark,\r\n  onEdit,\r\n  readOnly = false,\r\n  batchMode = false,\r\n  isSelected = false,\r\n  onToggleSelect,\r\n  showEditHint = false,\r\n}: TitleOnlyCardProps) {\r\n  const { t } = useTranslation('bookmarks')\r\n  const recordClick = useRecordClick()\r\n  const hasEditClickRef = useRef(false)\r\n  const { data: preferences } = usePreferences()\r\n  const defaultIcon: DefaultBookmarkIcon = preferences?.default_bookmark_icon || 'orbital-spinner'\r\n  const {\r\n    domain, googleFaviconUrl,\r\n    hasCoverImage, hasFavicon, hasGoogleFavicon, hasAnyIcon,\r\n    setCoverImageError, setFaviconError, checkIfGoogleDefaultIcon,\r\n  } = useFaviconFallback(bookmark)\r\n\r\n  const handleCardClick = () => {\r\n    if (batchMode && onToggleSelect) {\r\n      onToggleSelect(bookmark.id)\r\n    } else {\r\n      if (!readOnly) recordClick.mutate(bookmark.id)\r\n      window.open(bookmark.url, '_blank', 'noopener,noreferrer')\r\n    }\r\n  }\r\n\r\n  return (\r\n    <div className=\"relative group\">\r\n      <div className={`rounded-lg sm:rounded-xl border border-border/70 bg-card/95 backdrop-blur-sm shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-primary/10 ${\r\n        batchMode && isSelected ? 'ring-2 ring-primary' : ''\r\n      }`}>\r\n        <div className=\"pointer-events-none absolute inset-0 rounded-lg sm:rounded-xl bg-gradient-to-br from-primary/4 via-transparent to-secondary/8 opacity-0 group-hover:opacity-100 transition-opacity duration-200\" />\r\n\r\n        {batchMode && onToggleSelect && (\r\n          <BatchCheckbox bookmarkId={bookmark.id} isSelected={isSelected} onToggleSelect={onToggleSelect} size=\"sm\" />\r\n        )}\r\n        {!!onEdit && !readOnly && !batchMode && <EditButton onEdit={onEdit} showHint={showEditHint} />}\r\n\r\n        <div className=\"relative z-10 px-3 py-3 sm:px-5 sm:py-4 space-y-1.5 sm:space-y-2 pointer-events-none\">\r\n          {bookmark.is_pinned && (\r\n            <div className=\"flex items-center gap-1 mb-1\">\r\n              <span className=\"bg-warning text-warning-content text-[10px] sm:text-xs px-1.5 py-0.5 rounded-full font-medium\">\r\n                {t('status.pinned')}\r\n              </span>\r\n            </div>\r\n          )}\r\n\r\n          <div className=\"flex items-center gap-2 sm:gap-2.5\">\r\n            <FaviconIcon\r\n              bookmark={bookmark} hasCoverImage={hasCoverImage} hasFavicon={hasFavicon}\r\n              hasGoogleFavicon={hasGoogleFavicon} hasAnyIcon={hasAnyIcon}\r\n              googleFaviconUrl={googleFaviconUrl} defaultIcon={defaultIcon}\r\n              setCoverImageError={setCoverImageError} setFaviconError={setFaviconError}\r\n              checkIfGoogleDefaultIcon={checkIfGoogleDefaultIcon}\r\n            />\r\n            <div className=\"flex-1 min-w-0 flex items-baseline gap-1.5 sm:gap-2\">\r\n              <button\r\n                type=\"button\"\r\n                onClick={(e) => { if (hasEditClickRef.current) { hasEditClickRef.current = false; e.preventDefault(); return }; handleCardClick() }}\r\n                onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCardClick() } }}\r\n                className=\"pointer-events-auto flex-shrink min-w-0 text-left text-xs sm:text-sm font-semibold leading-snug text-foreground truncate hover:text-primary transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60 rounded-md pr-9 sm:pr-12\"\r\n                title={bookmark.title?.trim() || bookmark.url}\r\n              >\r\n                {bookmark.title?.trim() || bookmark.url}\r\n              </button>\r\n              <a\r\n                href={bookmark.url} target=\"_blank\" rel=\"noopener noreferrer\"\r\n                className=\"pointer-events-auto flex-shrink-0 text-[10px] sm:text-xs text-muted-foreground/60 hover:text-primary transition-colors truncate max-w-[40%]\"\r\n                onClick={(e) => { if (batchMode) { e.preventDefault(); onToggleSelect?.(bookmark.id) } else if (!readOnly) recordClick.mutate(bookmark.id) }}\r\n                title={domain}\r\n              >\r\n                {domain}\r\n              </a>\r\n            </div>\r\n          </div>\r\n\r\n          <div className=\"pt-0.5\">\r\n            <BookmarkTagList bookmark={bookmark} maxTags={3} />\r\n          </div>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n\r\nfunction FaviconIcon(props: {\r\n  bookmark: Bookmark; hasCoverImage: boolean; hasFavicon: boolean; hasGoogleFavicon: boolean; hasAnyIcon: boolean\r\n  googleFaviconUrl: string; defaultIcon: DefaultBookmarkIcon\r\n  setCoverImageError: (v: boolean) => void; setFaviconError: (v: boolean) => void; checkIfGoogleDefaultIcon: (img: HTMLImageElement) => void\r\n}) {\r\n  const imgClass = \"w-full h-full object-contain\"\r\n  return (\r\n    <div className=\"flex-shrink-0 w-5 h-5 sm:w-6 sm:h-6 rounded overflow-hidden bg-gradient-to-br from-primary/5 to-secondary/5 flex items-center justify-center\">\r\n      {props.hasAnyIcon ? (\r\n        props.hasCoverImage ? <img src={props.bookmark.cover_image!} alt=\"\" className={imgClass} onError={() => props.setCoverImageError(true)} /> :\r\n        props.hasFavicon ? <img src={props.bookmark.favicon!} alt=\"\" className={imgClass} onError={() => props.setFaviconError(true)} /> :\r\n        props.hasGoogleFavicon ? <img src={props.googleFaviconUrl} alt=\"\" className={imgClass} onLoad={(e) => props.checkIfGoogleDefaultIcon(e.target as HTMLImageElement)} onError={() => props.setFaviconError(true)} /> : null\r\n      ) : (\r\n        <div className=\"w-full h-full flex items-center justify-center\">\r\n          <DefaultBookmarkIconComponent icon={props.defaultIcon} className=\"w-4 h-4 sm:w-5 sm:h-5\" />\r\n        </div>\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/DefaultBookmarkIcon.tsx",
    "content": "import type { DefaultBookmarkIcon } from '@/lib/types'\r\n\r\ninterface DefaultBookmarkIconProps {\r\n  icon: DefaultBookmarkIcon\r\n  className?: string\r\n}\r\n\r\nexport function DefaultBookmarkIconComponent({\r\n  icon: _icon,\r\n  className = 'w-10 h-10 sm:w-8 sm:h-8',\r\n}: DefaultBookmarkIconProps) {\r\n  // 参数目前未区分不同默认图标，保留接口以便未来扩展\r\n  void _icon\r\n  // 简单的书签图标 - 线条风格\r\n  return (\r\n    <svg\r\n      className={`${className} text-muted-foreground`}\r\n      viewBox=\"0 0 24 24\"\r\n      fill=\"none\"\r\n      stroke=\"currentColor\"\r\n      strokeWidth=\"2\"\r\n      strokeLinecap=\"round\"\r\n      strokeLinejoin=\"round\"\r\n    >\r\n      <path d=\"M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\" />\r\n    </svg>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/SnapshotViewer.tsx",
    "content": "import { useState, useEffect } from 'react';\r\nimport { useTranslation } from 'react-i18next';\r\nimport { createPortal } from 'react-dom';\r\nimport { Camera, ExternalLink, Trash2 } from 'lucide-react';\r\nimport { format, formatDistanceToNow } from 'date-fns';\r\nimport { zhCN, enUS } from 'date-fns/locale';\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog';\r\nimport { Z_INDEX } from '@/lib/constants/z-index';\r\nimport { useSnapshots } from './useSnapshots';\r\n\r\ninterface SnapshotViewerProps {\r\n  bookmarkId: string;\r\n  bookmarkTitle: string;\r\n  snapshotCount?: number; // 从书签数据中传入，避免额外请求\r\n}\r\n\r\n// 格式化文件大小\r\nconst formatFileSize = (bytes: number): string => {\r\n  if (bytes === 0) return '0 B';\r\n  const k = 1024;\r\n  const sizes = ['B', 'KB', 'MB', 'GB'];\r\n  const i = Math.floor(Math.log(bytes) / Math.log(k));\r\n  return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;\r\n};\r\n\r\nexport function SnapshotViewer({ bookmarkId, bookmarkTitle, snapshotCount = 0 }: SnapshotViewerProps) {\r\n  const { t, i18n } = useTranslation('bookmarks');\r\n  const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS;\r\n  const [isOpen, setIsOpen] = useState(false);\r\n  \r\n  const {\r\n    snapshots,\r\n    isLoading,\r\n    deletingId,\r\n    pendingDelete,\r\n    showDeleteConfirm,\r\n    loadSnapshots,\r\n    handleRequestDelete,\r\n    handleConfirmDelete,\r\n    cancelDelete,\r\n  } = useSnapshots(bookmarkId);\r\n\r\n  const handleOpen = (e: React.MouseEvent) => {\r\n    e.preventDefault();\r\n    e.stopPropagation(); // 防止触发卡片点击\r\n    setIsOpen(true);\r\n    loadSnapshots();\r\n  };\r\n\r\n  // 键盘支持：ESC 关闭弹窗\r\n  useEffect(() => {\r\n    if (!isOpen) return;\r\n\r\n    const handleKeyDown = (e: KeyboardEvent) => {\r\n      if (e.key === 'Escape') {\r\n        setIsOpen(false);\r\n      }\r\n    };\r\n\r\n    window.addEventListener('keydown', handleKeyDown);\r\n    return () => window.removeEventListener('keydown', handleKeyDown);\r\n  }, [isOpen]);\r\n\r\n  const handleView = (viewUrl: string) => {\r\n    // 直接使用 API 返回的签名 URL\r\n    window.open(viewUrl, '_blank');\r\n  };\r\n\r\n  // 使用 Portal 将弹窗渲染到 body，避免被父容器限制\r\n  const modalContent = isOpen ? createPortal(\r\n    <div \r\n      className=\"fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4\" \r\n      style={{ zIndex: Z_INDEX.SNAPSHOT_VIEWER }}\r\n      onClick={(e) => {\r\n        e.stopPropagation();\r\n        setIsOpen(false);\r\n      }}\r\n    >\r\n      {/* 弹窗容器 - 使用和 BookmarkForm 相同的样式 */}\r\n      <div \r\n        className=\"card w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col\" \r\n        style={{ backgroundColor: 'var(--card)' }}\r\n        onClick={(e) => e.stopPropagation()}\r\n      >\r\n        {/* 头部 */}\r\n        <div className=\"flex items-center justify-between mb-4\">\r\n          <div className=\"flex-1 min-w-0 pr-2\">\r\n            <h2 className=\"text-xl font-bold text-foreground truncate\">\r\n              {bookmarkTitle}\r\n            </h2>\r\n            <p className=\"text-sm text-muted-foreground mt-1\">\r\n              {t('snapshot.count', { count: snapshots.length })}\r\n            </p>\r\n          </div>\r\n          <button\r\n            onClick={(e) => {\r\n              e.stopPropagation();\r\n              setIsOpen(false);\r\n            }}\r\n            className=\"w-8 h-8 rounded-lg hover:bg-muted flex items-center justify-center text-foreground transition-colors flex-shrink-0\"\r\n            aria-label={t('snapshot.close')}\r\n          >\r\n            <svg className=\"w-5 h-5\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\r\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M6 18L18 6M6 6l12 12\" />\r\n            </svg>\r\n          </button>\r\n        </div>\r\n\r\n        {/* 内容 */}\r\n        <div className=\"flex-1 overflow-y-auto -mx-6 px-6 scrollbar-hide\">\r\n          {isLoading ? (\r\n            <div className=\"flex flex-col items-center justify-center py-16\">\r\n              <div className=\"relative\">\r\n                <div className=\"animate-spin rounded-full h-12 w-12 border-4 border-primary/20\"></div>\r\n                <div className=\"animate-spin rounded-full h-12 w-12 border-4 border-transparent border-t-primary absolute top-0 left-0\"></div>\r\n              </div>\r\n              <p className=\"text-sm text-muted-foreground mt-4 font-medium\">{t('snapshot.loading')}</p>\r\n            </div>\r\n          ) : snapshots.length === 0 ? (\r\n            <div className=\"text-center py-16 text-muted-foreground\">\r\n              <div className=\"relative inline-block mb-4\">\r\n                <Camera className=\"w-16 h-16 mx-auto opacity-20\" />\r\n                <div className=\"absolute inset-0 flex items-center justify-center\">\r\n                  <div className=\"w-8 h-0.5 bg-border rotate-45\"></div>\r\n                </div>\r\n              </div>\r\n              <p className=\"text-base font-medium text-foreground mb-1\">{t('snapshot.empty')}</p>\r\n              <p className=\"text-xs text-muted-foreground\">{t('snapshot.emptyHint')}</p>\r\n            </div>\r\n          ) : (\r\n            <div className=\"space-y-2.5\">\r\n              {snapshots.map((snapshot) => (\r\n                <div\r\n                  key={snapshot.id}\r\n                  className=\"flex items-center gap-3 p-3 bg-muted/50 rounded-lg border border-border hover:border-primary/50 hover:bg-muted transition-all group\"\r\n                >\r\n                  <button\r\n                    onClick={(e) => {\r\n                      e.stopPropagation();\r\n                      handleView(snapshot.view_url);\r\n                    }}\r\n                    className=\"flex-1 flex items-center justify-between gap-3 text-left min-w-0 group/item\"\r\n                  >\r\n                    <div className=\"flex items-center gap-3 flex-1 min-w-0\">\r\n                      <div className=\"flex-shrink-0 w-11 h-11 rounded-lg bg-primary/10 flex items-center justify-center group-hover/item:scale-110 transition-transform\">\r\n                        <Camera className=\"w-5 h-5 text-primary\" />\r\n                      </div>\r\n                      <div className=\"flex-1 min-w-0\">\r\n                        {/* 快照标题（如果有） */}\r\n                        {snapshot.snapshot_title && snapshot.snapshot_title.trim() !== '' && (\r\n                          <div className=\"text-sm font-medium text-foreground truncate mb-0.5\">\r\n                            {snapshot.snapshot_title}\r\n                          </div>\r\n                        )}\r\n                        \r\n                        {/* 版本号 */}\r\n                        <div className=\"flex items-center gap-2 text-xs text-foreground/80\">\r\n                          <span className=\"font-medium\">{t('snapshot.version', { version: snapshot.version })}</span>\r\n                          {snapshot.file_size > 0 && (\r\n                            <>\r\n                              <span className=\"text-muted-foreground\">•</span>\r\n                              <span className=\"text-muted-foreground\">{formatFileSize(snapshot.file_size)}</span>\r\n                            </>\r\n                          )}\r\n                        </div>\r\n                        \r\n                        {/* 时间 - 相对时间 + 绝对时间 */}\r\n                        <div className=\"text-xs text-muted-foreground mt-0.5\">\r\n                          {formatDistanceToNow(new Date(snapshot.created_at), { \r\n                            addSuffix: true, \r\n                            locale: dateLocale \r\n                          })}\r\n                          <span className=\"mx-1\">•</span>\r\n                          {format(new Date(snapshot.created_at), 'yyyy-MM-dd HH:mm', { locale: dateLocale })}\r\n                        </div>\r\n                      </div>\r\n                    </div>\r\n                    \r\n                    <ExternalLink className=\"w-4 h-4 text-muted-foreground group-hover/item:text-primary group-hover/item:scale-110 transition-all flex-shrink-0\" />\r\n                  </button>\r\n                  \r\n                  <button\r\n                    onClick={(e) => handleRequestDelete(snapshot.id, snapshot.version, e)}\r\n                    disabled={deletingId === snapshot.id}\r\n                    className=\"p-2 rounded-lg hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all opacity-0 sm:group-hover:opacity-100 active:opacity-100 disabled:opacity-50 flex-shrink-0\"\r\n                    title={t('snapshot.delete')}\r\n                  >\r\n                    {deletingId === snapshot.id ? (\r\n                      <div className=\"animate-spin rounded-full h-4 w-4 border-b-2 border-destructive\"></div>\r\n                    ) : (\r\n                      <Trash2 className=\"w-4 h-4\" />\r\n                    )}\r\n                  </button>\r\n                </div>\r\n              ))}\r\n            </div>\r\n          )}\r\n        </div>\r\n      </div>\r\n    </div>,\r\n    document.body\r\n  ) : null;\r\n\r\n  return (\r\n    <>\r\n      <ConfirmDialog\r\n        isOpen={showDeleteConfirm}\r\n        title={t('snapshot.deleteTitle')}\r\n        message={pendingDelete ? t('snapshot.deleteMessage', { version: pendingDelete.version }) : t('snapshot.deleteConfirm')}\r\n        type=\"warning\"\r\n        onConfirm={handleConfirmDelete}\r\n        onCancel={cancelDelete}\r\n      />\r\n\r\n      {snapshotCount > 0 && (\r\n        <button\r\n          onClick={handleOpen}\r\n          className=\"inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-primary/10 text-primary hover:bg-primary/20 hover:scale-105 active:scale-95 transition-all\"\r\n          title={t('snapshot.viewCount', { count: snapshotCount })}\r\n        >\r\n          <Camera className=\"w-3 h-3\" strokeWidth={2} />\r\n          <span className=\"font-medium\">{snapshotCount}</span>\r\n        </button>\r\n      )}\r\n      {modalContent}\r\n    </>\r\n  );\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/TagSelector.tsx",
    "content": "import { useRef } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport type { Tag } from '@/lib/types'\n\ninterface TagSelectorProps {\n  tagInput: string\n  setTagInput: (val: string) => void\n  onTagInputKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => void\n  selectedTagIds: string[]\n  toggleTag: (tagId: string) => void\n  tags: Tag[]\n  isPending: boolean\n}\n\nexport function TagSelector({\n  tagInput,\n  setTagInput,\n  onTagInputKeyDown,\n  selectedTagIds,\n  toggleTag,\n  tags,\n  isPending,\n}: TagSelectorProps) {\n  const { t } = useTranslation('bookmarks')\n  const availableTagsScrollRef = useRef<HTMLDivElement | null>(null)\n  const availableTagsInnerRef = useRef<HTMLDivElement | null>(null)\n  \n  // Cache for wheel step\n  const scrollStepRef = useRef<number | null>(null)\n\n  const handleAvailableTagsWheel = (e: React.WheelEvent<HTMLDivElement>) => {\n    const scrollEl = availableTagsScrollRef.current\n    if (!scrollEl) return\n\n    e.preventDefault()\n    e.stopPropagation()\n\n    if (scrollStepRef.current === null) {\n      const innerEl = availableTagsInnerRef.current\n      const firstItem = innerEl?.querySelector('button') as HTMLButtonElement | null\n      const itemHeight = firstItem?.getBoundingClientRect().height ?? 24\n\n      let rowGap = 0\n      if (innerEl) {\n        const style = window.getComputedStyle(innerEl)\n        const gapValue = style.rowGap || style.gap\n        const parsed = Number.parseFloat(gapValue)\n        rowGap = Number.isFinite(parsed) ? parsed : 0\n      }\n      scrollStepRef.current = Math.max(1, Math.round(itemHeight + rowGap))\n    }\n\n    const direction = e.deltaY > 0 ? 1 : -1\n    scrollEl.scrollBy({ top: direction * (scrollStepRef.current || 24), behavior: 'auto' })\n  }\n\n  return (\n    <div>\n      <div className=\"flex items-center justify-between mb-1.5\">\n        <label className=\"block text-xs font-medium text-foreground\">\n          {t('form.tags')}\n          <span className=\"text-xs text-muted-foreground ml-1.5\">\n            {t('form.tagsBatchHint')}\n          </span>\n        </label>\n        {selectedTagIds.length > 0 && (\n          <span className=\"text-xs text-muted-foreground\">\n            {t('form.tagsSelected', { count: selectedTagIds.length })}\n          </span>\n        )}\n      </div>\n\n      {/* 标签输入框 */}\n      <input\n        type=\"text\"\n        className=\"input mb-2\"\n        placeholder={t('form.tagsInputPlaceholder')}\n        value={tagInput}\n        onChange={(e) => setTagInput(e.target.value)}\n        onKeyDown={onTagInputKeyDown}\n        disabled={isPending}\n      />\n\n      {/* 已选标签 */}\n      {selectedTagIds.length > 0 && (\n        <div className=\"mb-2 p-2 bg-primary/5 border border-primary/20 rounded-lg\">\n          <div className=\"flex flex-wrap gap-1.5\">\n            {selectedTagIds.map((tagId) => {\n              const tag = tags.find((t) => t.id === tagId)\n              if (!tag) return null\n              return (\n                <button\n                  key={tag.id}\n                  type=\"button\"\n                  onClick={() => toggleTag(tag.id)}\n                  className=\"text-xs px-2.5 py-1 rounded-full bg-primary text-primary-content hover:bg-primary/90 transition-colors shadow-sm\"\n                  disabled={isPending}\n                >\n                  {tag.name} ×\n                </button>\n              )\n            })}\n          </div>\n        </div>\n      )}\n\n      {/* 可选标签列表 */}\n      <div\n        ref={availableTagsScrollRef}\n        onWheelCapture={handleAvailableTagsWheel}\n        className=\"p-2.5 bg-muted rounded-lg max-h-[120px] overflow-y-auto scrollbar-theme min-h-0 overscroll-contain\"\n        style={{ scrollbarGutter: 'stable' }}\n      >\n        <div ref={availableTagsInnerRef} className=\"flex flex-wrap gap-1.5\">\n          {tags.length === 0 ? (\n            <p className=\"text-xs text-muted-foreground py-1\">\n              {t('form.noTags')}\n            </p>\n          ) : (\n            tags\n              .filter((tag) => !selectedTagIds.includes(tag.id))\n              .map((tag) => (\n                <button\n                  key={tag.id}\n                  type=\"button\"\n                  onClick={() => toggleTag(tag.id)}\n                  className=\"text-xs px-2.5 py-1 rounded-full bg-card border border-border text-foreground hover:border-primary/50 hover:bg-primary/5 transition-colors\"\n                  disabled={isPending}\n                >\n                  {tag.name}\n                </button>\n              ))\n          )}\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/bookmark-utils.ts",
    "content": "/**\n * 生成Google Favicon URL作为fallback\n */\nexport const getFaviconUrl = (url: string): string => {\n  try {\n    const urlObj = new URL(url)\n    return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=64`\n  } catch {\n    return ''\n  }\n}\n\n/**\n * 检测 Google Favicon 是否为默认灰色地球图标\n * Google的默认图标通常是16x16或更小\n */\nexport const isGoogleDefaultIcon = (img: HTMLImageElement): boolean => {\n  return img.naturalWidth <= 16 && img.naturalHeight <= 16\n}\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/defaultIconOptions.ts",
    "content": "import type { DefaultBookmarkIcon } from '@/lib/types'\r\n\r\n// 图标选项配置 - 仅保留动态图标\r\nexport const DEFAULT_ICON_OPTIONS: Array<{ value: DefaultBookmarkIcon; label: string; description: string }> = [\r\n  { value: 'orbital-spinner', label: '轨道旋转', description: '炫酷轨道动画' },\r\n]\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/hooks/useBookmarkFormState.ts",
    "content": "import { useState } from 'react'\r\nimport type { Bookmark } from '@/lib/types'\r\n\r\nexport function useBookmarkFormState(bookmark?: Bookmark | null) {\r\n  const isEditing = !!bookmark\r\n\r\n  const [title, setTitle] = useState(bookmark?.title || '')\r\n  const [url, setUrl] = useState(bookmark?.url || '')\r\n  const [description, setDescription] = useState(bookmark?.description || '')\r\n  const [coverImage, setCoverImage] = useState(bookmark?.cover_image || '')\r\n  const [selectedTagIds, setSelectedTagIds] = useState<string[]>(\r\n    bookmark?.tags.map((t) => t.id) || []\r\n  )\r\n  const [isPinned, setIsPinned] = useState(bookmark?.is_pinned || false)\r\n  const [isArchived, setIsArchived] = useState(bookmark?.is_archived || false)\r\n  const [isPublic, setIsPublic] = useState(bookmark?.is_public ?? true)\r\n  const [error, setError] = useState('')\r\n  const [urlWarning, setUrlWarning] = useState<{ exists: boolean; message: string } | null>(null)\r\n  const [isCheckingUrl, setIsCheckingUrl] = useState(false)\r\n  const [newTagName, setNewTagName] = useState('')\r\n  const [isCreatingTag, setIsCreatingTag] = useState(false)\r\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)\r\n\r\n  return {\r\n    isEditing,\r\n    title,\r\n    setTitle,\r\n    url,\r\n    setUrl,\r\n    description,\r\n    setDescription,\r\n    coverImage,\r\n    setCoverImage,\r\n    selectedTagIds,\r\n    setSelectedTagIds,\r\n    isPinned,\r\n    setIsPinned,\r\n    isArchived,\r\n    setIsArchived,\r\n    isPublic,\r\n    setIsPublic,\r\n    error,\r\n    setError,\r\n    urlWarning,\r\n    setUrlWarning,\r\n    isCheckingUrl,\r\n    setIsCheckingUrl,\r\n    newTagName,\r\n    setNewTagName,\r\n    isCreatingTag,\r\n    setIsCreatingTag,\r\n    showDeleteConfirm,\r\n    setShowDeleteConfirm,\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/shared/BookmarkActions.tsx",
    "content": "/**\n * 共享的书签卡片操作组件：批量选择复选框 + 编辑按钮\n */\n\nimport { useTranslation } from 'react-i18next'\n\ninterface BatchCheckboxProps {\n  bookmarkId: string\n  isSelected: boolean\n  onToggleSelect: (id: string) => void\n  size?: 'sm' | 'md'\n}\n\nexport function BatchCheckbox({ bookmarkId, isSelected, onToggleSelect, size = 'md' }: BatchCheckboxProps) {\n  const { t } = useTranslation('bookmarks')\n  const sizeClass = size === 'sm' ? 'w-5 h-5' : 'w-6 h-6'\n  const iconSize = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'\n\n  return (\n    <div className=\"absolute top-2 left-2 sm:top-3 sm:left-3 z-10\">\n      <button\n        onClick={(e) => { e.preventDefault(); e.stopPropagation(); onToggleSelect(bookmarkId) }}\n        className={`${sizeClass} rounded flex items-center justify-center transition-all ${\n          isSelected ? 'bg-primary text-primary-foreground' : 'bg-card border-2 border-border hover:border-primary'\n        }`}\n        title={isSelected ? t('batch.deselect') : t('batch.select')}\n      >\n        {isSelected && (\n          <svg className={iconSize} fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={3}>\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" />\n          </svg>\n        )}\n      </button>\n    </div>\n  )\n}\n\ninterface EditButtonProps {\n  onEdit: () => void\n  showHint: boolean\n}\n\nexport function EditButton({ onEdit, showHint }: EditButtonProps) {\n  const { t } = useTranslation('bookmarks')\n\n  return (\n    <button\n      onClick={(e) => { e.stopPropagation(); onEdit() }}\n      className={`absolute top-2 right-2 sm:top-3 sm:right-3 w-7 h-7 sm:w-8 sm:h-8 rounded-lg sm:rounded-xl flex items-center justify-center transition-all hover:scale-110 z-10 touch-manipulation ${\n        showHint ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 active:opacity-100'\n      }`}\n      title={t('action.edit')}\n    >\n      <svg className=\"w-3.5 h-3.5 sm:w-4 sm:h-4 text-base-content drop-shadow-lg\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\n        <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\" />\n      </svg>\n    </button>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/shared/BookmarkTagList.tsx",
    "content": "/**\n * 书签标签 + 快照按钮展示（共享）\n */\n\nimport type { Bookmark } from '@/lib/types'\nimport { SnapshotViewer } from '../SnapshotViewer'\n\ninterface BookmarkTagListProps {\n  bookmark: Bookmark\n  maxTags?: number\n}\n\nexport function BookmarkTagList({ bookmark, maxTags = 4 }: BookmarkTagListProps) {\n  const hasTags = bookmark.tags && bookmark.tags.length > 0\n  const hasSnapshot = bookmark.has_snapshot && (bookmark.snapshot_count ?? 0) > 0\n\n  if (!hasTags && !hasSnapshot) return null\n\n  return (\n    <div className=\"flex flex-wrap items-center gap-1 sm:gap-1.5\">\n      {hasSnapshot && (\n        <div onClick={(e) => e.stopPropagation()}>\n          <SnapshotViewer\n            bookmarkId={bookmark.id}\n            bookmarkTitle={bookmark.title}\n            snapshotCount={bookmark.snapshot_count ?? 0}\n          />\n        </div>\n      )}\n      {bookmark.tags?.slice(0, maxTags).map((tag) => (\n        <span\n          key={tag.id}\n          className=\"text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full bg-primary/10 text-primary font-medium\"\n        >\n          {tag.name}\n        </span>\n      ))}\n      {bookmark.tags && bookmark.tags.length > maxTags && (\n        <span className=\"text-[10px] sm:text-xs text-muted-foreground/60\">\n          +{bookmark.tags.length - maxTags}\n        </span>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/shared/MasonryGrid.tsx",
    "content": "/**\n * 瀑布流网格容器\n * 负责列计算 + 置顶/普通分组 + 渲染子项\n */\n\nimport { useRef, type ReactNode } from 'react'\nimport type { Bookmark } from '@/lib/types'\nimport { useResponsiveColumns, useEditHintVisibility } from './useResponsiveColumns'\n\ninterface MasonryGridProps {\n  bookmarks: Bookmark[]\n  /** 每列最小宽度 */\n  minColumnWidth: number\n  /** 列间距 */\n  gap: number\n  /** 最小列数 */\n  minCols?: number\n  /** 每项的间距 CSS class */\n  itemSpacing?: string\n  /** 渲染单个书签卡片 */\n  renderItem: (bookmark: Bookmark, showEditHint: boolean) => ReactNode\n}\n\nexport function MasonryGrid({\n  bookmarks,\n  minColumnWidth,\n  gap,\n  minCols = 1,\n  itemSpacing = 'mb-3 sm:mb-4',\n  renderItem,\n}: MasonryGridProps) {\n  const containerRef = useRef<HTMLDivElement>(null)\n  const columns = useResponsiveColumns(containerRef, { minColumnWidth, gap, minCols })\n  const showEditHint = useEditHintVisibility()\n\n  const pinnedBookmarks = bookmarks.filter(b => b.is_pinned)\n  const unpinnedBookmarks = bookmarks.filter(b => !b.is_pinned)\n\n  const cols: Bookmark[][] = Array.from({ length: columns }, () => [])\n  for (let i = 0; i < pinnedBookmarks.length; i++) {\n    cols[i % columns]!.push(pinnedBookmarks[i]!)\n  }\n  for (let i = 0; i < unpinnedBookmarks.length; i++) {\n    cols[i % columns]!.push(unpinnedBookmarks[i]!)\n  }\n\n  return (\n    <div ref={containerRef} className=\"w-full min-w-0\">\n      {cols.length > 0 && (\n        <div\n          className=\"w-full min-w-0\"\n          style={{\n            display: 'grid',\n            gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,\n            gap: `${gap}px`,\n          }}\n        >\n          {cols.map((col, colIndex) => (\n            <div key={`col-${colIndex}`} className=\"min-w-0\">\n              {col.map((bookmark) => (\n                <div key={bookmark.id} className={itemSpacing}>\n                  {renderItem(bookmark, showEditHint)}\n                </div>\n              ))}\n            </div>\n          ))}\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/shared/useFaviconFallback.ts",
    "content": "import { useState, useMemo } from 'react'\nimport type { Bookmark } from '@/lib/types'\n\n/**\n * 书签图标三级回退：cover_image → favicon → Google Favicon → 默认图标\n */\nexport function useFaviconFallback(bookmark: Bookmark) {\n  const [coverImageError, setCoverImageError] = useState(false)\n  const [faviconError, setFaviconError] = useState(false)\n  const [googleFaviconIsDefault, setGoogleFaviconIsDefault] = useState(false)\n\n  const domain = useMemo(() => {\n    try {\n      return new URL(bookmark.url).hostname\n    } catch {\n      return bookmark.url.replace(/^https?:\\/\\//i, '').split('/')[0] || bookmark.url\n    }\n  }, [bookmark.url])\n\n  const googleFaviconUrl = useMemo(() => {\n    try {\n      return `https://www.google.com/s2/favicons?domain=${new URL(bookmark.url).hostname}&sz=64`\n    } catch {\n      return ''\n    }\n  }, [bookmark.url])\n\n  const checkIfGoogleDefaultIcon = (img: HTMLImageElement) => {\n    if (img.naturalWidth <= 16 && img.naturalHeight <= 16) {\n      setGoogleFaviconIsDefault(true)\n    }\n  }\n\n  const hasCoverImage = !!bookmark.cover_image?.trim() && !coverImageError\n  const hasFavicon = !hasCoverImage && !!bookmark.favicon?.trim() && !faviconError\n  const hasGoogleFavicon = !hasCoverImage && !hasFavicon && !!googleFaviconUrl && !faviconError && !googleFaviconIsDefault\n  const hasAnyIcon = hasCoverImage || hasFavicon || hasGoogleFavicon\n\n  return {\n    domain,\n    googleFaviconUrl,\n    hasCoverImage,\n    hasFavicon,\n    hasGoogleFavicon,\n    hasAnyIcon,\n    setCoverImageError,\n    setFaviconError,\n    checkIfGoogleDefaultIcon,\n  }\n}\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/shared/useResponsiveColumns.ts",
    "content": "import { useState, useEffect, type RefObject } from 'react'\n\ninterface ColumnConfig {\n  minColumnWidth: number\n  gap: number\n  minCols?: number\n  maxCols?: number\n}\n\n/**\n * 根据容器宽度动态计算列数\n */\nexport function useResponsiveColumns(\n  containerRef: RefObject<HTMLDivElement | null>,\n  config: ColumnConfig,\n) {\n  const { minColumnWidth, gap, minCols = 1, maxCols = 4 } = config\n  const [columns, setColumns] = useState(minCols)\n\n  useEffect(() => {\n    const updateColumns = () => {\n      if (!containerRef.current) return\n      const containerWidth = containerRef.current.offsetWidth\n\n      let cols = minCols\n      for (let i = minCols; i <= maxCols; i++) {\n        const totalWidth = i * minColumnWidth + (i - 1) * gap\n        if (containerWidth >= totalWidth) {\n          cols = i\n        } else {\n          break\n        }\n      }\n      setColumns(cols)\n    }\n\n    updateColumns()\n    const resizeObserver = new ResizeObserver(updateColumns)\n    if (containerRef.current) {\n      resizeObserver.observe(containerRef.current)\n    }\n    return () => resizeObserver.disconnect()\n  }, [containerRef, minColumnWidth, gap, minCols, maxCols])\n\n  return columns\n}\n\n/**\n * 移动端 10 秒后隐藏编辑提示\n */\nexport function useEditHintVisibility() {\n  const [showEditHint, setShowEditHint] = useState(true)\n\n  useEffect(() => {\n    const isMobile = window.innerWidth < 640\n    if (isMobile) {\n      const timer = setTimeout(() => setShowEditHint(false), 10000)\n      return () => clearTimeout(timer)\n    }\n    setShowEditHint(false)\n  }, [])\n\n  return showEditHint\n}\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/useBookmarkForm.ts",
    "content": "import { useState, useEffect } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { logger } from '@/lib/logger'\nimport { useCreateBookmark, useUpdateBookmark, useDeleteBookmark } from '@/hooks/useBookmarks'\nimport { useCreateTag } from '@/hooks/useTags'\nimport { bookmarksService } from '@/services/bookmarks'\nimport type { Bookmark, CreateBookmarkRequest, UpdateBookmarkRequest, Tag } from '@/lib/types'\n\ninterface UseBookmarkFormProps {\n  bookmark?: Bookmark | null\n  onClose: () => void\n  onSuccess?: () => void\n  tags: Tag[]\n}\n\nexport function useBookmarkForm({ bookmark, onClose, onSuccess, tags }: UseBookmarkFormProps) {\n  const { t } = useTranslation('bookmarks')\n  const isEditing = !!bookmark\n\n  const [title, setTitle] = useState(bookmark?.title || '')\n  const [url, setUrl] = useState(bookmark?.url || '')\n  const [description, setDescription] = useState(bookmark?.description || '')\n  const [coverImage, setCoverImage] = useState(bookmark?.cover_image || '')\n  const [selectedTagIds, setSelectedTagIds] = useState<string[]>(\n    bookmark?.tags.map((t) => t.id) || []\n  )\n  const [isPinned, setIsPinned] = useState(bookmark?.is_pinned || false)\n  const [isArchived, setIsArchived] = useState(bookmark?.is_archived || false)\n  const [isPublic, setIsPublic] = useState(bookmark?.is_public || false)\n  const [error, setError] = useState('')\n  const [tagInput, setTagInput] = useState('')\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)\n  const [urlWarning, setUrlWarning] = useState<{ exists: true; bookmark: Bookmark } | null>(null)\n  const [checkingUrl, setCheckingUrl] = useState(false)\n\n  const createBookmark = useCreateBookmark()\n  const updateBookmark = useUpdateBookmark()\n  const deleteBookmark = useDeleteBookmark()\n  const createTag = useCreateTag()\n\n  useEffect(() => {\n    let isMounted = true\n    const checkUrl = async () => {\n      if (!url.trim() || (isEditing && url.trim() === bookmark?.url)) {\n        setUrlWarning(null)\n        setCheckingUrl(false)\n        return\n      }\n\n      if (url.trim().length < 10) {\n        setUrlWarning(null)\n        setCheckingUrl(false)\n        return\n      }\n\n      if (!validateUrl(url)) {\n        setUrlWarning(null)\n        setCheckingUrl(false)\n        return\n      }\n\n      setCheckingUrl(true)\n      try {\n        const result = await bookmarksService.checkUrlExists(url.trim())\n        if (!isMounted) return\n        if (result.exists && result.bookmark) {\n          setUrlWarning({ exists: true, bookmark: result.bookmark })\n        } else {\n          setUrlWarning(null)\n        }\n      } catch (error) {\n        logger.error('Failed to check URL:', error)\n      } finally {\n        if (isMounted) setCheckingUrl(false)\n      }\n    }\n\n    const timeoutId = setTimeout(checkUrl, 800)\n    return () => {\n      isMounted = false\n      clearTimeout(timeoutId)\n    }\n  }, [url, isEditing, bookmark?.url])\n\n  const validateUrl = (urlStr: string) => {\n    try {\n      new URL(urlStr)\n      return true\n    } catch {\n      return false\n    }\n  }\n\n  const handleSubmit = async (e: React.FormEvent) => {\n    e.preventDefault()\n    setError('')\n\n    if (!title.trim()) {\n      setError(t('form.validation.titleRequired'))\n      return\n    }\n\n    if (!url.trim()) {\n      setError(t('form.validation.urlRequired'))\n      return\n    }\n\n    if (!validateUrl(url)) {\n      setError(t('form.validation.urlInvalid'))\n      return\n    }\n\n    if (!isEditing && urlWarning?.exists) {\n      setError(t('form.validation.urlExists'))\n      return\n    }\n\n    try {\n      if (isEditing && bookmark) {\n        const updateData: UpdateBookmarkRequest = {\n          tag_ids: selectedTagIds,\n          is_pinned: isPinned,\n          is_archived: isArchived,\n          is_public: isPublic,\n        }\n\n        if (title.trim() !== (bookmark.title || '')) {\n          updateData.title = title.trim()\n        }\n\n        if (url.trim() !== (bookmark.url || '')) {\n          updateData.url = url.trim()\n        }\n\n        const originalDescription = bookmark.description || ''\n        if (description.trim() !== originalDescription) {\n          updateData.description = description.trim() ? description.trim() : null\n        }\n\n        const originalCoverImage = bookmark.cover_image || ''\n        if (coverImage.trim() !== originalCoverImage) {\n          updateData.cover_image = coverImage.trim() ? coverImage.trim() : null\n        }\n\n        await updateBookmark.mutateAsync({ id: bookmark.id, data: updateData })\n      } else {\n        const createData: CreateBookmarkRequest = {\n          title: title.trim(),\n          url: url.trim(),\n          description: description.trim() ? description.trim() : undefined,\n          cover_image: coverImage.trim() ? coverImage.trim() : undefined,\n          tag_ids: selectedTagIds,\n          is_pinned: isPinned,\n          is_archived: isArchived,\n          is_public: isPublic,\n        }\n\n        await createBookmark.mutateAsync(createData)\n      }\n      onSuccess?.()\n      onClose()\n    } catch (error) {\n      setError(error instanceof Error ? error.message : t('form.operationFailed'))\n    }\n  }\n\n  const toggleTag = (tagId: string) => {\n    if (selectedTagIds.includes(tagId)) {\n      setSelectedTagIds(selectedTagIds.filter((id) => id !== tagId))\n    } else {\n      setSelectedTagIds([...selectedTagIds, tagId])\n    }\n  }\n\n  const processTagInput = async () => {\n    const input = tagInput.trim()\n    if (!input) return\n\n    const tagNames = input\n      .split(/[,，]/)\n      .map(name => name.trim())\n      .filter(name => name.length > 0)\n\n    if (tagNames.length === 0) return\n\n    const newSelectedIds = [...selectedTagIds]\n\n    for (const tagName of tagNames) {\n      const existingTag = tags.find((t) => t.name.toLowerCase() === tagName.toLowerCase())\n      if (existingTag) {\n        if (!newSelectedIds.includes(existingTag.id)) {\n          newSelectedIds.push(existingTag.id)\n        }\n      } else {\n        try {\n          const newTag = await createTag.mutateAsync({ name: tagName })\n          newSelectedIds.push(newTag.id)\n        } catch (error) {\n          console.error('Failed to create tag:', error)\n          setError(t('form.createTagFailed', { name: tagName }))\n          return\n        }\n      }\n    }\n\n    setSelectedTagIds(newSelectedIds)\n    setTagInput('')\n  }\n\n  const handleDeleteClick = () => {\n    setShowDeleteConfirm(true)\n  }\n\n  const handleConfirmDelete = async () => {\n    if (!bookmark) return\n\n    setShowDeleteConfirm(false)\n    try {\n      await deleteBookmark.mutateAsync(bookmark.id)\n      onSuccess?.()\n      onClose()\n    } catch (error) {\n      setError(error instanceof Error ? error.message : t('form.deleteFailed'))\n    }\n  }\n\n  const isPending = createBookmark.isPending || updateBookmark.isPending || deleteBookmark.isPending || createTag.isPending\n\n  return {\n    title, setTitle,\n    url, setUrl,\n    description, setDescription,\n    coverImage, setCoverImage,\n    selectedTagIds, toggleTag,\n    isPinned, setIsPinned,\n    isArchived, setIsArchived,\n    isPublic, setIsPublic,\n    error, setError,\n    tagInput, setTagInput,\n    showDeleteConfirm, setShowDeleteConfirm,\n    urlWarning, checkingUrl,\n    isPending,\n    handleSubmit,\n    processTagInput,\n    handleDeleteClick,\n    handleConfirmDelete,\n    isEditing,\n    t\n  }\n}\n"
  },
  {
    "path": "tmarks/src/components/bookmarks/useSnapshots.ts",
    "content": "import { useState } from 'react';\nimport { useTranslation } from 'react-i18next';\nimport { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';\nimport { apiClient } from '@/lib/api-client';\nimport { useToastStore } from '@/stores/toastStore';\nimport { BOOKMARKS_QUERY_KEY } from '@/hooks/useBookmarks';\n\nexport interface Snapshot {\n  id: string;\n  version: number;\n  file_size: number;\n  snapshot_title: string;\n  created_at: string;\n  view_url: string; // 签名 URL\n}\n\nexport const SNAPSHOTS_QUERY_KEY = 'snapshots';\n\nexport function useSnapshots(bookmarkId: string) {\n  const { t } = useTranslation('bookmarks');\n  const queryClient = useQueryClient();\n  const { addToast } = useToastStore();\n  \n  const [pendingDelete, setPendingDelete] = useState<{ snapshotId: string; version: number } | null>(null);\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);\n\n  const { data: snapshots = [], isLoading, refetch } = useQuery({\n    queryKey: [SNAPSHOTS_QUERY_KEY, bookmarkId],\n    queryFn: async () => {\n      const response = await apiClient.get<{ snapshots: Snapshot[]; total: number }>(\n        `/bookmarks/${bookmarkId}/snapshots`\n      );\n      // API 返回格式: { data: { snapshots: [...], total: ... } }\n      // 注意：apiClient.get 已经剥离了一层 data，所以这里 response.data 是 API 返回的 payload\n      return response.data?.snapshots || [];\n    },\n    enabled: false, // 只有在打开弹窗时才触发\n  });\n\n  const deleteMutation = useMutation({\n    mutationFn: (snapshotId: string) => \n      apiClient.delete(`/bookmarks/${bookmarkId}/snapshots/${snapshotId}`),\n    onSuccess: (_, snapshotId) => {\n      // 乐观更新本地缓存\n      queryClient.setQueryData([SNAPSHOTS_QUERY_KEY, bookmarkId], (old: Snapshot[] | undefined) => \n        old ? old.filter(s => s.id !== snapshotId) : []\n      );\n      \n      // 刷新书签列表（更新快照计数）\n      queryClient.invalidateQueries({ queryKey: [BOOKMARKS_QUERY_KEY] });\n      \n      addToast('success', t('snapshot.deleteSuccess'));\n    },\n    onError: (error) => {\n      console.error('Failed to delete snapshot:', error);\n      addToast('error', t('snapshot.deleteFailed'));\n    },\n    onSettled: () => {\n      setPendingDelete(null);\n    }\n  });\n\n  const handleRequestDelete = (snapshotId: string, version: number, e: React.MouseEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    setPendingDelete({ snapshotId, version });\n    setShowDeleteConfirm(true);\n  };\n\n  const handleConfirmDelete = async () => {\n    if (!pendingDelete) {\n      setShowDeleteConfirm(false);\n      return;\n    }\n\n    const { snapshotId } = pendingDelete;\n    setShowDeleteConfirm(false);\n    try {\n      await deleteMutation.mutateAsync(snapshotId);\n    } catch {\n      // error already handled by onError callback\n    }\n  };\n\n  const cancelDelete = () => {\n    setShowDeleteConfirm(false);\n    setPendingDelete(null);\n  };\n\n  return {\n    snapshots,\n    isLoading,\n    deletingId: deleteMutation.isPending ? pendingDelete?.snapshotId || null : null,\n    pendingDelete,\n    showDeleteConfirm,\n    loadSnapshots: refetch,\n    handleRequestDelete,\n    handleConfirmDelete,\n    cancelDelete,\n  };\n}\n"
  },
  {
    "path": "tmarks/src/components/common/AdaptiveImage.tsx",
    "content": "import { useEffect, useState, useCallback, memo } from 'react'\r\nimport { analyzeImage, type ImageType } from '@/lib/image-utils'\r\n\r\ninterface AdaptiveImageProps {\r\n  src: string\r\n  alt: string\r\n  className?: string\r\n  onTypeDetected?: (type: ImageType) => void\r\n  onError?: () => void\r\n}\r\n\r\n/**\r\n * 自适应图片组件\r\n * 根据图片比例自动判断类型并应用不同的样式\r\n */\r\nexport const AdaptiveImage = memo(function AdaptiveImage({ src, alt, className = '', onTypeDetected, onError }: AdaptiveImageProps) {\r\n  const [imageType, setImageType] = useState<ImageType>('unknown')\r\n  const [isLoaded, setIsLoaded] = useState(false)\r\n  const [hasError, setHasError] = useState(false)\r\n\r\n  useEffect(() => {\r\n    let cancelled = false\r\n\r\n    analyzeImage(src)\r\n      .then((info) => {\r\n        if (!cancelled) {\r\n          setImageType(info.type)\r\n          onTypeDetected?.(info.type)\r\n        }\r\n      })\r\n      .catch(() => {\r\n        if (!cancelled) {\r\n          setImageType('unknown')\r\n          setHasError(true)\r\n          onError?.()\r\n        }\r\n      })\r\n\r\n    return () => {\r\n      cancelled = true\r\n    }\r\n  }, [src, onTypeDetected, onError])\r\n\r\n  const handleLoad = useCallback(() => {\r\n    setIsLoaded(true)\r\n    setHasError(false)\r\n  }, [])\r\n\r\n  const handleError = useCallback(() => {\r\n    setHasError(true)\r\n    setIsLoaded(false)\r\n    onError?.()\r\n  }, [onError])\r\n\r\n  if (hasError) {\r\n    return null\r\n  }\r\n\r\n  return (\r\n    <img\r\n      src={src}\r\n      alt={alt}\r\n      className={`${className} ${!isLoaded ? 'opacity-0' : 'opacity-100'} transition-opacity duration-200`}\r\n      data-image-type={imageType}\r\n      onLoad={handleLoad}\r\n      onError={handleError}\r\n    />\r\n  )\r\n})\r\n"
  },
  {
    "path": "tmarks/src/components/common/AlertDialog.tsx",
    "content": "import { useEffect } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\n\r\ninterface AlertDialogProps {\r\n  isOpen: boolean\r\n  title?: string\r\n  message: string\r\n  confirmText?: string\r\n  type?: 'info' | 'warning' | 'error' | 'success'\r\n  onConfirm: () => void\r\n}\r\n\r\nexport function AlertDialog({\r\n  isOpen,\r\n  title,\r\n  message,\r\n  confirmText,\r\n  type = 'info',\r\n  onConfirm,\r\n}: AlertDialogProps) {\r\n  const { t } = useTranslation('common')\r\n\r\n  // 使用翻译的默认值\r\n  const displayTitle = title ?? t('dialog.infoTitle')\r\n  const displayConfirmText = confirmText ?? t('button.confirm')\r\n\r\n  // 阻止背景滚动\r\n  useEffect(() => {\r\n    if (isOpen) {\r\n      document.body.style.overflow = 'hidden'\r\n    } else {\r\n      document.body.style.overflow = ''\r\n    }\r\n    return () => {\r\n      document.body.style.overflow = ''\r\n    }\r\n  }, [isOpen])\r\n\r\n  // ESC 键关闭\r\n  useEffect(() => {\r\n    const handleEsc = (e: KeyboardEvent) => {\r\n      if (e.key === 'Escape' && isOpen) {\r\n        onConfirm()\r\n      }\r\n    }\r\n    window.addEventListener('keydown', handleEsc)\r\n    return () => window.removeEventListener('keydown', handleEsc)\r\n  }, [isOpen, onConfirm])\r\n\r\n  const getTypeStyles = () => {\r\n    switch (type) {\r\n      case 'error':\r\n        return {\r\n          bg: 'bg-error/10',\r\n          icon: 'bg-error text-error-content',\r\n          iconRing: 'ring-error/20'\r\n        }\r\n      case 'warning':\r\n        return {\r\n          bg: 'bg-warning/10',\r\n          icon: 'bg-warning text-warning-content',\r\n          iconRing: 'ring-warning/20'\r\n        }\r\n      case 'success':\r\n        return {\r\n          bg: 'bg-success/10',\r\n          icon: 'bg-success text-success-content',\r\n          iconRing: 'ring-success/20'\r\n        }\r\n      default:\r\n        return {\r\n          bg: 'bg-info/10',\r\n          icon: 'bg-info text-info-content',\r\n          iconRing: 'ring-info/20'\r\n        }\r\n    }\r\n  }\r\n\r\n  if (!isOpen) return null\r\n\r\n  const styles = getTypeStyles()\r\n\r\n  const dialogContent = (\r\n    <div className=\"fixed inset-0 flex items-center justify-center p-4 sm:p-6 animate-fade-in\" style={{ zIndex: Z_INDEX.ALERT_DIALOG }}>\r\n      <div\r\n        className=\"absolute inset-0 bg-background/80 backdrop-blur-sm\"\r\n        onClick={onConfirm}\r\n      />\r\n\r\n      <div className=\"relative card rounded-2xl sm:rounded-3xl shadow-2xl border max-w-md w-full animate-scale-in p-6 sm:p-8\" style={{backgroundColor: 'var(--card)', borderColor: 'var(--border)'}}>\r\n        <div className=\"flex justify-center mb-4 sm:mb-6\">\r\n          <div className={`w-14 h-14 sm:w-16 sm:h-16 rounded-xl sm:rounded-2xl ${styles.icon} ${styles.iconRing} ring-4 sm:ring-8 flex items-center justify-center shadow-lg`}>\r\n            {type === 'error' && (\r\n              <svg className=\"w-8 h-8\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2.5}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\r\n              </svg>\r\n            )}\r\n            {type === 'warning' && (\r\n              <svg className=\"w-8 h-8\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2.5}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\r\n              </svg>\r\n            )}\r\n            {type === 'success' && (\r\n              <svg className=\"w-8 h-8\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2.5}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" />\r\n              </svg>\r\n            )}\r\n            {type === 'info' && (\r\n              <svg className=\"w-8 h-8\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2.5}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\r\n              </svg>\r\n            )}\r\n          </div>\r\n        </div>\r\n\r\n        <div className=\"text-center mb-6 sm:mb-8\">\r\n          <h3 className=\"font-bold text-xl sm:text-2xl mb-2 sm:mb-3 text-foreground\">{displayTitle}</h3>\r\n          <p className=\"text-sm sm:text-base text-muted-foreground leading-relaxed\">{message}</p>\r\n        </div>\r\n\r\n        <button onClick={onConfirm} className=\"btn w-full min-h-[44px]\">\r\n          {displayConfirmText}\r\n        </button>\r\n      </div>\r\n    </div>\r\n  )\r\n\r\n  return createPortal(dialogContent, document.body)\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/BookmarkIcons.tsx",
    "content": "import { \n  LayoutGrid, \n  List, \n  AlignLeft, \n  Type, \n  Eye, \n  Lock, \n  Layers, \n  Calendar, \n  RefreshCw, \n  Bookmark as BookmarkIcon, \n  TrendingUp \n} from 'lucide-react'\nimport type { ViewMode, VisibilityFilter } from '@/lib/constants/bookmarks'\nimport type { SortOption } from '@/components/common/SortSelector'\n\nexport function ViewModeIcon({ mode, className }: { mode: ViewMode, className?: string }) {\n  const baseClass = className || \"w-4 h-4\"\n  switch (mode) {\n    case 'card':\n      return <LayoutGrid className={baseClass} />\n    case 'list':\n      return <List className={baseClass} />\n    case 'minimal':\n      return <AlignLeft className={baseClass} />\n    case 'title':\n      return <Type className={baseClass} />\n    default:\n      return <LayoutGrid className={baseClass} />\n  }\n}\n\nexport function VisibilityIcon({ filter, className }: { filter: VisibilityFilter, className?: string }) {\n  const baseClass = className || \"w-4 h-4\"\n  switch (filter) {\n    case 'public':\n      return <Eye className={baseClass} />\n    case 'private':\n      return <Lock className={baseClass} />\n    case 'all':\n      return <Layers className={baseClass} />\n    default:\n      return <Layers className={baseClass} />\n  }\n}\n\nexport function SortIcon({ sort, className }: { sort: SortOption, className?: string }) {\n  const baseClass = className || \"w-4 h-4\"\n  switch (sort) {\n    case 'created':\n      return <Calendar className={baseClass} />\n    case 'updated':\n      return <RefreshCw className={baseClass} />\n    case 'pinned':\n      return <BookmarkIcon className={baseClass} />\n    case 'popular':\n      return <TrendingUp className={baseClass} />\n    default:\n      return <Calendar className={baseClass} />\n  }\n}\n"
  },
  {
    "path": "tmarks/src/components/common/BottomNav.tsx",
    "content": "import { Link, useLocation } from 'react-router-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Layers, CheckSquare, Trash2 } from 'lucide-react'\r\n\r\ninterface NavItem {\r\n  path: string\r\n  icon: React.ReactNode\r\n  labelKey: string\r\n}\r\n\r\nconst navItems: NavItem[] = [\r\n  {\r\n    path: '/tab',\r\n    icon: <Layers className=\"w-5 h-5\" />,\r\n    labelKey: 'nav.all',\r\n  },\r\n  {\r\n    path: '/tab/todo',\r\n    icon: <CheckSquare className=\"w-5 h-5\" />,\r\n    labelKey: 'nav.todo',\r\n  },\r\n  {\r\n    path: '/tab/trash',\r\n    icon: <Trash2 className=\"w-5 h-5\" />,\r\n    labelKey: 'nav.trash',\r\n  },\r\n]\r\n\r\nexport function BottomNav() {\r\n  const { t } = useTranslation('common')\r\n  const location = useLocation()\r\n\r\n  return (\r\n    <nav\r\n      className=\"fixed bottom-0 left-0 right-0 border-t border-border z-30 safe-area-bottom md:hidden\"\r\n      style={{ backgroundColor: 'var(--card)' }}\r\n    >\r\n      <div className=\"flex items-center justify-around h-16\">\r\n        {navItems.map((item) => {\r\n          const isActive = location.pathname === item.path\r\n          \r\n          return (\r\n            <Link\r\n              key={item.path}\r\n              to={item.path}\r\n              className={`flex flex-col items-center justify-center flex-1 h-full transition-colors ${\r\n                isActive\r\n                  ? 'text-primary'\r\n                  : 'text-muted-foreground hover:text-foreground'\r\n              }`}\r\n            >\r\n              <div className={`transition-transform ${isActive ? 'scale-110' : ''}`}>\r\n                {item.icon}\r\n              </div>\r\n              <span className=\"text-xs mt-1 font-medium\">{t(item.labelKey)}</span>\r\n            </Link>\r\n          )\r\n        })}\r\n      </div>\r\n    </nav>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/CircularProgress.tsx",
    "content": "import { useAnimatedProgress } from './useAnimatedProgress'\n\nexport interface CircularProgressProps {\n  percentage: number\n  size?: number\n  strokeWidth?: number\n  color?: string\n  backgroundColor?: string\n  showPercentage?: boolean\n  className?: string\n}\n\n/**\n * 圆形进度指示器\n */\nexport function CircularProgress({\n  percentage,\n  size = 64,\n  strokeWidth = 4,\n  color,\n  backgroundColor,\n  showPercentage = true,\n  className = ''\n}: CircularProgressProps) {\n  const animatedPercentage = useAnimatedProgress(percentage)\n\n  // 使用 CSS 变量作为默认颜色\n  const defaultColor = typeof window !== 'undefined'\n    ? getComputedStyle(document.documentElement).getPropertyValue('--primary').trim() || 'hsl(221.2 83.2% 53.3%)'\n    : 'hsl(221.2 83.2% 53.3%)'\n  const defaultBgColor = typeof window !== 'undefined'\n    ? getComputedStyle(document.documentElement).getPropertyValue('--muted').trim() || 'hsl(210 40% 96.1%)'\n    : 'hsl(210 40% 96.1%)'\n\n  const finalColor = color || defaultColor\n  const finalBgColor = backgroundColor || defaultBgColor\n\n  const radius = (size - strokeWidth) / 2\n  const circumference = radius * 2 * Math.PI\n  const strokeDasharray = circumference\n  const strokeDashoffset = circumference - (animatedPercentage / 100) * circumference\n\n  return (\n    <div className={`relative inline-flex items-center justify-center ${className}`}>\n      <svg\n        width={size}\n        height={size}\n        className=\"transform -rotate-90\"\n      >\n        {/* 背景圆 */}\n        <circle\n          cx={size / 2}\n          cy={size / 2}\n          r={radius}\n          stroke={finalBgColor}\n          strokeWidth={strokeWidth}\n          fill=\"transparent\"\n        />\n        {/* 进度圆 */}\n        <circle\n          cx={size / 2}\n          cy={size / 2}\n          r={radius}\n          stroke={finalColor}\n          strokeWidth={strokeWidth}\n          fill=\"transparent\"\n          strokeDasharray={strokeDasharray}\n          strokeDashoffset={strokeDashoffset}\n          strokeLinecap=\"round\"\n          className=\"transition-all duration-500 ease-out\"\n        />\n      </svg>\n      {showPercentage && (\n        <div className=\"absolute inset-0 flex items-center justify-center\">\n          <span className=\"text-sm font-medium text-foreground\">\n            {Math.round(animatedPercentage)}%\n          </span>\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/common/ColorThemeSelector.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { useThemeStore } from '@/stores/themeStore'\r\n\r\nexport function ColorThemeSelector() {\r\n  const { t } = useTranslation('common')\r\n  const { colorTheme, setColorTheme } = useThemeStore()\r\n\r\n  const toggleColorTheme = () => {\r\n    setColorTheme(colorTheme === 'default' ? 'orange' : 'default')\r\n  }\r\n\r\n  return (\r\n    <button\r\n      onClick={toggleColorTheme}\r\n      className=\"btn btn-sm btn-ghost p-2\"\r\n      aria-label={t('nav.toggleColorTheme')}\r\n      title={colorTheme === 'default' ? t('nav.switchToOrange') : t('nav.switchToDefault')}\r\n    >\r\n      {colorTheme === 'default' ? (\r\n        <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01\" />\r\n        </svg>\r\n      ) : (\r\n        <svg className=\"w-4 h-4\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\r\n          <path d=\"M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01\" />\r\n        </svg>\r\n      )}\r\n    </button>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/ConfirmDialog.tsx",
    "content": "import { useEffect } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\n\r\ninterface ConfirmDialogProps {\r\n  isOpen: boolean\r\n  title?: string\r\n  message: string\r\n  confirmText?: string\r\n  cancelText?: string\r\n  type?: 'info' | 'warning' | 'error' | 'success'\r\n  onConfirm: () => void\r\n  onCancel: () => void\r\n}\r\n\r\nexport function ConfirmDialog({\r\n  isOpen,\r\n  title,\r\n  message,\r\n  confirmText,\r\n  cancelText,\r\n  type = 'warning',\r\n  onConfirm,\r\n  onCancel,\r\n}: ConfirmDialogProps) {\r\n  const { t } = useTranslation('common')\r\n\r\n  // 使用翻译的默认值\r\n  const displayTitle = title ?? t('dialog.confirmTitle')\r\n  const displayConfirmText = confirmText ?? t('button.confirm')\r\n  const displayCancelText = cancelText ?? t('button.cancel')\r\n\r\n  // 阻止背景滚动\r\n  useEffect(() => {\r\n    if (isOpen) {\r\n      document.body.style.overflow = 'hidden'\r\n    } else {\r\n      document.body.style.overflow = ''\r\n    }\r\n    return () => {\r\n      document.body.style.overflow = ''\r\n    }\r\n  }, [isOpen])\r\n\r\n  // ESC 键关闭\r\n  useEffect(() => {\r\n    const handleEsc = (e: KeyboardEvent) => {\r\n      if (e.key === 'Escape' && isOpen) {\r\n        onCancel()\r\n      }\r\n    }\r\n    window.addEventListener('keydown', handleEsc)\r\n    return () => window.removeEventListener('keydown', handleEsc)\r\n  }, [isOpen, onCancel])\r\n\r\n  const getTypeStyles = () => {\r\n    switch (type) {\r\n      case 'error':\r\n        return {\r\n          bg: 'bg-error/10',\r\n          icon: 'bg-error text-error-content',\r\n          iconRing: 'ring-error/20'\r\n        }\r\n      case 'warning':\r\n        return {\r\n          bg: 'bg-warning/10',\r\n          icon: 'bg-warning text-warning-content',\r\n          iconRing: 'ring-warning/20'\r\n        }\r\n      case 'success':\r\n        return {\r\n          bg: 'bg-success/10',\r\n          icon: 'bg-success text-success-content',\r\n          iconRing: 'ring-success/20'\r\n        }\r\n      default:\r\n        return {\r\n          bg: 'bg-info/10',\r\n          icon: 'bg-info text-info-content',\r\n          iconRing: 'ring-info/20'\r\n        }\r\n    }\r\n  }\r\n\r\n  if (!isOpen) return null\r\n\r\n  const styles = getTypeStyles()\r\n\r\n  const dialogContent = (\r\n    <div className=\"fixed inset-0 flex items-center justify-center p-4 sm:p-6 animate-fade-in\" style={{ zIndex: Z_INDEX.CONFIRM_DIALOG }}>\r\n      <div\r\n        className=\"absolute inset-0 bg-background/80 backdrop-blur-sm\"\r\n        onClick={onCancel}\r\n      />\r\n\r\n      <div className=\"relative card rounded-2xl sm:rounded-3xl shadow-2xl border max-w-md w-full animate-scale-in p-6 sm:p-8\" style={{backgroundColor: 'var(--card)', borderColor: 'var(--border)'}}>\r\n        <div className=\"flex justify-center mb-4 sm:mb-6\">\r\n          <div className={`w-14 h-14 sm:w-16 sm:h-16 rounded-xl sm:rounded-2xl ${styles.icon} ${styles.iconRing} ring-4 sm:ring-8 flex items-center justify-center shadow-lg`}>\r\n            {type === 'error' && (\r\n              <svg className=\"w-8 h-8\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2.5}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\r\n              </svg>\r\n            )}\r\n            {type === 'warning' && (\r\n              <svg className=\"w-8 h-8\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2.5}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\r\n              </svg>\r\n            )}\r\n            {type === 'success' && (\r\n              <svg className=\"w-8 h-8\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2.5}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" />\r\n              </svg>\r\n            )}\r\n            {type === 'info' && (\r\n              <svg className=\"w-8 h-8\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2.5}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\r\n              </svg>\r\n            )}\r\n          </div>\r\n        </div>\r\n\r\n        <div className=\"text-center mb-6 sm:mb-8\">\r\n          <h3 className=\"font-bold text-xl sm:text-2xl mb-2 sm:mb-3 text-foreground\">{displayTitle}</h3>\r\n          <p className=\"text-sm sm:text-base text-muted-foreground leading-relaxed\">{message}</p>\r\n        </div>\r\n\r\n        <div className=\"flex gap-2 sm:gap-3\">\r\n          <button onClick={onCancel} className=\"btn btn-outline flex-1 min-h-[44px]\">\r\n            {displayCancelText}\r\n          </button>\r\n          <button onClick={onConfirm} className=\"btn flex-1 min-h-[44px]\">\r\n            {displayConfirmText}\r\n          </button>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n\r\n  return createPortal(dialogContent, document.body)\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/DialogHost.tsx",
    "content": "import { useDialogStore } from '@/stores/dialogStore'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { AlertDialog } from '@/components/common/AlertDialog'\r\n\r\nexport function DialogHost() {\r\n  const confirmDialog = useDialogStore((s) => s.confirmDialog)\r\n  const alertDialog = useDialogStore((s) => s.alertDialog)\r\n  const closeConfirm = useDialogStore((s) => s.closeConfirm)\r\n  const closeAlert = useDialogStore((s) => s.closeAlert)\r\n\r\n  return (\r\n    <>\r\n      {confirmDialog && (\r\n        <ConfirmDialog\r\n          isOpen={confirmDialog.isOpen}\r\n          title={confirmDialog.title}\r\n          message={confirmDialog.message}\r\n          type={confirmDialog.type}\r\n          confirmText={confirmDialog.confirmText}\r\n          cancelText={confirmDialog.cancelText}\r\n          onConfirm={() => closeConfirm(true)}\r\n          onCancel={() => closeConfirm(false)}\r\n        />\r\n      )}\r\n\r\n      {alertDialog && (\r\n        <AlertDialog\r\n          isOpen={alertDialog.isOpen}\r\n          title={alertDialog.title}\r\n          message={alertDialog.message}\r\n          type={alertDialog.type}\r\n          confirmText={alertDialog.confirmText}\r\n          onConfirm={closeAlert}\r\n        />\r\n      )}\r\n    </>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/DragDropUpload.tsx",
    "content": "/**\r\n * 拖拽上传组件\r\n * 支持拖拽和点击上传文件，提供良好的视觉反馈\r\n */\r\n\r\nimport { useState, useRef, useCallback } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Upload, FileText, AlertCircle, CheckCircle } from 'lucide-react'\r\n\r\ninterface DragDropUploadProps {\r\n  onFileSelect: (file: File) => void\r\n  accept?: string\r\n  maxSize?: number\r\n  disabled?: boolean\r\n  className?: string\r\n  children?: React.ReactNode\r\n}\r\n\r\ninterface UploadState {\r\n  isDragOver: boolean\r\n  isValidDrag: boolean\r\n  error: string | null\r\n}\r\n\r\nexport function DragDropUpload({\r\n  onFileSelect,\r\n  accept = '*',\r\n  maxSize = 10 * 1024 * 1024,\r\n  disabled = false,\r\n  className = '',\r\n  children\r\n}: DragDropUploadProps) {\r\n  const { t } = useTranslation('common')\r\n  const [state, setState] = useState<UploadState>({\r\n    isDragOver: false,\r\n    isValidDrag: false,\r\n    error: null\r\n  })\r\n  \r\n  const fileInputRef = useRef<HTMLInputElement>(null)\r\n\r\n  const validateFile = useCallback((file: File): string | null => {\r\n    if (file.size > maxSize) {\r\n      return t('upload.fileSizeExceeded', { size: formatFileSize(maxSize) })\r\n    }\r\n\r\n    if (accept !== '*') {\r\n      const acceptedTypes = accept.split(',').map(type => type.trim())\r\n      const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase()\r\n      const isValidType = acceptedTypes.some(type => {\r\n        if (type.startsWith('.')) {\r\n          return type === fileExtension\r\n        }\r\n        return file.type.match(type.replace('*', '.*'))\r\n      })\r\n      \r\n      if (!isValidType) {\r\n        return t('upload.fileTypeNotSupported', { types: acceptedTypes.join(', ') })\r\n      }\r\n    }\r\n\r\n    return null\r\n  }, [accept, maxSize, t])\r\n\r\n  const handleFileSelect = useCallback((file: File) => {\r\n    const error = validateFile(file)\r\n    if (error) {\r\n      setState(prev => ({ ...prev, error }))\r\n      return\r\n    }\r\n\r\n    setState(prev => ({ ...prev, error: null }))\r\n    onFileSelect(file)\r\n  }, [validateFile, onFileSelect])\r\n\r\n  const handleDragEnter = useCallback((e: React.DragEvent) => {\r\n    e.preventDefault()\r\n    e.stopPropagation()\r\n    \r\n    if (disabled) return\r\n\r\n    const files = Array.from(e.dataTransfer.files)\r\n    const isValidDrag = files.length === 1 && files[0] && validateFile(files[0]) === null\r\n\r\n    setState(prev => ({\r\n      ...prev,\r\n      isDragOver: true,\r\n      isValidDrag: isValidDrag || false\r\n    }))\r\n  }, [disabled, validateFile])\r\n\r\n  const handleDragOver = useCallback((e: React.DragEvent) => {\r\n    e.preventDefault()\r\n    e.stopPropagation()\r\n  }, [])\r\n\r\n  const handleDragLeave = useCallback((e: React.DragEvent) => {\r\n    e.preventDefault()\r\n    e.stopPropagation()\r\n    \r\n    if (!e.currentTarget.contains(e.relatedTarget as Node)) {\r\n      setState(prev => ({\r\n        ...prev,\r\n        isDragOver: false,\r\n        isValidDrag: false\r\n      }))\r\n    }\r\n  }, [])\r\n\r\n  const handleDrop = useCallback((e: React.DragEvent) => {\r\n    e.preventDefault()\r\n    e.stopPropagation()\r\n\r\n    setState(prev => ({\r\n      ...prev,\r\n      isDragOver: false,\r\n      isValidDrag: false\r\n    }))\r\n\r\n    if (disabled) return\r\n\r\n    const files = Array.from(e.dataTransfer.files)\r\n    if (files.length === 1) {\r\n      if (files[0]) {\r\n        handleFileSelect(files[0])\r\n      }\r\n    }\r\n  }, [disabled, handleFileSelect])\r\n\r\n  const handleClick = useCallback(() => {\r\n    if (disabled) return\r\n    fileInputRef.current?.click()\r\n  }, [disabled])\r\n\r\n  const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {\r\n    const file = e.target.files?.[0]\r\n    if (file) {\r\n      handleFileSelect(file)\r\n    }\r\n  }, [handleFileSelect])\r\n\r\n  const clearError = useCallback(() => {\r\n    setState(prev => ({ ...prev, error: null }))\r\n  }, [])\r\n\r\n  const containerClasses = [\r\n    'relative border-2 border-dashed rounded-lg transition-all duration-200 cursor-pointer',\r\n    'hover:border-muted-foreground/50',\r\n    'focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2',\r\n    className\r\n  ]\r\n\r\n  if (disabled) {\r\n    containerClasses.push('opacity-50 cursor-not-allowed')\r\n  } else if (state.isDragOver) {\r\n    if (state.isValidDrag) {\r\n      containerClasses.push('border-success bg-success/10')\r\n    } else {\r\n      containerClasses.push('border-destructive bg-destructive/10')\r\n    }\r\n  } else {\r\n    containerClasses.push('border-border')\r\n  }\r\n\r\n  return (\r\n    <div className=\"space-y-3\">\r\n      <div\r\n        className={containerClasses.join(' ')}\r\n        onDragEnter={handleDragEnter}\r\n        onDragOver={handleDragOver}\r\n        onDragLeave={handleDragLeave}\r\n        onDrop={handleDrop}\r\n        onClick={handleClick}\r\n      >\r\n        <input\r\n          ref={fileInputRef}\r\n          type=\"file\"\r\n          accept={accept}\r\n          onChange={handleInputChange}\r\n          className=\"sr-only\"\r\n          disabled={disabled}\r\n        />\r\n\r\n        {children || (\r\n          <div className=\"p-8 text-center\">\r\n            <div className=\"flex flex-col items-center space-y-4\">\r\n              <div className={`p-3 rounded-full ${\r\n                state.isDragOver\r\n                  ? state.isValidDrag\r\n                    ? 'bg-success/20'\r\n                    : 'bg-destructive/20'\r\n                  : 'bg-muted'\r\n              }`}>\r\n                {state.isDragOver ? (\r\n                  state.isValidDrag ? (\r\n                    <CheckCircle className=\"h-8 w-8 text-success\" />\r\n                  ) : (\r\n                    <AlertCircle className=\"h-8 w-8 text-destructive\" />\r\n                  )\r\n                ) : (\r\n                  <Upload className=\"h-8 w-8 text-muted-foreground\" />\r\n                )}\r\n              </div>\r\n\r\n              <div className=\"space-y-1\">\r\n                <p className=\"text-base font-medium text-foreground\">\r\n                  {state.isDragOver\r\n                    ? state.isValidDrag\r\n                      ? t('upload.dropToUpload')\r\n                      : t('upload.formatNotSupported')\r\n                    : t('upload.dragOrClick')\r\n                  }\r\n                </p>\r\n                {accept !== '*' && !state.isDragOver && (\r\n                  <p className=\"text-sm text-muted-foreground\">\r\n                    {t('upload.supportedFormats', { formats: accept })}\r\n                  </p>\r\n                )}\r\n              </div>\r\n\r\n              {!state.isDragOver && (\r\n                <button\r\n                  type=\"button\"\r\n                  className=\"inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-primary-foreground bg-primary hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:opacity-50 touch-manipulation\"\r\n                  disabled={disabled}\r\n                >\r\n                  <FileText className=\"h-4 w-4 mr-2\" />\r\n                  {t('upload.selectFile')}\r\n                </button>\r\n              )}\r\n            </div>\r\n          </div>\r\n        )}\r\n\r\n        {state.isDragOver && (\r\n          <div className=\"absolute inset-0 bg-muted/20 rounded-lg pointer-events-none\" />\r\n        )}\r\n      </div>\r\n\r\n      {state.error && (\r\n        <div className=\"flex items-center justify-between p-3 bg-destructive/10 border border-destructive/20 rounded-md\">\r\n          <div className=\"flex items-center space-x-2\">\r\n            <AlertCircle className=\"h-4 w-4 text-destructive\" />\r\n            <span className=\"text-sm text-destructive\">\r\n              {state.error}\r\n            </span>\r\n          </div>\r\n          <button\r\n            onClick={clearError}\r\n            className=\"text-destructive hover:text-destructive/80\"\r\n          >\r\n            <span className=\"sr-only\">{t('upload.close')}</span>\r\n            ×\r\n          </button>\r\n        </div>\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n\r\nfunction formatFileSize(bytes: number): string {\r\n  if (bytes === 0) return '0 B'\r\n  \r\n  const k = 1024\r\n  const sizes = ['B', 'KB', 'MB', 'GB']\r\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\r\n  \r\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/Drawer.tsx",
    "content": "import { useEffect, useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { createPortal } from 'react-dom'\r\nimport { X } from 'lucide-react'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\n\r\ninterface DrawerProps {\r\n  isOpen: boolean\r\n  onClose: () => void\r\n  children: React.ReactNode\r\n  title?: string\r\n  side?: 'left' | 'right'\r\n}\r\n\r\n/**\r\n * 移动端抽屉组件\r\n * 从左侧或右侧滑出的面板\r\n */\r\nexport function Drawer({ isOpen, onClose, children, title, side = 'left' }: DrawerProps) {\r\n  const { t } = useTranslation('common')\r\n  const [shouldRender, setShouldRender] = useState(false)\r\n\r\n  // 延迟卸载以显示关闭动画\r\n  useEffect(() => {\r\n    if (isOpen) {\r\n      setShouldRender(true)\r\n    } else {\r\n      const timer = setTimeout(() => setShouldRender(false), 300)\r\n      return () => clearTimeout(timer)\r\n    }\r\n  }, [isOpen])\r\n\r\n  // 阻止背景滚动\r\n  useEffect(() => {\r\n    if (isOpen) {\r\n      document.body.style.overflow = 'hidden'\r\n    } else {\r\n      document.body.style.overflow = ''\r\n    }\r\n    return () => {\r\n      document.body.style.overflow = ''\r\n    }\r\n  }, [isOpen])\r\n\r\n  // ESC 键关闭\r\n  useEffect(() => {\r\n    const handleEsc = (e: KeyboardEvent) => {\r\n      if (e.key === 'Escape' && isOpen) {\r\n        onClose()\r\n      }\r\n    }\r\n    window.addEventListener('keydown', handleEsc)\r\n    return () => window.removeEventListener('keydown', handleEsc)\r\n  }, [isOpen, onClose])\r\n\r\n  if (!shouldRender) return null\r\n\r\n  const slideClass = side === 'left'\r\n    ? 'left-0 translate-x-0'\r\n    : 'right-0 translate-x-0'\r\n\r\n  const slideOutClass = side === 'left'\r\n    ? '-translate-x-full'\r\n    : 'translate-x-full'\r\n\r\n  const drawerContent = (\r\n    <>\r\n      {/* 遮罩层 */}\r\n      <div\r\n        className={`fixed inset-0 bg-background/80 backdrop-blur-sm transition-opacity duration-300 ${\r\n          isOpen ? 'opacity-100' : 'opacity-0'\r\n        }`}\r\n        style={{ zIndex: Z_INDEX.DRAWER_BACKDROP }}\r\n        onClick={onClose}\r\n      />\r\n\r\n      {/* 抽屉内容 */}\r\n      <div\r\n        className={`fixed top-0 ${side}-0 h-full w-[280px] max-w-[80vw] border-r border-border transform transition-transform duration-300 ease-out flex flex-col ${\r\n          isOpen ? slideClass : slideOutClass\r\n        }`}\r\n        style={{\r\n          zIndex: Z_INDEX.DRAWER_CONTENT,\r\n          backgroundColor: 'var(--card)',\r\n        }}\r\n      >\r\n        {/* 头部 */}\r\n        {title && (\r\n          <div className=\"flex items-center justify-between p-4 border-b border-border flex-shrink-0\">\r\n            <h2 className=\"text-lg font-semibold text-foreground\">{title}</h2>\r\n            <button\r\n              onClick={onClose}\r\n              className=\"p-2 hover:bg-muted rounded-lg transition-colors\"\r\n              aria-label={t('action.close')}\r\n            >\r\n              <X className=\"w-5 h-5\" />\r\n            </button>\r\n          </div>\r\n        )}\r\n\r\n        {/* 内容区域 */}\r\n        <div className=\"flex-1 overflow-y-auto pb-20 min-h-0\">\r\n          {children}\r\n        </div>\r\n      </div>\r\n    </>\r\n  )\r\n\r\n  return createPortal(drawerContent, document.body)\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/src/components/common/DropdownMenu.tsx",
    "content": "import { useEffect, useRef, useState } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\n\r\nexport interface MenuItem {\r\n  label: string\r\n  icon?: React.ReactNode\r\n  onClick: () => void\r\n  danger?: boolean\r\n  disabled?: boolean\r\n  divider?: boolean  // 在此项之前显示分隔线\r\n}\r\n\r\ninterface DropdownMenuProps {\r\n  trigger: React.ReactNode\r\n  items: MenuItem[]\r\n  align?: 'left' | 'right'\r\n}\r\n\r\nexport function DropdownMenu({ trigger, items, align = 'right' }: DropdownMenuProps) {\r\n  const [isOpen, setIsOpen] = useState(false)\r\n  const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, right: 0 })\r\n  const triggerRef = useRef<HTMLDivElement>(null)\r\n  const menuRef = useRef<HTMLDivElement>(null)\r\n\r\n  // 更新菜单位置\r\n  useEffect(() => {\r\n    if (isOpen && triggerRef.current) {\r\n      const rect = triggerRef.current.getBoundingClientRect()\r\n      setMenuPosition({\r\n        top: rect.bottom + 4, // 4px gap\r\n        left: align === 'left' ? rect.left : 0,\r\n        right: align === 'right' ? window.innerWidth - rect.right : 0\r\n      })\r\n    }\r\n  }, [isOpen, align])\r\n\r\n  // 调整菜单位置，确保不超出屏幕\r\n  useEffect(() => {\r\n    if (isOpen && menuRef.current && triggerRef.current) {\r\n      const menuRect = menuRef.current.getBoundingClientRect()\r\n      const triggerRect = triggerRef.current.getBoundingClientRect()\r\n      const gap = 4\r\n      const padding = 10 // 距离屏幕边缘的最小距离\r\n\r\n      let adjustedTop = triggerRect.bottom + gap\r\n      let adjustedLeft = menuPosition.left\r\n      let adjustedRight = menuPosition.right\r\n\r\n      // 检查垂直方向溢出\r\n      if (adjustedTop + menuRect.height > window.innerHeight) {\r\n        // 尝试显示在触发器上方\r\n        const topPosition = triggerRect.top - menuRect.height - gap\r\n        if (topPosition >= padding) {\r\n          adjustedTop = topPosition\r\n        } else {\r\n          // 如果上方也放不下，则贴近屏幕底部\r\n          adjustedTop = window.innerHeight - menuRect.height - padding\r\n        }\r\n      }\r\n\r\n      // 检查水平方向溢出\r\n      if (align === 'left') {\r\n        // 左对齐：检查右侧是否溢出\r\n        if (adjustedLeft + menuRect.width > window.innerWidth - padding) {\r\n          adjustedLeft = window.innerWidth - menuRect.width - padding\r\n        }\r\n        // 检查左侧是否溢出\r\n        if (adjustedLeft < padding) {\r\n          adjustedLeft = padding\r\n        }\r\n      } else {\r\n        // 右对齐：检查左侧是否溢出\r\n        const leftEdge = window.innerWidth - adjustedRight - menuRect.width\r\n        if (leftEdge < padding) {\r\n          adjustedRight = window.innerWidth - menuRect.width - padding\r\n        }\r\n        // 检查右侧是否溢出\r\n        if (adjustedRight < padding) {\r\n          adjustedRight = padding\r\n        }\r\n      }\r\n\r\n      // 应用调整后的位置\r\n      menuRef.current.style.top = `${adjustedTop}px`\r\n      if (align === 'left') {\r\n        menuRef.current.style.left = `${adjustedLeft}px`\r\n        menuRef.current.style.right = 'auto'\r\n      } else {\r\n        menuRef.current.style.right = `${adjustedRight}px`\r\n        menuRef.current.style.left = 'auto'\r\n      }\r\n    }\r\n  }, [isOpen, menuPosition, align])\r\n\r\n  // 点击外部关闭\r\n  useEffect(() => {\r\n    const handleClickOutside = (event: MouseEvent) => {\r\n      if (\r\n        triggerRef.current &&\r\n        !triggerRef.current.contains(event.target as Node) &&\r\n        menuRef.current &&\r\n        !menuRef.current.contains(event.target as Node)\r\n      ) {\r\n        setIsOpen(false)\r\n      }\r\n    }\r\n\r\n    if (isOpen) {\r\n      document.addEventListener('mousedown', handleClickOutside)\r\n    }\r\n\r\n    return () => {\r\n      document.removeEventListener('mousedown', handleClickOutside)\r\n    }\r\n  }, [isOpen])\r\n\r\n  return (\r\n    <>\r\n      <div ref={triggerRef} onClick={() => setIsOpen(!isOpen)}>\r\n        {trigger}\r\n      </div>\r\n\r\n      {isOpen && createPortal(\r\n        <div\r\n          ref={menuRef}\r\n          className=\"fixed w-56 rounded shadow-2xl border py-1\"\r\n          style={{\r\n            top: `${menuPosition.top}px`,\r\n            left: align === 'left' ? `${menuPosition.left}px` : 'auto',\r\n            right: align === 'right' ? `${menuPosition.right}px` : 'auto',\r\n            zIndex: Z_INDEX.DROPDOWN,\r\n            backgroundColor: 'var(--card)',\r\n            borderColor: 'var(--border)'\r\n          }}\r\n        >\r\n          {items.map((item, index) => (\r\n            <div key={index}>\r\n              {item.divider && index > 0 && (\r\n                <div className=\"my-1 h-px bg-border\" />\r\n              )}\r\n              <button\r\n                onClick={() => {\r\n                  if (!item.disabled) {\r\n                    item.onClick()\r\n                    setIsOpen(false)\r\n                  }\r\n                }}\r\n                disabled={item.disabled}\r\n                className={`w-full flex items-center gap-3 px-4 py-2 text-sm text-left transition-colors ${\r\n                  item.danger\r\n                    ? 'text-destructive hover:bg-destructive/10'\r\n                    : 'text-foreground hover:bg-muted'\r\n                } ${item.disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}\r\n              >\r\n                {item.icon && <span className=\"flex-shrink-0\">{item.icon}</span>}\r\n                <span className=\"flex-1\">{item.label}</span>\r\n              </button>\r\n            </div>\r\n          ))}\r\n        </div>,\r\n        document.body\r\n      )}\r\n    </>\r\n  )\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/src/components/common/ErrorBoundary.tsx",
    "content": "import { Component } from 'react'\nimport type { ErrorInfo, ReactNode } from 'react'\n\ninterface Props {\n  children: ReactNode\n}\n\ninterface State {\n  hasError: boolean\n  error: Error | null\n}\n\nexport class ErrorBoundary extends Component<Props, State> {\n  constructor(props: Props) {\n    super(props)\n    this.state = { hasError: false, error: null }\n  }\n\n  static getDerivedStateFromError(error: Error): State {\n    return { hasError: true, error }\n  }\n\n  componentDidCatch(error: Error, errorInfo: ErrorInfo) {\n    console.error('[ErrorBoundary] Uncaught error:', error, errorInfo)\n  }\n\n  render() {\n    if (this.state.hasError) {\n      return (\n        <div style={{\n          display: 'flex',\n          flexDirection: 'column',\n          alignItems: 'center',\n          justifyContent: 'center',\n          height: '100vh',\n          fontFamily: 'system-ui, sans-serif',\n          padding: '2rem',\n          textAlign: 'center',\n        }}>\n          <h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>\n            Something went wrong\n          </h1>\n          <p style={{ color: '#666', marginBottom: '1.5rem', maxWidth: '500px' }}>\n            {this.state.error?.message || 'The application encountered an unexpected error'}\n          </p>\n          <button\n            onClick={() => window.location.reload()}\n            style={{\n              padding: '0.5rem 1.5rem',\n              borderRadius: '0.375rem',\n              border: '1px solid #d1d5db',\n              background: '#fff',\n              cursor: 'pointer',\n              fontSize: '0.875rem',\n            }}\n          >\n            Reload page\n          </button>\n        </div>\n      )\n    }\n\n    return this.props.children\n  }\n}\n"
  },
  {
    "path": "tmarks/src/components/common/ErrorDisplay.tsx",
    "content": "/**\r\n * 错误显示组件\r\n * 提供统一的错误状态显示和处理\r\n */\r\n\r\nimport { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { \r\n  AlertCircle, \r\n  AlertTriangle, \r\n  Info, \r\n  CheckCircle, \r\n  X, \r\n  ChevronDown, \r\n  ChevronUp,\r\n  RefreshCw,\r\n  Copy\r\n} from 'lucide-react'\r\n\r\ninterface ErrorItem {\r\n  id?: string\r\n  message: string\r\n  details?: string\r\n  code?: string\r\n  field?: string\r\n  timestamp?: string\r\n}\r\n\r\ninterface ErrorDisplayProps {\r\n  errors: ErrorItem[]\r\n  variant?: 'error' | 'warning' | 'info' | 'success'\r\n  title?: string\r\n  dismissible?: boolean\r\n  collapsible?: boolean\r\n  showDetails?: boolean\r\n  maxVisible?: number\r\n  onDismiss?: () => void\r\n  onRetry?: () => void\r\n  className?: string\r\n}\r\n\r\nexport function ErrorDisplay({\r\n  errors,\r\n  variant = 'error',\r\n  title,\r\n  dismissible = true,\r\n  collapsible = true,\r\n  showDetails = false,\r\n  maxVisible = 3,\r\n  onDismiss,\r\n  onRetry,\r\n  className = ''\r\n}: ErrorDisplayProps) {\r\n  const { t } = useTranslation('common')\r\n  const [isExpanded, setIsExpanded] = useState(false)\r\n  const [copiedId, setCopiedId] = useState<string | null>(null)\r\n\r\n  if (errors.length === 0) return null\r\n\r\n  // 样式配置\r\n  const variantConfig = {\r\n    error: {\r\n      containerClass: 'bg-destructive/10 border-destructive/20',\r\n      iconClass: 'text-destructive',\r\n      titleClass: 'text-destructive',\r\n      textClass: 'text-destructive/90',\r\n      icon: AlertCircle\r\n    },\r\n    warning: {\r\n      containerClass: 'bg-warning/10 border-warning/20',\r\n      iconClass: 'text-warning',\r\n      titleClass: 'text-warning',\r\n      textClass: 'text-warning/90',\r\n      icon: AlertTriangle\r\n    },\r\n    info: {\r\n      containerClass: 'bg-primary/10 border-primary/20',\r\n      iconClass: 'text-primary',\r\n      titleClass: 'text-primary',\r\n      textClass: 'text-primary/90',\r\n      icon: Info\r\n    },\r\n    success: {\r\n      containerClass: 'bg-success/10 border-success/20',\r\n      iconClass: 'text-success',\r\n      titleClass: 'text-success',\r\n      textClass: 'text-success/90',\r\n      icon: CheckCircle\r\n    }\r\n  }\r\n\r\n  const config = variantConfig[variant]\r\n  const Icon = config.icon\r\n\r\n  // 显示的错误数量\r\n  const visibleErrors = isExpanded ? errors : errors.slice(0, maxVisible)\r\n  const hasMore = errors.length > maxVisible\r\n\r\n  // 复制错误信息\r\n  const copyError = async (error: ErrorItem, index: number) => {\r\n    const errorText = [\r\n      `${t('error.error')}: ${error.message}`,\r\n      error.code && `${t('error.code')}: ${error.code}`,\r\n      error.field && `${t('error.field')}: ${error.field}`,\r\n      error.details && `${t('error.details')}: ${error.details}`,\r\n      error.timestamp && `${t('error.time')}: ${error.timestamp}`\r\n    ].filter(Boolean).join('\\n')\r\n\r\n    try {\r\n      await navigator.clipboard.writeText(errorText)\r\n      setCopiedId(error.id || index.toString())\r\n      setTimeout(() => setCopiedId(null), 2000)\r\n    } catch (err) {\r\n      console.error('Failed to copy error:', err)\r\n    }\r\n  }\r\n\r\n  return (\r\n    <div className={`rounded-lg border p-3 sm:p-4 ${config.containerClass} ${className}`}>\r\n      {/* 头部 */}\r\n      <div className=\"flex items-start justify-between\">\r\n        <div className=\"flex items-start space-x-2 sm:space-x-3 flex-1 min-w-0\">\r\n          <Icon className={`h-4 w-4 sm:h-5 sm:w-5 mt-0.5 flex-shrink-0 ${config.iconClass}`} />\r\n          <div className=\"flex-1 min-w-0\">\r\n            <h3 className={`text-sm sm:text-base font-semibold ${config.titleClass}`}>\r\n              {title || getDefaultTitle(variant, errors.length, t)}\r\n            </h3>\r\n\r\n            {/* 错误列表 */}\r\n            <div className=\"mt-2 space-y-1.5 sm:space-y-2\">\r\n              {visibleErrors.map((error, index) => (\r\n                <ErrorItem\r\n                  key={error.id || index}\r\n                  error={error}\r\n                  index={index}\r\n                  variant={variant}\r\n                  showDetails={showDetails}\r\n                  onCopy={() => copyError(error, index)}\r\n                  isCopied={copiedId === (error.id || index.toString())}\r\n                />\r\n              ))}\r\n            </div>\r\n\r\n            {/* 展开/收起按钮 */}\r\n            {hasMore && collapsible && (\r\n              <button\r\n                onClick={() => setIsExpanded(!isExpanded)}\r\n                className={`mt-3 inline-flex items-center space-x-1 text-xs sm:text-sm font-medium ${config.textClass} hover:underline touch-manipulation`}\r\n              >\r\n                {isExpanded ? (\r\n                  <>\r\n                    <ChevronUp className=\"h-3 w-3 sm:h-4 sm:w-4\" />\r\n                    <span>{t('action.collapse')}</span>\r\n                  </>\r\n                ) : (\r\n                  <>\r\n                    <ChevronDown className=\"h-3 w-3 sm:h-4 sm:w-4\" />\r\n                    <span className=\"hidden sm:inline\">{t('error.showMore', { count: errors.length - maxVisible })}</span>\r\n                    <span className=\"sm:hidden\">{t('error.more', { count: errors.length - maxVisible })}</span>\r\n                  </>\r\n                )}\r\n              </button>\r\n            )}\r\n          </div>\r\n        </div>\r\n\r\n        {/* 操作按钮 */}\r\n        <div className=\"flex items-center space-x-1 sm:space-x-2 ml-2 flex-shrink-0\">\r\n          {onRetry && (\r\n            <button\r\n              onClick={onRetry}\r\n              className={`p-1.5 sm:p-2 rounded-md ${config.textClass} hover:bg-muted/50 touch-manipulation`}\r\n              title={t('action.retry')}\r\n            >\r\n              <RefreshCw className=\"h-3 w-3 sm:h-4 sm:w-4\" />\r\n            </button>\r\n          )}\r\n\r\n          {dismissible && (\r\n            <button\r\n              onClick={onDismiss}\r\n              className={`p-1.5 sm:p-2 rounded-md ${config.textClass} hover:bg-muted/50 touch-manipulation`}\r\n              title={t('action.close')}\r\n            >\r\n              <X className=\"h-3 w-3 sm:h-4 sm:w-4\" />\r\n            </button>\r\n          )}\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n\r\n/**\r\n * 单个错误项组件\r\n */\r\ninterface ErrorItemProps {\r\n  error: ErrorItem\r\n  index: number\r\n  variant: 'error' | 'warning' | 'info' | 'success'\r\n  showDetails: boolean\r\n  onCopy: () => void\r\n  isCopied: boolean\r\n}\r\n\r\nfunction ErrorItem({ error, variant, showDetails, onCopy, isCopied }: ErrorItemProps) {\r\n  const { t } = useTranslation('common')\r\n  const [isDetailsExpanded, setIsDetailsExpanded] = useState(false)\r\n\r\n  const variantConfig = {\r\n    error: 'text-destructive/90',\r\n    warning: 'text-warning/90',\r\n    info: 'text-primary/90',\r\n    success: 'text-success/90'\r\n  }\r\n\r\n  const textClass = variantConfig[variant]\r\n\r\n  return (\r\n    <div className=\"space-y-1\">\r\n      <div className=\"flex items-start justify-between\">\r\n        <div className=\"flex-1 min-w-0\">\r\n          <p className={`text-xs sm:text-sm ${textClass} leading-relaxed`}>\r\n            {error.field && (\r\n              <span className=\"font-semibold\">{error.field}: </span>\r\n            )}\r\n            <span className=\"break-words\">{error.message}</span>\r\n            {error.code && (\r\n              <span className=\"ml-2 px-1.5 py-0.5 text-xs bg-muted/50 rounded whitespace-nowrap\">\r\n                {error.code}\r\n              </span>\r\n            )}\r\n          </p>\r\n\r\n          {error.details && (showDetails || isDetailsExpanded) && (\r\n            <div className=\"mt-2 p-2 bg-muted/30 rounded text-xs font-mono break-all\">\r\n              {error.details}\r\n            </div>\r\n          )}\r\n        </div>\r\n\r\n        <div className=\"flex items-center space-x-1 ml-2 flex-shrink-0\">\r\n          {error.details && !showDetails && (\r\n            <button\r\n              onClick={() => setIsDetailsExpanded(!isDetailsExpanded)}\r\n              className={`p-1.5 rounded text-xs ${textClass} hover:bg-muted/50 touch-manipulation`}\r\n              title={t('action.viewDetails')}\r\n            >\r\n              {isDetailsExpanded ? (\r\n                <ChevronUp className=\"h-3 w-3\" />\r\n              ) : (\r\n                <ChevronDown className=\"h-3 w-3\" />\r\n              )}\r\n            </button>\r\n          )}\r\n\r\n          <button\r\n            onClick={onCopy}\r\n            className={`p-1.5 rounded text-xs ${textClass} hover:bg-muted/50 touch-manipulation`}\r\n            title={isCopied ? t('status.copied') : t('action.copyError')}\r\n          >\r\n            {isCopied ? (\r\n              <CheckCircle className=\"h-3 w-3\" />\r\n            ) : (\r\n              <Copy className=\"h-3 w-3\" />\r\n            )}\r\n          </button>\r\n        </div>\r\n      </div>\r\n\r\n      {error.timestamp && (\r\n        <p className={`text-xs ${textClass} opacity-75 truncate`}>\r\n          {new Date(error.timestamp).toLocaleString()}\r\n        </p>\r\n      )}\r\n    </div>\r\n  )\n}\n\nfunction getDefaultTitle(variant: string, count: number, t: (key: string, options?: Record<string, unknown>) => string): string {\n  const titles = {\n    error: count === 1 ? t('error.occurred') : t('error.occurredCount', { count }),\n    warning: count === 1 ? t('error.warning') : t('error.warningCount', { count }),\n    info: count === 1 ? t('error.info') : t('error.infoCount', { count }),\n    success: count === 1 ? t('error.success') : t('error.successCount', { count })\r\n  }\r\n  \r\n  return titles[variant as keyof typeof titles] || t('error.notification')\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/LanguageSelector.tsx",
    "content": "import { useLanguage } from '@/hooks/useLanguage'\r\nimport { Globe } from 'lucide-react'\r\n\r\ninterface LanguageSelectorProps {\r\n  className?: string\r\n  showLabel?: boolean\r\n}\r\n\r\nexport function LanguageSelector({ className = '', showLabel = true }: LanguageSelectorProps) {\r\n  const { currentLanguage, changeLanguage, supportedLanguages } = useLanguage()\r\n\r\n  return (\r\n    <div className={`flex items-center gap-2 ${className}`}>\r\n      {showLabel && <Globe className=\"w-4 h-4 text-muted-foreground\" />}\r\n      <select\r\n        value={currentLanguage}\r\n        onChange={(e) => changeLanguage(e.target.value as typeof currentLanguage)}\r\n        className=\"input py-1.5 px-3 text-sm min-w-[120px]\"\r\n      >\r\n        {supportedLanguages.map((lang) => (\r\n          <option key={lang.code} value={lang.code}>\r\n            {lang.nativeName}\r\n          </option>\r\n        ))}\r\n      </select>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/LazyImage.tsx",
    "content": "/**\r\n * 懒加载图片组件\r\n * 支持图片懒加载和错误处理\r\n */\r\n\r\nimport { useState, useRef, useEffect, memo } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\n\r\ninterface LazyImageProps {\r\n  src: string\r\n  alt: string\r\n  className?: string\r\n  placeholder?: string\r\n  onLoad?: () => void\r\n  onError?: () => void\r\n}\r\n\r\nexport const LazyImage = memo(function LazyImage({\r\n  src,\r\n  alt,\r\n  className = '',\r\n  placeholder,\r\n  onLoad,\r\n  onError,\r\n}: LazyImageProps) {\r\n  const { t } = useTranslation('common')\r\n  const [isLoaded, setIsLoaded] = useState(false)\r\n  const [hasError, setHasError] = useState(false)\r\n  const [isInView, setIsInView] = useState(false)\r\n  const imgRef = useRef<HTMLImageElement>(null)\r\n\r\n  useEffect(() => {\r\n    const observer = new IntersectionObserver(\r\n      ([entry]) => {\r\n        if (entry?.isIntersecting) {\r\n          setIsInView(true)\r\n          observer.disconnect()\r\n        }\r\n      },\r\n      {\r\n        threshold: 0.1,\r\n        rootMargin: '50px',\r\n      }\r\n    )\r\n\r\n    if (imgRef.current) {\r\n      observer.observe(imgRef.current)\r\n    }\r\n\r\n    return () => observer.disconnect()\r\n  }, [])\r\n\r\n  const handleLoad = () => {\r\n    setIsLoaded(true)\r\n    setHasError(false)\r\n    onLoad?.()\r\n  }\r\n\r\n  const handleError = () => {\r\n    setHasError(true)\r\n    setIsLoaded(false)\r\n    onError?.()\r\n  }\r\n\r\n  if (hasError) {\r\n    return placeholder ? (\r\n      <div className={`${className} bg-base-200 flex items-center justify-center`}>\r\n        <span className=\"text-base-content/50 text-xs\">{t('status.loadFailed')}</span>\r\n      </div>\r\n    ) : null\r\n  }\r\n\r\n  return (\r\n    <div className={`relative ${className}`}>\r\n      {/* 占位符 */}\r\n      {!isLoaded && (\r\n        <div className=\"absolute inset-0 bg-base-200 animate-pulse rounded\" />\r\n      )}\r\n      \r\n      {/* 实际图片 */}\r\n      <img\r\n        ref={imgRef}\r\n        src={isInView ? src : undefined}\r\n        alt={alt}\r\n        className={`${className} transition-opacity duration-200 ${\r\n          isLoaded ? 'opacity-100' : 'opacity-0'\r\n        }`}\r\n        onLoad={handleLoad}\r\n        onError={handleError}\r\n        loading=\"lazy\"\r\n      />\r\n    </div>\r\n  )\r\n})\r\n"
  },
  {
    "path": "tmarks/src/components/common/MobileHeader.tsx",
    "content": "import { useTranslation } from 'react-i18next'\nimport { Menu, Search, MoreVertical } from 'lucide-react'\n\ninterface MobileHeaderProps {\n  title: string\n  onMenuClick?: () => void\n  onSearchClick?: () => void\n  onMoreClick?: () => void\n  showMenu?: boolean\n  showSearch?: boolean\n  showMore?: boolean\n}\n\n/**\n * 移动端顶部工具栏\n */\nexport function MobileHeader({\n  title,\n  onMenuClick,\n  onSearchClick,\n  onMoreClick,\n  showMenu = true,\n  showSearch = true,\n  showMore = true,\n}: MobileHeaderProps) {\n  const { t } = useTranslation('common')\n  \n  return (\n    <header\n      className=\"sticky top-0 left-0 right-0 border-b border-border z-30 md:hidden\"\n      style={{ backgroundColor: 'var(--card)' }}\n    >\n      <div className=\"flex items-center justify-between h-14 px-4\">\n        {/* 左侧：菜单按钮 */}\n        <div className=\"flex items-center gap-2\">\n          {showMenu && onMenuClick && (\n            <button\n              onClick={onMenuClick}\n              className=\"p-2 hover:bg-muted rounded-lg transition-colors\"\n              aria-label={t('action.openMenu')}\n            >\n              <Menu className=\"w-5 h-5\" />\n            </button>\n          )}\n        </div>\n\n        {/* 中间：标题 */}\n        <h1 className=\"text-lg font-semibold text-foreground truncate flex-1 text-center\">\n          {title}\n        </h1>\n\n        {/* 右侧：操作按钮 */}\n        <div className=\"flex items-center gap-2\">\n          {showSearch && onSearchClick && (\n            <button\n              onClick={onSearchClick}\n              className=\"p-2 hover:bg-muted rounded-lg transition-colors\"\n              aria-label={t('action.search')}\n            >\n              <Search className=\"w-5 h-5\" />\n            </button>\n          )}\n          {showMore && onMoreClick && (\n            <button\n              onClick={onMoreClick}\n              className=\"p-2 hover:bg-muted rounded-lg transition-colors\"\n              aria-label={t('action.more')}\n            >\n              <MoreVertical className=\"w-5 h-5\" />\n            </button>\n          )}\n        </div>\n      </div>\n    </header>\n  )\n}\n\n"
  },
  {
    "path": "tmarks/src/components/common/PaginationFooter.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\n\r\ninterface PaginationFooterProps {\r\n  hasMore: boolean\r\n  isLoading: boolean\r\n  onLoadMore: () => void\r\n  currentCount: number\r\n  totalLoaded: number\r\n}\r\n\r\nexport function PaginationFooter({\r\n  hasMore,\r\n  isLoading,\r\n  onLoadMore,\r\n  currentCount,\r\n  totalLoaded,\r\n}: PaginationFooterProps) {\r\n  const { t } = useTranslation('common')\r\n  const { t: tb } = useTranslation('bookmarks')\r\n\r\n  if (!hasMore && currentCount === 0) {\r\n    return null\r\n  }\r\n\r\n  return (\r\n    <div className=\"card text-center py-6 mt-6\">\r\n      {/* 统计信息 */}\r\n      <div className=\"text-sm text-base-content/60 mb-4\">\r\n        {hasMore ? (\r\n          <>{t('pagination.total', { count: totalLoaded })}</>\r\n        ) : (\r\n          <>{t('pagination.total', { count: totalLoaded })}</>\r\n        )}\r\n      </div>\r\n\r\n      {/* 加载更多按钮 */}\r\n      {hasMore && (\r\n        <button\r\n          onClick={onLoadMore}\r\n          disabled={isLoading}\r\n          className=\"btn btn-primary\"\r\n        >\r\n          {isLoading ? (\r\n            <>\r\n              <span className=\"loading loading-spinner loading-sm\"></span>\r\n              {t('status.loading')}\r\n            </>\r\n          ) : (\r\n            t('button.loadMore')\r\n          )}\r\n        </button>\r\n      )}\r\n\r\n      {/* 已加载全部 */}\r\n      {!hasMore && totalLoaded > 0 && (\r\n        <div className=\"text-sm text-base-content/40\">\r\n          {tb('empty.title')}\r\n        </div>\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/ProgressIndicator.tsx",
    "content": "/**\r\n * 进度指示器组件\r\n * 提供丰富的进度显示和动画效果\r\n */\r\n\r\nimport { useState, useEffect } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { CheckCircle, Loader2, Clock, Zap } from 'lucide-react'\r\nimport { useAnimatedProgress } from './useAnimatedProgress'\r\n\r\nexport interface ProgressInfo {\r\n  current: number\r\n  total: number\r\n  percentage: number\r\n  status: string\r\n  message?: string\r\n  estimated_remaining?: number\r\n  speed?: number // items per second\r\n}\r\n\r\nexport interface ProgressIndicatorProps {\r\n  progress: ProgressInfo\r\n  variant?: 'default' | 'compact' | 'detailed'\r\n  showSpeed?: boolean\r\n  showETA?: boolean\r\n  className?: string\r\n}\r\n\r\nexport function ProgressIndicator({\r\n  progress,\r\n  variant = 'default',\r\n  showSpeed = true,\r\n  showETA = true,\r\n  className = ''\r\n}: ProgressIndicatorProps) {\r\n  const { t } = useTranslation('common')\r\n  const animatedPercentage = useAnimatedProgress(progress.percentage)\r\n  const [isComplete, setIsComplete] = useState(false)\r\n\r\n  // 完成状态检测\r\n  useEffect(() => {\r\n    if (progress.percentage >= 100) {\r\n      const timer = setTimeout(() => {\r\n        setIsComplete(true)\r\n      }, 500)\r\n      return () => clearTimeout(timer)\r\n    } else {\r\n      setIsComplete(false)\r\n    }\r\n  }, [progress.percentage])\r\n\r\n  // 紧凑模式\r\n  if (variant === 'compact') {\r\n    return (\r\n      <div className={`flex items-center space-x-3 ${className}`}>\r\n        <div className=\"flex-shrink-0\">\r\n          {isComplete ? (\r\n            <CheckCircle className=\"h-5 w-5 text-success\" />\r\n          ) : (\r\n            <Loader2 className=\"h-5 w-5 text-primary animate-spin\" />\r\n          )}\r\n        </div>\r\n        <div className=\"flex-1 min-w-0\">\r\n          <div className=\"flex items-center justify-between text-sm\">\r\n            <span className=\"font-medium text-foreground truncate\">\r\n              {progress.status}\r\n            </span>\r\n            <span className=\"text-muted-foreground ml-2\">\r\n              {Math.round(animatedPercentage)}%\r\n            </span>\r\n          </div>\r\n          <div className=\"mt-1 w-full bg-muted rounded-full h-1.5\">\r\n            <div \r\n              className=\"bg-primary h-1.5 rounded-full transition-all duration-500 ease-out\"\r\n              style={{ width: `${animatedPercentage}%` }}\r\n            />\r\n          </div>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  // 详细模式\r\n  if (variant === 'detailed') {\r\n    return (\r\n      <div className={`space-y-3 sm:space-y-4 ${className}`}>\r\n        {/* 状态头部 */}\r\n        <div className=\"flex items-center justify-between\">\r\n          <div className=\"flex items-center space-x-2 sm:space-x-3 flex-1 min-w-0\">\r\n            <div className=\"flex-shrink-0\">\r\n              {isComplete ? (\r\n                <CheckCircle className=\"h-5 w-5 sm:h-6 sm:w-6 text-success\" />\r\n              ) : (\r\n                <Loader2 className=\"h-5 w-5 sm:h-6 sm:w-6 text-primary animate-spin\" />\r\n              )}\r\n            </div>\r\n            <div className=\"flex-1 min-w-0\">\r\n              <h4 className=\"text-sm sm:text-base md:text-lg font-semibold text-foreground truncate\">\r\n                {progress.status}\r\n              </h4>\r\n              {progress.message && (\r\n                <p className=\"text-xs sm:text-sm text-muted-foreground truncate\">\r\n                  {progress.message}\r\n                </p>\r\n              )}\r\n            </div>\r\n          </div>\r\n          <div className=\"text-right flex-shrink-0\">\r\n            <div className=\"text-lg sm:text-xl md:text-2xl font-bold text-foreground\">\r\n              {Math.round(animatedPercentage)}%\r\n            </div>\r\n            <div className=\"text-xs sm:text-sm text-muted-foreground\">\r\n              {progress.current} / {progress.total}\r\n            </div>\r\n          </div>\r\n        </div>\r\n\r\n        {/* 进度条 */}\r\n        <div className=\"space-y-2\">\r\n          <div className=\"w-full bg-muted rounded-full h-2 sm:h-3 overflow-hidden\">\r\n            <div\r\n              className=\"h-2 sm:h-3 rounded-full transition-all duration-500 ease-out bg-gradient-to-r from-primary to-primary/90\"\r\n              style={{ width: `${animatedPercentage}%` }}\r\n            >\r\n              {/* 动画光效 */}\r\n              <div className=\"h-full w-full bg-gradient-to-r from-transparent via-white/20 to-transparent animate-pulse\" />\r\n            </div>\r\n          </div>\r\n\r\n          {/* 统计信息 */}\r\n          <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-1 sm:space-y-0 text-xs sm:text-sm text-muted-foreground\">\r\n            <div className=\"flex items-center space-x-3 sm:space-x-4\">\r\n              {showSpeed && progress.speed && (\r\n                <div className=\"flex items-center space-x-1\">\r\n                  <Zap className=\"h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0\" />\r\n                  <span className=\"whitespace-nowrap\">{t('progress.itemsPerSecond', { count: Math.round(progress.speed) })}</span>\r\n                </div>\r\n              )}\r\n              {showETA && progress.estimated_remaining && (\r\n                <div className=\"flex items-center space-x-1\">\r\n                  <Clock className=\"h-3 w-3 sm:h-4 sm:w-4 flex-shrink-0\" />\r\n                  <span className=\"whitespace-nowrap\">{t('progress.remaining', { time: formatTime(progress.estimated_remaining, t) })}</span>\r\n                </div>\r\n              )}\r\n            </div>\r\n            <div className=\"whitespace-nowrap\">\r\n              {t('progress.processed', { count: progress.current })}\r\n            </div>\r\n          </div>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  // 默认模式\r\n  return (\r\n    <div className={`space-y-2 sm:space-y-3 ${className}`}>\r\n      {/* 状态和百分比 */}\r\n      <div className=\"flex items-center justify-between\">\r\n        <div className=\"flex items-center space-x-2 flex-1 min-w-0\">\r\n          {isComplete ? (\r\n            <CheckCircle className=\"h-4 w-4 sm:h-5 sm:w-5 text-success flex-shrink-0\" />\r\n          ) : (\r\n            <Loader2 className=\"h-4 w-4 sm:h-5 sm:w-5 text-primary animate-spin flex-shrink-0\" />\r\n          )}\r\n          <span className=\"text-xs sm:text-sm font-medium text-foreground truncate\">\r\n            {progress.status}\r\n          </span>\r\n        </div>\r\n        <span className=\"text-xs sm:text-sm font-medium text-foreground flex-shrink-0 ml-2\">\r\n          {Math.round(animatedPercentage)}%\r\n        </span>\r\n      </div>\r\n\r\n      {/* 进度条 */}\r\n      <div className=\"w-full bg-muted rounded-full h-1.5 sm:h-2 overflow-hidden\">\r\n        <div\r\n          className=\"bg-primary h-1.5 sm:h-2 rounded-full transition-all duration-500 ease-out relative\"\r\n          style={{ width: `${animatedPercentage}%` }}\r\n        >\r\n          {/* 动画条纹 */}\r\n          {!isComplete && (\r\n            <div className=\"absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-pulse\" />\r\n          )}\r\n        </div>\r\n      </div>\r\n\r\n      {/* 详细信息 */}\r\n      <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between space-y-1 sm:space-y-0 text-xs text-muted-foreground\">\r\n        <div className=\"flex items-center space-x-2 sm:space-x-3\">\r\n          <span className=\"whitespace-nowrap\">{progress.current} / {progress.total}</span>\r\n          {showSpeed && progress.speed && (\r\n            <span className=\"whitespace-nowrap\">{t('progress.itemsPerSecond', { count: Math.round(progress.speed) })}</span>\r\n          )}\r\n          {showETA && progress.estimated_remaining && (\r\n            <span className=\"whitespace-nowrap\">{t('progress.remaining', { time: formatTime(progress.estimated_remaining, t) })}</span>\r\n          )}\r\n        </div>\r\n        {progress.message && (\r\n          <span className=\"text-xs text-muted-foreground truncate\">\r\n            {progress.message}\r\n          </span>\r\n        )}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n\r\n// 工具函数：格式化时间\r\nfunction formatTime(seconds: number, t: (key: string, options?: Record<string, unknown>) => string): string {\r\n  if (seconds < 60) {\r\n    return t('time.seconds', { count: Math.round(seconds) })\r\n  } else if (seconds < 3600) {\r\n    const minutes = Math.floor(seconds / 60)\r\n    const remainingSeconds = Math.round(seconds % 60)\r\n    return remainingSeconds > 0 \r\n      ? t('time.minutesSeconds', { minutes, seconds: remainingSeconds })\r\n      : t('time.minutes', { count: minutes })\r\n  } else {\r\n    const hours = Math.floor(seconds / 3600)\r\n    const minutes = Math.floor((seconds % 3600) / 60)\r\n    return minutes > 0 \r\n      ? t('time.hoursMinutes', { hours, minutes })\r\n      : t('time.hours', { count: hours })\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/ResizablePanel.tsx",
    "content": "import { useState, useRef, useEffect } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { GripVertical, RotateCcw } from 'lucide-react'\r\n\r\ninterface ResizablePanelProps {\r\n  children: React.ReactNode\r\n  side: 'left' | 'right'\r\n  defaultWidth: number\r\n  minWidth: number\r\n  maxWidth: number\r\n  storageKey: string\r\n}\r\n\r\nexport function ResizablePanel({\r\n  children,\r\n  side,\r\n  defaultWidth,\r\n  minWidth,\r\n  maxWidth,\r\n  storageKey,\r\n}: ResizablePanelProps) {\r\n  const { t } = useTranslation('common')\r\n  const [width, setWidth] = useState(() => {\r\n    const saved = localStorage.getItem(storageKey)\r\n    return saved ? parseInt(saved, 10) : defaultWidth\r\n  })\r\n  const [isResizing, setIsResizing] = useState(false)\r\n  const panelRef = useRef<HTMLDivElement>(null)\r\n\r\n  useEffect(() => {\r\n    localStorage.setItem(storageKey, width.toString())\r\n  }, [width, storageKey])\r\n\r\n  const handleMouseDown = (e: React.MouseEvent) => {\r\n    e.preventDefault()\r\n    setIsResizing(true)\r\n  }\r\n\r\n  useEffect(() => {\r\n    const handleMouseMove = (e: MouseEvent) => {\r\n      if (!isResizing || !panelRef.current) return\r\n\r\n      const rect = panelRef.current.getBoundingClientRect()\r\n      let newWidth: number\r\n\r\n      if (side === 'left') {\r\n        newWidth = e.clientX - rect.left\r\n      } else {\r\n        newWidth = rect.right - e.clientX\r\n      }\r\n\r\n      newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth))\r\n      setWidth(newWidth)\r\n    }\r\n\r\n    const handleMouseUp = () => {\r\n      setIsResizing(false)\r\n    }\r\n\r\n    if (isResizing) {\r\n      document.addEventListener('mousemove', handleMouseMove)\r\n      document.addEventListener('mouseup', handleMouseUp)\r\n    }\r\n\r\n    return () => {\r\n      document.removeEventListener('mousemove', handleMouseMove)\r\n      document.removeEventListener('mouseup', handleMouseUp)\r\n    }\r\n  }, [isResizing, side, minWidth, maxWidth])\r\n\r\n  const handleReset = () => {\r\n    setWidth(defaultWidth)\r\n  }\r\n\r\n  return (\r\n    <div\r\n      ref={panelRef}\r\n      className=\"relative flex-shrink-0 bg-card h-full\"\r\n      style={{ width: `${width}px` }}\r\n    >\r\n      {children}\r\n\r\n      {/* Resize Handle */}\r\n      <div\r\n        className={`absolute top-0 ${side === 'left' ? 'right-0' : 'left-0'} h-full w-1 cursor-col-resize group hover:bg-primary transition-colors ${\r\n          isResizing ? 'bg-primary' : 'bg-transparent'\r\n        }`}\r\n        onMouseDown={handleMouseDown}\r\n      >\r\n        {/* Grip Icon */}\r\n        <div className=\"absolute top-1/2 -translate-y-1/2 -translate-x-1/2 left-1/2 bg-card border border-border rounded p-1 opacity-0 group-hover:opacity-100 transition-opacity shadow-sm\">\r\n          <GripVertical className=\"w-3 h-3 text-muted-foreground\" />\r\n        </div>\r\n      </div>\r\n\r\n      {/* Reset Button */}\r\n      <button\r\n        onClick={handleReset}\r\n        className=\"absolute bottom-4 left-1/2 -translate-x-1/2 p-2 bg-card border border-border rounded shadow-sm hover:bg-muted hover:shadow-md transition-all opacity-0 hover:opacity-100 group-hover:opacity-100\"\r\n        title={t('action.resetWidth')}\r\n      >\r\n        <RotateCcw className=\"w-4 h-4 text-muted-foreground\" />\r\n      </button>\r\n    </div>\r\n  )\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/src/components/common/SearchToolbar.tsx",
    "content": "/**\n * 共享搜索工具栏组件\n * 搜索输入 + 排序/可见性/视图模式切换\n * 被 BookmarksPage (TopActionBar) 和 PublicSharePage 复用\n */\n\nimport { ReactNode } from 'react'\nimport { Tag as TagIcon, Search, Bookmark as BookmarkIcon } from 'lucide-react'\nimport { useTranslation } from 'react-i18next'\nimport type { ViewMode, VisibilityFilter } from '@/lib/constants/bookmarks'\nimport type { SortOption } from '@/components/common/SortSelector'\nimport { ViewModeIcon, VisibilityIcon, SortIcon } from '@/components/common/BookmarkIcons'\n\ninterface SearchToolbarProps {\n  searchMode: 'bookmark' | 'tag'\n  onSearchModeToggle: () => void\n  searchKeyword: string\n  onSearchKeywordChange: (keyword: string) => void\n  sortBy: SortOption\n  onSortByChange: () => void\n  visibilityFilter: VisibilityFilter\n  onVisibilityChange: () => void\n  viewMode: ViewMode\n  onViewModeChange: () => void\n  /** 移动端标签抽屉触发按钮 */\n  onOpenTagDrawer?: () => void\n  /** 额外的操作按钮（批量模式、添加、回收站等） */\n  extraActions?: ReactNode\n  /** 搜索框 placeholder 的 i18n namespace */\n  i18nNs?: string\n}\n\nexport function SearchToolbar({\n  searchMode,\n  onSearchModeToggle,\n  searchKeyword,\n  onSearchKeywordChange,\n  sortBy,\n  onSortByChange,\n  visibilityFilter,\n  onVisibilityChange,\n  viewMode,\n  onViewModeChange,\n  onOpenTagDrawer,\n  extraActions,\n  i18nNs = 'bookmarks',\n}: SearchToolbarProps) {\n  const { t } = useTranslation(i18nNs)\n\n  const sortLabel = t(`sort.${sortBy}`, sortBy)\n  const visLabel = t(`filter.${visibilityFilter}`, visibilityFilter)\n  const viewLabel = t(`viewMode.${viewMode}`, viewMode)\n\n  return (\n    <>\n      {/* Mobile: two-row layout */}\n      <div className=\"flex flex-col gap-3 w-full lg:hidden\">\n        <div className=\"flex items-center gap-3 w-full\">\n          {onOpenTagDrawer && (\n            <button\n              onClick={onOpenTagDrawer}\n              className=\"group w-11 h-11 rounded-xl flex items-center justify-center transition-all bg-card border border-border hover:bg-muted text-foreground shadow-sm flex-shrink-0\"\n            >\n              <TagIcon className=\"w-5 h-5\" />\n            </button>\n          )}\n          <SearchInput\n            searchMode={searchMode}\n            onSearchModeToggle={onSearchModeToggle}\n            searchKeyword={searchKeyword}\n            onSearchKeywordChange={onSearchKeywordChange}\n            placeholder={searchMode === 'bookmark' ? t('search.placeholder', t('search.bookmarkPlaceholder', '')) : t('search.tagPlaceholder', '')}\n          />\n        </div>\n        <div className=\"flex items-center gap-1 w-full sm:w-auto justify-center\">\n          <ToolButton onClick={onSortByChange} title={sortLabel}><SortIcon sort={sortBy} /></ToolButton>\n          <ToolButton onClick={onVisibilityChange} title={visLabel} active={visibilityFilter !== 'all'}><VisibilityIcon filter={visibilityFilter} /></ToolButton>\n          <ToolButton onClick={onViewModeChange} title={viewLabel}><ViewModeIcon mode={viewMode} /></ToolButton>\n          {extraActions}\n        </div>\n      </div>\n\n      {/* PC: single-row layout */}\n      <div className=\"hidden lg:flex items-center gap-3 w-full\">\n        <SearchInput\n          searchMode={searchMode}\n          onSearchModeToggle={onSearchModeToggle}\n          searchKeyword={searchKeyword}\n          onSearchKeywordChange={onSearchKeywordChange}\n          placeholder={searchMode === 'bookmark' ? t('search.placeholder', t('search.bookmarkPlaceholder', '')) : t('search.tagPlaceholder', '')}\n        />\n        <div className=\"flex items-center gap-2 flex-shrink-0\">\n          <ToolButton onClick={onSortByChange} title={sortLabel}><SortIcon sort={sortBy} /></ToolButton>\n          <ToolButton onClick={onVisibilityChange} title={visLabel} active={visibilityFilter !== 'all'}><VisibilityIcon filter={visibilityFilter} /></ToolButton>\n          <ToolButton onClick={onViewModeChange} title={viewLabel}><ViewModeIcon mode={viewMode} /></ToolButton>\n          {extraActions}\n        </div>\n      </div>\n    </>\n  )\n}\n\nfunction SearchInput({\n  searchMode,\n  onSearchModeToggle,\n  searchKeyword,\n  onSearchKeywordChange,\n  placeholder,\n}: {\n  searchMode: 'bookmark' | 'tag'\n  onSearchModeToggle: () => void\n  searchKeyword: string\n  onSearchKeywordChange: (v: string) => void\n  placeholder: string\n}) {\n  return (\n    <div className=\"flex-1 min-w-0\">\n      <div className=\"relative w-full\">\n        <button\n          onClick={onSearchModeToggle}\n          className=\"absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 z-10 flex items-center justify-center transition-all hover:text-primary hover:scale-110\"\n        >\n          {searchMode === 'bookmark' ? <BookmarkIcon className=\"w-5 h-5\" /> : <TagIcon className=\"w-5 h-5\" />}\n        </button>\n        <Search className=\"absolute left-10 sm:left-12 top-1/2 -translate-y-1/2 w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground pointer-events-none\" />\n        <input\n          type=\"text\"\n          className=\"input w-full !pl-16 sm:!pl-[4.5rem] h-11 sm:h-auto text-sm sm:text-base\"\n          placeholder={placeholder}\n          value={searchKeyword}\n          onChange={(e) => onSearchKeywordChange(e.target.value)}\n        />\n      </div>\n    </div>\n  )\n}\n\nfunction ToolButton({\n  onClick,\n  title,\n  active,\n  children,\n}: {\n  onClick: () => void\n  title: string\n  active?: boolean\n  children: ReactNode\n}) {\n  return (\n    <button\n      onClick={onClick}\n      className={`btn btn-sm btn-ghost p-2 flex-shrink-0 ${active ? 'text-primary' : ''}`}\n      title={title}\n    >\n      {children}\n    </button>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/common/SimpleProgress.tsx",
    "content": "import { useAnimatedProgress } from './useAnimatedProgress'\n\nexport interface SimpleProgressProps {\n  percentage: number\n  size?: 'sm' | 'md' | 'lg'\n  color?: 'blue' | 'green' | 'red' | 'yellow'\n  animated?: boolean\n  className?: string\n}\n\n/**\n * 简单的进度条组件\n */\nexport function SimpleProgress({\n  percentage,\n  size = 'md',\n  color = 'blue',\n  animated = false,\n  className = ''\n}: SimpleProgressProps) {\n  const animatedPercentage = useAnimatedProgress(percentage)\n\n  const sizeClasses = {\n    sm: 'h-1',\n    md: 'h-2',\n    lg: 'h-3'\n  }\n\n  const colorClasses = {\n    blue: 'bg-primary',\n    green: 'bg-success',\n    red: 'bg-destructive',\n    yellow: 'bg-warning'\n  }\n\n  return (\n    <div className={`w-full bg-muted rounded-full overflow-hidden ${sizeClasses[size]} ${className}`}>\n      <div \n        className={`${sizeClasses[size]} rounded-full transition-all duration-500 ease-out ${colorClasses[color]} ${\n          animated ? 'relative' : ''\n        }`}\n        style={{ width: `${animatedPercentage}%` }}\n      >\n        {animated && (\n          <div className=\"absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-pulse\" />\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/common/SortSelector.tsx",
    "content": "import { useState, useRef, useEffect } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { createPortal } from 'react-dom'\r\nimport { ChevronDown, Clock, RefreshCw, Pin, TrendingUp, Calendar } from 'lucide-react'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\n\r\nexport type SortOption = 'created' | 'updated' | 'pinned' | 'popular'\r\n\r\ninterface SortSelectorProps {\r\n  value: SortOption\r\n  onChange: (value: SortOption) => void\r\n  className?: string\r\n}\r\n\r\ninterface SortOptionConfig {\r\n  value: SortOption\r\n  labelKey: string\r\n  icon: React.ComponentType<{ className?: string }>\r\n  descriptionKey: string\r\n  group: 'time' | 'priority' | 'engagement'\r\n}\r\n\r\ninterface MenuPosition {\r\n  top: number\r\n  left: number\r\n  width?: number\r\n}\r\n\r\nconst SORT_OPTIONS: SortOptionConfig[] = [\r\n  {\r\n    value: 'created',\r\n    labelKey: 'sort.byCreated',\r\n    icon: Calendar,\r\n    descriptionKey: 'sort.byCreatedDesc',\r\n    group: 'time'\r\n  },\r\n  {\r\n    value: 'updated',\r\n    labelKey: 'sort.byUpdated',\r\n    icon: RefreshCw,\r\n    descriptionKey: 'sort.byUpdatedDesc',\r\n    group: 'time'\r\n  },\r\n  {\r\n    value: 'pinned',\r\n    labelKey: 'sort.pinnedFirst',\r\n    icon: Pin,\r\n    descriptionKey: 'sort.pinnedFirstDesc',\r\n    group: 'priority'\r\n  },\r\n  {\r\n    value: 'popular',\r\n    labelKey: 'sort.byPopular',\r\n    icon: TrendingUp,\r\n    descriptionKey: 'sort.byPopularDesc',\r\n    group: 'engagement'\r\n  }\r\n]\r\n\r\n\r\n\r\nexport function SortSelector({ value, onChange, className = '' }: SortSelectorProps) {\r\n  const { t } = useTranslation('common')\r\n  const [isOpen, setIsOpen] = useState(false)\r\n  const [focusedIndex, setFocusedIndex] = useState(-1)\r\n  const [menuPosition, setMenuPosition] = useState<MenuPosition | null>(null)\r\n  const buttonRef = useRef<HTMLButtonElement>(null)\r\n  const optionsRef = useRef<HTMLDivElement | null>(null)\r\n\r\n  const currentOption = SORT_OPTIONS.find(option => option.value === value)\r\n  const CurrentIcon = currentOption?.icon || Clock\r\n\r\n\r\n\r\n  // 处理键盘导航\r\n  useEffect(() => {\r\n    const handleKeyDown = (e: KeyboardEvent) => {\r\n      if (!isOpen) return\r\n\r\n      switch (e.key) {\r\n        case 'Escape':\r\n          setIsOpen(false)\r\n          buttonRef.current?.focus()\r\n          break\r\n        case 'ArrowDown':\r\n          e.preventDefault()\r\n          setFocusedIndex(prev => \r\n            prev < SORT_OPTIONS.length - 1 ? prev + 1 : 0\r\n          )\r\n          break\r\n        case 'ArrowUp':\r\n          e.preventDefault()\r\n          setFocusedIndex(prev => \r\n            prev > 0 ? prev - 1 : SORT_OPTIONS.length - 1\r\n          )\r\n          break\r\n        case 'Enter':\r\n        case ' ': {\r\n          e.preventDefault()\r\n          const option = SORT_OPTIONS[focusedIndex]\r\n          if (focusedIndex >= 0 && option) {\r\n            onChange(option.value)\r\n            setIsOpen(false)\r\n            buttonRef.current?.focus()\r\n          }\r\n          break\r\n        }\r\n      }\r\n    }\r\n\r\n    if (isOpen) {\r\n      document.addEventListener('keydown', handleKeyDown)\r\n      return () => document.removeEventListener('keydown', handleKeyDown)\r\n    }\r\n  }, [isOpen, focusedIndex, onChange])\r\n\r\n  // 点击外部关闭\r\n  useEffect(() => {\r\n    const handleClickOutside = (event: MouseEvent) => {\r\n      const target = event.target as Node\r\n      if (\r\n        isOpen &&\r\n        !buttonRef.current?.contains(target) &&\r\n        !optionsRef.current?.contains(target)\r\n      ) {\r\n        setIsOpen(false)\r\n        setMenuPosition(null)\r\n      }\r\n    }\r\n\r\n    document.addEventListener('mousedown', handleClickOutside)\r\n    return () => document.removeEventListener('mousedown', handleClickOutside)\r\n  }, [isOpen])\r\n\r\n  const handleToggle = () => {\r\n    setIsOpen((prev) => {\r\n      const next = !prev\r\n      if (next) {\r\n        if (buttonRef.current) {\r\n          const rect = buttonRef.current.getBoundingClientRect()\r\n          const width = Math.max(rect.width, 200)\r\n          const maxLeft = window.scrollX + window.innerWidth - width - 12\r\n          const left = Math.min(rect.left + window.scrollX, maxLeft)\r\n          setMenuPosition({\r\n            top: rect.bottom + window.scrollY + 8,\r\n            left,\r\n            width,\r\n          })\r\n        }\r\n      } else {\r\n        setMenuPosition(null)\r\n      }\r\n      return next\r\n    })\r\n    setFocusedIndex(-1)\r\n  }\r\n\r\n  const handleOptionClick = (optionValue: SortOption) => {\r\n    onChange(optionValue)\r\n    setIsOpen(false)\r\n    setMenuPosition(null)\r\n    buttonRef.current?.focus()\r\n  }\r\n\r\n  const menuPortal =\r\n    typeof document !== 'undefined' && isOpen && menuPosition\r\n      ? createPortal(\r\n          <div\r\n            ref={(node) => {\r\n              optionsRef.current = node\r\n            }}\r\n            className=\"rounded-lg border border-border shadow-lg overflow-hidden\"\r\n            style={{\r\n              position: 'absolute',\r\n              top: menuPosition.top,\r\n              left: menuPosition.left,\r\n              width: menuPosition.width ?? 200,\r\n              backgroundColor: 'var(--card)',\r\n              zIndex: Z_INDEX.DROPDOWN,\r\n            }}\r\n            role=\"listbox\"\r\n            aria-label={t('sort.options')}\r\n          >\r\n            {SORT_OPTIONS.map((option) => (\r\n              <button\r\n                key={option.value}\r\n                type=\"button\"\r\n                onClick={() => handleOptionClick(option.value)}\r\n                className={`w-full px-3 py-2 text-sm flex items-center gap-2 transition-colors ${\r\n                  value === option.value\r\n                    ? 'bg-primary/10 text-primary font-medium'\r\n                    : 'text-base-content/80 hover:bg-base-200/60'\r\n                }`}\r\n              >\r\n                <option.icon className=\"w-4 h-4\" />\r\n                <span>{t(option.labelKey)}</span>\r\n              </button>\r\n            ))}\r\n          </div>,\r\n          document.body,\r\n        )\r\n      : null\r\n\r\n  return (\r\n    <>\r\n      {menuPortal}\r\n      <div className={`relative ${className}`}>\r\n        {/* 触发按钮 */}\r\n        <button\r\n          ref={buttonRef}\r\n          type=\"button\"\r\n          onClick={handleToggle}\r\n          className={`\r\n            w-auto min-w-[100px] sm:min-w-[160px] h-10 sm:h-11 px-2 sm:px-4 py-2\r\n            border border-border rounded-xl\r\n            flex items-center justify-between gap-1.5 sm:gap-3\r\n            text-sm font-medium text-foreground\r\n            transition-all duration-200 ease-out\r\n            hover:border-primary/30\r\n            focus:outline-none\r\n            shadow-sm hover:shadow-md\r\n            ${isOpen ? 'border-primary/50 shadow-md' : ''}\r\n          `}\r\n          style={{\r\n            backgroundColor: isOpen ? 'var(--muted)' : 'var(--card)',\r\n            outline: 'none'\r\n          }}\r\n          aria-expanded={isOpen}\r\n          aria-haspopup=\"listbox\"\r\n          aria-label={t('sort.selectSort')}\r\n        >\r\n          <div className=\"flex items-center gap-1 sm:gap-2\">\r\n            <CurrentIcon className=\"w-3.5 h-3.5 sm:w-4 sm:h-4 text-primary flex-shrink-0\" />\r\n            <span className=\"truncate text-[11px] sm:text-sm\">{currentOption ? t(currentOption.labelKey) : ''}</span>\r\n          </div>\r\n          <ChevronDown\r\n            className={`w-3.5 h-3.5 sm:w-4 sm:h-4 text-muted-foreground transition-transform duration-200 flex-shrink-0 ${\r\n              isOpen ? 'rotate-180' : ''\r\n            }`}\r\n          />\r\n        </button>\r\n      </div>\r\n    </>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/ThemeToggle.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { useThemeStore } from '@/stores/themeStore'\r\n\r\nexport function ThemeToggle() {\r\n  const { t } = useTranslation('common')\r\n  const { theme, toggleTheme } = useThemeStore()\r\n\r\n  return (\r\n    <button\r\n      onClick={toggleTheme}\r\n      className=\"btn btn-sm btn-ghost p-2\"\r\n      aria-label={theme === 'light' ? t('nav.toggleDarkMode') : t('nav.toggleLightMode')}\r\n      title={theme === 'light' ? t('nav.toggleDarkMode') : t('nav.toggleLightMode')}\r\n    >\r\n      {theme === 'light' ? (\r\n        <svg className=\"w-4 h-4\" fill=\"currentColor\" viewBox=\"0 0 20 20\" xmlns=\"http://www.w3.org/2000/svg\">\r\n          <path d=\"M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z\" />\r\n        </svg>\r\n      ) : (\r\n        <svg className=\"w-4 h-4\" fill=\"currentColor\" viewBox=\"0 0 20 20\" xmlns=\"http://www.w3.org/2000/svg\">\r\n          <path fillRule=\"evenodd\" d=\"M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z\" clipRule=\"evenodd\" />\r\n        </svg>\r\n      )}\r\n    </button>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/Toast.tsx",
    "content": "import { useEffect } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\n\r\nexport type ToastType = 'success' | 'error' | 'info' | 'warning'\r\n\r\nexport interface ToastProps {\r\n  id: string\r\n  type: ToastType\r\n  message: string\r\n  duration?: number\r\n  onClose: (id: string) => void\r\n}\r\n\r\nconst ICONS = {\r\n  success: CheckCircle,\r\n  error: AlertCircle,\r\n  info: Info,\r\n  warning: AlertTriangle,\r\n}\r\n\r\nconst COLORS = {\r\n  success: {\r\n    bg: 'toast-surface',\r\n    bgOverlay: 'bg-success/10',\r\n    border: 'border-success',\r\n    icon: 'text-success',\r\n    text: 'text-foreground',\r\n  },\r\n  error: {\r\n    bg: 'toast-surface',\r\n    bgOverlay: 'bg-destructive/10',\r\n    border: 'border-destructive',\r\n    icon: 'text-destructive',\r\n    text: 'text-foreground',\r\n  },\r\n  info: {\r\n    bg: 'toast-surface',\r\n    bgOverlay: 'bg-primary/10',\r\n    border: 'border-primary',\r\n    icon: 'text-primary',\r\n    text: 'text-foreground',\r\n  },\r\n  warning: {\r\n    bg: 'toast-surface',\r\n    bgOverlay: 'bg-warning/10',\r\n    border: 'border-warning',\r\n    icon: 'text-warning',\r\n    text: 'text-foreground',\r\n  },\r\n}\r\n\r\nexport function Toast({ id, type, message, duration = 3000, onClose }: ToastProps) {\r\n  const { t } = useTranslation('common')\r\n  const Icon = ICONS[type]\r\n  const colors = COLORS[type]\r\n\r\n  useEffect(() => {\r\n    if (duration > 0) {\r\n      const timer = setTimeout(() => {\r\n        onClose(id)\r\n      }, duration)\r\n\r\n      return () => clearTimeout(timer)\r\n    }\r\n  }, [id, duration, onClose])\r\n\r\n  return (\r\n    <div\r\n      className={`relative flex items-start gap-3 p-4 rounded-lg border-2 shadow-lg ${colors.bg} ${colors.border} min-w-[320px] max-w-md animate-slide-in backdrop-blur-sm overflow-hidden`}\r\n    >\r\n      <div className={`absolute inset-0 rounded-lg ${colors.bgOverlay} z-0 pointer-events-none`}></div>\r\n      \r\n      <Icon className={`relative z-10 w-5 h-5 ${colors.icon} flex-shrink-0 mt-0.5`} />\r\n      <p className={`relative z-10 flex-1 text-sm font-medium ${colors.text}`}>{message}</p>\r\n      <button\r\n        onClick={() => onClose(id)}\r\n        className={`relative z-10 ${colors.icon} hover:opacity-70 transition-opacity flex-shrink-0`}\r\n        aria-label={t('button.close')}\r\n      >\r\n        <X className=\"w-5 h-5\" />\r\n      </button>\r\n    </div>\r\n  )\r\n}\r\n\r\nexport function ToastContainer({ toasts, onClose }: { toasts: ToastProps[]; onClose: (id: string) => void }) {\r\n  return (\r\n    <div className=\"fixed top-4 right-4 flex flex-col gap-2\" style={{ zIndex: Z_INDEX.TOAST }}>\r\n      {toasts.map((toast) => (\r\n        <Toast key={toast.id} {...toast} onClose={onClose} />\r\n      ))}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/Toggle.tsx",
    "content": "/**\r\n * Toggle 开关组件\r\n * 修复开关按钮颜色显示问题\r\n */\r\n\r\ninterface ToggleProps {\r\n  checked: boolean\r\n  onChange: (checked: boolean) => void\r\n  disabled?: boolean\r\n  label?: string\r\n  description?: string\r\n}\r\n\r\nexport function Toggle({ checked, onChange, disabled = false, label, description }: ToggleProps) {\r\n  return (\r\n    <label className=\"relative inline-flex items-center cursor-pointer\">\r\n      <input\r\n        type=\"checkbox\"\r\n        checked={checked}\r\n        onChange={(e) => onChange(e.target.checked)}\r\n        disabled={disabled}\r\n        className=\"sr-only peer\"\r\n      />\r\n      <div \r\n        className={`\r\n          w-11 h-6 rounded-full transition-colors duration-200 ease-in-out\r\n          relative\r\n          ${checked ? 'bg-primary' : 'bg-muted-foreground/30'}\r\n          ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}\r\n          peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary/20\r\n        `}\r\n      >\r\n        <div\r\n          className={`\r\n            absolute top-[2px] left-[2px]\r\n            w-5 h-5 rounded-full\r\n            bg-white shadow-md\r\n            transition-transform duration-200 ease-in-out\r\n            ${checked ? 'translate-x-5' : 'translate-x-0'}\r\n          `}\r\n        />\r\n      </div>\r\n      {(label || description) && (\r\n        <div className=\"ml-3\">\r\n          {label && <div className=\"text-sm font-medium text-foreground\">{label}</div>}\r\n          {description && <div className=\"text-xs text-muted-foreground\">{description}</div>}\r\n        </div>\r\n      )}\r\n    </label>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/common/progressUtils.ts",
    "content": "/**\n * 格式化时间\n * @param seconds 秒数\n * @param t 翻译函数\n * @returns 格式化后的时间字符串\n */\nexport function formatTime(\n  seconds: number, \n  t: (key: string, options?: Record<string, unknown>) => string\n): string {\n  if (seconds < 60) {\n    return t('time.seconds', { count: Math.round(seconds) })\n  } else if (seconds < 3600) {\n    const minutes = Math.floor(seconds / 60)\n    const remainingSeconds = Math.round(seconds % 60)\n    return remainingSeconds > 0 \n      ? t('time.minutesSeconds', { minutes, seconds: remainingSeconds })\n      : t('time.minutes', { count: minutes })\n  } else {\n    const hours = Math.floor(seconds / 3600)\n    const minutes = Math.floor((seconds % 3600) / 60)\n    return minutes > 0 \n      ? t('time.hoursMinutes', { hours, minutes })\n      : t('time.hours', { count: hours })\n  }\n}\n"
  },
  {
    "path": "tmarks/src/components/common/useAnimatedProgress.ts",
    "content": "import { useState, useEffect } from 'react'\n\n/**\n * 自定义 Hook，用于平滑动画显示进度百分比\n * 从 0 开始动画过渡到目标百分比\n */\nexport function useAnimatedProgress(percentage: number) {\n  const [animatedPercentage, setAnimatedPercentage] = useState(0)\n\n  useEffect(() => {\n    const timer = setTimeout(() => {\n      setAnimatedPercentage(percentage)\n    }, 100)\n    return () => clearTimeout(timer)\n  }, [percentage])\n\n  return animatedPercentage\n}\n"
  },
  {
    "path": "tmarks/src/components/import-export/ExportOptionsForm.tsx",
    "content": "import type { Dispatch, SetStateAction } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport type { ExportOptions } from '@shared/import-export-types'\n\nexport type ExportScope = 'all' | 'bookmarks' | 'tab_groups'\n\ninterface ExportOptionsFormProps {\n  scope: ExportScope\n  setScope: (scope: ExportScope) => void\n  includeDeleted: boolean\n  setIncludeDeleted: (include: boolean) => void\n  options: ExportOptions\n  setOptions: Dispatch<SetStateAction<ExportOptions>>\n  disabled?: boolean\n}\n\nexport function ExportOptionsForm({\n  scope,\n  setScope,\n  includeDeleted,\n  setIncludeDeleted,\n  options,\n  setOptions,\n  disabled,\n}: ExportOptionsFormProps) {\n  const { t } = useTranslation('import')\n  const isBookmarksScope = scope === 'all' || scope === 'bookmarks'\n\n  return (\n    <div className=\"space-y-4\">\n      <div className=\"space-y-2\">\n        <label className=\"block text-sm font-medium text-foreground\">\n          {t('export.scope')}\n        </label>\n        <select\n          value={scope}\n          onChange={(e) => setScope(e.target.value as ExportScope)}\n          disabled={disabled}\n          className=\"w-full sm:w-64 h-10 px-3 text-sm bg-card border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50\"\n        >\n          <option value=\"all\">{t('export.scopeAll')}</option>\n          <option value=\"bookmarks\">{t('export.scopeBookmarks')}</option>\n          <option value=\"tab_groups\">{t('export.scopeTabGroups')}</option>\n        </select>\n      </div>\n\n      <div className=\"space-y-3\">\n        <label className=\"flex items-center space-x-3\">\n          <input\n            type=\"checkbox\"\n            checked={includeDeleted}\n            onChange={(e) => setIncludeDeleted(e.target.checked)}\n            disabled={disabled}\n            className=\"h-4 w-4 text-primary border-border rounded focus:ring-primary disabled:opacity-50\"\n          />\n          <span className=\"text-sm text-foreground\">{t('export.includeDeleted')}</span>\n        </label>\n\n        <label className={`flex items-center space-x-3 ${!isBookmarksScope ? 'opacity-50' : ''}`}>\n          <input\n            type=\"checkbox\"\n            checked={options.include_tags}\n            onChange={(e) => setOptions((prev) => ({ ...prev, include_tags: e.target.checked }))}\n            disabled={disabled || !isBookmarksScope}\n            className=\"h-4 w-4 text-primary border-border rounded focus:ring-primary disabled:opacity-50\"\n          />\n          <span className=\"text-sm text-foreground\">{t('export.includeTags')}</span>\n        </label>\n\n        <label className=\"flex items-center space-x-3\">\n          <input\n            type=\"checkbox\"\n            checked={options.include_metadata}\n            onChange={(e) => setOptions((prev) => ({ ...prev, include_metadata: e.target.checked }))}\n            disabled={disabled}\n            className=\"h-4 w-4 text-primary border-border rounded focus:ring-primary disabled:opacity-50\"\n          />\n          <span className=\"text-sm text-foreground\">{t('export.includeMetadata')}</span>\n        </label>\n\n        <label className=\"flex items-center space-x-3\">\n          <input\n            type=\"checkbox\"\n            checked={options.format_options?.pretty_print}\n            onChange={(e) =>\n              setOptions((prev) => ({\n                ...prev,\n                format_options: { ...prev.format_options, pretty_print: e.target.checked },\n              }))\n            }\n            disabled={disabled}\n            className=\"h-4 w-4 text-primary border-border rounded focus:ring-primary disabled:opacity-50\"\n          />\n          <span className=\"text-sm text-foreground\">{t('export.prettyPrint')}</span>\n        </label>\n\n        <label className={`flex items-center space-x-3 ${!isBookmarksScope ? 'opacity-50' : ''}`}>\n          <input\n            type=\"checkbox\"\n            checked={options.format_options?.include_click_stats}\n            onChange={(e) =>\n              setOptions((prev) => ({\n                ...prev,\n                format_options: { ...prev.format_options, include_click_stats: e.target.checked },\n              }))\n            }\n            disabled={disabled || !isBookmarksScope}\n            className=\"h-4 w-4 text-primary border-border rounded focus:ring-primary disabled:opacity-50\"\n          />\n          <span className=\"text-sm text-foreground\">{t('export.includeStats')}</span>\n        </label>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/import-export/ExportSection.tsx",
    "content": "/**\r\n * 导出功能组件\r\n */\r\n\r\nimport { useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { Download, Code, Loader2 } from 'lucide-react'\nimport { ProgressIndicator } from '../common/ProgressIndicator'\nimport { ErrorDisplay } from '../common/ErrorDisplay'\nimport { ExportOptionsForm, type ExportScope } from './ExportOptionsForm'\nimport { useAuthStore } from '@/stores/authStore'\nimport type { ExportFormat, ExportOptions } from '@shared/import-export-types'\n\r\ninterface ExportSectionProps {\r\n  onExport?: (format: ExportFormat, options: ExportOptions) => void\r\n}\r\n\r\ninterface ExportStats {\n  total_bookmarks: number\n  total_tags: number\n  pinned_bookmarks: number\n  total_tab_groups: number\n  estimated_size: number\n}\n\r\nexport function ExportSection({ onExport }: ExportSectionProps) {\n  const { t } = useTranslation('import')\n  const exportFormat: ExportFormat = 'json'\n  const [isExporting, setIsExporting] = useState(false)\n  const [scope, setScope] = useState<ExportScope>('all')\n  const [includeDeleted, setIncludeDeleted] = useState(false)\n  const [exportStats, setExportStats] = useState<ExportStats | null>(null)\n  const [exportError, setExportError] = useState<string | null>(null)\n  const [exportProgress, setExportProgress] = useState<{\n    current: number\r\n    total: number\r\n    status: string\r\n  } | null>(null)\r\n  const [options, setOptions] = useState<ExportOptions>({\r\n    include_tags: true,\r\n    include_metadata: true,\r\n    format_options: {\r\n      pretty_print: true,\r\n      include_click_stats: false,\r\n      include_user_info: false\r\n    }\r\n  })\r\n\r\n  const fetchExportPreview = async () => {\n    try {\n      const token = useAuthStore.getState().accessToken\n      const response = await fetch('/api/v1/export', {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n          ...(token ? { 'Authorization': `Bearer ${token}` } : {})\n        },\n        body: JSON.stringify({ format: exportFormat, scope, include_deleted: includeDeleted })\n      })\n\n      if (response.ok) {\n        const data = await response.json()\n        setExportStats({\n          ...data.stats,\n          estimated_size: data.estimated_size\n        })\n      }\n    } catch (error) {\n      console.error('Failed to fetch export preview:', error)\n    }\n  }\n\r\n  const handleExport = async () => {\n    setIsExporting(true)\n    setExportError(null)\n    setExportProgress({ current: 0, total: 100, status: t('export.preparing') })\n\n    try {\n      const params = new URLSearchParams({\n        format: exportFormat,\n        scope,\n        include_deleted: includeDeleted.toString(),\n        include_metadata: options.include_metadata.toString(),\n        include_tags: options.include_tags.toString(),\n        pretty_print: options.format_options?.pretty_print?.toString() || 'true',\n        include_stats: options.format_options?.include_click_stats?.toString() || 'false',\n        include_user: options.format_options?.include_user_info?.toString() || 'false'\n      })\n\r\n      setExportProgress({ current: 25, total: 100, status: t('export.generating') })\r\n\r\n      const token = useAuthStore.getState().accessToken\r\n      const response = await fetch(`/api/v1/export?${params}`, {\r\n        headers: token ? { 'Authorization': `Bearer ${token}` } : {}\r\n      })\r\n\r\n      if (!response.ok) {\r\n        const error = await response.json()\r\n        throw new Error(error.message || 'Export failed')\r\n      }\r\n\r\n      setExportProgress({ current: 75, total: 100, status: t('export.downloading') })\r\n\n      const contentDisposition = response.headers.get('Content-Disposition')\n      const filename = contentDisposition?.match(/filename=\"([^\"]+)\"/)?.[1] ||\n                     `tmarks-export-${Date.now()}.json`\n\r\n      const blob = await response.blob()\r\n      const url = window.URL.createObjectURL(blob)\r\n      const a = document.createElement('a')\r\n      a.href = url\r\n      a.download = filename\r\n      document.body.appendChild(a)\r\n      a.click()\r\n      document.body.removeChild(a)\r\n      window.URL.revokeObjectURL(url)\n\n      setExportProgress({ current: 100, total: 100, status: t('export.complete') })\n      onExport?.(exportFormat, options)\n\n    } catch (error) {\n      const message = error instanceof Error ? error.message : t('export.failedRetry')\n      setExportError(message)\n      console.error('Export failed:', error)\n    } finally {\r\n      setIsExporting(false)\r\n      setTimeout(() => setExportProgress(null), 2000)\r\n    }\n  }\n\n  return (\n    <div className=\"space-y-4 sm:space-y-6\">\n      <div>\n        <h3 className=\"text-base sm:text-lg font-semibold text-foreground\">\r\n          {t('export.title')}\r\n        </h3>\r\n        <p className=\"text-sm text-muted-foreground mt-1\">\r\n          {t('export.description')}\r\n        </p>\r\n      </div>\n\n      <div className=\"space-y-2\">\n        <div className=\"block text-sm font-medium text-foreground\">\n          {t('export.selectFormat')}\n        </div>\n        <div className=\"flex items-start gap-3 rounded-lg border border-border bg-card p-3 sm:p-4\">\n          <Code className=\"h-5 w-5 text-muted-foreground mt-0.5 flex-shrink-0\" />\n          <div className=\"min-w-0\">\n            <div className=\"flex items-center gap-2\">\n              <span className=\"font-medium text-foreground text-sm sm:text-base\">\n                {t('format.json')}\n              </span>\n              <span className=\"px-2 py-0.5 text-xs bg-success/10 text-success rounded flex-shrink-0\">\n                {t('format.recommended')}\n              </span>\n            </div>\n            <p className=\"text-xs sm:text-sm text-muted-foreground mt-1\">\n              {t('format.jsonDesc')}\n            </p>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"space-y-4\">\n        <label className=\"block text-sm font-medium text-foreground\">\n          {t('export.options')}\n        </label>\n\n        <ExportOptionsForm\n          scope={scope}\n          setScope={setScope}\n          includeDeleted={includeDeleted}\n          setIncludeDeleted={setIncludeDeleted}\n          options={options}\n          setOptions={setOptions}\n          disabled={isExporting}\n        />\n      </div>\n\r\n      {exportStats && (\n        <div className=\"bg-muted rounded-lg p-3 sm:p-4\">\n          <h4 className=\"text-sm font-semibold text-foreground mb-3\">{t('export.previewTitle')}</h4>\n          <div className=\"grid grid-cols-2 sm:grid-cols-5 gap-3 sm:gap-4\">\n            <div className=\"text-center sm:text-left\">\n              <div className=\"text-lg sm:text-xl font-bold text-primary\">{exportStats.total_bookmarks}</div>\n              <div className=\"text-xs sm:text-sm text-muted-foreground mt-1\">{t('export.bookmarkCount')}</div>\n            </div>\n            <div className=\"text-center sm:text-left\">\n              <div className=\"text-lg sm:text-xl font-bold text-success\">{exportStats.total_tags}</div>\n              <div className=\"text-xs sm:text-sm text-muted-foreground mt-1\">{t('export.tagCount')}</div>\n            </div>\n            <div className=\"text-center sm:text-left\">\n              <div className=\"text-lg sm:text-xl font-bold text-warning\">{exportStats.pinned_bookmarks}</div>\n              <div className=\"text-xs sm:text-sm text-muted-foreground mt-1\">{t('export.pinnedCount')}</div>\n            </div>\n            <div className=\"text-center sm:text-left\">\n              <div className=\"text-lg sm:text-xl font-bold text-primary\">{exportStats.total_tab_groups}</div>\n              <div className=\"text-xs sm:text-sm text-muted-foreground mt-1\">{t('export.tabGroupCount')}</div>\n            </div>\n            <div className=\"text-center sm:text-left\">\n              <div className=\"text-lg sm:text-xl font-bold text-foreground\">{formatFileSize(exportStats.estimated_size)}</div>\n              <div className=\"text-xs sm:text-sm text-muted-foreground mt-1\">{t('export.estimatedSize')}</div>\n            </div>\n          </div>\n        </div>\n      )}\n\r\n      {exportProgress && (\r\n        <ProgressIndicator\r\n          progress={{\r\n            current: exportProgress.current,\r\n            total: exportProgress.total,\r\n            percentage: (exportProgress.current / exportProgress.total) * 100,\r\n            status: exportProgress.status\r\n          }}\r\n          variant=\"default\"\r\n          showSpeed={false}\r\n          showETA={false}\r\n        />\r\n      )}\r\n\r\n      {exportError && (\r\n        <ErrorDisplay\r\n          errors={[{ message: exportError }]}\r\n          variant=\"error\"\r\n          title={t('export.failed')}\r\n          dismissible={true}\r\n          onDismiss={() => setExportError(null)}\r\n          onRetry={handleExport}\r\n        />\r\n      )}\r\n\r\n      <div className=\"flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-3\">\r\n        <button\r\n          onClick={fetchExportPreview}\r\n          disabled={isExporting}\r\n          className=\"w-full sm:w-auto px-4 py-3 sm:py-2 text-sm font-medium text-foreground bg-card border border-border rounded-md hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed touch-manipulation\"\r\n        >\r\n          {t('export.preview')}\r\n        </button>\r\n\r\n        <button\r\n          onClick={handleExport}\r\n          disabled={isExporting}\r\n          className=\"w-full sm:w-auto flex items-center justify-center space-x-2 px-4 py-3 sm:py-2 text-sm font-medium text-primary-foreground bg-primary border border-transparent rounded-md hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed touch-manipulation\"\r\n        >\r\n          {isExporting ? (\r\n            <Loader2 className=\"h-4 w-4 animate-spin\" />\r\n          ) : (\r\n            <Download className=\"h-4 w-4\" />\r\n          )}\r\n          <span>{isExporting ? t('export.exporting') : t('export.startExport')}</span>\r\n        </button>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n\r\nfunction formatFileSize(bytes: number): string {\r\n  if (bytes === 0) return '0 B'\r\n  const k = 1024\r\n  const sizes = ['B', 'KB', 'MB', 'GB']\r\n  const i = Math.floor(Math.log(bytes) / Math.log(k))\r\n  return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/layout/AppShell.tsx",
    "content": "﻿import { Outlet, useNavigate, useLocation } from 'react-router-dom'\nimport { useTranslation } from 'react-i18next'\nimport { BookOpen, User, Layers } from 'lucide-react'\nimport { useAuthStore } from '@/stores/authStore'\nimport { ThemeToggle } from '@/components/common/ThemeToggle'\nimport { ColorThemeSelector } from '@/components/common/ColorThemeSelector'\nimport { MobileBottomNav } from '@/components/layout/MobileBottomNav'\nimport { ThemedRoot } from '@/components/layout/ThemedRoot'\nimport { ShellHeader } from '@/components/layout/ShellHeader'\n\nexport function AppShell() {\n  const { t } = useTranslation('common')\n  const navigate = useNavigate()\n  const location = useLocation()\n  const { user } = useAuthStore()\n\n  const isOnTabGroupsPage = location.pathname.startsWith('/tab')\n\n  const handleToggleView = () => {\n    if (isOnTabGroupsPage) {\n      navigate('/')\n    } else {\n      navigate('/tab')\n    }\n  }\n\n  return (\n    <ThemedRoot>\n      <ShellHeader\n        title=\"TMarks\"\n        subtitle={isOnTabGroupsPage ? t('nav.manageTabGroups') : t('nav.smartBookmarkManagement')}\n        onHome={() => navigate('/')}\n        right={\n          <div className=\"flex items-center gap-2\">\n            <button\n              onClick={handleToggleView}\n              className=\"hidden sm:flex items-center justify-center w-11 h-11 rounded-2xl transition-all duration-300 bg-card hover:bg-primary/5 active:scale-95 text-foreground shadow-sm hover:shadow-md\"\n              title={isOnTabGroupsPage ? t('nav.switchToBookmarks') : t('nav.switchToTabGroups')}\n            >\n              {isOnTabGroupsPage ? <BookOpen className=\"w-5 h-5\" /> : <Layers className=\"w-5 h-5\" />}\n            </button>\n\n            <ThemeToggle />\n            <ColorThemeSelector />\n\n            {user && (\n              <button\n                onClick={() => navigate('/settings/general')}\n                className=\"flex items-center justify-center w-11 h-11 rounded-2xl transition-all duration-300 bg-card hover:bg-primary/5 active:scale-95 text-foreground shadow-sm hover:shadow-md\"\n                title={t('nav.userSettings', { username: user.username })}\n              >\n                <User className=\"w-5 h-5\" />\n              </button>\n            )}\n          </div>\n        }\n      />\n\n      <main className=\"w-full pb-16 sm:pb-6 pt-3 sm:pt-6 flex flex-col min-h-0 flex-1 bg-muted/30\">\n        <div className=\"mx-auto w-full px-3 sm:px-6\">\n          <Outlet />\n        </div>\n      </main>\n\n      <MobileBottomNav />\n    </ThemedRoot>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/layout/FullScreenAppShell.tsx",
    "content": "﻿import { Outlet, useNavigate, useLocation } from 'react-router-dom'\nimport { useTranslation } from 'react-i18next'\nimport { BookOpen, User, Layers } from 'lucide-react'\nimport { useAuthStore } from '@/stores/authStore'\nimport { ThemeToggle } from '@/components/common/ThemeToggle'\nimport { ColorThemeSelector } from '@/components/common/ColorThemeSelector'\nimport { MobileBottomNav } from '@/components/layout/MobileBottomNav'\nimport { ThemedRoot } from '@/components/layout/ThemedRoot'\nimport { ShellHeader } from '@/components/layout/ShellHeader'\n\nexport function FullScreenAppShell() {\n  const { t } = useTranslation('common')\n  const navigate = useNavigate()\n  const location = useLocation()\n  const { user } = useAuthStore()\n\n  const isOnTabGroupsPage = location.pathname.startsWith('/tab')\n\n  const handleToggleView = () => {\n    if (isOnTabGroupsPage) {\n      navigate('/')\n    } else {\n      navigate('/tab')\n    }\n  }\n\n  return (\n    <ThemedRoot>\n      <ShellHeader\n        title=\"TMarks\"\n        subtitle={isOnTabGroupsPage ? t('nav.manageTabGroups') : t('nav.smartBookmarkManagement')}\n        onHome={() => navigate('/')}\n        right={\n          <div className=\"flex items-center gap-1.5 sm:gap-2 md:gap-4\">\n            <button\n              onClick={handleToggleView}\n              className=\"hidden sm:flex btn btn-sm btn-ghost p-2\"\n              title={isOnTabGroupsPage ? t('nav.switchToBookmarks') : t('nav.switchToTabGroups')}\n            >\n              {isOnTabGroupsPage ? <BookOpen className=\"w-4 h-4\" /> : <Layers className=\"w-4 h-4\" />}\n            </button>\n\n            <ThemeToggle />\n            <ColorThemeSelector />\n\n            {user && (\n              <button\n                onClick={() => navigate('/settings/general')}\n                className=\"btn btn-sm btn-ghost p-2\"\n                title={t('nav.userSettings', { username: user.username })}\n              >\n                <User className=\"w-4 h-4\" />\n              </button>\n            )}\n          </div>\n        }\n      />\n\n      <main className=\"w-full pb-16 sm:pb-0 flex flex-col min-h-0 flex-1\">\n        <Outlet />\n      </main>\n\n      <MobileBottomNav />\n    </ThemedRoot>\n  )\n}\r\n"
  },
  {
    "path": "tmarks/src/components/layout/MobileBottomNav.tsx",
    "content": "/**\r\n * 移动端底部导航栏组件\r\n * 提供移动端专用的导航体验\r\n */\r\n\r\nimport { useNavigate, useLocation } from 'react-router-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { BookOpen, Layers, Download } from 'lucide-react'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\n\r\ninterface NavItem {\r\n  id: string\r\n  labelKey: string\r\n  icon: React.ComponentType<{ className?: string }>\r\n  path: string\r\n  badge?: number\r\n}\r\n\r\nexport function MobileBottomNav() {\r\n  const { t } = useTranslation('common')\r\n  const navigate = useNavigate()\r\n  const location = useLocation()\r\n\r\n  const navItems: NavItem[] = [\r\n    {\r\n      id: 'bookmarks',\r\n      labelKey: 'nav.bookmarks',\r\n      icon: BookOpen,\r\n      path: '/'\r\n    },\r\n    {\r\n      id: 'tab-groups',\r\n      labelKey: 'nav.tabGroups',\r\n      icon: Layers,\r\n      path: '/tab'\r\n    },\r\n    {\r\n      id: 'extension',\r\n      labelKey: 'nav.extension',\r\n      icon: Download,\r\n      path: '/extension'\r\n    }\r\n  ]\r\n\r\n  const isActive = (path: string) => {\r\n    if (path === '/') {\r\n      return location.pathname === '/'\r\n    }\r\n    return location.pathname === path || location.pathname.startsWith(path + '/')\r\n  }\r\n\r\n  return (\r\n    <div className=\"fixed bottom-0 left-0 right-0 bg-card border-t border-border sm:hidden\" style={{ zIndex: Z_INDEX.MOBILE_BOTTOM_NAV }}>\r\n      <div className=\"grid grid-cols-3 h-16\">\r\n        {navItems.map((item) => {\r\n          const Icon = item.icon\r\n          const active = isActive(item.path)\r\n          \r\n          return (\r\n            <button\r\n              key={item.id}\r\n              onClick={() => navigate(item.path)}\r\n              className={`flex flex-col items-center justify-center space-y-1 px-2 py-2 transition-colors duration-200 touch-manipulation ${\r\n                active\r\n                  ? 'text-primary'\r\n                  : 'text-muted-foreground hover:text-foreground'\r\n              }`}\r\n            >\r\n              <div className=\"relative\">\r\n                <Icon className={`h-5 w-5 ${\r\n                  active ? 'text-primary' : ''\r\n                }`} />\r\n                {item.badge && item.badge > 0 && (\r\n                  <span className=\"absolute -top-1 -right-1 h-4 w-4 bg-destructive text-destructive-foreground text-xs rounded-full flex items-center justify-center\">\r\n                    {item.badge > 99 ? '99+' : item.badge}\r\n                  </span>\r\n                )}\r\n              </div>\r\n              <span className={`text-xs font-medium ${\r\n                active ? 'text-primary' : ''\r\n              }`}>\r\n                {t(item.labelKey)}\r\n              </span>\r\n            </button>\r\n          )\r\n        })}\r\n      </div>\r\n      \r\n      {/* 安全区域适配 */}\r\n      <div className=\"h-safe-area-inset-bottom bg-card\" />\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/layout/PublicAppShell.tsx",
    "content": "﻿import { Outlet, useNavigate } from 'react-router-dom'\nimport { useTranslation } from 'react-i18next'\nimport { Download } from 'lucide-react'\nimport { ThemeToggle } from '@/components/common/ThemeToggle'\nimport { ColorThemeSelector } from '@/components/common/ColorThemeSelector'\nimport { ThemedRoot } from '@/components/layout/ThemedRoot'\nimport { ShellHeader } from '@/components/layout/ShellHeader'\n\nexport function PublicAppShell() {\n  const { t } = useTranslation('common')\n  const navigate = useNavigate()\n\n  return (\n    <ThemedRoot>\n      <ShellHeader\n        title=\"TMarks\"\n        subtitle={t('nav.smartBookmarkManagement')}\n        onHome={() => navigate('/')}\n        right={\n          <>\n            <ThemeToggle />\n            <ColorThemeSelector />\n\n            <button\n              onClick={() => {\n                const link = document.createElement('a')\n                link.href = '/tmarks-extension.zip'\n                link.download = 'tmarks-extension.zip'\n                document.body.appendChild(link)\n                link.click()\n                document.body.removeChild(link)\n              }}\n              className=\"hidden sm:flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-lg transition-all duration-200 border border-border hover:border-primary hover:bg-card/50\"\n              style={{ color: 'var(--foreground)' }}\n              title={t('nav.extension')}\n            >\n              <Download className=\"w-4 h-4\" />\n              <span className=\"hidden md:inline\">{t('nav.extension')}</span>\n            </button>\n\n            <button\n              onClick={() => navigate('/login')}\n              className=\"px-3 py-2 sm:px-4 sm:py-2 text-sm font-medium rounded-lg transition-all duration-200 bg-primary text-primary-content hover:bg-primary/90 shadow-float\"\n            >\n              {t('action.login')}\n            </button>\n          </>\n        }\n      />\n\n      <main className=\"w-full px-3 sm:px-6\">\n        <div className=\"mx-auto\" style={{ maxWidth: '100%' }}>\n          <Outlet />\n        </div>\n      </main>\n    </ThemedRoot>\n  )\n}\r\n"
  },
  {
    "path": "tmarks/src/components/layout/ShellHeader.tsx",
    "content": "import type React from 'react'\nimport { BookOpen } from 'lucide-react'\n\nexport function ShellHeader({\n  title,\n  subtitle,\n  onHome,\n  right,\n}: {\n  title: string\n  subtitle?: React.ReactNode\n  onHome: () => void\n  right?: React.ReactNode\n}) {\n  return (\n    <header className=\"h-16 sm:h-20 sticky top-0 z-50 backdrop-filter backdrop-blur-xl bg-card/80 border-b border-border/50 shadow-float flex items-center\">\n      <div className=\"w-full mx-auto px-3 sm:px-6 flex items-center justify-between\">\n        <button\n          onClick={onHome}\n          className=\"flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity duration-200 focus:outline-none\"\n        >\n          <div className=\"w-9 h-9 sm:w-11 sm:h-11 rounded-lg sm:rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-float\">\n            <BookOpen className=\"w-5 h-5 sm:w-7 sm:h-7\" style={{ color: 'var(--foreground)' }} />\n          </div>\n          <div className=\"block text-left\">\n            <h1 className=\"text-lg sm:text-2xl font-bold\" style={{ color: 'var(--primary)' }}>\n              {title}\n            </h1>\n            {subtitle && (\n              <p className=\"text-xs font-medium hidden sm:block\" style={{ color: 'var(--muted-foreground)' }}>\n                {subtitle}\n              </p>\n            )}\n          </div>\n        </button>\n\n        {right ? <div className=\"flex items-center gap-2\">{right}</div> : null}\n      </div>\n    </header>\n  )\n}\n\n"
  },
  {
    "path": "tmarks/src/components/layout/ThemedRoot.tsx",
    "content": "import type React from 'react'\nimport { useThemeStore } from '@/stores/themeStore'\n\nexport function ThemedRoot({\n  children,\n  className = '',\n}: {\n  children: React.ReactNode\n  className?: string\n}) {\n  const { theme, colorTheme } = useThemeStore()\n\n  return (\n    <div\n      className={`min-h-screen ${className}`.trim()}\n      style={{ backgroundColor: 'var(--background)' }}\n      data-theme={theme}\n      data-color-theme={colorTheme}\n    >\n      {children}\n    </div>\n  )\n}\n\n"
  },
  {
    "path": "tmarks/src/components/settings/InfoBox.tsx",
    "content": "import { ReactNode } from 'react'\r\nimport { LucideIcon } from 'lucide-react'\r\n\r\ninterface InfoBoxProps {\r\n  icon: LucideIcon\r\n  title: string\r\n  children: ReactNode\r\n  variant?: 'info' | 'success' | 'warning'\r\n}\r\n\r\nexport function InfoBox({ icon: Icon, title, children, variant = 'info' }: InfoBoxProps) {\r\n  const variantStyles = {\r\n    info: {\r\n      container: 'bg-primary/5 border-primary/20',\r\n      icon: 'text-primary',\r\n      title: 'text-foreground',\r\n      content: 'text-muted-foreground'\r\n    },\r\n    success: {\r\n      container: 'bg-success/5 border-success/20',\r\n      icon: 'text-success',\r\n      title: 'text-foreground',\r\n      content: 'text-muted-foreground'\r\n    },\r\n    warning: {\r\n      container: 'bg-warning/5 border-warning/20',\r\n      icon: 'text-warning',\r\n      title: 'text-foreground',\r\n      content: 'text-muted-foreground'\r\n    }\r\n  }\r\n\r\n  const styles = variantStyles[variant]\r\n\r\n  return (\r\n    <div className={`rounded-lg border p-4 ${styles.container}`}>\r\n      <div className=\"flex items-start gap-2\">\r\n        <Icon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${styles.icon}`} />\r\n        <div>\r\n          <h4 className={`text-sm font-semibold mb-2 ${styles.title}`}>\r\n            {title}\r\n          </h4>\r\n          <div className={`text-xs space-y-1 ${styles.content}`}>\r\n            {children}\r\n          </div>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/settings/SearchAutoClearSettings.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\n\r\ninterface SearchAutoClearSettingsProps {\r\n  enabled: boolean\r\n  seconds: number\r\n  onEnabledChange: (enabled: boolean) => void\r\n  onSecondsChange: (seconds: number) => void\r\n}\r\n\r\nexport function SearchAutoClearSettings({\r\n  enabled,\r\n  seconds,\r\n  onEnabledChange,\r\n  onSecondsChange,\r\n}: SearchAutoClearSettingsProps) {\r\n  const { t } = useTranslation('settings')\r\n\r\n  return (\r\n    <div className=\"space-y-4\">\r\n      <div>\r\n        <h3 className=\"text-lg font-semibold text-foreground\">{t('automation.searchAutoClear.title')}</h3>\r\n        <p className=\"text-sm text-muted-foreground mt-1\">\r\n          {t('automation.searchAutoClear.description')}\r\n        </p>\r\n      </div>\r\n\r\n      <div className=\"flex items-center gap-4\">\r\n        <label className=\"flex items-center gap-2 cursor-pointer\">\r\n          <input\r\n            type=\"checkbox\"\r\n            checked={enabled}\r\n            onChange={(e) => onEnabledChange(e.target.checked)}\r\n            className=\"w-4 h-4 rounded border-border text-primary focus:ring-primary\"\r\n          />\r\n          <span className=\"text-sm text-foreground\">{t('automation.searchAutoClear.enable')}</span>\r\n        </label>\r\n      </div>\r\n\r\n      {enabled && (\r\n        <div className=\"space-y-2\">\r\n          <label className=\"block text-sm font-medium text-foreground\">\r\n            {t('automation.searchAutoClear.timeLabel')}\r\n          </label>\r\n          <div className=\"flex items-center gap-4\">\r\n            <input\r\n              type=\"range\"\r\n              min=\"5\"\r\n              max=\"60\"\r\n              step=\"5\"\r\n              value={seconds}\r\n              onChange={(e) => onSecondsChange(Number(e.target.value))}\r\n              className=\"flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer\"\r\n            />\r\n            <input\r\n              type=\"number\"\r\n              min=\"5\"\r\n              max=\"60\"\r\n              value={seconds}\r\n              onChange={(e) => onSecondsChange(Number(e.target.value))}\r\n              className=\"w-20 px-3 py-2 border border-border rounded-lg text-sm text-foreground bg-background\"\r\n            />\r\n            <span className=\"text-sm text-muted-foreground\">{t('automation.searchAutoClear.seconds')}</span>\r\n          </div>\r\n          <p className=\"text-xs text-muted-foreground\">\r\n            {t('automation.searchAutoClear.hint', { seconds })}\r\n          </p>\r\n        </div>\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/settings/SettingsNav.tsx",
    "content": "/**\n * 设置页面侧栏导航组件\n * Desktop: 固定侧栏 | Mobile: 下拉选择器\n */\n\nimport { ReactNode, useState } from 'react'\nimport { ChevronDown } from 'lucide-react'\nimport type { LucideIcon } from 'lucide-react'\n\nexport interface SettingsNavItem {\n  id: string\n  label: string\n  icon: LucideIcon\n}\n\nexport interface SettingsNavGroup {\n  label: string\n  items: SettingsNavItem[]\n}\n\ninterface SettingsNavProps {\n  groups: SettingsNavGroup[]\n  activeSection: string\n  onSectionChange: (sectionId: string) => void\n  children: ReactNode\n}\n\nexport function SettingsNav({ groups, activeSection, onSectionChange, children }: SettingsNavProps) {\n  const [showMobileMenu, setShowMobileMenu] = useState(false)\n\n  const allItems = groups.flatMap((g) => g.items)\n  const activeItem = allItems.find((item) => item.id === activeSection)\n\n  return (\n    <div className=\"flex flex-col lg:flex-row gap-4 lg:gap-6\">\n      {/* Mobile: 下拉选择器 */}\n      <div className=\"lg:hidden relative\">\n        <button\n          onClick={() => setShowMobileMenu(!showMobileMenu)}\n          className=\"w-full flex items-center justify-between gap-2 px-4 py-3 text-sm font-medium bg-card rounded-lg border border-border\"\n        >\n          <div className=\"flex items-center gap-2\">\n            {activeItem && <activeItem.icon className=\"w-4 h-4 text-primary\" />}\n            <span>{activeItem?.label}</span>\n          </div>\n          <ChevronDown className={`w-4 h-4 transition-transform ${showMobileMenu ? 'rotate-180' : ''}`} />\n        </button>\n        {showMobileMenu && (\n          <div className=\"absolute z-20 mt-2 w-full rounded-lg border border-border bg-popover shadow-lg overflow-hidden\">\n            {groups.map((group) => (\n              <div key={group.label}>\n                <div className=\"px-4 py-2 text-xs font-medium text-muted-foreground bg-muted/30\">\n                  {group.label}\n                </div>\n                {group.items.map((item) => {\n                  const Icon = item.icon\n                  return (\n                    <button\n                      key={item.id}\n                      onClick={() => {\n                        onSectionChange(item.id)\n                        setShowMobileMenu(false)\n                      }}\n                      className={`w-full flex items-center gap-2 px-4 py-3 text-sm font-medium transition-colors ${\n                        activeSection === item.id\n                          ? 'text-primary bg-primary/10'\n                          : 'text-foreground hover:bg-muted/50'\n                      }`}\n                    >\n                      <Icon className=\"w-4 h-4\" />\n                      <span>{item.label}</span>\n                    </button>\n                  )\n                })}\n              </div>\n            ))}\n          </div>\n        )}\n      </div>\n\n      {/* Desktop: 侧栏导航 */}\n      <nav className=\"hidden lg:block w-52 flex-shrink-0\">\n        <div className=\"sticky top-6 space-y-5\">\n          {groups.map((group) => (\n            <div key={group.label}>\n              <div className=\"px-3 mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider\">\n                {group.label}\n              </div>\n              <div className=\"space-y-0.5\">\n                {group.items.map((item) => {\n                  const Icon = item.icon\n                  const isActive = activeSection === item.id\n                  return (\n                    <button\n                      key={item.id}\n                      onClick={() => onSectionChange(item.id)}\n                      className={`w-full flex items-center gap-2.5 px-3 py-2 text-sm rounded-lg transition-colors ${\n                        isActive\n                          ? 'text-primary bg-primary/10 font-medium'\n                          : 'text-muted-foreground hover:text-foreground hover:bg-muted/50'\n                      }`}\n                    >\n                      <Icon className=\"w-4 h-4\" />\n                      <span>{item.label}</span>\n                    </button>\n                  )\n                })}\n              </div>\n            </div>\n          ))}\n        </div>\n      </nav>\n\n      {/* 内容区域 */}\n      <div className=\"flex-1 min-w-0\">\n        <div className=\"animate-in fade-in duration-200\">{children}</div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/settings/SettingsSaveBar.tsx",
    "content": "/**\n * 设置保存栏\n * 当有未保存的变更时从底部滑入显示\n */\n\nimport { useTranslation } from 'react-i18next'\nimport { Save, RotateCcw } from 'lucide-react'\n\ninterface SettingsSaveBarProps {\n  isDirty: boolean\n  isSaving: boolean\n  onSave: () => void\n  onDiscard: () => void\n}\n\nexport function SettingsSaveBar({ isDirty, isSaving, onSave, onDiscard }: SettingsSaveBarProps) {\n  const { t } = useTranslation('settings')\n\n  if (!isDirty) return null\n\n  return (\n    <div className=\"sticky bottom-0 z-10 mt-6 -mx-3 sm:-mx-6 animate-in slide-in-from-bottom duration-300\">\n      <div className=\"bg-card/95 backdrop-blur-sm border-t border-border px-4 py-3 flex items-center justify-between gap-3\">\n        <span className=\"text-sm text-muted-foreground\">\n          {t('message.unsavedChanges')}\n        </span>\n        <div className=\"flex gap-2\">\n          <button\n            onClick={onDiscard}\n            disabled={isSaving}\n            className=\"btn btn-ghost btn-sm flex items-center gap-1.5\"\n          >\n            <RotateCcw className=\"w-3.5 h-3.5\" />\n            {t('action.discard')}\n          </button>\n          <button\n            onClick={onSave}\n            disabled={isSaving}\n            className=\"btn btn-primary btn-sm flex items-center gap-1.5\"\n          >\n            <Save className=\"w-3.5 h-3.5\" />\n            {isSaving ? t('action.saving') : t('action.save')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/settings/SettingsSection.tsx",
    "content": "/**\r\n * 设置区块组件\r\n * 统一的设置项布局\r\n */\r\n\r\nimport type { ReactNode } from 'react'\r\nimport type { LucideIcon } from 'lucide-react'\r\n\r\ninterface SettingsSectionProps {\r\n  title: string\r\n  description?: string\r\n  icon?: LucideIcon\r\n  children: ReactNode\r\n  className?: string\r\n}\r\n\r\nexport function SettingsSection({ title, description, icon: Icon, children, className = '' }: SettingsSectionProps) {\r\n  return (\r\n    <div className={`space-y-4 ${className}`}>\r\n      <div className=\"flex items-start gap-3\">\r\n        {Icon && (\r\n          <div className=\"w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center flex-shrink-0\">\r\n            <Icon className=\"w-5 h-5 text-primary\" />\r\n          </div>\r\n        )}\r\n        <div className=\"flex-1\">\r\n          <h3 className=\"text-lg font-semibold text-foreground\">{title}</h3>\r\n          {description && <p className=\"text-sm text-muted-foreground mt-1\">{description}</p>}\r\n        </div>\r\n      </div>\r\n      <div>{children}</div>\r\n    </div>\r\n  )\r\n}\r\n\r\ninterface SettingsItemProps {\r\n  title: string\r\n  description?: string\r\n  icon?: LucideIcon\r\n  iconColor?: string\r\n  action?: ReactNode\r\n  children?: ReactNode\r\n}\r\n\r\nexport function SettingsItem({ title, description, icon: Icon, iconColor = 'text-primary', action, children }: SettingsItemProps) {\r\n  return (\r\n    <div className=\"flex items-center justify-between p-4 rounded-lg bg-card border border-border\">\r\n      <div className=\"flex items-start gap-3 flex-1 min-w-0\">\r\n        {Icon && <Icon className={`w-5 h-5 ${iconColor} flex-shrink-0 mt-0.5`} />}\r\n        <div className=\"flex-1 min-w-0\">\r\n          <div className=\"text-sm font-medium text-foreground\">{title}</div>\r\n          {description && (\r\n            <div className=\"text-xs text-muted-foreground mt-1\">{description}</div>\r\n          )}\r\n          {children}\r\n        </div>\r\n      </div>\r\n      {action && <div className=\"flex-shrink-0 ml-4\">{action}</div>}\r\n    </div>\r\n  )\r\n}\r\n\r\nexport function SettingsDivider() {\r\n  return <div className=\"border-t border-border\" />\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/settings/SettingsTips.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\n\r\ninterface SettingsTipsProps {\r\n  tips: string[]\r\n}\r\n\r\nexport function SettingsTips({ tips }: SettingsTipsProps) {\r\n  const { t } = useTranslation('settings')\r\n\r\n  return (\r\n    <div className=\"bg-primary/5 border border-primary/20 rounded-lg p-4\">\r\n      <h4 className=\"text-sm font-semibold text-foreground mb-2\">\r\n        {t('tips.title')}\r\n      </h4>\r\n      <ul className=\"text-xs text-muted-foreground space-y-1\">\r\n        {tips.map((tip, index) => (\r\n          <li key={index}>• {tip}</li>\r\n        ))}\r\n      </ul>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/settings/TagSelectionAutoClearSettings.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\n\r\ninterface TagSelectionAutoClearSettingsProps {\r\n  enabled: boolean\r\n  seconds: number\r\n  onEnabledChange: (enabled: boolean) => void\r\n  onSecondsChange: (seconds: number) => void\r\n}\r\n\r\nexport function TagSelectionAutoClearSettings({\r\n  enabled,\r\n  seconds,\r\n  onEnabledChange,\r\n  onSecondsChange,\r\n}: TagSelectionAutoClearSettingsProps) {\r\n  const { t } = useTranslation('settings')\r\n\r\n  return (\r\n    <div className=\"space-y-4\">\r\n      <div>\r\n        <h3 className=\"text-lg font-semibold text-foreground\">{t('automation.tagSelectionAutoClear.title')}</h3>\r\n        <p className=\"text-sm text-muted-foreground mt-1\">\r\n          {t('automation.tagSelectionAutoClear.description')}\r\n        </p>\r\n      </div>\r\n\r\n      <div className=\"flex items-center gap-4\">\r\n        <label className=\"flex items-center gap-2 cursor-pointer\">\r\n          <input\r\n            type=\"checkbox\"\r\n            checked={enabled}\r\n            onChange={(e) => onEnabledChange(e.target.checked)}\r\n            className=\"w-4 h-4 rounded border-border text-primary focus:ring-primary\"\r\n          />\r\n          <span className=\"text-sm text-foreground\">{t('automation.tagSelectionAutoClear.enable')}</span>\r\n        </label>\r\n      </div>\r\n\r\n      {enabled && (\r\n        <div className=\"space-y-2\">\r\n          <label className=\"block text-sm font-medium text-foreground\">\r\n            {t('automation.tagSelectionAutoClear.timeLabel')}\r\n          </label>\r\n          <div className=\"flex items-center gap-4\">\r\n            <input\r\n              type=\"range\"\r\n              min=\"10\"\r\n              max=\"120\"\r\n              step=\"10\"\r\n              value={seconds}\r\n              onChange={(e) => onSecondsChange(Number(e.target.value))}\r\n              className=\"flex-1 h-2 bg-muted rounded-lg appearance-none cursor-pointer\"\r\n            />\r\n            <input\r\n              type=\"number\"\r\n              min=\"10\"\r\n              max=\"120\"\r\n              value={seconds}\r\n              onChange={(e) => onSecondsChange(Number(e.target.value))}\r\n              className=\"w-20 px-3 py-2 border border-border rounded-lg text-sm text-foreground bg-background\"\r\n            />\r\n            <span className=\"text-sm text-muted-foreground\">{t('automation.tagSelectionAutoClear.seconds')}</span>\r\n          </div>\r\n          <p className=\"text-xs text-muted-foreground\">\r\n            {t('automation.tagSelectionAutoClear.hint', { seconds })}\r\n          </p>\r\n        </div>\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/settings/tabs/ApiSettingsTab.tsx",
    "content": "/**\r\n * API 密钥设置标签页\r\n * 管理 API 密钥的创建、查看、撤销和删除\r\n */\r\n\r\nimport { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Key, Copy, Trash2, Plus, Eye, Ban, Info, AlertTriangle } from 'lucide-react'\r\nimport { useApiKeys, useRevokeApiKey, useDeleteApiKey } from '@/hooks/useApiKeys'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { CreateApiKeyModal } from '@/components/api-keys/CreateApiKeyModal'\r\nimport { ApiKeyDetailModal } from '@/components/api-keys/ApiKeyDetailModal'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { SettingsSection, SettingsDivider } from '../SettingsSection'\r\nimport { InfoBox } from '../InfoBox'\r\nimport type { ApiKey } from '@/services/api-keys'\r\n\r\nexport function ApiSettingsTab() {\r\n  const { t } = useTranslation('settings')\r\n  const { data, isLoading } = useApiKeys()\r\n  const revokeApiKey = useRevokeApiKey()\r\n  const deleteApiKey = useDeleteApiKey()\r\n  const { addToast } = useToastStore()\r\n\r\n  const [showCreateModal, setShowCreateModal] = useState(false)\r\n  const [selectedKey, setSelectedKey] = useState<ApiKey | null>(null)\r\n  const [confirmState, setConfirmState] = useState<{\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n  } | null>(null)\r\n\r\n  const handleRevoke = async (id: string) => {\r\n    setConfirmState({\r\n      isOpen: true,\r\n      title: t('apiKey.page.revokeTitle'),\r\n      message: t('apiKey.page.revokeMessage'),\r\n      onConfirm: async () => {\r\n        setConfirmState(null)\r\n        try {\r\n          await revokeApiKey.mutateAsync(id)\r\n          addToast('success', t('apiKey.page.revokeSuccess'))\r\n        } catch {\r\n          addToast('error', t('apiKey.page.revokeFailed'))\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handleDelete = async (id: string) => {\r\n    setConfirmState({\r\n      isOpen: true,\r\n      title: t('apiKey.page.deleteTitle'),\r\n      message: t('apiKey.page.deleteMessage'),\r\n      onConfirm: async () => {\r\n        setConfirmState(null)\r\n        try {\r\n          await deleteApiKey.mutateAsync(id)\r\n          addToast('success', t('apiKey.page.deleteSuccess'))\r\n        } catch {\r\n          addToast('error', t('apiKey.page.deleteFailed'))\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handleCopy = (key: string) => {\r\n    navigator.clipboard.writeText(key)\r\n    addToast('success', t('share.copySuccess'))\r\n  }\r\n\r\n  if (isLoading) {\r\n    return (\r\n      <div className=\"flex items-center justify-center h-64\">\r\n        <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-primary\"></div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  const keys = data?.keys || []\r\n  const quota = data?.quota || { used: 0, limit: 3 }\r\n\r\n  return (\r\n    <div className=\"space-y-6\">\r\n      {confirmState && (\r\n        <ConfirmDialog\r\n          isOpen={confirmState.isOpen}\r\n          title={confirmState.title}\r\n          message={confirmState.message}\r\n          type=\"warning\"\r\n          onConfirm={confirmState.onConfirm}\r\n          onCancel={() => setConfirmState(null)}\r\n        />\r\n      )}\r\n\r\n      {/* 密钥管理 */}\r\n      <SettingsSection icon={Key} title={t('apiKey.page.title')} description={t('apiKey.page.description')}>\r\n        <div className=\"space-y-4\">\r\n          {/* 配额和创建按钮 */}\r\n          <div className=\"flex items-center justify-between p-3 bg-muted/30 rounded-lg\">\r\n            <div className=\"text-sm\">\r\n              <span className=\"text-muted-foreground\">{t('apiKey.page.currentUsage')}</span>\r\n              <span className=\"font-medium ml-2\">\r\n                {quota.used} / {quota.limit >= 999 ? t('apiKey.page.unlimited') : quota.limit}\r\n              </span>\r\n            </div>\r\n            <button\r\n              onClick={() => setShowCreateModal(true)}\r\n              disabled={quota.used >= quota.limit}\r\n              className=\"btn btn-primary btn-sm flex items-center gap-2\"\r\n            >\r\n              <Plus className=\"w-4 h-4\" />\r\n              {t('apiKey.page.createNew')}\r\n            </button>\r\n          </div>\r\n\r\n          {/* 密钥列表 */}\r\n          {keys.length === 0 ? (\r\n            <div className=\"text-center py-8 text-muted-foreground\">\r\n              <Key className=\"w-10 h-10 mx-auto mb-2 opacity-30\" />\r\n              <p className=\"text-sm\">{t('apiKey.page.empty')}</p>\r\n            </div>\r\n          ) : (\r\n            <div className=\"space-y-2\">\r\n              {keys.map((key: ApiKey) => (\r\n                <div\r\n                  key={key.id}\r\n                  className={`p-3 rounded-lg border ${\r\n                    key.status === 'revoked'\r\n                      ? 'border-error/30 bg-error/5'\r\n                      : 'border-border bg-card'\r\n                  }`}\r\n                >\r\n                  <div className=\"flex items-center justify-between gap-3\">\r\n                    <div className=\"flex-1 min-w-0\">\r\n                      <div className=\"flex items-center gap-2 mb-1\">\r\n                        <Key className={`w-4 h-4 ${key.status === 'revoked' ? 'text-error' : 'text-primary'}`} />\r\n                        <span className=\"font-medium text-sm\">{key.name}</span>\r\n                        {key.status === 'revoked' && (\r\n                          <span className=\"text-xs px-1.5 py-0.5 rounded bg-error/20 text-error\">\r\n                            {t('apiKey.status.revoked')}\r\n                          </span>\r\n                        )}\r\n                      </div>\r\n                      <code className=\"text-xs bg-muted px-2 py-0.5 rounded font-mono\">\r\n                        {key.key_prefix}••••••••\r\n                      </code>\r\n                    </div>\r\n                    <div className=\"flex items-center gap-1\">\r\n                      <button onClick={() => handleCopy(key.key_prefix)} className=\"p-1.5 hover:bg-muted rounded\" title={t('apiKey.copyPrefix')}>\r\n                        <Copy className=\"w-4 h-4\" />\r\n                      </button>\r\n                      <button onClick={() => setSelectedKey(key)} className=\"p-1.5 hover:bg-muted rounded\" title={t('apiKey.viewDetails')}>\r\n                        <Eye className=\"w-4 h-4\" />\r\n                      </button>\r\n                      {key.status === 'active' && (\r\n                        <button onClick={() => handleRevoke(key.id)} className=\"p-1.5 text-warning hover:bg-warning/10 rounded\" title={t('apiKey.revoke')}>\r\n                          <Ban className=\"w-4 h-4\" />\r\n                        </button>\r\n                      )}\r\n                      <button onClick={() => handleDelete(key.id)} className=\"p-1.5 text-error hover:bg-error/10 rounded\" title={t('apiKey.delete')}>\r\n                        <Trash2 className=\"w-4 h-4\" />\r\n                      </button>\r\n                    </div>\r\n                  </div>\r\n                </div>\r\n              ))}\r\n            </div>\r\n          )}\r\n        </div>\r\n      </SettingsSection>\r\n\r\n      <SettingsDivider />\r\n\r\n      <div className=\"grid sm:grid-cols-2 gap-3\">\r\n        <InfoBox icon={Info} title={t('apiKey.infoBox.usageTitle')} variant=\"info\">\r\n          <ul className=\"space-y-1 text-xs\">\r\n            <li>• {t('apiKey.infoBox.usageTip1')}</li>\r\n            <li>• {t('apiKey.page.tip2')}</li>\r\n          </ul>\r\n        </InfoBox>\r\n\r\n        <InfoBox icon={AlertTriangle} title={t('apiKey.infoBox.securityTitle')} variant=\"warning\">\r\n          <ul className=\"space-y-1 text-xs\">\r\n            <li>• {t('apiKey.infoBox.securityTip1')}</li>\r\n            <li>• {t('apiKey.infoBox.securityTip2')}</li>\r\n          </ul>\r\n        </InfoBox>\r\n      </div>\r\n\r\n      {showCreateModal && <CreateApiKeyModal onClose={() => setShowCreateModal(false)} />}\r\n      {selectedKey && <ApiKeyDetailModal apiKey={selectedKey} onClose={() => setSelectedKey(null)} />}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/settings/tabs/AutomationSettingsTab.tsx",
    "content": "/**\r\n * 自动化设置标签页\r\n * 搜索和标签的自动清除设置\r\n */\r\n\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Search, Tag, Zap } from 'lucide-react'\r\nimport { Toggle } from '@/components/common/Toggle'\r\nimport { SettingsSection, SettingsItem, SettingsDivider } from '../SettingsSection'\r\nimport { InfoBox } from '../InfoBox'\r\n\r\ninterface AutomationSettingsTabProps {\r\n  searchEnabled: boolean\r\n  searchSeconds: number\r\n  tagEnabled: boolean\r\n  tagSeconds: number\r\n  onSearchEnabledChange: (enabled: boolean) => void\r\n  onSearchSecondsChange: (seconds: number) => void\r\n  onTagEnabledChange: (enabled: boolean) => void\r\n  onTagSecondsChange: (seconds: number) => void\r\n}\r\n\r\nexport function AutomationSettingsTab({\r\n  searchEnabled,\r\n  searchSeconds,\r\n  tagEnabled,\r\n  tagSeconds,\r\n  onSearchEnabledChange,\r\n  onSearchSecondsChange,\r\n  onTagEnabledChange,\r\n  onTagSecondsChange,\r\n}: AutomationSettingsTabProps) {\r\n  const { t } = useTranslation('settings')\r\n\r\n  return (\r\n    <div className=\"space-y-6\">\r\n      {/* 搜索自动清除 */}\r\n      <SettingsSection icon={Search} title={t('automation.search.title')} description={t('automation.search.description')}>\r\n        <div className=\"p-4 rounded-lg bg-card border border-border space-y-4\">\r\n          <SettingsItem\r\n            title={t('automation.search.enable')}\r\n            description={t('automation.search.enableHint')}\r\n            action={<Toggle checked={searchEnabled} onChange={onSearchEnabledChange} />}\r\n          />\r\n          {searchEnabled && (\r\n            <div className=\"flex items-center gap-3 pt-3 border-t border-border\">\r\n              <span className=\"text-sm text-muted-foreground\">{t('automation.search.delay')}</span>\r\n              <input\r\n                type=\"number\"\r\n                value={searchSeconds}\r\n                onChange={(e) => onSearchSecondsChange(parseInt(e.target.value) || 0)}\r\n                min=\"1\"\r\n                max=\"300\"\r\n                className=\"input w-20 text-center\"\r\n              />\r\n              <span className=\"text-sm text-muted-foreground\">{t('automation.search.unit')}</span>\r\n            </div>\r\n          )}\r\n        </div>\r\n      </SettingsSection>\r\n\r\n      <SettingsDivider />\r\n\r\n      {/* 标签选择自动清除 */}\r\n      <SettingsSection icon={Tag} title={t('automation.tag.title')} description={t('automation.tag.description')}>\r\n        <div className=\"p-4 rounded-lg bg-card border border-border space-y-4\">\r\n          <SettingsItem\r\n            title={t('automation.tag.enable')}\r\n            description={t('automation.tag.enableHint')}\r\n            action={<Toggle checked={tagEnabled} onChange={onTagEnabledChange} />}\r\n          />\r\n          {tagEnabled && (\r\n            <div className=\"flex items-center gap-3 pt-3 border-t border-border\">\r\n              <span className=\"text-sm text-muted-foreground\">{t('automation.tag.delay')}</span>\r\n              <input\r\n                type=\"number\"\r\n                value={tagSeconds}\r\n                onChange={(e) => onTagSecondsChange(parseInt(e.target.value) || 0)}\r\n                min=\"1\"\r\n                max=\"300\"\r\n                className=\"input w-20 text-center\"\r\n              />\r\n              <span className=\"text-sm text-muted-foreground\">{t('automation.tag.unit')}</span>\r\n            </div>\r\n          )}\r\n        </div>\r\n      </SettingsSection>\r\n\r\n      <SettingsDivider />\r\n\r\n      <InfoBox icon={Zap} title={t('automation.infoBox.title')} variant=\"info\">\r\n        <ul className=\"space-y-1 text-xs\">\r\n          <li>• {t('automation.infoBox.tip1')}</li>\r\n        </ul>\r\n      </InfoBox>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/settings/tabs/BasicSettingsTab.tsx",
    "content": "/**\r\n * 基础设置标签页\r\n * 包含账户信息、安全设置、语言设置\r\n */\r\n\r\nimport { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { User, Mail, Calendar, Shield, Lock, Eye, EyeOff, Globe } from 'lucide-react'\r\nimport { useAuthStore } from '@/stores/authStore'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { apiClient } from '@/lib/api-client'\r\nimport { useLanguage } from '@/hooks/useLanguage'\r\nimport { SettingsSection, SettingsItem, SettingsDivider } from '../SettingsSection'\r\n\r\nexport function BasicSettingsTab() {\r\n  const { t, i18n } = useTranslation('settings')\r\n  const { user } = useAuthStore()\r\n  const { addToast } = useToastStore()\r\n  const { currentLanguage, changeLanguage, supportedLanguages } = useLanguage()\r\n\r\n  const [showPasswordForm, setShowPasswordForm] = useState(false)\r\n  const [currentPassword, setCurrentPassword] = useState('')\r\n  const [newPassword, setNewPassword] = useState('')\r\n  const [confirmPassword, setConfirmPassword] = useState('')\r\n  const [showCurrentPassword, setShowCurrentPassword] = useState(false)\r\n  const [showNewPassword, setShowNewPassword] = useState(false)\r\n  const [showConfirmPassword, setShowConfirmPassword] = useState(false)\r\n  const [isChangingPassword, setIsChangingPassword] = useState(false)\r\n\r\n  const handleChangePassword = async (e: React.FormEvent) => {\r\n    e.preventDefault()\r\n\r\n    if (!currentPassword || !newPassword || !confirmPassword) {\r\n      addToast('error', t('basic.password.fillAllFields'))\r\n      return\r\n    }\r\n\r\n    if (newPassword !== confirmPassword) {\r\n      addToast('error', t('basic.password.mismatch'))\r\n      return\r\n    }\r\n\r\n    if (newPassword.length < 6) {\r\n      addToast('error', t('basic.password.tooShort'))\r\n      return\r\n    }\r\n\r\n    setIsChangingPassword(true)\r\n    try {\r\n      await apiClient.post('/v1/change-password', {\r\n        current_password: currentPassword,\r\n        new_password: newPassword,\r\n      })\r\n\r\n      addToast('success', t('basic.password.changeSuccess'))\r\n      setShowPasswordForm(false)\r\n      setCurrentPassword('')\r\n      setNewPassword('')\r\n      setConfirmPassword('')\r\n    } catch (error) {\r\n      const message = error instanceof Error ? error.message : t('basic.password.changeFailed')\r\n      addToast('error', message)\r\n    } finally {\r\n      setIsChangingPassword(false)\r\n    }\r\n  }\r\n\r\n  const formatDate = (dateString?: string) => {\r\n    if (!dateString) return t('basic.unknown')\r\n    try {\r\n      return new Date(dateString).toLocaleDateString(i18n.language === 'zh-CN' ? 'zh-CN' : 'en-US', {\r\n        year: 'numeric',\r\n        month: 'long',\r\n        day: 'numeric',\r\n      })\r\n    } catch {\r\n      return dateString\r\n    }\r\n  }\r\n\r\n  return (\r\n    <div className=\"space-y-6\">\r\n      {/* 账户信息 */}\r\n      <SettingsSection icon={User} title={t('basic.accountInfo.title')} description={t('basic.accountInfo.description')}>\r\n        <div className=\"grid gap-3 sm:grid-cols-2\">\r\n          <SettingsItem icon={User} title={t('basic.username')} description={user?.username || t('basic.notSet')} />\r\n          {user?.email && <SettingsItem icon={Mail} title={t('basic.email')} description={user.email} />}\r\n          <SettingsItem icon={Calendar} title={t('basic.registeredAt')} description={formatDate(user?.created_at)} />\r\n          <SettingsItem\r\n            icon={Shield}\r\n            title={t('basic.role')}\r\n            description={user?.role === 'admin' ? t('basic.roleAdmin') : t('basic.roleUser')}\r\n          />\r\n        </div>\r\n      </SettingsSection>\r\n\r\n      <SettingsDivider />\r\n\r\n      {/* 安全设置 */}\r\n      <SettingsSection icon={Lock} title={t('basic.security.title')} description={t('basic.security.description')}>\r\n        {!showPasswordForm ? (\r\n          <div className=\"p-4 rounded-lg bg-card border border-border\">\r\n            <div className=\"flex items-center justify-between\">\r\n              <div>\r\n                <div className=\"text-sm font-medium\">{t('basic.password.change')}</div>\r\n                <div className=\"text-xs text-muted-foreground\">{t('basic.security.description')}</div>\r\n              </div>\r\n              <button onClick={() => setShowPasswordForm(true)} className=\"btn btn-primary btn-sm flex items-center gap-2\">\r\n                <Lock className=\"w-4 h-4\" />\r\n                {t('basic.password.change')}\r\n              </button>\r\n            </div>\r\n          </div>\r\n        ) : (\r\n          <form onSubmit={handleChangePassword} className=\"space-y-4 p-4 rounded-lg bg-card border border-border\">\r\n            <div className=\"grid sm:grid-cols-2 gap-4\">\r\n              <PasswordInput\r\n                label={t('basic.password.current')}\r\n                placeholder={t('basic.password.currentPlaceholder')}\r\n                value={currentPassword}\r\n                onChange={setCurrentPassword}\r\n                show={showCurrentPassword}\r\n                onToggleShow={() => setShowCurrentPassword(!showCurrentPassword)}\r\n              />\r\n              <PasswordInput\r\n                label={t('basic.password.new')}\r\n                placeholder={t('basic.password.newPlaceholder')}\r\n                value={newPassword}\r\n                onChange={setNewPassword}\r\n                show={showNewPassword}\r\n                onToggleShow={() => setShowNewPassword(!showNewPassword)}\r\n                minLength={6}\r\n              />\r\n            </div>\r\n            <PasswordInput\r\n              label={t('basic.password.confirm')}\r\n              placeholder={t('basic.password.confirmPlaceholder')}\r\n              value={confirmPassword}\r\n              onChange={setConfirmPassword}\r\n              show={showConfirmPassword}\r\n              onToggleShow={() => setShowConfirmPassword(!showConfirmPassword)}\r\n            />\r\n            <div className=\"flex gap-2 pt-2\">\r\n              <button\r\n                type=\"button\"\r\n                onClick={() => {\r\n                  setShowPasswordForm(false)\r\n                  setCurrentPassword('')\r\n                  setNewPassword('')\r\n                  setConfirmPassword('')\r\n                }}\r\n                className=\"btn btn-ghost flex-1\"\r\n                disabled={isChangingPassword}\r\n              >\r\n                {t('basic.password.cancel')}\r\n              </button>\r\n              <button type=\"submit\" className=\"btn btn-primary flex-1\" disabled={isChangingPassword}>\r\n                {isChangingPassword ? t('basic.password.changing') : t('basic.password.submit')}\r\n              </button>\r\n            </div>\r\n          </form>\r\n        )}\r\n      </SettingsSection>\r\n\r\n      <SettingsDivider />\r\n\r\n      {/* 语言设置 */}\r\n      <SettingsSection icon={Globe} title={t('language.title')} description={t('language.description')}>\r\n        <div className=\"p-4 rounded-lg bg-card border border-border\">\r\n          <div className=\"flex items-center justify-between\">\r\n            <div>\r\n              <div className=\"text-sm font-medium\">{t('language.label')}</div>\r\n              <div className=\"text-xs text-muted-foreground\">{t('language.hint')}</div>\r\n            </div>\r\n            <select\r\n              value={currentLanguage}\r\n              onChange={(e) => changeLanguage(e.target.value as typeof currentLanguage)}\r\n              className=\"input w-40\"\r\n            >\r\n              {supportedLanguages.map((lang) => (\r\n                <option key={lang.code} value={lang.code}>\r\n                  {lang.nativeName}\r\n                </option>\r\n              ))}\r\n            </select>\r\n          </div>\r\n        </div>\r\n      </SettingsSection>\r\n    </div>\r\n  )\r\n}\r\n\r\n// 密码输入组件\r\nfunction PasswordInput({\r\n  label,\r\n  placeholder,\r\n  value,\r\n  onChange,\r\n  show,\r\n  onToggleShow,\r\n  minLength,\r\n}: {\r\n  label: string\r\n  placeholder: string\r\n  value: string\r\n  onChange: (value: string) => void\r\n  show: boolean\r\n  onToggleShow: () => void\r\n  minLength?: number\r\n}) {\r\n  return (\r\n    <div className=\"space-y-2\">\r\n      <label className=\"text-sm font-medium text-foreground\">{label}</label>\r\n      <div className=\"relative\">\r\n        <input\r\n          type={show ? 'text' : 'password'}\r\n          value={value}\r\n          onChange={(e) => onChange(e.target.value)}\r\n          className=\"input w-full pr-10\"\r\n          placeholder={placeholder}\r\n          required\r\n          minLength={minLength}\r\n        />\r\n        <button\r\n          type=\"button\"\r\n          onClick={onToggleShow}\r\n          className=\"absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground\"\r\n        >\r\n          {show ? <EyeOff className=\"w-4 h-4\" /> : <Eye className=\"w-4 h-4\" />}\r\n        </button>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/settings/tabs/BrowserSettingsTab.tsx",
    "content": "/**\r\n * 浏览器扩展设置标签页\r\n * 简化版本：下载、安装指南、权限说明\r\n */\r\n\r\nimport { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Shield, Download, Info, ChevronDown, ChevronUp, HelpCircle } from 'lucide-react'\r\nimport { InfoBox } from '../InfoBox'\r\nimport { SettingsSection, SettingsDivider } from '../SettingsSection'\r\nimport { siGooglechrome, siBrave, siOpera, siQq } from 'simple-icons'\r\n\r\ntype BrowserType = 'chrome' | 'edge' | 'opera' | 'brave' | '360' | 'qq' | 'sogou'\r\n\r\nexport function BrowserSettingsTab() {\r\n  const { t } = useTranslation('settings')\r\n  const [showInstallGuide, setShowInstallGuide] = useState(false)\r\n  const [showFaq, setShowFaq] = useState(false)\r\n\r\n  const handleDownload = (_browser: BrowserType) => {\r\n    void _browser\r\n    const link = document.createElement('a')\r\n    link.href = `/extensions/tmarks-extension-chrome.zip`\r\n    link.download = 'tmarks-extension-chrome.zip'\r\n    document.body.appendChild(link)\r\n    link.click()\r\n    document.body.removeChild(link)\r\n  }\r\n\r\n  const BrowserIcon = ({ browser, className }: { browser: string; className?: string }) => {\r\n    const baseClass = className || 'w-8 h-8'\r\n\r\n    const getIconData = () => {\r\n      switch (browser) {\r\n        case 'chrome': return siGooglechrome\r\n        case 'brave': return siBrave\r\n        case 'opera': return siOpera\r\n        case 'qq': return siQq\r\n        default: return null\r\n      }\r\n    }\r\n\r\n    const iconData = getIconData()\r\n\r\n    if (!iconData) {\r\n      if (browser === 'edge') {\r\n        return (\r\n          <svg className={`${baseClass} text-[#0078D4]`} viewBox=\"0 0 24 24\" fill=\"currentColor\">\r\n            <path d=\"M20.5 12c0-4.7-3.8-8.5-8.5-8.5S3.5 7.3 3.5 12c0 4.1 2.9 7.5 6.8 8.3.5.1 1 .2 1.5.2 4.7 0 8.5-3.8 8.5-8.5h.2zm-8.5 7c-3.9 0-7-3.1-7-7s3.1-7 7-7 7 3.1 7 7-3.1 7-7 7z\"/>\r\n          </svg>\r\n        )\r\n      }\r\n      return (\r\n        <div className={`${baseClass} rounded-full bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground`}>\r\n          {browser.charAt(0).toUpperCase()}\r\n        </div>\r\n      )\r\n    }\r\n\r\n    return (\r\n      <svg className={baseClass} viewBox=\"0 0 24 24\" fill=\"currentColor\" style={{ color: `#${iconData.hex}` }}>\r\n        <path d={iconData.path} />\r\n      </svg>\r\n    )\r\n  }\r\n\r\n  const browsers = [\r\n    { id: 'chrome', name: 'Chrome' },\r\n    { id: 'edge', name: 'Edge' },\r\n    { id: 'brave', name: 'Brave' },\r\n    { id: 'opera', name: 'Opera' },\r\n    { id: '360', name: '360' },\r\n    { id: 'qq', name: 'QQ' },\r\n    { id: 'sogou', name: 'Sogou' },\r\n  ]\r\n\r\n  return (\r\n    <div className=\"space-y-6\">\r\n      {/* 下载扩展 */}\r\n      <SettingsSection icon={Download} title={t('browser.download.title')} description={t('browser.download.description')}>\r\n        <div className=\"grid grid-cols-4 gap-2\">\r\n          {browsers.map((browser) => (\r\n            <button\r\n              key={browser.id}\r\n              onClick={() => handleDownload(browser.id as BrowserType)}\r\n              className=\"p-3 rounded-lg border-2 border-border hover:border-primary/50 transition-all text-center group\"\r\n            >\r\n              <div className=\"mx-auto mb-1 flex justify-center\">\r\n                <BrowserIcon browser={browser.id} className=\"w-6 h-6 sm:w-8 sm:h-8\" />\r\n              </div>\r\n              <div className=\"text-xs font-medium truncate\">{browser.name}</div>\r\n            </button>\r\n          ))}\r\n        </div>\r\n      </SettingsSection>\r\n\r\n      <SettingsDivider />\r\n\r\n      {/* 权限说明 */}\r\n      <SettingsSection icon={Shield} title={t('browser.permissions.title')} description={t('browser.permissions.description')}>\r\n        <div className=\"grid sm:grid-cols-3 gap-3\">\r\n          {['bookmarks', 'tabs', 'storage'].map((perm) => (\r\n            <div key={perm} className=\"p-3 rounded-lg bg-muted/30 border border-border\">\r\n              <div className=\"flex items-center gap-2 mb-1\">\r\n                <Shield className=\"w-4 h-4 text-success\" />\r\n                <span className=\"text-sm font-medium\">{t(`browser.permissions.${perm}.title`)}</span>\r\n              </div>\r\n              <p className=\"text-xs text-muted-foreground\">{t(`browser.permissions.${perm}.description`)}</p>\r\n            </div>\r\n          ))}\r\n        </div>\r\n      </SettingsSection>\r\n\r\n      <SettingsDivider />\r\n\r\n      {/* 安装指南 - 可折叠 */}\r\n      <button\r\n        onClick={() => setShowInstallGuide(!showInstallGuide)}\r\n        className=\"w-full flex items-center justify-between p-4 rounded-lg bg-card border border-border hover:border-primary/50 transition-colors\"\r\n      >\r\n        <div className=\"flex items-center gap-3\">\r\n          <Info className=\"w-5 h-5 text-primary\" />\r\n          <span className=\"text-sm font-medium\">{t('browser.install.title')}</span>\r\n        </div>\r\n        {showInstallGuide ? <ChevronUp className=\"w-5 h-5\" /> : <ChevronDown className=\"w-5 h-5\" />}\r\n      </button>\r\n\r\n      {showInstallGuide && (\r\n        <div className=\"space-y-2 pl-4\">\r\n          {[1, 2, 3, 4, 5, 6].map((step) => (\r\n            <div key={step} className=\"flex gap-3 p-3 rounded-lg bg-muted/30\">\r\n              <div className=\"flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center\">\r\n                <span className=\"text-xs font-bold text-primary\">{step}</span>\r\n              </div>\r\n              <div>\r\n                <div className=\"text-sm font-medium\">{t(`browser.install.step${step}Title`)}</div>\r\n                <div className=\"text-xs text-muted-foreground\">{t(`browser.install.step${step}Desc`)}</div>\r\n              </div>\r\n            </div>\r\n          ))}\r\n        </div>\r\n      )}\r\n\r\n      {/* FAQ - 可折叠 */}\r\n      <button\r\n        onClick={() => setShowFaq(!showFaq)}\r\n        className=\"w-full flex items-center justify-between p-4 rounded-lg bg-card border border-border hover:border-primary/50 transition-colors\"\r\n      >\r\n        <div className=\"flex items-center gap-3\">\r\n          <HelpCircle className=\"w-5 h-5 text-primary\" />\r\n          <span className=\"text-sm font-medium\">{t('browser.faq.title')}</span>\r\n        </div>\r\n        {showFaq ? <ChevronUp className=\"w-5 h-5\" /> : <ChevronDown className=\"w-5 h-5\" />}\r\n      </button>\r\n\r\n      {showFaq && (\r\n        <div className=\"space-y-2 pl-4\">\r\n          {['iconNotFound', 'howToGetApiKey', 'supportedBrowsers'].map((faq) => (\r\n            <div key={faq} className=\"p-3 rounded-lg bg-muted/30\">\r\n              <div className=\"text-sm font-medium mb-1\">{t(`browser.faq.${faq}`)}</div>\r\n              <div className=\"text-xs text-muted-foreground\">{t(`browser.faq.${faq}Answer`)}</div>\r\n            </div>\r\n          ))}\r\n        </div>\r\n      )}\r\n\r\n      <SettingsDivider />\r\n\r\n      <InfoBox icon={Info} title={t('browser.infoBox.title')} variant=\"info\">\r\n        <ul className=\"space-y-1 text-xs\">\r\n          <li>• {t('browser.infoBox.tip1')}</li>\r\n          <li>• {t('browser.infoBox.tip2')}</li>\r\n        </ul>\r\n      </InfoBox>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/settings/tabs/DataSettingsTab.tsx",
    "content": "/**\r\n * 数据设置标签页\r\n * 简化版本：数据导出、存储管理、快照清理\r\n */\r\n\r\nimport { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Database, Download, Camera, Trash2 } from 'lucide-react'\r\nimport { useQueryClient } from '@tanstack/react-query'\r\nimport { ExportSection } from '@/components/import-export/ExportSection'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { BOOKMARKS_QUERY_KEY } from '@/hooks/useBookmarks'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { useAuthStore } from '@/stores/authStore'\r\nimport { useR2StorageQuota } from '@/hooks/useStorage'\r\nimport { SettingsSection, SettingsDivider } from '../SettingsSection'\r\nimport type { ExportFormat, ExportOptions } from '@shared/import-export-types'\r\n\r\n/** Run async tasks with limited concurrency */\r\nasync function runWithConcurrency<T>(\r\n  tasks: (() => Promise<T>)[],\r\n  concurrency: number,\r\n): Promise<T[]> {\r\n  const results: T[] = []\r\n  let index = 0\r\n\r\n  async function next(): Promise<void> {\r\n    while (index < tasks.length) {\r\n      const i = index++\r\n      results[i] = await tasks[i]!()\r\n    }\r\n  }\r\n\r\n  await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, () => next()))\r\n  return results\r\n}\r\n\r\nexport function DataSettingsTab() {\r\n  const { t } = useTranslation('settings')\r\n  const queryClient = useQueryClient()\r\n  const { addToast } = useToastStore()\r\n  const { accessToken } = useAuthStore()\r\n  const { data: r2Quota, isLoading: isLoadingR2Quota } = useR2StorageQuota()\r\n  const [isCleaningSnapshots, setIsCleaningSnapshots] = useState(false)\r\n  const [showCleanupConfirm, setShowCleanupConfirm] = useState(false)\r\n\r\n  const handleExportComplete = (format: ExportFormat, options: ExportOptions) => {\r\n    const details = `${format.toUpperCase()}${options.include_tags ? ' + tags' : ''}${options.include_metadata ? ' + metadata' : ''}`\r\n    addToast('success', t('data.exportSuccess', { details }))\r\n  }\r\n\r\n  const handleCleanupAllSnapshots = async () => {\r\n    setShowCleanupConfirm(true)\r\n  }\r\n\r\n  const confirmCleanupAllSnapshots = async () => {\r\n    setShowCleanupConfirm(false)\r\n    setIsCleaningSnapshots(true)\r\n    try {\r\n      const response = await fetch('/api/v1/bookmarks?page=1&page_size=1000', {\r\n        headers: { 'Authorization': `Bearer ${accessToken}` },\r\n      })\r\n\r\n      if (!response.ok) throw new Error(t('data.fetchBookmarksFailed'))\r\n\r\n      const data = await response.json()\r\n      const bookmarks = data.data?.bookmarks || []\r\n      let totalCleaned = 0\r\n\r\n      const tasks: (() => Promise<number>)[] = bookmarks\r\n        .filter((b: { snapshot_count: number }) => b.snapshot_count > 0)\r\n        .map((bookmark: { id: string }) => async (): Promise<number> => {\r\n          try {\r\n            const cleanupResponse = await fetch(`/api/v1/bookmarks/${bookmark.id}/snapshots/cleanup`, {\r\n              method: 'POST',\r\n              headers: {\r\n                'Authorization': `Bearer ${accessToken}`,\r\n                'Content-Type': 'application/json',\r\n              },\r\n              body: JSON.stringify({ verify_and_fix: true }),\r\n            })\r\n            if (cleanupResponse.ok) {\r\n              const result = await cleanupResponse.json()\r\n              return result.data?.deleted_count || 0\r\n            }\r\n          } catch (error) {\r\n            console.error(`Clean snapshot failed for bookmark ${bookmark.id}:`, error)\r\n          }\r\n          return 0\r\n        })\r\n\r\n      const counts = await runWithConcurrency(tasks, 3)\r\n      totalCleaned = counts.reduce((sum, c) => sum + c, 0)\r\n\r\n      if (totalCleaned > 0) {\r\n        addToast('success', t('data.cleanSnapshotsSuccess', { count: totalCleaned }))\r\n        queryClient.invalidateQueries({ queryKey: [BOOKMARKS_QUERY_KEY] })\r\n      } else {\r\n        addToast('info', t('data.cleanSnapshotsNone'))\r\n      }\r\n    } catch (error) {\r\n      console.error('Clean snapshots failed:', error)\r\n      addToast('error', t('data.cleanSnapshotsFailed'))\r\n    } finally {\r\n      setIsCleaningSnapshots(false)\r\n    }\r\n  }\r\n\r\n  return (\r\n    <div className=\"space-y-6\">\r\n      <ConfirmDialog\r\n        isOpen={showCleanupConfirm}\r\n        title={t('data.cleanSnapshotsConfirmTitle')}\r\n        message={t('data.cleanSnapshotsConfirmMessage')}\r\n        type=\"warning\"\r\n        onConfirm={confirmCleanupAllSnapshots}\r\n        onCancel={() => setShowCleanupConfirm(false)}\r\n      />\r\n\r\n      {/* 存储用量 */}\r\n      <SettingsSection icon={Database} title={t('data.r2Storage.title')} description={t('data.r2Storage.description')}>\r\n        <div className=\"p-4 rounded-lg bg-card border border-border\">\r\n          <div className=\"flex items-center justify-between\">\r\n            <span className=\"text-sm text-muted-foreground\">{t('data.r2Storage.currentUsage')}</span>\r\n            <span className=\"text-sm font-medium\">\r\n              {isLoadingR2Quota || !r2Quota ? (\r\n                t('data.loading')\r\n              ) : (\r\n                <>\r\n                  {(r2Quota.used_bytes / (1024 * 1024 * 1024)).toFixed(2)} GB\r\n                  {' / '}\r\n                  {r2Quota.unlimited || r2Quota.limit_bytes === null\r\n                    ? t('data.r2Storage.unlimited')\r\n                    : `${(r2Quota.limit_bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`}\r\n                </>\r\n              )}\r\n            </span>\r\n          </div>\r\n          {r2Quota && !r2Quota.unlimited && r2Quota.limit_bytes && (\r\n            <div className=\"mt-2 h-2 bg-muted rounded-full overflow-hidden\">\r\n              <div\r\n                className=\"h-full bg-primary transition-all\"\r\n                style={{ width: `${Math.min((r2Quota.used_bytes / r2Quota.limit_bytes) * 100, 100)}%` }}\r\n              />\r\n            </div>\r\n          )}\r\n        </div>\r\n      </SettingsSection>\r\n\r\n      <SettingsDivider />\r\n\r\n      {/* 导出数据 */}\r\n      <SettingsSection icon={Download} title={t('data.export.title')} description={t('data.export.description')}>\r\n        <div className=\"space-y-4\">\r\n          <div className=\"p-4 rounded-lg border border-border bg-card\">\r\n            <ExportSection onExport={handleExportComplete} />\r\n          </div>\r\n        </div>\r\n      </SettingsSection>\r\n\r\n      <SettingsDivider />\r\n\r\n      {/* 快照清理 */}\r\n      <SettingsSection icon={Camera} title={t('data.snapshotManagement.title')} description={t('data.snapshotManagement.description')}>\r\n        <div className=\"p-4 rounded-lg border border-border bg-card\">\r\n          <div className=\"flex items-center justify-between\">\r\n            <div className=\"flex items-center gap-3\">\r\n              <Trash2 className=\"w-5 h-5 text-warning\" />\r\n              <div>\r\n                <div className=\"text-sm font-medium\">{t('data.snapshotManagement.cleanOrphan')}</div>\r\n                <div className=\"text-xs text-muted-foreground\">{t('data.cleanSnapshots.tip1')}</div>\r\n              </div>\r\n            </div>\r\n            <button\r\n              onClick={handleCleanupAllSnapshots}\r\n              disabled={isCleaningSnapshots}\r\n              className=\"btn btn-warning btn-sm\"\r\n            >\r\n              {isCleaningSnapshots ? t('data.snapshotManagement.cleaning') : t('data.snapshotManagement.cleanButton')}\r\n            </button>\r\n          </div>\r\n        </div>\r\n      </SettingsSection>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/settings/tabs/ShareSettingsTab.tsx",
    "content": "/**\r\n * 分享设置标签页\r\n * 公开分享书签的配置\r\n */\r\n\r\nimport { useState, useEffect, useMemo } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Share2, Copy, RefreshCw, Info } from 'lucide-react'\r\nimport { useShareSettings, useUpdateShareSettings } from '@/hooks/useShare'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { Toggle } from '@/components/common/Toggle'\r\nimport { SettingsSection, SettingsDivider } from '../SettingsSection'\r\nimport { InfoBox } from '../InfoBox'\r\n\r\nexport function ShareSettingsTab() {\r\n  const { t } = useTranslation('settings')\r\n  const { data, isLoading } = useShareSettings()\r\n  const updateShare = useUpdateShareSettings()\r\n  const { addToast } = useToastStore()\r\n\r\n  const [enabled, setEnabled] = useState(false)\r\n  const [slug, setSlug] = useState('')\r\n  const [title, setTitle] = useState('')\r\n  const [description, setDescription] = useState('')\r\n  const [, setCopied] = useState(false)\r\n\r\n  useEffect(() => {\r\n    if (data) {\r\n      setEnabled(data.enabled || false)\r\n      setSlug(data.slug || '')\r\n      setTitle(data.title || '')\r\n      setDescription(data.description || '')\r\n    }\r\n  }, [data])\r\n\r\n  const shareUrl = useMemo(() => {\r\n    if (!slug) return ''\r\n    return `${window.location.origin}/share/${slug}`\r\n  }, [slug])\r\n\r\n  const handleSave = async () => {\r\n    try {\r\n      await updateShare.mutateAsync({\r\n        enabled,\r\n        slug: slug.trim() || null,\r\n        title: title.trim() || null,\r\n        description: description.trim() || null,\r\n      })\r\n      addToast('success', t('share.saveSuccess'))\r\n    } catch {\r\n      addToast('error', t('share.saveFailed'))\r\n    }\r\n  }\r\n\r\n  const handleRegenerate = async () => {\r\n    try {\r\n      await updateShare.mutateAsync({\r\n        regenerate_slug: true,\r\n        enabled: true,\r\n        title: title.trim() || null,\r\n        description: description.trim() || null,\r\n      })\r\n      addToast('success', t('share.regenerateSuccess'))\r\n    } catch {\r\n      addToast('error', t('share.regenerateFailed'))\r\n    }\r\n  }\r\n\r\n  const handleCopyLink = async () => {\r\n    if (!shareUrl) return\r\n    try {\r\n      await navigator.clipboard.writeText(shareUrl)\r\n      setCopied(true)\r\n      setTimeout(() => setCopied(false), 1500)\r\n      addToast('success', t('share.copySuccess'))\r\n    } catch {\r\n      addToast('error', t('share.copyFailed'))\r\n    }\r\n  }\r\n\r\n  if (isLoading) {\r\n    return (\r\n      <div className=\"flex items-center justify-center h-64\">\r\n        <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-primary\"></div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <div className=\"space-y-6\">\r\n      {/* 启用分享 */}\r\n      <SettingsSection icon={Share2} title={t('share.publicShare.title')} description={t('share.publicShare.description')}>\r\n        <div className=\"p-4 rounded-lg bg-card border border-border\">\r\n          <div className=\"flex items-center justify-between\">\r\n            <div>\r\n              <div className=\"text-sm font-medium\">{t('share.publicShare.enable')}</div>\r\n              <div className=\"text-xs text-muted-foreground\">{t('share.publicShare.enableHint')}</div>\r\n            </div>\r\n            <Toggle checked={enabled} onChange={setEnabled} />\r\n          </div>\r\n        </div>\r\n      </SettingsSection>\r\n\r\n      {enabled && (\r\n        <>\r\n          <SettingsDivider />\r\n\r\n          {/* 分享配置 */}\r\n          <SettingsSection icon={Copy} title={t('share.shareLink.label')} description={t('share.slug.hint')}>\r\n            <div className=\"space-y-4\">\r\n              <div className=\"grid sm:grid-cols-2 gap-4\">\r\n                {/* Slug */}\r\n                <div className=\"space-y-2\">\r\n                  <label className=\"text-sm font-medium\">{t('share.slug.label')}</label>\r\n                  <div className=\"flex gap-2\">\r\n                    <input\r\n                      type=\"text\"\r\n                      value={slug}\r\n                      onChange={(e) => setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}\r\n                      placeholder={t('share.slug.placeholder')}\r\n                      className=\"input flex-1\"\r\n                      disabled={updateShare.isPending}\r\n                    />\r\n                    <button\r\n                      onClick={handleRegenerate}\r\n                      disabled={updateShare.isPending}\r\n                      className=\"btn btn-ghost px-3\"\r\n                      title={t('share.slug.regenerate')}\r\n                    >\r\n                      <RefreshCw className=\"w-4 h-4\" />\r\n                    </button>\r\n                  </div>\r\n                </div>\r\n\r\n                {/* 标题 */}\r\n                <div className=\"space-y-2\">\r\n                  <label className=\"text-sm font-medium\">{t('share.pageTitle.label')}</label>\r\n                  <input\r\n                    type=\"text\"\r\n                    value={title}\r\n                    onChange={(e) => setTitle(e.target.value)}\r\n                    placeholder={t('share.pageTitle.placeholder')}\r\n                    className=\"input\"\r\n                    disabled={updateShare.isPending}\r\n                  />\r\n                </div>\r\n              </div>\r\n\r\n              {/* 描述 */}\r\n              <div className=\"space-y-2\">\r\n                <label className=\"text-sm font-medium\">{t('share.pageDescription.label')}</label>\r\n                <textarea\r\n                  value={description}\r\n                  onChange={(e) => setDescription(e.target.value)}\r\n                  placeholder={t('share.pageDescription.placeholder')}\r\n                  className=\"input min-h-[60px]\"\r\n                  disabled={updateShare.isPending}\r\n                />\r\n              </div>\r\n\r\n              {/* 分享链接和保存 */}\r\n              <div className=\"flex gap-2\">\r\n                <input\r\n                  type=\"text\"\r\n                  readOnly\r\n                  value={shareUrl || t('share.shareLink.placeholder')}\r\n                  className=\"input flex-1 bg-muted/30\"\r\n                />\r\n                <button\r\n                  onClick={handleCopyLink}\r\n                  disabled={!shareUrl}\r\n                  className=\"btn btn-ghost\"\r\n                >\r\n                  <Copy className=\"w-4 h-4\" />\r\n                </button>\r\n                <button\r\n                  onClick={handleSave}\r\n                  disabled={updateShare.isPending}\r\n                  className=\"btn btn-primary\"\r\n                >\r\n                  {updateShare.isPending ? t('action.saving') : t('action.save')}\r\n                </button>\r\n              </div>\r\n            </div>\r\n          </SettingsSection>\r\n        </>\r\n      )}\r\n\r\n      <SettingsDivider />\r\n\r\n      <InfoBox icon={Info} title={t('share.infoBox.title')} variant=\"success\">\r\n        <ul className=\"space-y-1 text-xs\">\r\n          <li>• {t('share.infoBox.tip1')}</li>\r\n          <li>• {t('share.infoBox.tip2')}</li>\r\n        </ul>\r\n      </InfoBox>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/settings/tabs/SnapshotSettingsTab.tsx",
    "content": "/**\r\n * 快照设置标签页\r\n * 仅保留快照保留数量设置（唯一被后端消费的快照设置项）\r\n */\r\n\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Camera, Info } from 'lucide-react'\r\nimport { SettingsSection } from '../SettingsSection'\r\nimport { InfoBox } from '../InfoBox'\r\n\r\ninterface SnapshotSettingsTabProps {\r\n  retentionCount: number\r\n  onRetentionCountChange: (count: number) => void\r\n}\r\n\r\nexport function SnapshotSettingsTab({\r\n  retentionCount,\r\n  onRetentionCountChange,\r\n}: SnapshotSettingsTabProps) {\r\n  const { t } = useTranslation('settings')\r\n\r\n  return (\r\n    <div className=\"space-y-6\">\r\n      <SettingsSection icon={Camera} title={t('snapshot.retention.title')} description={t('snapshot.retention.description')}>\r\n        <div className=\"p-4 rounded-lg bg-card border border-border\">\r\n          <div className=\"flex items-center justify-between mb-2\">\r\n            <span className=\"text-sm font-medium\">{t('snapshot.retention.count')}</span>\r\n            <div className=\"flex items-center gap-2\">\r\n              <input\r\n                type=\"number\"\r\n                value={retentionCount}\r\n                onChange={(e) => onRetentionCountChange(parseInt(e.target.value) || 0)}\r\n                min=\"-1\"\r\n                max=\"100\"\r\n                className=\"input w-16 text-center text-sm\"\r\n              />\r\n              <span className=\"text-xs text-muted-foreground\">{t('snapshot.retention.unit')}</span>\r\n            </div>\r\n          </div>\r\n          <p className=\"text-xs text-muted-foreground\">{t('snapshot.retention.tip')}</p>\r\n        </div>\r\n      </SettingsSection>\r\n\r\n      <InfoBox icon={Info} title={t('snapshot.infoBox.title')} variant=\"info\">\r\n        <ul className=\"space-y-1 text-xs\">\r\n          <li>• {t('snapshot.infoBox.tip1')}</li>\r\n          <li>• {t('snapshot.infoBox.tip2')}</li>\r\n        </ul>\r\n      </InfoBox>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/BatchActionBar.tsx",
    "content": "import { Trash2, Pin, CheckSquare, Download, X } from 'lucide-react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\n\r\ninterface BatchActionBarProps {\r\n  selectedCount: number\r\n  onSelectAll: () => void\r\n  onDeselectAll: () => void\r\n  onBatchDelete: () => void\r\n  onBatchPin: () => void\r\n  onBatchTodo: () => void\r\n  onBatchExport: () => void\r\n  onCancel: () => void\r\n}\r\n\r\nexport function BatchActionBar({\r\n  selectedCount,\r\n  onSelectAll,\r\n  onDeselectAll,\r\n  onBatchDelete,\r\n  onBatchPin,\r\n  onBatchTodo,\r\n  onBatchExport,\r\n  onCancel,\r\n}: BatchActionBarProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const isMobile = useIsMobile()\r\n\r\n  return (\r\n    <div className={`bg-primary/10 border border-primary/20 rounded mb-6 ${\r\n      isMobile ? 'fixed bottom-16 left-0 right-0 z-20 rounded-none border-x-0 p-3' : 'p-4'\r\n    }`}>\r\n      <div className={`flex items-center ${isMobile ? 'flex-col gap-2' : 'justify-between'}`}>\r\n        <div className={`flex items-center ${isMobile ? 'w-full justify-between' : 'gap-4'}`}>\r\n          <span className=\"text-sm font-medium text-foreground\">\r\n            {t('batch.selected', { count: selectedCount })}\r\n          </span>\r\n          <div className=\"flex items-center gap-2\">\r\n            <button\r\n              onClick={onSelectAll}\r\n              className=\"text-sm text-primary hover:text-primary/80 transition-colors\"\r\n            >\r\n              {t('batch.selectAll')}\r\n            </button>\r\n            <span className=\"text-border\">|</span>\r\n            <button\r\n              onClick={onDeselectAll}\r\n              className=\"text-sm text-primary hover:text-primary/80 transition-colors\"\r\n            >\r\n              {t('batch.deselectAll')}\r\n            </button>\r\n          </div>\r\n        </div>\r\n\r\n        <div className={`flex items-center gap-2 ${isMobile ? 'w-full justify-between' : ''}`}>\r\n          <button\r\n            onClick={onBatchPin}\r\n            className={`flex items-center gap-2 px-3 py-1.5 border rounded hover:bg-muted transition-colors text-sm ${isMobile ? 'flex-1' : ''}`}\r\n            style={{ backgroundColor: 'var(--card)', borderColor: 'var(--border)' }}\r\n            disabled={selectedCount === 0}\r\n          >\r\n            <Pin className=\"w-4 h-4\" />\r\n            {!isMobile && t('batch.pin')}\r\n          </button>\r\n          <button\r\n            onClick={onBatchTodo}\r\n            className={`flex items-center gap-2 px-3 py-1.5 border rounded hover:bg-muted transition-colors text-sm ${isMobile ? 'flex-1' : ''}`}\r\n            style={{ backgroundColor: 'var(--card)', borderColor: 'var(--border)' }}\r\n            disabled={selectedCount === 0}\r\n          >\r\n            <CheckSquare className=\"w-4 h-4\" />\r\n            {!isMobile && t('batch.todo')}\r\n          </button>\r\n          <button\r\n            onClick={onBatchExport}\r\n            className={`flex items-center gap-2 px-3 py-1.5 border rounded hover:bg-muted transition-colors text-sm ${isMobile ? 'flex-1' : ''}`}\r\n            style={{ backgroundColor: 'var(--card)', borderColor: 'var(--border)' }}\r\n            disabled={selectedCount === 0}\r\n          >\r\n            <Download className=\"w-4 h-4\" />\r\n            {!isMobile && t('batch.export')}\r\n          </button>\r\n          <button\r\n            onClick={onBatchDelete}\r\n            className={`flex items-center gap-2 px-3 py-1.5 bg-destructive text-destructive-foreground rounded hover:bg-destructive/90 transition-colors text-sm ${isMobile ? 'flex-1' : ''}`}\r\n            disabled={selectedCount === 0}\r\n          >\r\n            <Trash2 className=\"w-4 h-4\" />\r\n            {!isMobile && t('batch.delete')}\r\n          </button>\r\n          <button\r\n            onClick={onCancel}\r\n            className={`flex items-center gap-2 px-3 py-1.5 bg-muted text-foreground rounded hover:bg-muted/80 transition-colors text-sm ${isMobile ? 'flex-1' : ''}`}\r\n          >\r\n            <X className=\"w-4 h-4\" />\r\n            {!isMobile && t('batch.cancel')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/ColorPicker.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { Check } from 'lucide-react'\r\nimport { useEffect, useRef } from 'react'\r\nimport { COLORS } from './colorUtils'\r\n\r\ninterface ColorPickerProps {\r\n  currentColor: string | null\r\n  onColorChange: (color: string | null) => void\r\n  onClose: () => void\r\n}\r\n\r\nexport function ColorPicker({ currentColor, onColorChange, onClose }: ColorPickerProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const pickerRef = useRef<HTMLDivElement>(null)\r\n\r\n  useEffect(() => {\r\n    const handleClickOutside = (event: MouseEvent) => {\r\n      if (pickerRef.current && !pickerRef.current.contains(event.target as Node)) {\r\n        onClose()\r\n      }\r\n    }\r\n\r\n    document.addEventListener('mousedown', handleClickOutside)\r\n    return () => {\r\n      document.removeEventListener('mousedown', handleClickOutside)\r\n    }\r\n  }, [onClose])\r\n\r\n  return (\r\n    <div\r\n      ref={pickerRef}\r\n      className=\"absolute top-full right-0 mt-2 rounded-lg shadow-lg border p-5 z-50 min-w-[280px]\"\r\n      style={{ backgroundColor: 'var(--card)', borderColor: 'var(--border)' }}\r\n    >\r\n      <h4 className=\"text-sm font-medium text-foreground mb-3\">{t('menu.setColor')}</h4>\r\n      <div className=\"grid grid-cols-4 gap-4\">\r\n        {COLORS.map((color) => (\r\n          <div key={color.key} className=\"flex flex-col items-center gap-1\">\r\n            <button\r\n              onClick={() => {\r\n                onColorChange(color.value)\r\n                onClose()\r\n              }}\r\n              className={`w-12 h-12 rounded border-2 ${color.bg} ${color.border} hover:scale-110 hover:shadow-md transition-all relative flex items-center justify-center`}\r\n              title={t(`color.${color.key}`)}\r\n            >\r\n              {currentColor === color.value && (\r\n                <Check className=\"w-5 h-5 text-foreground\" />\r\n              )}\r\n            </button>\r\n            <span className=\"text-xs text-muted-foreground\">{t(`color.${color.key}`)}</span>\r\n          </div>\r\n        ))}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/EmptyState.tsx",
    "content": "import { Layers, Search } from 'lucide-react'\r\nimport { useTranslation } from 'react-i18next'\r\n\r\ninterface EmptyStateProps {\r\n  isSearching: boolean\r\n  searchQuery?: string\r\n}\r\n\r\nexport function EmptyState({ isSearching, searchQuery }: EmptyStateProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n\r\n  if (isSearching) {\r\n    return (\r\n      <div className=\"text-center py-16\">\r\n        <Search className=\"w-16 h-16 text-muted-foreground/50 mx-auto mb-4\" />\r\n        <h3 className=\"text-lg font-medium text-foreground mb-2\">\r\n          {t('search.noResults')}\r\n        </h3>\r\n        <p className=\"text-muted-foreground\">\r\n          {t('search.tryDifferent', { query: searchQuery })}\r\n        </p>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <div className=\"text-center py-16 w-full\">\r\n      <div className=\"w-24 h-24 mx-auto mb-6 rounded-full bg-primary/10 flex items-center justify-center\">\r\n        <Layers className=\"w-12 h-12 text-primary\" />\r\n      </div>\r\n      <h3 className=\"text-2xl font-bold text-foreground mb-3\">\r\n        {t('empty.title')}\r\n      </h3>\r\n      <p className=\"text-muted-foreground mb-6\">\r\n        {t('empty.description')}\r\n      </p>\r\n      <div className=\"flex items-center justify-center gap-4\">\r\n        <div className=\"text-sm text-muted-foreground/80\">\r\n          {t('empty.tip')}\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/InsertionIndicator.tsx",
    "content": "/**\r\n * 插入线指示器 - Notion 风格\r\n * 在拖拽时显示插入位置\r\n */\r\n\r\ninterface InsertionIndicatorProps {\r\n  position: 'before' | 'after' | 'inside'\r\n}\r\n\r\nexport function InsertionIndicator({ position }: InsertionIndicatorProps) {\r\n  if (position === 'inside') {\r\n    return (\r\n      <div className=\"absolute inset-0 bg-primary/10 border-2 border-primary border-dashed rounded-lg pointer-events-none\" />\r\n    )\r\n  }\r\n\r\n  return (\r\n    <div\r\n      className={`absolute left-0 right-0 h-0.5 bg-primary pointer-events-none ${\r\n        position === 'before' ? '-top-1' : '-bottom-1'\r\n      }`}\r\n    >\r\n      {/* 左侧圆点 */}\r\n      <div className=\"absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full\" />\r\n      {/* 右侧圆点 */}\r\n      <div className=\"absolute right-0 top-1/2 -translate-y-1/2 w-2 h-2 bg-primary rounded-full\" />\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/MoveItemDialog.tsx",
    "content": "import { useState, useEffect } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { createPortal } from 'react-dom'\r\nimport { X, FolderOpen } from 'lucide-react'\r\nimport type { TabGroup } from '@/lib/types'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\n\r\ninterface MoveItemDialogProps {\r\n  isOpen: boolean\r\n  itemTitle: string\r\n  currentGroupId: string\r\n  availableGroups: TabGroup[]\r\n  onMove: (targetGroupId: string) => void\r\n  onClose: () => void\r\n}\r\n\r\nexport function MoveItemDialog({\r\n  isOpen,\r\n  itemTitle,\r\n  currentGroupId,\r\n  availableGroups,\r\n  onMove,\r\n  onClose,\r\n}: MoveItemDialogProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const { t: tc } = useTranslation('common')\r\n  const isMobile = useIsMobile()\r\n  const [selectedGroupId, setSelectedGroupId] = useState<string>('')\r\n\r\n  // 阻止背景滚动\r\n  useEffect(() => {\r\n    if (isOpen) {\r\n      document.body.style.overflow = 'hidden'\r\n    } else {\r\n      document.body.style.overflow = ''\r\n    }\r\n    return () => {\r\n      document.body.style.overflow = ''\r\n    }\r\n  }, [isOpen])\r\n\r\n  // ESC 键关闭\r\n  useEffect(() => {\r\n    const handleEsc = (e: KeyboardEvent) => {\r\n      if (e.key === 'Escape' && isOpen) {\r\n        onClose()\r\n      }\r\n    }\r\n    window.addEventListener('keydown', handleEsc)\r\n    return () => window.removeEventListener('keydown', handleEsc)\r\n  }, [isOpen, onClose])\r\n\r\n  if (!isOpen) return null\r\n\r\n  // 过滤掉当前组和文件夹\r\n  const targetGroups = availableGroups.filter(\r\n    (g) => g.id !== currentGroupId && g.is_folder !== 1\r\n  )\r\n\r\n  const handleMove = () => {\r\n    if (selectedGroupId) {\r\n      onMove(selectedGroupId)\r\n      onClose()\r\n    }\r\n  }\r\n\r\n  const dialogContent = (\r\n    <div className=\"fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4\" style={{ zIndex: Z_INDEX.MOVE_ITEM_DIALOG }} onClick={onClose}>\r\n      <div className=\"border border-border rounded-2xl sm:rounded-3xl shadow-xl w-full max-w-md\" style={{ backgroundColor: 'var(--card)' }} onClick={(e) => e.stopPropagation()}>\r\n        {/* Header */}\r\n        <div className={`flex items-center justify-between border-b border-border ${isMobile ? 'p-4' : 'p-5'}`}>\r\n          <h2 className={`font-semibold text-foreground ${isMobile ? 'text-base' : 'text-lg'}`}>{t('todo.moveTab')}</h2>\r\n          <button\r\n            onClick={onClose}\r\n            className=\"p-1 text-muted-foreground hover:text-foreground rounded transition-colors\"\r\n          >\r\n            <X className={isMobile ? 'w-5 h-5' : 'w-6 h-6'} />\r\n          </button>\r\n        </div>\r\n\r\n        {/* Content */}\r\n        <div className={`space-y-4 ${isMobile ? 'p-4' : 'p-5'}`}>\r\n          <div>\r\n            <p className=\"text-sm text-muted-foreground mb-2\">\r\n              {t('todo.moveTabTo', { title: itemTitle })}\r\n            </p>\r\n          </div>\r\n\r\n          {/* Group List */}\r\n          <div className={`overflow-y-auto space-y-2 ${isMobile ? 'max-h-[50vh]' : 'max-h-96'}`}>\r\n            {targetGroups.length === 0 ? (\r\n              <div className=\"text-center py-8 text-muted-foreground\">\r\n                <FolderOpen className=\"w-12 h-12 mx-auto mb-2 opacity-50\" />\r\n                <p>{t('todo.noGroupsToMove')}</p>\r\n              </div>\r\n            ) : (\r\n              targetGroups.map((group) => (\r\n                <button\r\n                  key={group.id}\r\n                  onClick={() => setSelectedGroupId(group.id)}\r\n                  className={`w-full text-left rounded-lg border-2 transition-all ${isMobile ? 'p-3 min-h-[60px]' : 'p-3'} ${selectedGroupId === group.id\r\n                      ? 'border-primary shadow-md'\r\n                      : 'border-border hover:bg-muted hover:border-muted-foreground/20'\r\n                    }`}\r\n                >\r\n                  <div className=\"flex items-center justify-between\">\r\n                    <div className=\"flex-1 min-w-0\">\r\n                      <p className=\"font-medium text-foreground truncate\">{group.title}</p>\r\n                      <p className=\"text-xs text-muted-foreground\">\r\n                        {t('header.tabCount', { count: group.item_count || 0 })}\r\n                      </p>\r\n                    </div>\r\n                    {selectedGroupId === group.id && (\r\n                      <div className=\"w-7 h-7 rounded-full flex items-center justify-center flex-shrink-0 ml-2\" style={{ backgroundColor: 'var(--primary)' }}>\r\n                        <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={3.5}>\r\n                          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 13l4 4L19 7\" style={{ color: 'var(--primary-foreground)' }} />\r\n                        </svg>\r\n                      </div>\r\n                    )}\r\n                  </div>\r\n                </button>\r\n              ))\r\n            )}\r\n          </div>\r\n        </div>\r\n\r\n        {/* Footer */}\r\n        <div className={`flex items-center gap-2 border-t border-border ${isMobile ? 'flex-col-reverse p-4' : 'justify-end p-5'}`}>\r\n          <button\r\n            onClick={onClose}\r\n            className={`text-muted-foreground hover:text-foreground rounded-lg hover:bg-muted transition-colors ${isMobile ? 'w-full py-3 min-h-[44px]' : 'px-4 py-2 text-sm'}`}\r\n          >\r\n            {tc('button.cancel')}\r\n          </button>\r\n          <button\r\n            onClick={handleMove}\r\n            disabled={!selectedGroupId}\r\n            className={`bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${isMobile ? 'w-full py-3 min-h-[44px]' : 'px-4 py-2 text-sm'}`}\r\n          >\r\n            {t('menu.move')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n\r\n  return createPortal(dialogContent, document.body)\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/MoveToFolderDialog.tsx",
    "content": "import { useState, useEffect } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Folder, Home, ChevronRight, ChevronDown } from 'lucide-react'\r\nimport type { TabGroup } from '@/lib/types'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\n\r\ninterface MoveToFolderDialogProps {\r\n  isOpen: boolean\r\n  currentGroup: TabGroup\r\n  allGroups: TabGroup[]\r\n  onConfirm: (targetFolderId: string | null) => void\r\n  onCancel: () => void\r\n}\r\n\r\nexport function MoveToFolderDialog({\r\n  isOpen,\r\n  currentGroup,\r\n  allGroups,\r\n  onConfirm,\r\n  onCancel,\r\n}: MoveToFolderDialogProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const { t: tc } = useTranslation('common')\r\n  const isMobile = useIsMobile()\r\n  const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null)\r\n  const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set())\r\n\r\n  useEffect(() => {\r\n    if (isOpen) {\r\n      document.body.style.overflow = 'hidden'\r\n    } else {\r\n      document.body.style.overflow = ''\r\n    }\r\n    return () => {\r\n      document.body.style.overflow = ''\r\n    }\r\n  }, [isOpen])\r\n\r\n  useEffect(() => {\r\n    const handleEsc = (e: KeyboardEvent) => {\r\n      if (e.key === 'Escape' && isOpen) {\r\n        onCancel()\r\n      }\r\n    }\r\n    window.addEventListener('keydown', handleEsc)\r\n    return () => window.removeEventListener('keydown', handleEsc)\r\n  }, [isOpen, onCancel])\r\n\r\n  const getDescendantIds = (groupId: string): Set<string> => {\r\n    const descendants = new Set<string>([groupId])\r\n    const children = allGroups.filter(g => g.parent_id === groupId)\r\n    children.forEach(child => {\r\n      getDescendantIds(child.id).forEach(id => descendants.add(id))\r\n    })\r\n    return descendants\r\n  }\r\n\r\n  const excludedIds = getDescendantIds(currentGroup.id)\r\n  const availableFolders = allGroups.filter(\r\n    g => g.is_folder === 1 && !excludedIds.has(g.id)\r\n  )\r\n\r\n  const buildTree = (parentId: string | null = null): TabGroup[] => {\r\n    return availableFolders\r\n      .filter(g => (g.parent_id || null) === parentId)\r\n      .sort((a, b) => (a.position || 0) - (b.position || 0))\r\n  }\r\n\r\n  const toggleFolder = (folderId: string) => {\r\n    const newExpanded = new Set(expandedFolders)\r\n    if (newExpanded.has(folderId)) {\r\n      newExpanded.delete(folderId)\r\n    } else {\r\n      newExpanded.add(folderId)\r\n    }\r\n    setExpandedFolders(newExpanded)\r\n  }\r\n\r\n  const handleConfirm = () => {\r\n    onConfirm(selectedFolderId)\r\n  }\r\n\r\n  const handleCancel = () => {\r\n    setSelectedFolderId(null)\r\n    onCancel()\r\n  }\r\n\r\n  if (!isOpen) return null\r\n\r\n  const renderFolderTree = (folders: TabGroup[], level: number = 0) => {\r\n    return folders.map(folder => {\r\n      const isExpanded = expandedFolders.has(folder.id)\r\n      const isSelected = selectedFolderId === folder.id\r\n      const children = buildTree(folder.id)\r\n      const hasChildren = children.length > 0\r\n\r\n      return (\r\n        <div key={folder.id}>\r\n          <div\r\n            className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${\r\n              isSelected\r\n                ? 'bg-primary text-primary-foreground font-medium ring-2 ring-primary ring-offset-2 ring-offset-background'\r\n                : 'hover:bg-muted'\r\n            }`}\r\n            style={{ paddingLeft: `${level * 20 + 12}px` }}\r\n            onClick={() => setSelectedFolderId(folder.id)}\r\n          >\r\n            {hasChildren ? (\r\n              <button\r\n                onClick={(e) => {\r\n                  e.stopPropagation()\r\n                  toggleFolder(folder.id)\r\n                }}\r\n                className=\"w-4 h-4 flex items-center justify-center hover:bg-muted/80 rounded\"\r\n              >\r\n                {isExpanded ? (\r\n                  <ChevronDown className=\"w-3 h-3\" />\r\n                ) : (\r\n                  <ChevronRight className=\"w-3 h-3\" />\r\n                )}\r\n              </button>\r\n            ) : (\r\n              <div className=\"w-4\" />\r\n            )}\r\n            <Folder className=\"w-4 h-4 flex-shrink-0\" />\r\n            <span className=\"flex-1 truncate text-sm\">{folder.title}</span>\r\n            {folder.item_count !== undefined && (\r\n              <span className=\"text-xs opacity-60\">{folder.item_count}</span>\r\n            )}\r\n          </div>\r\n          {isExpanded && hasChildren && (\r\n            <div>{renderFolderTree(children, level + 1)}</div>\r\n          )}\r\n        </div>\r\n      )\r\n    })\r\n  }\r\n\r\n  const rootFolders = buildTree(null)\r\n\r\n  const dialogContent = (\r\n    <div className=\"fixed inset-0 flex items-center justify-center p-4 sm:p-6 animate-fade-in\" style={{ zIndex: Z_INDEX.MOVE_TO_FOLDER_DIALOG }} onClick={handleCancel}>\r\n      <div\r\n        className={`relative card rounded-2xl sm:rounded-3xl shadow-2xl border max-w-lg w-full animate-scale-in ${isMobile ? 'p-4' : 'p-6'}`}\r\n        style={{ backgroundColor: 'var(--card)', borderColor: 'var(--border)' }}\r\n        onClick={(e) => e.stopPropagation()}\r\n      >\r\n        <div className={isMobile ? 'mb-4' : 'mb-6'}>\r\n          <h3 className={`font-bold text-base-content ${isMobile ? 'text-lg' : 'text-2xl'}`}>\r\n            {t('moveToFolder.title')}\r\n          </h3>\r\n          <p className={`text-base-content/70 mt-2 ${isMobile ? 'text-xs' : 'text-sm'}`}>\r\n            {t('moveToFolder.description', { title: currentGroup.title })}\r\n          </p>\r\n        </div>\r\n\r\n        <div\r\n          className={`border rounded-xl ${isMobile ? 'mb-4' : 'mb-6'}`}\r\n          style={{\r\n            borderColor: 'var(--border)',\r\n            maxHeight: isMobile ? '50vh' : '400px',\r\n            overflowY: 'auto',\r\n          }}\r\n        >\r\n          <div className=\"p-1 border-b\" style={{ borderColor: 'var(--border)' }}>\r\n            <div\r\n              className={`flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer transition-all ${\r\n                selectedFolderId === null\r\n                  ? 'bg-primary text-primary-foreground font-medium ring-2 ring-primary ring-offset-2 ring-offset-background'\r\n                  : 'hover:bg-muted'\r\n              }`}\r\n              onClick={() => setSelectedFolderId(null)}\r\n            >\r\n              <Home className=\"w-4 h-4 flex-shrink-0\" />\r\n              <span className=\"flex-1 text-sm font-medium\">{t('moveToFolder.rootFolder')}</span>\r\n            </div>\r\n          </div>\r\n\r\n          <div className=\"p-1\">\r\n            {rootFolders.length > 0 ? (\r\n              renderFolderTree(rootFolders)\r\n            ) : (\r\n              <div className=\"px-3 py-8 text-center text-sm text-muted-foreground\">\r\n                {t('moveToFolder.noFolders')}\r\n              </div>\r\n            )}\r\n          </div>\r\n        </div>\r\n\r\n        <div className={`flex gap-2 sm:gap-3 ${isMobile ? 'flex-col-reverse' : ''}`}>\r\n          <button onClick={handleCancel} className={`btn btn-outline flex-1 ${isMobile ? 'min-h-[44px]' : ''}`}>\r\n            {tc('button.cancel')}\r\n          </button>\r\n          <button\r\n            onClick={handleConfirm}\r\n            className={`btn flex-1 ${isMobile ? 'min-h-[44px]' : ''}`}\r\n            disabled={selectedFolderId === currentGroup.parent_id}\r\n          >\r\n            {t('moveToFolder.confirm')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n\r\n  return createPortal(dialogContent, document.body)\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/PinnedItemsSection.tsx",
    "content": "/**\r\n * 固定标签页区域组件\r\n * 显示所有分组中被固定的标签页\r\n */\r\n\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Pin, ExternalLink, Folder } from 'lucide-react'\r\nimport type { TabGroup, TabGroupItem } from '@/lib/types'\r\n\r\ninterface PinnedItem extends TabGroupItem {\r\n  groupTitle: string\r\n  groupId: string\r\n}\r\n\r\ninterface PinnedItemsSectionProps {\r\n  tabGroups: TabGroup[]\r\n  onUnpin?: (groupId: string, itemId: string) => void\r\n}\r\n\r\nexport function PinnedItemsSection({ tabGroups, onUnpin }: PinnedItemsSectionProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  \r\n  // 收集所有固定的标签页\r\n  const pinnedItems: PinnedItem[] = []\r\n  \r\n  tabGroups.forEach(group => {\r\n    if (group.items && group.items.length > 0) {\r\n      group.items.forEach(item => {\r\n        if (item.is_pinned) {\r\n          pinnedItems.push({\r\n            ...item,\r\n            groupTitle: group.title,\r\n            groupId: group.id,\r\n          })\r\n        }\r\n      })\r\n    }\r\n  })\r\n\r\n  // 如果没有固定的标签页，不显示这个区域\r\n  if (pinnedItems.length === 0) {\r\n    return null\r\n  }\r\n\r\n  return (\r\n    <div className=\"mb-6 card p-6 border-l-4 border-l-warning bg-warning/5\">\r\n      {/* 标题 */}\r\n      <div className=\"flex items-center gap-2 mb-4\">\r\n        <Pin className=\"w-5 h-5 text-warning\" />\r\n        <h2 className=\"text-lg font-semibold text-foreground\">\r\n          {t('item.pinned')}\r\n        </h2>\r\n        <span className=\"text-sm text-muted-foreground\">\r\n          ({pinnedItems.length})\r\n        </span>\r\n      </div>\r\n\r\n      {/* 固定标签页列表 */}\r\n      <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3\">\r\n        {pinnedItems.map(item => (\r\n          <div\r\n            key={item.id}\r\n            className=\"group flex items-start gap-3 p-3 rounded border border-border bg-card hover:bg-muted hover:border-warning/50 transition-all\"\r\n          >\r\n            {/* 固定图标按钮 - 点击取消固定 */}\r\n            {onUnpin ? (\r\n              <button\r\n                onClick={(e) => {\r\n                  e.preventDefault()\r\n                  e.stopPropagation()\r\n                  onUnpin(item.groupId, item.id)\r\n                }}\r\n                className=\"flex-shrink-0 mt-0.5 p-1 text-warning hover:bg-warning/10 rounded transition-colors\"\r\n                title={t('menu.unpin')}\r\n              >\r\n                <Pin className=\"w-4 h-4\" />\r\n              </button>\r\n            ) : (\r\n              <div className=\"flex-shrink-0 mt-0.5 p-1\">\r\n                <Pin className=\"w-4 h-4 text-warning\" />\r\n              </div>\r\n            )}\r\n\r\n            {/* Favicon */}\r\n            <div className=\"flex-shrink-0 mt-0.5\">\r\n              {item.favicon ? (\r\n                <img\r\n                  src={item.favicon}\r\n                  alt=\"\"\r\n                  className=\"w-5 h-5 rounded\"\r\n                  onError={(e) => {\r\n                    e.currentTarget.style.display = 'none'\r\n                  }}\r\n                />\r\n              ) : (\r\n                <div className=\"w-5 h-5 rounded bg-muted flex items-center justify-center\">\r\n                  <ExternalLink className=\"w-3 h-3 text-muted-foreground\" />\r\n                </div>\r\n              )}\r\n            </div>\r\n\r\n            {/* 内容 - 可点击打开链接 */}\r\n            <a\r\n              href={item.url}\r\n              target=\"_blank\"\r\n              rel=\"noopener noreferrer\"\r\n              className=\"flex-1 min-w-0 flex items-start gap-2\"\r\n            >\r\n              <div className=\"flex-1 min-w-0\">\r\n                {/* 标题 */}\r\n                <div className=\"text-sm font-medium text-foreground truncate group-hover:text-primary\">\r\n                  {item.title}\r\n                </div>\r\n                \r\n                {/* 分组名称 */}\r\n                <div className=\"flex items-center gap-1 mt-1 text-xs text-muted-foreground\">\r\n                  <Folder className=\"w-3 h-3\" />\r\n                  <span className=\"truncate\">{item.groupTitle}</span>\r\n                </div>\r\n              </div>\r\n\r\n              {/* 外部链接图标 */}\r\n              <ExternalLink className=\"w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 mt-0.5\" />\r\n            </a>\r\n          </div>\r\n        ))}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/SearchBar.tsx",
    "content": "import { Search, CheckCircle, Archive, ArrowUpDown } from 'lucide-react'\r\nimport { Link } from 'react-router-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport type { SortOption } from './sortUtils'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\n\r\ninterface SearchBarProps {\r\n  searchQuery: string\r\n  onSearchChange: (query: string) => void\r\n  sortBy: SortOption\r\n  onSortChange: (sort: SortOption) => void\r\n  onBatchModeToggle: () => void\r\n  batchMode: boolean\r\n}\r\n\r\nexport function SearchBar({\r\n  searchQuery,\r\n  onSearchChange,\r\n  sortBy,\r\n  onSortChange,\r\n  onBatchModeToggle,\r\n  batchMode,\r\n}: SearchBarProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const isMobile = useIsMobile()\r\n\r\n  const sortOptions: { value: SortOption; label: string }[] = [\r\n    { value: 'created', label: t('sort.created') },\r\n    { value: 'title', label: t('sort.title') },\r\n    { value: 'count', label: t('sort.count') },\r\n  ]\r\n\r\n  const currentSortLabel = sortOptions.find(opt => opt.value === sortBy)?.label || t('sort.label')\r\n\r\n  return (\r\n    <div className=\"flex items-center gap-2 flex-1\">\r\n      {/* Search Input */}\r\n      <div className=\"flex-1 relative min-w-0\">\r\n        <Search className=\"absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground\" />\r\n        <input\r\n          type=\"text\"\r\n          placeholder={t('search.placeholder')}\r\n          value={searchQuery}\r\n          onChange={(e) => onSearchChange(e.target.value)}\r\n          className=\"input w-full pl-10\"\r\n        />\r\n      </div>\r\n\r\n      {/* Sort Selector - Icon Only */}\r\n      <div className=\"relative flex-shrink-0\">\r\n        <select\r\n          value={sortBy}\r\n          onChange={(e) => onSortChange(e.target.value as SortOption)}\r\n          className=\"appearance-none w-10 h-10 flex items-center justify-center border border-border rounded hover:bg-muted transition-colors cursor-pointer opacity-0 absolute inset-0\"\r\n          title={currentSortLabel}\r\n        >\r\n          {sortOptions.map(option => (\r\n            <option key={option.value} value={option.value}>\r\n              {option.label}\r\n            </option>\r\n          ))}\r\n        </select>\r\n        <div className=\"w-10 h-10 flex items-center justify-center border border-border rounded hover:bg-muted transition-colors pointer-events-none\" style={{backgroundColor: 'var(--card)'}}>\r\n          <ArrowUpDown className=\"w-5 h-5 text-muted-foreground\" />\r\n        </div>\r\n      </div>\r\n\r\n      {/* Batch Mode Toggle - Icon Only */}\r\n      <button\r\n        onClick={onBatchModeToggle}\r\n        className={`w-10 h-10 flex-shrink-0 flex items-center justify-center rounded transition-colors ${\r\n          batchMode\r\n            ? 'bg-primary text-primary-foreground'\r\n            : 'border border-border hover:bg-muted text-muted-foreground'\r\n        }`}\r\n        title={batchMode ? t('batch.exit') : t('batch.enter')}\r\n      >\r\n        <CheckCircle className=\"w-5 h-5\" />\r\n      </button>\r\n\r\n      {/* Trash Link - 移动端隐藏（在底部导航） */}\r\n      {!isMobile && (\r\n        <Link\r\n          to=\"/tab/trash\"\r\n          className=\"w-10 h-10 flex-shrink-0 flex items-center justify-center border border-border rounded hover:bg-muted transition-colors text-muted-foreground\"\r\n          title={t('trash.title')}\r\n        >\r\n          <Archive className=\"w-5 h-5\" />\r\n        </Link>\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/ShareDialog.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react'\r\nimport { createPortal } from 'react-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { X, Copy, Check, Share2, Eye } from 'lucide-react'\r\nimport { tabGroupsService } from '@/services/tab-groups'\r\nimport type { Share } from '@/lib/types'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { AlertDialog } from '@/components/common/AlertDialog'\r\n\r\ninterface ShareDialogProps {\r\n  groupId: string\r\n  groupTitle: string\r\n  onClose: () => void\r\n}\r\n\r\nexport function ShareDialog({ groupId, groupTitle, onClose }: ShareDialogProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const { t: tc } = useTranslation('common')\r\n  const isMobile = useIsMobile()\r\n  const [share, setShare] = useState<Share | null>(null)\r\n  const [shareUrl, setShareUrl] = useState('')\r\n  const [isLoading, setIsLoading] = useState(true)\r\n  const [isCopied, setIsCopied] = useState(false)\r\n  const [error, setError] = useState<string | null>(null)\r\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)\r\n  const [showCopyError, setShowCopyError] = useState(false)\r\n\r\n  useEffect(() => {\r\n    document.body.style.overflow = 'hidden'\r\n    return () => {\r\n      document.body.style.overflow = ''\r\n    }\r\n  }, [])\r\n\r\n  useEffect(() => {\r\n    const handleEsc = (e: KeyboardEvent) => {\r\n      if (e.key === 'Escape') {\r\n        onClose()\r\n      }\r\n    }\r\n    window.addEventListener('keydown', handleEsc)\r\n    return () => window.removeEventListener('keydown', handleEsc)\r\n  }, [onClose])\r\n\r\n  const loadOrCreateShare = useCallback(async () => {\r\n    try {\r\n      setIsLoading(true)\r\n      setError(null)\r\n\r\n      try {\r\n        const response = await tabGroupsService.getShare(groupId)\r\n        setShare(response.share)\r\n        setShareUrl(response.share_url)\r\n      } catch {\r\n        const response = await tabGroupsService.createShare(groupId, { is_public: true })\r\n        setShare(response.share)\r\n        setShareUrl(response.share_url)\r\n      }\r\n    } catch (error) {\r\n      console.error('Failed to load/create share:', error)\r\n      setError(t('share.createFailed'))\r\n    } finally {\r\n      setIsLoading(false)\r\n    }\r\n  }, [groupId, t])\r\n\r\n  useEffect(() => {\r\n    loadOrCreateShare()\r\n  }, [loadOrCreateShare])\r\n\r\n  const handleCopy = async () => {\r\n    try {\r\n      await navigator.clipboard.writeText(shareUrl)\r\n      setIsCopied(true)\r\n      setTimeout(() => setIsCopied(false), 2000)\r\n    } catch (err) {\r\n      console.error('Failed to copy:', err)\r\n      setShowCopyError(true)\r\n    }\r\n  }\r\n\r\n  const handleDelete = async () => {\r\n    try {\r\n      await tabGroupsService.deleteShare(groupId)\r\n      onClose()\r\n    } catch (err) {\r\n      console.error('Failed to delete share:', err)\r\n      setError(t('share.deleteFailed'))\r\n    }\r\n  }\r\n\r\n  const dialogContent = (\r\n    <div className=\"fixed inset-0 bg-background/80 backdrop-blur-sm flex items-center justify-center p-4 sm:p-6\" style={{ zIndex: Z_INDEX.SHARE_DIALOG }} onClick={onClose}>\r\n      <div className=\"rounded-2xl sm:rounded-3xl shadow-xl max-w-md w-full border border-border\" style={{backgroundColor: 'var(--card)'}} onClick={(e) => e.stopPropagation()}>\r\n        <ConfirmDialog\r\n          isOpen={showDeleteConfirm}\r\n          title={tc('dialog.confirmTitle')}\r\n          message={t('share.confirmDelete')}\r\n          type=\"warning\"\r\n          onConfirm={() => {\r\n            setShowDeleteConfirm(false)\r\n            handleDelete()\r\n          }}\r\n          onCancel={() => setShowDeleteConfirm(false)}\r\n        />\r\n\r\n        <AlertDialog\r\n          isOpen={showCopyError}\r\n          title={tc('dialog.errorTitle')}\r\n          message={t('share.copyFailed')}\r\n          type=\"error\"\r\n          onConfirm={() => setShowCopyError(false)}\r\n        />\r\n\r\n        {/* Header */}\r\n        <div className={`flex items-center justify-between border-b border-border ${isMobile ? 'p-4' : 'p-6'}`}>\r\n          <div className=\"flex items-center gap-2 sm:gap-3\">\r\n            <Share2 className={`text-primary ${isMobile ? 'w-5 h-5' : 'w-6 h-6'}`} />\r\n            <h2 className={`font-semibold text-foreground ${isMobile ? 'text-lg' : 'text-xl'}`}>{t('share.title')}</h2>\r\n          </div>\r\n          <button\r\n            onClick={onClose}\r\n            className=\"text-muted-foreground hover:text-foreground transition-colors p-1\"\r\n          >\r\n            <X className={isMobile ? 'w-5 h-5' : 'w-6 h-6'} />\r\n          </button>\r\n        </div>\r\n\r\n        {/* Content */}\r\n        <div className={isMobile ? 'p-4' : 'p-6'}>\r\n          {isLoading ? (\r\n            <div className=\"text-center py-8\">\r\n              <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4\"></div>\r\n              <p className=\"text-muted-foreground\">{t('share.generating')}</p>\r\n            </div>\r\n          ) : error ? (\r\n            <div className=\"text-center py-8\">\r\n              <p className=\"text-destructive mb-4\">{error}</p>\r\n              <button\r\n                onClick={loadOrCreateShare}\r\n                className=\"px-4 py-2 bg-primary text-primary-foreground rounded hover:bg-primary/90\"\r\n              >\r\n                {tc('button.retry')}\r\n              </button>\r\n            </div>\r\n          ) : (\r\n            <>\r\n              <div className=\"mb-4\">\r\n                <p className=\"text-sm text-muted-foreground mb-2\">{t('share.groupName')}</p>\r\n                <p className=\"text-foreground font-medium\">{groupTitle}</p>\r\n              </div>\r\n\r\n              {share && (\r\n                <div className=\"mb-4\">\r\n                  <div className=\"flex items-center gap-2 text-sm text-muted-foreground mb-2\">\r\n                    <Eye className=\"w-4 h-4\" />\r\n                    <span>{t('share.viewCount')}: {share.view_count}</span>\r\n                  </div>\r\n                </div>\r\n              )}\r\n\r\n              <div className=\"mb-4 sm:mb-6\">\r\n                <p className=\"text-sm text-muted-foreground mb-2\">{t('share.link')}</p>\r\n                <div className={`flex gap-2 ${isMobile ? 'flex-col' : ''}`}>\r\n                  <input\r\n                    type=\"text\"\r\n                    value={shareUrl}\r\n                    readOnly\r\n                    className=\"input flex-1 text-sm\"\r\n                  />\r\n                  <button\r\n                    onClick={handleCopy}\r\n                    className={`bg-primary text-primary-foreground rounded hover:bg-primary/90 transition-colors flex items-center justify-center gap-2 ${isMobile ? 'py-3 min-h-[44px]' : 'px-4 py-2'}`}\r\n                  >\r\n                    {isCopied ? (\r\n                      <>\r\n                        <Check className=\"w-4 h-4\" />\r\n                        <span>{t('share.copied')}</span>\r\n                      </>\r\n                    ) : (\r\n                      <>\r\n                        <Copy className=\"w-4 h-4\" />\r\n                        <span>{t('share.copy')}</span>\r\n                      </>\r\n                    )}\r\n                  </button>\r\n                </div>\r\n              </div>\r\n\r\n              <div className=\"bg-primary/10 border border-primary/20 rounded p-3 sm:p-4 mb-4\">\r\n                <p className=\"text-xs sm:text-sm text-foreground\">\r\n                  {t('share.tip')}\r\n                </p>\r\n              </div>\r\n\r\n              <div className={`flex gap-2 ${isMobile ? 'flex-col-reverse' : 'justify-between'}`}>\r\n                <button\r\n                  onClick={() => setShowDeleteConfirm(true)}\r\n                  className={`text-destructive hover:bg-destructive/10 rounded transition-colors ${isMobile ? 'py-3 min-h-[44px]' : 'px-4 py-2'}`}\r\n                >\r\n                  {t('share.delete')}\r\n                </button>\r\n                <button\r\n                  onClick={onClose}\r\n                  className={`bg-muted text-foreground rounded hover:bg-muted/80 transition-colors ${isMobile ? 'py-3 min-h-[44px]' : 'px-4 py-2'}`}\r\n                >\r\n                  {t('share.close')}\r\n                </button>\r\n              </div>\r\n            </>\r\n          )}\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n\r\n  return createPortal(dialogContent, document.body)\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/SortSelector.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { ArrowUpDown } from 'lucide-react'\r\nimport type { SortOption } from './sortUtils'\r\n\r\ninterface SortSelectorProps {\r\n  value: SortOption\r\n  onChange: (value: SortOption) => void\r\n}\r\n\r\nexport function SortSelector({ value, onChange }: SortSelectorProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n\r\n  return (\r\n    <div className=\"flex items-center gap-2\">\r\n      <ArrowUpDown className=\"w-5 h-5 text-muted-foreground\" />\r\n      <select\r\n        value={value}\r\n        onChange={(e) => onChange(e.target.value as SortOption)}\r\n        className=\"px-3 py-2 border border-border rounded focus:outline-none focus:ring-2 focus:ring-primary bg-card text-foreground\"\r\n      >\r\n        <option value=\"created\">{t('sort.byCreated')}</option>\r\n        <option value=\"title\">{t('sort.byTitle')}</option>\r\n        <option value=\"count\">{t('sort.byCount')}</option>\r\n      </select>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/TabGroupCard.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { Calendar, Layers, Trash2, Edit2, FolderOpen, Download, Palette, Tag, Share2, Check, X } from 'lucide-react'\r\nimport type { TabGroup } from '@/lib/types'\r\nimport { formatDistanceToNow } from 'date-fns'\r\nimport { zhCN, enUS } from 'date-fns/locale'\r\nimport { getColorClasses, getLeftBorderColor } from './colorUtils'\r\nimport { TagsList } from './TagsInput'\r\n\r\ninterface TabItem {\r\n  id: string\r\n  url: string\r\n  title: string\r\n  favicon?: string | null\r\n}\r\n\r\ninterface TabGroupCardProps {\r\n  group: TabGroup\r\n  onDelete: (id: string, title: string) => void\r\n  onOpenAll: (items: TabItem[]) => void\r\n  onExport: (group: TabGroup) => void\r\n  onColorClick: (groupId: string) => void\r\n  onTagsClick: (groupId: string) => void\r\n  onShareClick: (groupId: string) => void\r\n  isEditingTitle: boolean\r\n  editingTitle: string\r\n  onStartEdit: () => void\r\n  onCancelEdit: () => void\r\n  onSaveEdit: () => void\r\n  onTitleChange: (title: string) => void\r\n  children: React.ReactNode\r\n}\r\n\r\nexport function TabGroupCard({\r\n  group,\r\n  onDelete,\r\n  onOpenAll,\r\n  onExport,\r\n  onColorClick,\r\n  onTagsClick,\r\n  onShareClick,\r\n  isEditingTitle,\r\n  editingTitle,\r\n  onStartEdit,\r\n  onCancelEdit,\r\n  onSaveEdit,\r\n  onTitleChange,\r\n  children,\r\n}: TabGroupCardProps) {\r\n  const { t, i18n } = useTranslation('tabGroups')\r\n  const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS\r\n  const colorClasses = getColorClasses(group.color)\r\n  const leftBorderColor = getLeftBorderColor(group.color)\r\n\r\n  return (\r\n    <div className={`card border-l-4 hover:shadow-xl transition-all duration-200 ${colorClasses} ${leftBorderColor}`}>\r\n      {/* Header */}\r\n      <div className=\"flex items-start justify-between mb-4\">\r\n        <div className=\"flex-1\">\r\n          {isEditingTitle ? (\r\n            <div className=\"flex items-center gap-2\">\r\n              <input\r\n                type=\"text\"\r\n                value={editingTitle}\r\n                onChange={(e) => onTitleChange(e.target.value)}\r\n                onKeyDown={(e) => {\r\n                  if (e.key === 'Enter') onSaveEdit()\r\n                  if (e.key === 'Escape') onCancelEdit()\r\n                }}\r\n                className=\"input flex-1\"\r\n                autoFocus\r\n              />\r\n              <button\r\n                onClick={onSaveEdit}\r\n                className=\"p-2 text-success hover:bg-success/10 rounded transition-colors\"\r\n              >\r\n                <Check className=\"w-5 h-5\" />\r\n              </button>\r\n              <button\r\n                onClick={onCancelEdit}\r\n                className=\"p-2 text-muted-foreground hover:bg-muted rounded transition-colors\"\r\n              >\r\n                <X className=\"w-5 h-5\" />\r\n              </button>\r\n            </div>\r\n          ) : (\r\n            <div className=\"flex items-center gap-2 group\">\r\n              <h3 className=\"text-xl font-semibold text-foreground\">{group.title}</h3>\r\n              <button\r\n                onClick={onStartEdit}\r\n                className=\"opacity-0 group-hover:opacity-100 p-1 text-muted-foreground hover:text-foreground transition-opacity\"\r\n              >\r\n                <Edit2 className=\"w-4 h-4\" />\r\n              </button>\r\n            </div>\r\n          )}\r\n\r\n          <div className=\"flex items-center gap-4 text-sm text-muted-foreground mt-2\">\r\n            <div className=\"flex items-center gap-1\">\r\n              <Layers className=\"w-4 h-4\" />\r\n              <span>{t('header.tabCount', { count: group.item_count || 0 })}</span>\r\n            </div>\r\n            <div className=\"flex items-center gap-1\">\r\n              <Calendar className=\"w-4 h-4\" />\r\n              <span>\r\n                {formatDistanceToNow(new Date(group.created_at), {\r\n                  addSuffix: true,\r\n                  locale: dateLocale,\r\n                })}\r\n              </span>\r\n            </div>\r\n          </div>\r\n\r\n          {group.tags && group.tags.length > 0 && (\r\n            <div className=\"mt-2\">\r\n              <TagsList tags={group.tags} />\r\n            </div>\r\n          )}\r\n        </div>\r\n\r\n        {/* Actions */}\r\n        <div className=\"flex items-center gap-2\">\r\n          <button\r\n            onClick={() => onColorClick(group.id)}\r\n            className=\"p-2 text-muted-foreground hover:bg-muted rounded-lg transition-colors\"\r\n            title={t('menu.setColor')}\r\n          >\r\n            <Palette className=\"w-5 h-5\" />\r\n          </button>\r\n          <button\r\n            onClick={() => onTagsClick(group.id)}\r\n            className=\"p-2 text-muted-foreground hover:bg-muted rounded-lg transition-colors\"\r\n            title={t('export.tags')}\r\n          >\r\n            <Tag className=\"w-5 h-5\" />\r\n          </button>\r\n          <button\r\n            onClick={() => onShareClick(group.id)}\r\n            className=\"p-2 text-muted-foreground hover:bg-muted rounded-lg transition-colors\"\r\n            title={t('action.share')}\r\n          >\r\n            <Share2 className=\"w-5 h-5\" />\r\n          </button>\r\n          <button\r\n            onClick={() => onOpenAll(group.items || [])}\r\n            className=\"p-2 text-success hover:bg-success/10 rounded-lg transition-colors\"\r\n            title={t('action.openAll')}\r\n          >\r\n            <FolderOpen className=\"w-5 h-5\" />\r\n          </button>\r\n          <button\r\n            onClick={() => onExport(group)}\r\n            className=\"p-2 text-muted-foreground hover:bg-muted rounded-lg transition-colors\"\r\n            title={t('action.export')}\r\n          >\r\n            <Download className=\"w-5 h-5\" />\r\n          </button>\r\n          <button\r\n            onClick={() => onDelete(group.id, group.title)}\r\n            className=\"p-2 text-destructive hover:bg-destructive/10 rounded-lg transition-colors\"\r\n            title={t('action.delete')}\r\n          >\r\n            <Trash2 className=\"w-5 h-5\" />\r\n          </button>\r\n        </div>\r\n      </div>\r\n\r\n      {/* Items */}\r\n      {children}\r\n    </div>\r\n  )\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/TabGroupHeader.tsx",
    "content": "import { Calendar, Edit2, Check, X, Share2, FolderOpen, Download, Trash2, MoreVertical } from 'lucide-react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { formatDistanceToNow } from 'date-fns'\r\nimport { zhCN, enUS } from 'date-fns/locale'\r\nimport type { TabGroup } from '@/lib/types'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\nimport { DropdownMenu } from '@/components/common/DropdownMenu'\r\n\r\ninterface TabGroupHeaderProps {\r\n  group: TabGroup\r\n  isEditingTitle: boolean\r\n  editingTitle: string\r\n  onEditTitle: () => void\r\n  onSaveTitle: () => void\r\n  onCancelEdit: () => void\r\n  onTitleChange: (title: string) => void\r\n  onShareClick: () => void\r\n  onOpenAll: () => void\r\n  onExport: () => void\r\n  onDelete: () => void\r\n  isDeleting: boolean\r\n}\r\n\r\nexport function TabGroupHeader({\r\n  group,\r\n  isEditingTitle,\r\n  editingTitle,\r\n  onEditTitle,\r\n  onSaveTitle,\r\n  onCancelEdit,\r\n  onTitleChange,\r\n  onShareClick,\r\n  onOpenAll,\r\n  onExport,\r\n  onDelete,\r\n  isDeleting,\r\n}: TabGroupHeaderProps) {\r\n  const { t, i18n } = useTranslation('tabGroups')\r\n  const { t: tc } = useTranslation('common')\r\n  const isMobile = useIsMobile()\r\n\r\n  // 根据当前语言选择 date-fns locale\r\n  const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS\r\n\r\n  return (\r\n    <div className=\"flex items-start justify-between mb-4\">\r\n      <div className=\"flex-1\">\r\n        {/* Title */}\r\n        <div className=\"flex items-center gap-3 mb-2\">\r\n          {isEditingTitle ? (\r\n            <div className=\"flex items-center gap-2 flex-1\">\r\n              <input\r\n                type=\"text\"\r\n                value={editingTitle}\r\n                onChange={(e) => onTitleChange(e.target.value)}\r\n                onKeyDown={(e) => {\r\n                  if (e.key === 'Enter') {\r\n                    onSaveTitle()\r\n                  } else if (e.key === 'Escape') {\r\n                    onCancelEdit()\r\n                  }\r\n                }}\r\n                className=\"input flex-1\"\r\n                autoFocus\r\n              />\r\n              <button\r\n                onClick={onSaveTitle}\r\n                className=\"p-2 text-success hover:bg-success/10 rounded transition-colors\"\r\n                title={tc('button.save')}\r\n              >\r\n                <Check className=\"w-5 h-5\" />\r\n              </button>\r\n              <button\r\n                onClick={onCancelEdit}\r\n                className=\"p-2 text-muted-foreground hover:bg-muted rounded transition-colors\"\r\n                title={tc('button.cancel')}\r\n              >\r\n                <X className=\"w-5 h-5\" />\r\n              </button>\r\n            </div>\r\n          ) : (\r\n            <>\r\n              <h3 className=\"text-xl font-semibold text-foreground flex-1\">\r\n                {group.title}\r\n              </h3>\r\n              <button\r\n                onClick={onEditTitle}\r\n                className=\"p-2 text-muted-foreground hover:bg-muted rounded transition-colors\"\r\n                title={t('action.rename')}\r\n              >\r\n                <Edit2 className=\"w-5 h-5\" />\r\n              </button>\r\n            </>\r\n          )}\r\n        </div>\r\n\r\n        {/* Metadata */}\r\n        <div className=\"flex items-center gap-4 text-sm text-muted-foreground\">\r\n          <div className=\"flex items-center gap-1\">\r\n            <Calendar className=\"w-4 h-4\" />\r\n            <span>\r\n              {formatDistanceToNow(new Date(group.created_at), {\r\n                addSuffix: true,\r\n                locale: dateLocale,\r\n              })}\r\n            </span>\r\n          </div>\r\n          <div className=\"flex items-center gap-1\">\r\n            <span>{t('header.tabCount', { count: group.items?.length || 0 })}</span>\r\n          </div>\r\n        </div>\r\n      </div>\r\n\r\n      {/* Action Buttons */}\r\n      <div className=\"flex items-center gap-2 ml-4\">\r\n        {isMobile ? (\r\n          <DropdownMenu\r\n            trigger={\r\n              <button className=\"p-2 text-muted-foreground hover:bg-muted rounded transition-colors\">\r\n                <MoreVertical className=\"w-5 h-5\" />\r\n              </button>\r\n            }\r\n            items={[\r\n              {\r\n                label: t('action.openAll'),\r\n                icon: <FolderOpen className=\"w-4 h-4\" />,\r\n                onClick: onOpenAll,\r\n                disabled: !group.items || group.items.length === 0,\r\n              },\r\n              {\r\n                label: t('action.export'),\r\n                icon: <Download className=\"w-4 h-4\" />,\r\n                onClick: onExport,\r\n                disabled: !group.items || group.items.length === 0,\r\n              },\r\n              {\r\n                label: t('action.share'),\r\n                icon: <Share2 className=\"w-4 h-4\" />,\r\n                onClick: onShareClick,\r\n              },\r\n              {\r\n                label: isDeleting ? t('action.deleting') : t('action.delete'),\r\n                icon: <Trash2 className=\"w-4 h-4\" />,\r\n                onClick: onDelete,\r\n                disabled: isDeleting,\r\n                danger: true,\r\n              },\r\n            ]}\r\n          />\r\n        ) : (\r\n          <>\r\n            <button\r\n              onClick={onOpenAll}\r\n              disabled={!group.items || group.items.length === 0}\r\n              className=\"p-2 text-muted-foreground hover:bg-muted rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed\"\r\n              title={t('action.openAll')}\r\n            >\r\n              <FolderOpen className=\"w-5 h-5\" />\r\n            </button>\r\n            <button\r\n              onClick={onExport}\r\n              disabled={!group.items || group.items.length === 0}\r\n              className=\"p-2 text-muted-foreground hover:bg-muted rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed\"\r\n              title={t('action.export')}\r\n            >\r\n              <Download className=\"w-5 h-5\" />\r\n            </button>\r\n            <button\r\n              onClick={onShareClick}\r\n              className=\"p-2 text-muted-foreground hover:bg-muted rounded transition-colors\"\r\n              title={t('action.share')}\r\n            >\r\n              <Share2 className=\"w-5 h-5\" />\r\n            </button>\r\n            <button\r\n              onClick={onDelete}\r\n              disabled={isDeleting}\r\n              className=\"p-2 text-destructive hover:bg-destructive/10 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed\"\r\n              title={isDeleting ? t('action.deleting') : t('action.delete')}\r\n            >\r\n              <Trash2 className=\"w-5 h-5\" />\r\n            </button>\r\n          </>\r\n        )}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/TabGroupSidebar.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport type { TabGroup } from '@/lib/types'\r\nimport { ChevronRight, ChevronDown, Circle } from 'lucide-react'\r\nimport { useState } from 'react'\r\n\r\ninterface TabGroupSidebarProps {\r\n  tabGroups: TabGroup[]\r\n  selectedGroupId: string | null\r\n  onSelectGroup: (groupId: string | null) => void\r\n}\r\n\r\nexport function TabGroupSidebar({\r\n  tabGroups,\r\n  selectedGroupId,\r\n  onSelectGroup,\r\n}: TabGroupSidebarProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())\r\n  const totalCount = tabGroups.reduce((sum, group) => sum + (group.item_count || 0), 0)\r\n\r\n  const toggleGroup = (groupId: string, e: React.MouseEvent) => {\r\n    e.stopPropagation()\r\n    setExpandedGroups((prev) => {\r\n      const next = new Set(prev)\r\n      if (next.has(groupId)) {\r\n        next.delete(groupId)\r\n      } else {\r\n        next.add(groupId)\r\n      }\r\n      return next\r\n    })\r\n  }\r\n\r\n  return (\r\n    <div className=\"w-full h-full bg-card border-r border-border flex flex-col overflow-hidden\">\r\n      {/* Header */}\r\n      <div className=\"px-3 py-2 border-b border-border\">\r\n        <div className=\"text-xs font-semibold text-muted-foreground uppercase tracking-wider\">\r\n          {t('sidebar.title')}\r\n        </div>\r\n      </div>\r\n\r\n      {/* List */}\r\n      <div className=\"flex-1 overflow-y-auto\">\r\n        {/* 全部 */}\r\n        <div\r\n          onClick={() => onSelectGroup(null)}\r\n          className={`group flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-muted ${\r\n            selectedGroupId === null ? 'bg-primary/10' : ''\r\n          }`}\r\n        >\r\n          <div className=\"w-4 h-4 flex items-center justify-center\">\r\n            <Circle className={`w-2 h-2 ${selectedGroupId === null ? 'fill-primary text-primary' : 'text-muted-foreground'}`} />\r\n          </div>\r\n          <span className={`text-sm flex-1 ${selectedGroupId === null ? 'text-primary font-medium' : 'text-foreground'}`}>\r\n            {t('sidebar.all')}\r\n          </span>\r\n          <span className=\"text-xs text-muted-foreground\">{totalCount}</span>\r\n        </div>\r\n\r\n        {/* 标签页组列表 */}\r\n        {tabGroups.length === 0 ? (\r\n          <div className=\"px-3 py-8 text-center\">\r\n            <p className=\"text-xs text-muted-foreground/50\">{t('sidebar.noGroups')}</p>\r\n          </div>\r\n        ) : (\r\n          tabGroups.map((group) => {\r\n            const isSelected = selectedGroupId === group.id\r\n            const isExpanded = expandedGroups.has(group.id)\r\n            const hasItems = (group.items?.length || 0) > 0\r\n\r\n            return (\r\n              <div key={group.id}>\r\n                {/* 分组行 */}\r\n                <div\r\n                  className={`group flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-muted ${\r\n                    isSelected ? 'bg-primary/10' : ''\r\n                  }`}\r\n                >\r\n                  {/* 展开/折叠按钮 */}\r\n                  <button\r\n                    onClick={(e) => toggleGroup(group.id, e)}\r\n                    className=\"w-4 h-4 flex items-center justify-center hover:bg-muted rounded\"\r\n                  >\r\n                    {hasItems ? (\r\n                      isExpanded ? (\r\n                        <ChevronDown className=\"w-3 h-3 text-muted-foreground\" />\r\n                      ) : (\r\n                        <ChevronRight className=\"w-3 h-3 text-muted-foreground\" />\r\n                      )\r\n                    ) : (\r\n                      <div className=\"w-3 h-3\" />\r\n                    )}\r\n                  </button>\r\n\r\n                  {/* 圆点 */}\r\n                  <Circle\r\n                    className={`w-2 h-2 flex-shrink-0 text-primary ${isSelected ? 'fill-current' : ''}`}\r\n                  />\r\n\r\n                  {/* 标题 */}\r\n                  <span\r\n                    onClick={() => onSelectGroup(group.id)}\r\n                    className={`text-sm flex-1 truncate ${\r\n                      isSelected ? 'text-primary font-medium' : 'text-foreground'\r\n                    }`}\r\n                  >\r\n                    {group.title}\r\n                  </span>\r\n\r\n                  {/* 数量 */}\r\n                  <span className=\"text-xs text-muted-foreground\">{group.item_count || 0}</span>\r\n                </div>\r\n\r\n                {/* 子项列表 */}\r\n                {isExpanded && hasItems && (\r\n                  <div className=\"bg-muted/30\">\r\n                    {group.items?.slice(0, 10).map((item) => (\r\n                      <div\r\n                        key={item.id}\r\n                        className=\"flex items-center gap-2 px-3 py-1 pl-11 hover:bg-muted cursor-pointer\"\r\n                        onClick={() => { try { window.open(item.url, '_blank') } catch { /* invalid URL */ } }}\r\n                      >\r\n                        <Circle className=\"w-1.5 h-1.5 text-muted-foreground\" />\r\n                        <span className=\"text-xs text-muted-foreground truncate flex-1\">{item.title}</span>\r\n                      </div>\r\n                    ))}\r\n                    {(group.items?.length || 0) > 10 && (\r\n                      <div className=\"px-3 py-1 pl-11 text-xs text-muted-foreground/70\">\r\n                        {t('sidebar.moreItems', { count: (group.items?.length || 0) - 10 })}\r\n                      </div>\r\n                    )}\r\n                  </div>\r\n                )}\r\n              </div>\r\n            )\r\n          })\r\n        )}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/TabGroupTree.tsx",
    "content": "import { useState, useEffect } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { FolderPlus, Circle, Folder } from 'lucide-react'\r\nimport { DndContext, DragOverlay } from '@dnd-kit/core'\r\nimport { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'\r\nimport type { TabGroup } from '@/lib/types'\r\nimport { TreeNode } from './tree/TreeNode'\r\nimport { buildTree } from './tree/TreeUtils'\r\nimport { useDragAndDrop } from './tree/useDragAndDrop'\r\nimport { MoveToFolderDialog } from './MoveToFolderDialog'\r\n\r\ninterface TabGroupTreeProps {\r\n  tabGroups: TabGroup[]\r\n  selectedGroupId: string | null\r\n  onSelectGroup: (groupId: string | null) => void\r\n  onCreateFolder?: () => void\r\n  onRenameGroup?: (groupId: string, newTitle: string) => Promise<void>\r\n  onMoveGroup?: (groupId: string, newParentId: string | null, newPosition: number) => Promise<void>\r\n  onRefresh?: () => Promise<void>\r\n}\r\n\r\nexport function TabGroupTree({\r\n  tabGroups,\r\n  selectedGroupId,\r\n  onSelectGroup,\r\n  onCreateFolder,\r\n  onRenameGroup,\r\n  onMoveGroup,\r\n  onRefresh,\r\n}: TabGroupTreeProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  \r\n  // 状态管理\r\n  const [expandedGroups, setExpandedGroups] = useState<Set<string>>(() => {\r\n    const folderIds = tabGroups.filter(g => g.is_folder === 1).map(g => g.id)\r\n    return new Set(folderIds)\r\n  })\r\n  const [editingGroupId, setEditingGroupId] = useState<string | null>(null)\r\n  const [editingTitle, setEditingTitle] = useState('')\r\n  const [moveDialogOpen, setMoveDialogOpen] = useState(false)\r\n  const [movingGroup, setMovingGroup] = useState<TabGroup | null>(null)\r\n\r\n  // 当 tabGroups 变化时，自动展开新增的文件夹\r\n  useEffect(() => {\r\n    const folderIds = tabGroups.filter(g => g.is_folder === 1).map(g => g.id)\r\n    setExpandedGroups(prev => {\r\n      const next = new Set(prev)\r\n      folderIds.forEach(id => next.add(id))\r\n      return next\r\n    })\r\n  }, [tabGroups])\r\n\r\n  // 拖拽功能\r\n  const {\r\n    sensors,\r\n    collisionDetection,\r\n    activeId,\r\n    overId,\r\n    dropPosition,\r\n    handleDragStart,\r\n    handleDragOver,\r\n    handleDragEnd,\r\n    handleDragCancel\r\n  } = useDragAndDrop({ tabGroups, onMoveGroup })\r\n\r\n  // 展开/折叠\r\n  const toggleGroup = (groupId: string, e: React.MouseEvent) => {\r\n    e.stopPropagation()\r\n    setExpandedGroups((prev) => {\r\n      const next = new Set(prev)\r\n      if (next.has(groupId)) {\r\n        next.delete(groupId)\r\n      } else {\r\n        next.add(groupId)\r\n      }\r\n      return next\r\n    })\r\n  }\r\n\r\n  // 构建树形结构\r\n  const treeData = buildTree(tabGroups)\r\n  const allIds = tabGroups.map(g => g.id)\r\n  const totalCount = tabGroups.reduce((sum, group) => {\r\n    if (group.is_folder === 1) return sum\r\n    return sum + (group.item_count || 0)\r\n  }, 0)\r\n\r\n  return (\r\n    <>\r\n      <DndContext\r\n        sensors={sensors}\r\n        collisionDetection={collisionDetection}\r\n        onDragStart={handleDragStart}\r\n        onDragOver={handleDragOver}\r\n        onDragEnd={handleDragEnd}\r\n        onDragCancel={handleDragCancel}\r\n      >\r\n        <div className=\"w-full h-full bg-card border-r border-border flex flex-col overflow-hidden\">\r\n          {/* Header */}\r\n          <div className=\"px-3 py-2 border-b border-border flex items-center justify-between flex-shrink-0\">\r\n            <div className=\"text-xs font-semibold text-muted-foreground uppercase tracking-wider\">\r\n              {t('title')}\r\n            </div>\r\n            {onCreateFolder && (\r\n              <button\r\n                onClick={onCreateFolder}\r\n                className=\"w-6 h-6 flex items-center justify-center hover:bg-muted rounded transition-colors\"\r\n                title={t('menu.createFolder')}\r\n              >\r\n                <FolderPlus className=\"w-4 h-4 text-muted-foreground\" />\r\n              </button>\r\n            )}\r\n          </div>\r\n\r\n          {/* List */}\r\n          <SortableContext items={allIds} strategy={verticalListSortingStrategy}>\r\n            <div className=\"flex-1 overflow-y-auto min-h-0\">\r\n              {/* All - root node */}\r\n              <div className=\"relative\">\r\n                <div\r\n                  onClick={() => onSelectGroup(null)}\r\n                  className={`group flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-muted ${\r\n                    selectedGroupId === null ? 'bg-primary/10' : ''\r\n                  }`}\r\n                >\r\n                  <div className=\"w-4 h-4 flex items-center justify-center\">\r\n                    <Circle className={`w-2 h-2 ${selectedGroupId === null ? 'fill-primary text-primary' : 'text-muted-foreground'}`} />\r\n                  </div>\r\n                  <span className={`text-sm flex-1 ${selectedGroupId === null ? 'text-primary font-medium' : 'text-foreground'}`}>\r\n                    {t('filter.all', { ns: 'bookmarks' })}\r\n                  </span>\r\n                  <span className=\"text-xs text-muted-foreground\">{totalCount}</span>\r\n                </div>\r\n\r\n                {/* Tree list */}\r\n                {treeData.length === 0 ? (\r\n                  <div className=\"px-3 py-8 text-center\">\r\n                    <p className=\"text-xs text-muted-foreground/50\">{t('empty.title')}</p>\r\n                  </div>\r\n                ) : (\r\n                  <div className=\"relative\">\r\n                    {/* Vertical line from \"All\" */}\r\n                    {treeData.length > 0 && (\r\n                      <div\r\n                        className=\"absolute pointer-events-none top-0 bottom-0\"\r\n                        style={{\r\n                          left: '20px',\r\n                          width: '1px',\r\n                          backgroundColor: 'var(--border)',\r\n                        }}\r\n                      />\r\n                    )}\r\n\r\n                    {treeData.map((group, index) => (\r\n                      <TreeNode\r\n                        key={group.id}\r\n                        group={group}\r\n                        level={1}\r\n                        isLast={index === treeData.length - 1}\r\n                        parentLines={[true]}\r\n                        selectedGroupId={selectedGroupId}\r\n                        onSelectGroup={onSelectGroup}\r\n                        expandedGroups={expandedGroups}\r\n                        toggleGroup={toggleGroup}\r\n                        editingGroupId={editingGroupId}\r\n                        setEditingGroupId={setEditingGroupId}\r\n                        editingTitle={editingTitle}\r\n                        setEditingTitle={setEditingTitle}\r\n                        onRenameGroup={onRenameGroup}\r\n                        onRefresh={onRefresh}\r\n                        activeId={activeId}\r\n                        overId={overId}\r\n                        dropPosition={dropPosition}\r\n                        onOpenMoveDialog={(group) => {\r\n                          setMovingGroup(group)\r\n                          setMoveDialogOpen(true)\r\n                        }}\r\n                      />\r\n                    ))}\r\n                  </div>\r\n                )}\r\n              </div>\r\n            </div>\r\n          </SortableContext>\r\n        </div>\r\n\r\n        {/* DragOverlay - 拖拽时显示的浮动元素 */}\r\n        <DragOverlay>\r\n          {activeId ? (\r\n            <div\r\n              className=\"bg-card border-2 border-primary rounded shadow-xl cursor-grabbing px-3 py-1.5 opacity-95\"\r\n              style={{\r\n                transform: 'scale(1.05)',\r\n              }}\r\n            >\r\n              {(() => {\r\n                const draggedGroup = tabGroups.find(g => g.id === activeId)\r\n                if (!draggedGroup) return null\r\n                const isFolder = draggedGroup.is_folder === 1\r\n                return (\r\n                  <div className=\"flex items-center gap-2\">\r\n                    {isFolder ? (\r\n                      <Folder className=\"w-3.5 h-3.5 text-primary\" />\r\n                    ) : (\r\n                      <Circle className=\"w-2 h-2 text-primary fill-current\" />\r\n                    )}\r\n                    <span className=\"text-sm font-medium text-foreground\">{draggedGroup.title}</span>\r\n                  </div>\r\n                )\r\n              })()}\r\n            </div>\r\n          ) : null}\r\n        </DragOverlay>\r\n      </DndContext>\r\n\r\n      {/* 移动对话框 */}\r\n      {moveDialogOpen && movingGroup && (\r\n        <MoveToFolderDialog\r\n          isOpen={moveDialogOpen}\r\n          currentGroup={movingGroup}\r\n          allGroups={tabGroups}\r\n          onConfirm={async (targetFolderId) => {\r\n            if (onMoveGroup) {\r\n              await onMoveGroup(movingGroup.id, targetFolderId, 0)\r\n              await onRefresh?.()\r\n            }\r\n            setMoveDialogOpen(false)\r\n            setMovingGroup(null)\r\n          }}\r\n          onCancel={() => {\r\n            setMoveDialogOpen(false)\r\n            setMovingGroup(null)\r\n          }}\r\n        />\r\n      )}\r\n    </>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/TabItem.tsx",
    "content": "import { ExternalLink, Trash2, Edit2, Pin, CheckSquare, Check, X, GripVertical, FolderInput, MoreVertical } from 'lucide-react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport type { TabGroupItem } from '@/lib/types'\r\nimport { useSortable } from '@dnd-kit/sortable'\r\nimport { CSS } from '@dnd-kit/utilities'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\nimport { DropdownMenu } from '@/components/common/DropdownMenu'\r\n\r\ninterface TabItemProps {\r\n  item: TabGroupItem\r\n  groupId: string\r\n  isHighlighted: boolean\r\n  isSelected: boolean\r\n  batchMode: boolean\r\n  editingItemId: string | null\r\n  editingTitle: string\r\n  onItemClick: (item: TabGroupItem, e: React.MouseEvent | React.ChangeEvent<HTMLInputElement>) => void\r\n  onEditItem: (item: TabGroupItem) => void\r\n  onSaveEdit: (groupId: string, itemId: string) => void\r\n  onTogglePin: (groupId: string, itemId: string, currentPinned: boolean) => void\r\n  onToggleTodo: (groupId: string, itemId: string, currentTodo: boolean) => void\r\n  onDeleteItem: (groupId: string, itemId: string, title: string) => void\r\n  onMoveItem?: (item: TabGroupItem) => void\r\n  setEditingItemId: (id: string | null) => void\r\n  setEditingTitle: (title: string) => void\r\n  extractDomain: (url: string) => string\r\n}\r\n\r\nexport function TabItem({\r\n  item,\r\n  groupId,\r\n  isHighlighted,\r\n  isSelected,\r\n  batchMode,\r\n  editingItemId,\r\n  editingTitle,\r\n  onItemClick,\r\n  onEditItem,\r\n  onSaveEdit,\r\n  onTogglePin,\r\n  onToggleTodo,\r\n  onDeleteItem,\r\n  onMoveItem,\r\n  setEditingItemId,\r\n  setEditingTitle,\r\n  extractDomain,\r\n}: TabItemProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const isMobile = useIsMobile()\r\n\r\n  const {\r\n    attributes,\r\n    listeners,\r\n    setNodeRef,\r\n    transform,\r\n    isDragging,\r\n  } = useSortable({\r\n    id: item.id,\r\n    animateLayoutChanges: () => false, // 禁用布局动画，避免闪烁\r\n  })\r\n\r\n  const style = {\r\n    transform: CSS.Transform.toString(transform),\r\n    transition: 'none', // 完全禁用 transition，避免从下往上拖拽时闪烁\r\n    opacity: isDragging ? 0.4 : 1,\r\n  }\r\n\r\n  const isEditing = editingItemId === item.id\r\n\r\n  return (\r\n    <div\r\n      ref={setNodeRef}\r\n      style={style}\r\n      className={`group flex items-center gap-3 rounded border ${\r\n        isMobile ? 'p-4 min-h-[60px]' : 'p-3'\r\n      } ${\r\n        isHighlighted\r\n          ? 'bg-warning/10 border-warning/30'\r\n          : isSelected\r\n            ? 'bg-primary/10 border-primary/30'\r\n            : 'bg-card border-border hover:bg-muted'\r\n      }`}\r\n    >\r\n      {/* Drag Handle */}\r\n      {!batchMode && (\r\n        <button\r\n          {...attributes}\r\n          {...listeners}\r\n          className=\"cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground\"\r\n        >\r\n          <GripVertical className=\"w-4 h-4\" />\r\n        </button>\r\n      )}\r\n\r\n      {/* Checkbox for batch mode */}\r\n      {batchMode && (\r\n        <input\r\n          type=\"checkbox\"\r\n          checked={isSelected}\r\n          onChange={(e) => {\r\n            e.stopPropagation()\r\n            onItemClick(item, e)\r\n          }}\r\n          className=\"checkbox\"\r\n        />\r\n      )}\r\n\r\n      {/* Favicon */}\r\n      <img\r\n        src={item.favicon || `https://www.google.com/s2/favicons?domain=${extractDomain(item.url)}&sz=32`}\r\n        alt=\"\"\r\n        className=\"w-5 h-5 flex-shrink-0\"\r\n        onError={(e) => {\r\n          const target = e.currentTarget\r\n          const googleFaviconUrl = `https://www.google.com/s2/favicons?domain=${extractDomain(item.url)}&sz=32`\r\n          // 如果当前不是 Google Favicon API，先尝试它\r\n          if (!target.src.includes('google.com/s2/favicons')) {\r\n            target.src = googleFaviconUrl\r\n          } else {\r\n            // 最终回退到默认图标\r\n            target.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"%236b7280\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"/><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"/></svg>')\r\n          }\r\n        }}\r\n      />\r\n\r\n      {/* Title and URL */}\r\n      <div className=\"flex-1 min-w-0\">\r\n        {isEditing ? (\r\n          <input\r\n            type=\"text\"\r\n            value={editingTitle}\r\n            onChange={(e) => setEditingTitle(e.target.value)}\r\n            onKeyDown={(e) => {\r\n              if (e.key === 'Enter') {\r\n                onSaveEdit(groupId, item.id)\r\n              } else if (e.key === 'Escape') {\r\n                setEditingItemId(null)\r\n              }\r\n            }}\r\n            className=\"input w-full text-sm\"\r\n            autoFocus\r\n          />\r\n        ) : (\r\n          <>\r\n            <div className=\"flex items-center gap-2\">\r\n              <a\r\n                href={item.url}\r\n                target=\"_blank\"\r\n                rel=\"noopener noreferrer\"\r\n                className=\"text-sm font-medium text-foreground hover:text-primary truncate\"\r\n                onClick={(e) => !batchMode && e.stopPropagation()}\r\n              >\r\n                {item.title}\r\n              </a>\r\n              {item.is_pinned && (\r\n                <Pin className=\"w-3 h-3 text-warning flex-shrink-0\" />\r\n              )}\r\n              {item.is_todo && (\r\n                <CheckSquare className=\"w-3 h-3 text-accent flex-shrink-0\" />\r\n              )}\r\n            </div>\r\n            <p className=\"text-xs text-muted-foreground truncate\">{item.url}</p>\r\n          </>\r\n        )}\r\n      </div>\r\n\r\n      {/* Actions */}\r\n      {!batchMode && (\r\n        <div className={`flex items-center gap-1 ${isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>\r\n          {isEditing ? (\r\n            <>\r\n              <button\r\n                onClick={() => onSaveEdit(groupId, item.id)}\r\n                className=\"p-1.5 text-success hover:bg-success/10 rounded transition-colors\"\r\n                title={t('item.save')}\r\n              >\r\n                <Check className=\"w-4 h-4\" />\r\n              </button>\r\n              <button\r\n                onClick={() => setEditingItemId(null)}\r\n                className=\"p-1.5 text-muted-foreground hover:bg-muted rounded transition-colors\"\r\n                title={t('item.cancel')}\r\n              >\r\n                <X className=\"w-4 h-4\" />\r\n              </button>\r\n            </>\r\n          ) : isMobile ? (\r\n            /* Mobile: use dropdown menu */\r\n            <DropdownMenu\r\n              trigger={\r\n                <button className=\"p-2 text-muted-foreground hover:bg-muted rounded transition-colors\">\r\n                  <MoreVertical className=\"w-5 h-5\" />\r\n                </button>\r\n              }\r\n              items={[\r\n                {\r\n                  label: t('menu.openLink'),\r\n                  icon: <ExternalLink className=\"w-4 h-4\" />,\r\n                  onClick: () => window.open(item.url, '_blank'),\r\n                },\r\n                {\r\n                  label: t('item.edit'),\r\n                  icon: <Edit2 className=\"w-4 h-4\" />,\r\n                  onClick: () => onEditItem(item),\r\n                },\r\n                {\r\n                  label: item.is_pinned ? t('menu.unpin') : t('menu.pin'),\r\n                  icon: <Pin className=\"w-4 h-4\" />,\r\n                  onClick: () => onTogglePin(groupId, item.id, item.is_pinned || false),\r\n                },\r\n                {\r\n                  label: item.is_todo ? t('menu.unmarkTodo') : t('menu.markTodo'),\r\n                  icon: <CheckSquare className=\"w-4 h-4\" />,\r\n                  onClick: () => onToggleTodo(groupId, item.id, item.is_todo || false),\r\n                },\r\n                ...(onMoveItem ? [{\r\n                  label: t('menu.moveToOtherGroup'),\r\n                  icon: <FolderInput className=\"w-4 h-4\" />,\r\n                  onClick: () => onMoveItem(item),\r\n                }] : []),\r\n                {\r\n                  label: t('item.delete'),\r\n                  icon: <Trash2 className=\"w-4 h-4\" />,\r\n                  onClick: () => onDeleteItem(groupId, item.id, item.title),\r\n                  danger: true,\r\n                },\r\n              ]}\r\n            />\r\n          ) : (\r\n            /* Desktop: show all buttons */\r\n            <>\r\n              <a\r\n                href={item.url}\r\n                target=\"_blank\"\r\n                rel=\"noopener noreferrer\"\r\n                className=\"p-1.5 text-muted-foreground hover:bg-muted rounded transition-colors\"\r\n                title={t('menu.openLink')}\r\n              >\r\n                <ExternalLink className=\"w-4 h-4\" />\r\n              </a>\r\n              <button\r\n                onClick={() => onEditItem(item)}\r\n                className=\"p-1.5 text-muted-foreground hover:bg-muted rounded transition-colors\"\r\n                title={t('item.edit')}\r\n              >\r\n                <Edit2 className=\"w-4 h-4\" />\r\n              </button>\r\n              <button\r\n                onClick={() => onTogglePin(groupId, item.id, item.is_pinned || false)}\r\n                className={`p-1.5 rounded transition-colors ${\r\n                  item.is_pinned\r\n                    ? 'text-warning bg-warning/10 hover:bg-warning/20'\r\n                    : 'text-muted-foreground hover:bg-muted'\r\n                }`}\r\n                title={item.is_pinned ? t('menu.unpin') : t('menu.pin')}\r\n              >\r\n                <Pin className=\"w-4 h-4\" />\r\n              </button>\r\n              <button\r\n                onClick={() => onToggleTodo(groupId, item.id, item.is_todo || false)}\r\n                className={`p-1.5 rounded transition-colors ${\r\n                  item.is_todo\r\n                    ? 'text-accent bg-accent/10 hover:bg-accent/20'\r\n                    : 'text-muted-foreground hover:bg-muted'\r\n                }`}\r\n                title={item.is_todo ? t('menu.unmarkTodo') : t('menu.markTodo')}\r\n              >\r\n                <CheckSquare className=\"w-4 h-4\" />\r\n              </button>\r\n              {onMoveItem && (\r\n                <button\r\n                  onClick={() => onMoveItem(item)}\r\n                  className=\"p-1.5 text-muted-foreground hover:bg-muted rounded transition-colors\"\r\n                  title={t('menu.moveToOtherGroup')}\r\n                >\r\n                  <FolderInput className=\"w-4 h-4\" />\r\n                </button>\r\n              )}\r\n              <button\r\n                onClick={() => onDeleteItem(groupId, item.id, item.title)}\r\n                className=\"p-1.5 text-destructive hover:bg-destructive/10 rounded transition-colors\"\r\n                title={t('item.delete')}\r\n              >\r\n                <Trash2 className=\"w-4 h-4\" />\r\n              </button>\r\n            </>\r\n          )}\r\n        </div>\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/TabItemList.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { TabItem } from './TabItem'\r\nimport type { TabGroupItem } from '@/lib/types'\r\nimport {\r\n  SortableContext,\r\n  verticalListSortingStrategy,\r\n} from '@dnd-kit/sortable'\r\n\r\ninterface TabItemListProps {\r\n  items: TabGroupItem[]\r\n  groupId: string\r\n  highlightedDomain: string | null\r\n  selectedItems: Set<string>\r\n  batchMode: boolean\r\n  editingItemId: string | null\r\n  editingTitle: string\r\n  onItemClick: (item: TabGroupItem, e: React.MouseEvent | React.ChangeEvent<HTMLInputElement>) => void\r\n  onEditItem: (item: TabGroupItem) => void\r\n  onSaveEdit: (groupId: string, itemId: string) => void\r\n  onTogglePin: (groupId: string, itemId: string, currentPinned: boolean) => void\r\n  onToggleTodo: (groupId: string, itemId: string, currentTodo: boolean) => void\r\n  onDeleteItem: (groupId: string, itemId: string, title: string) => void\r\n  onMoveItem?: (item: TabGroupItem) => void\r\n  setEditingItemId: (id: string | null) => void\r\n  setEditingTitle: (title: string) => void\r\n  extractDomain: (url: string) => string\r\n}\r\n\r\nexport function TabItemList({\r\n  items,\r\n  groupId,\r\n  highlightedDomain,\r\n  selectedItems,\r\n  batchMode,\r\n  editingItemId,\r\n  editingTitle,\r\n  onItemClick,\r\n  onEditItem,\r\n  onSaveEdit,\r\n  onTogglePin,\r\n  onToggleTodo,\r\n  onDeleteItem,\r\n  onMoveItem,\r\n  setEditingItemId,\r\n  setEditingTitle,\r\n  extractDomain,\r\n}: TabItemListProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  \r\n  if (!items || items.length === 0) {\r\n    return (\r\n      <div className=\"text-center py-8 text-muted-foreground\">\r\n        {t('message.noTabsInGroup')}\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <SortableContext\r\n      items={items.map((item) => item.id)}\r\n      strategy={verticalListSortingStrategy}\r\n    >\r\n      <div className=\"space-y-2\">\r\n        {items.map((item) => {\r\n          const domain = extractDomain(item.url)\r\n          const isHighlighted = highlightedDomain === domain\r\n          const isSelected = selectedItems.has(item.id)\r\n\r\n          return (\r\n            <TabItem\r\n              key={item.id}\r\n              item={item}\r\n              groupId={groupId}\r\n              isHighlighted={isHighlighted}\r\n              isSelected={isSelected}\r\n              batchMode={batchMode}\r\n              editingItemId={editingItemId}\r\n              editingTitle={editingTitle}\r\n              onItemClick={onItemClick}\r\n              onEditItem={onEditItem}\r\n              onSaveEdit={onSaveEdit}\r\n              onTogglePin={onTogglePin}\r\n              onToggleTodo={onToggleTodo}\r\n              onDeleteItem={onDeleteItem}\r\n              onMoveItem={onMoveItem}\r\n              setEditingItemId={setEditingItemId}\r\n              setEditingTitle={setEditingTitle}\r\n              extractDomain={extractDomain}\r\n            />\r\n          )\r\n        })}\r\n      </div>\r\n    </SortableContext>\r\n  )\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/TagsInput.tsx",
    "content": "import { useState, useEffect, useRef } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { X, Plus } from 'lucide-react'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\n\r\ninterface TagsInputProps {\r\n  tags: string[]\r\n  onTagsChange: (tags: string[]) => void\r\n  onClose: () => void\r\n}\r\n\r\nexport function TagsInput({ tags, onTagsChange, onClose }: TagsInputProps) {\r\n  const { t } = useTranslation('tags')\r\n  const [inputValue, setInputValue] = useState('')\r\n  const inputRef = useRef<HTMLDivElement>(null)\r\n\r\n  useEffect(() => {\r\n    const handleClickOutside = (event: MouseEvent) => {\r\n      if (inputRef.current && !inputRef.current.contains(event.target as Node)) {\r\n        onClose()\r\n      }\r\n    }\r\n\r\n    document.addEventListener('mousedown', handleClickOutside)\r\n    return () => {\r\n      document.removeEventListener('mousedown', handleClickOutside)\r\n    }\r\n  }, [onClose])\r\n\r\n  const handleAddTag = () => {\r\n    const trimmed = inputValue.trim()\r\n    if (trimmed && !tags.includes(trimmed)) {\r\n      onTagsChange([...tags, trimmed])\r\n      setInputValue('')\r\n    }\r\n  }\r\n\r\n  const handleRemoveTag = (tagToRemove: string) => {\r\n    onTagsChange(tags.filter((tag) => tag !== tagToRemove))\r\n  }\r\n\r\n  const handleKeyDown = (e: React.KeyboardEvent) => {\r\n    if (e.key === 'Enter') {\r\n      e.preventDefault()\r\n      handleAddTag()\r\n    } else if (e.key === 'Escape') {\r\n      onClose()\r\n    }\r\n  }\r\n\r\n  return (\r\n    <div\r\n      ref={inputRef}\r\n      className=\"absolute top-full right-0 mt-2 rounded-lg shadow-xl border p-4 z-50 w-80\"\r\n      style={{ backgroundColor: 'var(--card)', borderColor: 'var(--border)' }}\r\n    >\r\n      <div className=\"mb-3\">\r\n        <div className=\"flex gap-2\">\r\n          <input\r\n            type=\"text\"\r\n            value={inputValue}\r\n            onChange={(e) => setInputValue(e.target.value)}\r\n            onKeyDown={handleKeyDown}\r\n            placeholder={t('form.placeholder')}\r\n            className=\"flex-1 px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary text-foreground\"\r\n            style={{ backgroundColor: 'var(--card)', borderColor: 'var(--border)' }}\r\n            autoFocus\r\n          />\r\n          <button\r\n            onClick={handleAddTag}\r\n            disabled={!inputValue.trim()}\r\n            className=\"px-3 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed\"\r\n          >\r\n            <Plus className=\"w-5 h-5\" />\r\n          </button>\r\n        </div>\r\n      </div>\r\n\r\n      {tags.length > 0 && (\r\n        <div className=\"flex flex-wrap gap-2 mb-3\">\r\n          {tags.map((tag) => (\r\n            <span\r\n              key={tag}\r\n              className=\"inline-flex items-center gap-1 px-3 py-1 bg-primary/10 text-primary rounded-full text-sm\"\r\n            >\r\n              {tag}\r\n              <button\r\n                onClick={() => handleRemoveTag(tag)}\r\n                className=\"hover:bg-primary/20 rounded-full p-0.5\"\r\n              >\r\n                <X className=\"w-3 h-3\" />\r\n              </button>\r\n            </span>\r\n          ))}\r\n        </div>\r\n      )}\r\n\r\n      <div className=\"flex justify-end gap-2 pt-3 border-t\" style={{ borderColor: 'var(--border)' }}>\r\n        <button\r\n          onClick={onClose}\r\n          className=\"px-4 py-2 text-foreground hover:bg-muted rounded-lg\"\r\n        >\r\n          {t('action.cancel')}\r\n        </button>\r\n        <button\r\n          onClick={onClose}\r\n          className=\"px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90\"\r\n        >\r\n          {t('action.done')}\r\n        </button>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n\r\nexport function TagsList({ tags }: { tags: string[] | null }) {\r\n  const isMobile = useIsMobile()\r\n\r\n  if (!tags || tags.length === 0) return null\r\n\r\n  return (\r\n    <div\r\n      className={`flex gap-1.5 ${\r\n        isMobile\r\n          ? 'overflow-x-auto scrollbar-hide -mx-1 px-1'\r\n          : 'flex-wrap'\r\n      }`}\r\n      style={isMobile ? {\r\n        scrollbarWidth: 'none',\r\n        msOverflowStyle: 'none',\r\n        WebkitOverflowScrolling: 'touch'\r\n      } : undefined}\r\n    >\r\n      {tags.map((tag) => (\r\n        <span\r\n          key={tag}\r\n          className={`inline-flex items-center px-2 py-0.5 bg-primary/10 text-primary rounded text-xs ${\r\n            isMobile ? 'flex-shrink-0' : ''\r\n          }`}\r\n        >\r\n          {tag}\r\n        </span>\r\n      ))}\r\n    </div>\r\n  )\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/TodoItemCard.tsx",
    "content": "import { useTranslation } from 'react-i18next'\nimport type { TabGroupItem } from '@/lib/types'\nimport { ExternalLink, Trash2, Check, CheckCircle2, Circle, MoreVertical, Edit2, FolderInput, Archive } from 'lucide-react'\nimport { formatDistanceToNow } from 'date-fns'\nimport type { Locale } from 'date-fns'\nimport { DropdownMenu } from '@/components/common/DropdownMenu'\n\ninterface TodoItemCardProps {\n  item: TabGroupItem\n  groupId: string\n  groupTitle: string\n  processingId: string | null\n  editingItemId: string | null\n  editingTitle: string\n  dateLocale: Locale\n  onToggleTodo: (itemId: string, currentStatus: boolean) => void\n  onDelete: (itemId: string) => void\n  onRename: (item: TabGroupItem) => void\n  onSaveRename: (itemId: string) => void\n  onOpenTab: (url: string) => void\n  onOpenInCurrentTab: (url: string) => void\n  onOpenInIncognito: () => void\n  onMove: (itemId: string, groupId: string) => void\n  onArchive: (itemId: string) => void\n  setEditingItemId: (id: string | null) => void\n  setEditingTitle: (title: string) => void\n}\n\nexport function TodoItemCard({\n  item,\n  groupId,\n  groupTitle,\n  processingId,\n  editingItemId,\n  editingTitle,\n  dateLocale,\n  onToggleTodo,\n  onDelete,\n  onRename,\n  onSaveRename,\n  onOpenTab,\n  onOpenInCurrentTab,\n  onOpenInIncognito,\n  onMove,\n  onArchive,\n  setEditingItemId,\n  setEditingTitle,\n}: TodoItemCardProps) {\n  const { t } = useTranslation('tabGroups')\n\n  const relativeTime = item.created_at\n    ? formatDistanceToNow(new Date(item.created_at), { addSuffix: true, locale: dateLocale })\n    : ''\n\n  return (\n    <div\n      className=\"group bg-card rounded-lg p-4 border border-border hover:shadow-md hover:border-primary/30 transition-all duration-200\"\n    >\n      {/* 标题和操作 */}\n      <div className=\"flex items-start gap-3\">\n        {/* 复选框 */}\n        <button\n          onClick={() => onToggleTodo(item.id, item.is_todo || false)}\n          disabled={processingId === item.id}\n          className={`flex-shrink-0 w-6 h-6 rounded border-2 flex items-center justify-center transition-all duration-200 ${\n            processingId === item.id\n              ? 'opacity-50 cursor-not-allowed'\n              : 'hover:scale-110 hover:border-primary'\n          } ${\n            item.is_todo\n              ? 'border-primary bg-primary/10'\n              : 'border-border hover:bg-muted'\n          }`}\n        >\n          {item.is_todo && (\n            <Check className=\"w-4 h-4 text-primary\" />\n          )}\n        </button>\n\n        {/* 内容 */}\n        <div className=\"flex-1 min-w-0\">\n          {editingItemId === item.id ? (\n            <div className=\"flex items-center gap-2\">\n              <input\n                type=\"text\"\n                value={editingTitle}\n                onChange={(e) => setEditingTitle(e.target.value)}\n                onKeyDown={(e) => {\n                  if (e.key === 'Enter') {\n                    onSaveRename(item.id)\n                  } else if (e.key === 'Escape') {\n                    setEditingItemId(null)\n                    setEditingTitle('')\n                  }\n                }}\n                className=\"input flex-1 text-sm\"\n                autoFocus\n              />\n              <button\n                onClick={() => onSaveRename(item.id)}\n                className=\"text-success hover:text-success/80\"\n              >\n                <Check className=\"w-4 h-4\" />\n              </button>\n              <button\n                onClick={() => {\n                  setEditingItemId(null)\n                  setEditingTitle('')\n                }}\n                className=\"text-muted-foreground hover:text-foreground\"\n              >\n                <Trash2 className=\"w-4 h-4\" />\n              </button>\n            </div>\n          ) : (\n            <h3 className=\"text-sm font-semibold text-foreground truncate group-hover:text-primary transition-colors\">\n              {item.title}\n            </h3>\n          )}\n\n          {/* 来源标签 */}\n          <div className=\"flex items-center gap-2 mt-2\">\n            <span className=\"inline-flex items-center gap-1 px-2 py-0.5 bg-primary/10 text-primary text-xs rounded-full font-medium\">\n              <Circle className=\"w-2 h-2 fill-current\" />\n              {groupTitle}\n            </span>\n            {relativeTime && (\n              <span className=\"text-xs text-muted-foreground/70\">\n                {relativeTime}\n              </span>\n            )}\n          </div>\n\n          {/* URL */}\n          {item.url && (\n            <div className=\"flex items-center gap-1 mt-2\">\n              <ExternalLink className=\"w-3 h-3 text-muted-foreground/70\" />\n              <p className=\"text-xs text-muted-foreground truncate\">\n                {(() => { try { return new URL(item.url).hostname } catch { return item.url } })()}\n              </p>\n            </div>\n          )}\n        </div>\n\n        {/* 三个点菜单 */}\n        <DropdownMenu\n          trigger={\n            <button className=\"flex-shrink-0 p-1 text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors\">\n              <MoreVertical className=\"w-4 h-4\" />\n            </button>\n          }\n          items={[\n            {\n              label: t('menu.openInNewWindow'),\n              icon: <ExternalLink className=\"w-4 h-4\" />,\n              onClick: () => onOpenTab(item.url),\n            },\n            {\n              label: t('menu.openInCurrentWindow'),\n              icon: <ExternalLink className=\"w-4 h-4\" />,\n              onClick: () => onOpenInCurrentTab(item.url),\n            },\n            {\n              label: t('menu.openInIncognito'),\n              icon: <ExternalLink className=\"w-4 h-4\" />,\n              onClick: () => onOpenInIncognito(),\n            },\n            {\n              label: t('menu.rename'),\n              icon: <Edit2 className=\"w-4 h-4\" />,\n              onClick: () => onRename(item),\n            },\n            {\n              label: item.is_todo ? t('todo.cancelTaskMark') : t('todo.markAsCompleted'),\n              icon: <CheckCircle2 className=\"w-4 h-4\" />,\n              onClick: () => onToggleTodo(item.id, item.is_todo || false),\n            },\n            {\n              label: t('todo.moveToOtherGroup'),\n              icon: <FolderInput className=\"w-4 h-4\" />,\n              onClick: () => onMove(item.id, groupId),\n            },\n            {\n              label: t('todo.markAsArchived'),\n              icon: <Archive className=\"w-4 h-4\" />,\n              onClick: () => onArchive(item.id),\n            },\n            {\n              label: t('menu.moveToTrash'),\n              icon: <Trash2 className=\"w-4 h-4\" />,\n              onClick: () => onDelete(item.id),\n              danger: true,\n            },\n          ]}\n        />\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/TodoSidebar.tsx",
    "content": "import { useState, useMemo } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport type { TabGroup, TabGroupItem } from '@/lib/types'\r\nimport { ListTodo, Circle } from 'lucide-react'\r\nimport { tabGroupsService } from '@/services/tab-groups'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { zhCN, enUS } from 'date-fns/locale'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\nimport { useInvalidateTabGroups } from '@/hooks/useTabGroupsQuery'\r\nimport { TodoItemCard } from './TodoItemCard'\r\n\r\ninterface TodoSidebarProps {\r\n  tabGroups: TabGroup[]\r\n}\r\n\r\nexport function TodoSidebar({ tabGroups }: TodoSidebarProps) {\r\n  const { t, i18n } = useTranslation('tabGroups')\r\n  const isMobile = useIsMobile()\r\n  const invalidateTabGroups = useInvalidateTabGroups()\r\n  const [processingId, setProcessingId] = useState<string | null>(null)\r\n  const [editingItemId, setEditingItemId] = useState<string | null>(null)\r\n  const [editingTitle, setEditingTitle] = useState('')\r\n  const [confirmState, setConfirmState] = useState<{\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n  } | null>(null)\r\n  const { success, error: showError } = useToastStore()\r\n  \r\n  const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS\r\n\r\n  // 收集所有TODO项并按创建时间排序\r\n  const sortedTodos = useMemo(() => {\r\n    const todoItems: Array<{ item: TabGroupItem; groupId: string; groupTitle: string }> = []\r\n    tabGroups.forEach((group) => {\r\n      group.items?.forEach((item) => {\r\n        if (item.is_todo) {\r\n          todoItems.push({\r\n            item,\r\n            groupId: group.id,\r\n            groupTitle: group.title,\r\n          })\r\n        }\r\n      })\r\n    })\r\n    return todoItems.sort((a, b) =>\r\n      new Date(b.item.created_at || 0).getTime() - new Date(a.item.created_at || 0).getTime()\r\n    )\r\n  }, [tabGroups])\r\n\r\n  const handleToggleTodo = async (itemId: string, currentStatus: boolean) => {\r\n    setProcessingId(itemId)\r\n    try {\r\n      await tabGroupsService.updateTabGroupItem(itemId, {\r\n        is_todo: !currentStatus,\r\n      })\r\n      await invalidateTabGroups()\r\n      success(currentStatus ? t('todo.todoUnmarked') : t('todo.todoMarked'))\r\n    } catch (err) {\r\n      console.error('Failed to toggle todo:', err)\r\n      showError(t('message.operationFailed'))\r\n    } finally {\r\n      setProcessingId(null)\r\n    }\r\n  }\r\n\r\n  const handleDelete = async (itemId: string) => {\r\n    setConfirmState({\r\n      isOpen: true,\r\n      title: t('confirm.deleteItem'),\r\n      message: t('confirm.deleteItemMessage', { title: '' }),\r\n      onConfirm: async () => {\r\n        setConfirmState(null)\r\n        setProcessingId(itemId)\r\n        try {\r\n          await tabGroupsService.deleteTabGroupItem(itemId)\r\n          await invalidateTabGroups()\r\n          success(t('todo.tabDeleted'))\r\n        } catch (err) {\r\n          console.error('Failed to delete item:', err)\r\n          showError(t('message.deleteFailed'))\r\n        } finally {\r\n          setProcessingId(null)\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handleOpenTab = (url: string) => {\r\n    window.open(url, '_blank', 'noopener,noreferrer')\r\n  }\r\n\r\n  const handleOpenInCurrentTab = (url: string) => {\r\n    window.location.href = url\r\n  }\r\n\r\n  const handleOpenInIncognito = () => {\r\n    showError(t('todo.incognitoNotSupported'))\r\n  }\r\n\r\n  const handleRename = (item: TabGroupItem) => {\r\n    setEditingItemId(item.id)\r\n    setEditingTitle(item.title)\r\n  }\r\n\r\n  const handleSaveRename = async (itemId: string) => {\r\n    if (!editingTitle.trim()) {\r\n      showError(t('message.titleRequired'))\r\n      return\r\n    }\r\n\r\n    setProcessingId(itemId)\r\n    try {\r\n      await tabGroupsService.updateTabGroupItem(itemId, {\r\n        title: editingTitle.trim(),\r\n      })\r\n      await invalidateTabGroups()\r\n      success(t('todo.renameSuccess'))\r\n      setEditingItemId(null)\r\n      setEditingTitle('')\r\n    } catch (err) {\r\n      console.error('Failed to rename item:', err)\r\n      showError(t('message.renameFailed'))\r\n    } finally {\r\n      setProcessingId(null)\r\n    }\r\n  }\r\n\r\n  const handleMove = async (itemId: string, currentGroupId: string) => {\r\n    const availableGroups = tabGroups.filter(g => g.id !== currentGroupId && !g.is_folder)\r\n    \r\n    if (availableGroups.length === 0) {\r\n      showError(t('todo.noGroupsToMove'))\r\n      return\r\n    }\r\n\r\n    const targetGroup = availableGroups[0]\r\n    \r\n    if (!targetGroup) {\r\n      showError(t('todo.noGroupsToMove'))\r\n      return\r\n    }\r\n\r\n    setConfirmState({\r\n      isOpen: true,\r\n      title: t('todo.moveTab'),\r\n      message: t('todo.moveTabMessage', { title: targetGroup.title }),\r\n      onConfirm: async () => {\r\n        setConfirmState(null)\r\n        setProcessingId(itemId)\r\n        try {\r\n          await tabGroupsService.moveTabGroupItem(itemId, targetGroup.id)\r\n          await invalidateTabGroups()\r\n          success(t('todo.tabMoved', { title: targetGroup.title }))\r\n        } catch (err) {\r\n          console.error('Failed to move item:', err)\r\n          showError(t('page.moveFailed'))\r\n        } finally {\r\n          setProcessingId(null)\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handleArchive = async (itemId: string) => {\r\n    setConfirmState({\r\n      isOpen: true,\r\n      title: t('todo.archiveTab'),\r\n      message: t('todo.archiveTabMessage'),\r\n      onConfirm: async () => {\r\n        setConfirmState(null)\r\n        setProcessingId(itemId)\r\n        try {\r\n          await tabGroupsService.updateTabGroupItem(itemId, {\r\n            is_archived: true,\r\n          })\r\n          await invalidateTabGroups()\r\n          success(t('todo.tabArchived'))\r\n        } catch (err) {\r\n          console.error('Failed to archive item:', err)\r\n          showError(t('message.operationFailed'))\r\n        } finally {\r\n          setProcessingId(null)\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  return (\r\n    <div className={`w-full h-full bg-card overflow-y-auto flex flex-col ${isMobile ? '' : 'border-l border-border'}`}>\r\n      {confirmState && (\r\n        <ConfirmDialog\r\n          isOpen={confirmState.isOpen}\r\n          title={confirmState.title}\r\n          message={confirmState.message}\r\n          type=\"warning\"\r\n          onConfirm={confirmState.onConfirm}\r\n          onCancel={() => setConfirmState(null)}\r\n        />\r\n      )}\r\n\r\n      {/* 标题栏 */}\r\n      <div className={`p-4 border-b border-border bg-muted sticky top-0 z-10 shadow-md ${isMobile ? 'pt-safe-area-top' : ''}`}>\r\n        <div className=\"flex items-center gap-2\">\r\n          <ListTodo className=\"w-5 h-5 text-foreground\" />\r\n          <h2 className=\"text-lg font-bold text-foreground\">{t('todo.title')}</h2>\r\n        </div>\r\n        <div className=\"flex items-center gap-4 mt-2\">\r\n          <div className=\"flex items-center gap-1.5\">\r\n            <Circle className=\"w-3 h-3 text-muted-foreground\" />\r\n            <span className=\"text-xs text-muted-foreground\">\r\n              {sortedTodos.length} {t('todo.title').toLowerCase()}\r\n            </span>\r\n          </div>\r\n        </div>\r\n      </div>\r\n\r\n      {/* TODO列表 */}\r\n      <div className={`p-4 space-y-3 ${isMobile ? 'pb-20' : ''}`}>\r\n        {sortedTodos.length === 0 ? (\r\n          <div className=\"text-center py-16\">\r\n            <div className=\"w-20 h-20 bg-muted rounded-full flex items-center justify-center mx-auto mb-4\">\r\n              <ListTodo className=\"w-10 h-10 text-muted-foreground\" />\r\n            </div>\r\n            <p className=\"text-muted-foreground text-sm font-medium\">{t('todo.empty')}</p>\r\n            <p className=\"text-muted-foreground/70 text-xs mt-2\">\r\n              {t('todo.emptyTip')}\r\n            </p>\r\n          </div>\r\n        ) : (\r\n          sortedTodos.map(({ item, groupId, groupTitle }) => (\r\n            <TodoItemCard\r\n              key={item.id}\r\n              item={item}\r\n              groupId={groupId}\r\n              groupTitle={groupTitle}\r\n              processingId={processingId}\r\n              editingItemId={editingItemId}\r\n              editingTitle={editingTitle}\r\n              dateLocale={dateLocale}\r\n              onToggleTodo={handleToggleTodo}\r\n              onDelete={handleDelete}\r\n              onRename={handleRename}\r\n              onSaveRename={handleSaveRename}\r\n              onOpenTab={handleOpenTab}\r\n              onOpenInCurrentTab={handleOpenInCurrentTab}\r\n              onOpenInIncognito={handleOpenInIncognito}\r\n              onMove={handleMove}\r\n              onArchive={handleArchive}\r\n              setEditingItemId={setEditingItemId}\r\n              setEditingTitle={setEditingTitle}\r\n            />\r\n          ))\r\n        )}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/colorUtils.ts",
    "content": "/**\r\n * 颜色工具函数和常量\r\n */\r\n\r\n// 颜色值使用英文标识符，便于存储和国际化\r\nexport const COLORS = [\r\n  { key: 'none', value: null, bg: 'bg-gray-100', border: 'border-gray-300' },\r\n  { key: 'red', value: 'red', bg: 'bg-red-100', border: 'border-red-300' },\r\n  { key: 'orange', value: 'orange', bg: 'bg-orange-100', border: 'border-orange-300' },\r\n  { key: 'yellow', value: 'yellow', bg: 'bg-yellow-100', border: 'border-yellow-300' },\r\n  { key: 'green', value: 'green', bg: 'bg-green-100', border: 'border-green-300' },\r\n  { key: 'blue', value: 'blue', bg: 'bg-blue-100', border: 'border-blue-300' },\r\n  { key: 'purple', value: 'purple', bg: 'bg-purple-100', border: 'border-purple-300' },\r\n  { key: 'pink', value: 'pink', bg: 'bg-pink-100', border: 'border-pink-300' },\r\n] as const\r\n\r\n// 兼容旧的中文颜色值\r\nconst COLOR_MAP: Record<string, string> = {\r\n  '红色': 'bg-red-50 border-red-300 hover:bg-red-100',\r\n  '橙色': 'bg-orange-50 border-orange-300 hover:bg-orange-100',\r\n  '黄色': 'bg-yellow-50 border-yellow-300 hover:bg-yellow-100',\r\n  '绿色': 'bg-green-50 border-green-300 hover:bg-green-100',\r\n  '蓝色': 'bg-blue-50 border-blue-300 hover:bg-blue-100',\r\n  '紫色': 'bg-purple-50 border-purple-300 hover:bg-purple-100',\r\n  '粉色': 'bg-pink-50 border-pink-300 hover:bg-pink-100',\r\n  'red': 'bg-red-50 border-red-300 hover:bg-red-100',\r\n  'orange': 'bg-orange-50 border-orange-300 hover:bg-orange-100',\r\n  'yellow': 'bg-yellow-50 border-yellow-300 hover:bg-yellow-100',\r\n  'green': 'bg-green-50 border-green-300 hover:bg-green-100',\r\n  'blue': 'bg-blue-50 border-blue-300 hover:bg-blue-100',\r\n  'purple': 'bg-purple-50 border-purple-300 hover:bg-purple-100',\r\n  'pink': 'bg-pink-50 border-pink-300 hover:bg-pink-100',\r\n}\r\n\r\nconst LEFT_BORDER_MAP: Record<string, string> = {\r\n  '红色': 'border-l-red-500',\r\n  '橙色': 'border-l-orange-500',\r\n  '黄色': 'border-l-yellow-500',\r\n  '绿色': 'border-l-green-500',\r\n  '蓝色': 'border-l-blue-500',\r\n  '紫色': 'border-l-purple-500',\r\n  '粉色': 'border-l-pink-500',\r\n  'red': 'border-l-red-500',\r\n  'orange': 'border-l-orange-500',\r\n  'yellow': 'border-l-yellow-500',\r\n  'green': 'border-l-green-500',\r\n  'blue': 'border-l-blue-500',\r\n  'purple': 'border-l-purple-500',\r\n  'pink': 'border-l-pink-500',\r\n}\r\n\r\nexport function getColorClasses(color: string | null): string {\r\n  if (!color) return 'bg-card border-border hover:bg-accent'\r\n  return COLOR_MAP[color] || 'bg-card border-border hover:bg-accent'\r\n}\r\n\r\nexport function getLeftBorderColor(color: string | null): string {\r\n  if (!color) return 'border-l-border'\r\n  return LEFT_BORDER_MAP[color] || 'border-l-border'\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/sortUtils.ts",
    "content": "/**\r\n * Tab-group list view sorting helpers.\r\n *\r\n * Server canonical order:\r\n * - `/api/v1/tab-groups` returns groups in `created_at DESC`\r\n *\r\n * Frontend view order:\r\n * - `created`: preserve server semantics\r\n * - `title`: local presentation sort only\r\n * - `count`: local presentation sort only\r\n */\r\n\r\nexport type SortOption = 'created' | 'title' | 'count'\r\n\r\nexport function sortTabGroupsForView<T extends { title: string; created_at: string; item_count?: number }>(\r\n  groups: T[],\r\n  sortBy: SortOption\r\n): T[] {\r\n  const sorted = [...groups]\r\n\r\n  switch (sortBy) {\r\n    case 'title':\r\n      return sorted.sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'))\r\n    case 'count':\r\n      return sorted.sort((a, b) => (b.item_count || 0) - (a.item_count || 0))\r\n    case 'created':\r\n    default:\r\n      return sorted.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())\r\n  }\r\n}\r\n\r\nexport const sortTabGroups = sortTabGroupsForView\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/tree/TreeNode.css",
    "content": "/**\r\n * VSCode 风格的树形连接线\r\n * 使用 CSS 伪元素绘制，性能更好，代码更简洁\r\n */\r\n\r\n/* 树形节点容器 */\r\n.tree-node {\r\n  position: relative;\r\n}\r\n\r\n/* 树形节点行 */\r\n.tree-node-row {\r\n  position: relative;\r\n  display: flex;\r\n  align-items: center;\r\n  padding: 0.375rem 0.75rem;\r\n  cursor: pointer;\r\n}\r\n\r\n.tree-node-row:hover {\r\n  background-color: var(--muted);\r\n}\r\n\r\n.tree-node-row.selected {\r\n  background-color: rgb(var(--primary) / 0.1);\r\n}\r\n\r\n/* 缩进容器 */\r\n.tree-indent {\r\n  position: relative;\r\n  display: inline-block;\r\n  width: 20px;\r\n  height: 100%;\r\n  flex-shrink: 0;\r\n}\r\n\r\n.tree-indent:first-child {\r\n  width: 24px;\r\n}\r\n\r\n/* 垂直线 - 使用伪元素 */\r\n.tree-indent.has-vertical-line::before {\r\n  content: '';\r\n  position: absolute;\r\n  left: 0;\r\n  top: 0;\r\n  bottom: 0;\r\n  width: 1px;\r\n  background-color: var(--border);\r\n}\r\n\r\n/* L 形转角 - 使用伪元素 */\r\n.tree-indent.has-corner::after {\r\n  content: '';\r\n  position: absolute;\r\n  left: 0;\r\n  top: 0;\r\n  width: 100%;\r\n  height: 50%;\r\n  border-left: 1px solid var(--border);\r\n  border-bottom: 1px solid var(--border);\r\n}\r\n\r\n/* 子节点容器 */\r\n.tree-children {\r\n  position: relative;\r\n}\r\n\r\n/* 从父节点延伸的垂直线 */\r\n.tree-children::before {\r\n  content: '';\r\n  position: absolute;\r\n  left: 12px; /* 对齐父节点的缩进 */\r\n  top: 0;\r\n  bottom: 0;\r\n  width: 1px;\r\n  background-color: var(--border);\r\n}\r\n\r\n/* 最后一个子节点，垂直线只到节点中间 */\r\n.tree-node:last-child > .tree-children::before {\r\n  bottom: 50%;\r\n}\r\n\r\n/* 深色主题适配 - 使用统一的 CSS 变量，无需单独覆盖 */\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/tree/TreeNode.tsx",
    "content": "import React from 'react'\r\nimport { useSortable } from '@dnd-kit/sortable'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { TreeNodeContent } from './TreeNodeContent'\r\nimport type { TabGroup } from '@/lib/types'\r\n\r\ninterface TreeNodeProps {\r\n  group: TabGroup\r\n  level: number\r\n  isLast: boolean\r\n  parentLines: boolean[]\r\n  selectedGroupId: string | null\r\n  onSelectGroup: (groupId: string | null) => void\r\n  expandedGroups: Set<string>\r\n  toggleGroup: (groupId: string, e: React.MouseEvent) => void\r\n  editingGroupId: string | null\r\n  setEditingGroupId: (id: string | null) => void\r\n  editingTitle: string\r\n  setEditingTitle: (title: string) => void\r\n  onRenameGroup?: (groupId: string, newTitle: string) => Promise<void>\r\n  onRefresh?: () => Promise<void>\r\n  activeId: string | null\r\n  overId: string | null\r\n  dropPosition: 'before' | 'inside' | 'after' | null\r\n  onOpenMoveDialog?: (group: TabGroup) => void\r\n}\r\n\r\nexport function TreeNode({\r\n  group,\r\n  level,\r\n  isLast,\r\n  parentLines,\r\n  selectedGroupId,\r\n  onSelectGroup,\r\n  expandedGroups,\r\n  toggleGroup,\r\n  editingGroupId,\r\n  setEditingGroupId,\r\n  editingTitle,\r\n  setEditingTitle,\r\n  onRenameGroup,\r\n  onRefresh,\r\n  activeId,\r\n  overId,\r\n  dropPosition,\r\n  onOpenMoveDialog,\r\n}: TreeNodeProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const isSelected = selectedGroupId === group.id\r\n  const isExpanded = expandedGroups.has(group.id)\r\n  const hasChildren = (group.children?.length || 0) > 0\r\n  const isFolder = group.is_folder === 1\r\n  const isEditing = editingGroupId === group.id\r\n  const isBeingDragged = activeId === group.id\r\n  const isDropTarget = overId === group.id && !isBeingDragged\r\n  const isLocked = group.tags?.includes('__locked__') || false\r\n\r\n  const {\r\n    attributes,\r\n    listeners,\r\n    setNodeRef,\r\n    isDragging,\r\n  } = useSortable({\r\n    id: group.id,\r\n    data: {\r\n      type: isFolder ? 'folder' : 'group',\r\n      parentId: group.parent_id,\r\n    },\r\n    disabled: isLocked,\r\n    // 完全禁用布局动画\r\n    animateLayoutChanges: () => false,\r\n  })\r\n\r\n  // 不应用 transform，元素保持原位置不动\r\n  // 只通过 DragOverlay 显示拖拽预览，通过指示器显示插入位置\r\n  const style = {\r\n    opacity: isDragging ? 0.5 : 1,\r\n    cursor: isLocked ? 'not-allowed' : 'grab',\r\n  }\r\n\r\n  return (\r\n    <div ref={setNodeRef} style={style}>\r\n      {/* 拖放指示器 - before（蓝色横线 + 左侧圆点） */}\r\n      {isDropTarget && dropPosition === 'before' && (\r\n        <div className=\"relative h-1 mx-2\">\r\n          <div className=\"absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-primary border-2 border-background\" />\r\n          <div className=\"absolute left-2 right-0 top-1/2 -translate-y-1/2 h-0.5 bg-primary\" />\r\n        </div>\r\n      )}\r\n\r\n      {/* 节点内容组件 */}\r\n      <TreeNodeContent\r\n        group={group}\r\n        level={level}\r\n        isSelected={isSelected}\r\n        isExpanded={isExpanded}\r\n        hasChildren={hasChildren}\r\n        isFolder={isFolder}\r\n        isEditing={isEditing}\r\n        isBeingDragged={isBeingDragged}\r\n        isLocked={isLocked}\r\n        isDropTarget={isDropTarget}\r\n        dropPosition={dropPosition}\r\n        attributes={attributes}\r\n        listeners={listeners}\r\n        toggleGroup={toggleGroup}\r\n        onSelectGroup={onSelectGroup}\r\n        editingTitle={editingTitle}\r\n        setEditingTitle={setEditingTitle}\r\n        setEditingGroupId={setEditingGroupId}\r\n        onRenameGroup={onRenameGroup}\r\n        onRefresh={onRefresh}\r\n        onOpenMoveDialog={onOpenMoveDialog}\r\n      />\r\n\r\n      {/* 拖放指示器 - after（蓝色横线 + 左侧圆点） */}\r\n      {isDropTarget && dropPosition === 'after' && (\r\n        <div className=\"relative h-1 mx-2\">\r\n          <div className=\"absolute left-0 top-1/2 -translate-y-1/2 w-2 h-2 rounded-full bg-primary border-2 border-background\" />\r\n          <div className=\"absolute left-2 right-0 top-1/2 -translate-y-1/2 h-0.5 bg-primary\" />\r\n        </div>\r\n      )}\r\n\r\n      {/* 空文件夹拖放区域 - 当拖拽到空文件夹内部时显示 */}\r\n      {isFolder && isDropTarget && dropPosition === 'inside' && !hasChildren && (\r\n        <div\r\n          className=\"mx-2 py-3 border-2 border-dashed border-primary rounded-lg bg-primary/5 text-center\"\r\n          style={{ marginLeft: `${(level + 1) * 20 + 12}px` }}\r\n        >\r\n          <span className=\"text-xs text-primary font-medium\">{t('folder.dropHere')}</span>\r\n        </div>\r\n      )}\r\n\r\n      {/* 子节点 */}\r\n      {isExpanded && hasChildren && group.children && (\r\n        <div>\r\n          {group.children?.map((child, index) => (\r\n            <TreeNode\r\n              key={child.id}\r\n              group={child}\r\n              level={level + 1}\r\n              isLast={index === (group.children?.length ?? 0) - 1}\r\n              parentLines={[...parentLines, !isLast]}\r\n              selectedGroupId={selectedGroupId}\r\n              onSelectGroup={onSelectGroup}\r\n              expandedGroups={expandedGroups}\r\n              toggleGroup={toggleGroup}\r\n              editingGroupId={editingGroupId}\r\n              setEditingGroupId={setEditingGroupId}\r\n              editingTitle={editingTitle}\r\n              setEditingTitle={setEditingTitle}\r\n              onRenameGroup={onRenameGroup}\r\n              onRefresh={onRefresh}\r\n              activeId={activeId}\r\n              overId={overId}\r\n              dropPosition={dropPosition}\r\n              onOpenMoveDialog={onOpenMoveDialog}\r\n            />\r\n          ))}\r\n        </div>\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/tree/TreeNodeContent.tsx",
    "content": "import React from 'react'\nimport {\n  ChevronRight,\n  ChevronDown,\n  Folder,\n  FolderOpen,\n  Circle,\n  MoreHorizontal,\n} from 'lucide-react'\nimport type { DraggableAttributes, DraggableSyntheticListeners } from '@dnd-kit/core'\nimport { useTranslation } from 'react-i18next'\nimport { DropdownMenu } from '@/components/common/DropdownMenu'\nimport { useTabGroupMenu } from '@/hooks/useTabGroupMenu'\nimport { buildTreeNodeMenu } from './TreeNodeMenu'\nimport { getTotalItemCount } from './TreeUtils'\nimport type { TabGroup } from '@/lib/types'\n\nexport interface TreeNodeContentProps {\n  group: TabGroup\n  level: number\n  isSelected: boolean\n  isExpanded: boolean\n  hasChildren: boolean\n  isFolder: boolean\n  isEditing: boolean\n  isBeingDragged: boolean\n  isLocked: boolean\n  isDropTarget: boolean\n  dropPosition: 'before' | 'inside' | 'after' | null\n  attributes: DraggableAttributes\n  listeners: DraggableSyntheticListeners\n  toggleGroup: (groupId: string, e: React.MouseEvent) => void\n  onSelectGroup: (groupId: string | null) => void\n  editingTitle: string\n  setEditingTitle: (title: string) => void\n  setEditingGroupId: (id: string | null) => void\n  onRenameGroup?: (groupId: string, newTitle: string) => Promise<void>\n  onRefresh?: () => Promise<void>\n  onOpenMoveDialog?: (group: TabGroup) => void\n}\n\nexport function TreeNodeContent({\n  group,\n  level,\n  isSelected,\n  isExpanded,\n  hasChildren,\n  isFolder,\n  isEditing,\n  isBeingDragged,\n  isLocked,\n  isDropTarget,\n  dropPosition,\n  attributes,\n  listeners,\n  toggleGroup,\n  onSelectGroup,\n  editingTitle,\n  setEditingTitle,\n  setEditingGroupId,\n  onRenameGroup,\n  onRefresh,\n  onOpenMoveDialog,\n}: TreeNodeContentProps) {\n  const { t } = useTranslation('tabGroups')\n\n  const handleRenameSubmit = async () => {\n    if (!editingTitle.trim() || editingTitle === group.title) {\n      setEditingGroupId(null)\n      setEditingTitle(group.title)\n      return\n    }\n\n    try {\n      await onRenameGroup?.(group.id, editingTitle.trim())\n      setEditingGroupId(null)\n    } catch (error) {\n      console.error('Failed to rename:', error)\n      setEditingTitle(group.title)\n    }\n  }\n\n  const handleRenameKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === 'Enter') {\n      e.preventDefault()\n      handleRenameSubmit()\n    } else if (e.key === 'Escape') {\n      setEditingGroupId(null)\n      setEditingTitle(group.title)\n    }\n  }\n\n  const menuActions = useTabGroupMenu({\n    onRefresh: onRefresh || (async () => {}),\n    onStartRename: (groupId, title) => {\n      setEditingGroupId(groupId)\n      setEditingTitle(title)\n    },\n    onOpenMoveDialog,\n  })\n\n  const menuItems = buildTreeNodeMenu({\n    group,\n    isFolder,\n    isLocked,\n    menuActions,\n    t,\n  })\n\n  // 拖放指示器样式\n  let dropIndicatorClass = ''\n  if (isDropTarget && dropPosition) {\n    if (dropPosition === 'before') {\n      dropIndicatorClass = 'border-t-2 border-t-primary'\n    } else if (dropPosition === 'after') {\n      dropIndicatorClass = 'border-b-2 border-b-primary'\n    } else if (dropPosition === 'inside' && isFolder) {\n      dropIndicatorClass = 'bg-primary/10 border-2 border-primary border-dashed'\n    }\n  }\n\n  return (\n    <div\n      style={{\n        paddingLeft: `${level * 20 + 12}px`, // 使用缩进代替树状线\n      }}\n      className={`treeItem group flex items-center gap-1 py-1.5 pr-3 hover:bg-muted relative ${\n        isSelected ? 'bg-primary/10' : ''\n      } ${isBeingDragged ? 'opacity-50' : ''} ${dropIndicatorClass}`}\n    >\n      {/* 展开/折叠按钮 */}\n      <button\n        onClick={(e) => {\n          e.stopPropagation()\n          toggleGroup(group.id, e)\n        }}\n        className=\"w-4 h-4 flex items-center justify-center hover:bg-muted rounded flex-shrink-0 mr-1\"\n      >\n        {hasChildren ? (\n          isExpanded ? (\n            <ChevronDown className=\"w-3 h-3 text-muted-foreground\" />\n          ) : (\n            <ChevronRight className=\"w-3 h-3 text-muted-foreground\" />\n          )\n        ) : (\n          <div className=\"w-3 h-3\" />\n        )}\n      </button>\n\n      {/* 图标和标题区域 - 可拖拽区域 */}\n      <div\n        {...attributes}\n        {...(isLocked ? {} : listeners)}\n        className={`flex items-center gap-1.5 flex-1 min-w-0 ${\n          isLocked ? 'cursor-not-allowed' : 'cursor-grab active:cursor-grabbing'\n        }`}\n      >\n        {/* 图标 */}\n        {isFolder ? (\n          isExpanded ? (\n            <FolderOpen className=\"w-3.5 h-3.5 text-primary flex-shrink-0\" />\n          ) : (\n            <Folder className=\"w-3.5 h-3.5 text-primary flex-shrink-0\" />\n          )\n        ) : (\n          <Circle\n            className={`w-2 h-2 flex-shrink-0 ${\n              isSelected ? 'fill-primary text-primary' : 'text-muted-foreground'\n            }`}\n          />\n        )}\n\n        {/* 标题 */}\n        {isEditing ? (\n          <input\n            type=\"text\"\n            value={editingTitle}\n            onChange={(e) => setEditingTitle(e.target.value)}\n            onBlur={handleRenameSubmit}\n            onKeyDown={handleRenameKeyDown}\n            className=\"text-xs flex-1 px-1 py-0.5 border border-primary rounded bg-card text-foreground focus:outline-none\"\n            autoFocus\n            onClick={(e) => e.stopPropagation()}\n          />\n        ) : (\n          <span\n            onClick={(e) => {\n              e.stopPropagation()\n              onSelectGroup(group.id)\n            }}\n            className={`text-xs flex-1 truncate leading-[19px] ${\n              isSelected ? 'font-semibold text-primary' : 'text-foreground'\n            }`}\n          >\n            {group.title}\n          </span>\n        )}\n\n        {/* 标签页数量 */}\n        {!isEditing && (\n          <span className=\"text-xs text-muted-foreground flex-shrink-0 ml-auto\">\n            {getTotalItemCount(group)}\n          </span>\n        )}\n      </div>\n\n      {/* 右键菜单 */}\n      {!isEditing && (\n        <div className=\"opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0 ml-1\">\n          <DropdownMenu\n            trigger={\n              <button className=\"p-0.5 hover:bg-muted rounded\">\n                <MoreHorizontal className=\"w-3.5 h-3.5\" />\n              </button>\n            }\n            items={menuItems}\n          />\n        </div>\n      )}\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/tree/TreeNodeMenu.tsx",
    "content": "import {\r\n  ExternalLink,\r\n  Edit2,\r\n  Share2,\r\n  Copy,\r\n  FolderPlus as FolderPlusIcon,\r\n  Trash2,\r\n  Move,\r\n  Lock,\r\n  Pin\r\n} from 'lucide-react'\r\nimport type { MenuItem } from '@/components/common/DropdownMenu'\r\nimport type { TabGroup } from '@/lib/types'\r\nimport type { TabGroupMenuActions } from '@/hooks/useTabGroupMenu'\r\nimport type { TFunction } from 'i18next'\r\n\r\ninterface TreeNodeMenuConfig {\r\n  group: TabGroup\r\n  isFolder: boolean\r\n  isLocked: boolean\r\n  menuActions: TabGroupMenuActions\r\n  t: TFunction<'tabGroups', undefined>\r\n}\r\n\r\n/**\r\n * Build context menu items for tree node\r\n */\r\nexport function buildTreeNodeMenu({\r\n  group,\r\n  isFolder,\r\n  isLocked,\r\n  menuActions,\r\n  t\r\n}: TreeNodeMenuConfig): MenuItem[] {\r\n  return [\r\n    {\r\n      label: t('menu.openInNewWindow'),\r\n      icon: <ExternalLink className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onOpenInNewWindow(group),\r\n      disabled: isFolder\r\n    },\r\n    {\r\n      label: t('menu.openInCurrentWindow'),\r\n      icon: <ExternalLink className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onOpenInCurrentWindow(group),\r\n      disabled: isFolder\r\n    },\r\n    {\r\n      label: t('menu.rename'),\r\n      icon: <Edit2 className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onRename(group),\r\n      disabled: isLocked,\r\n      divider: true\r\n    },\r\n    {\r\n      label: t('menu.shareAsPage'),\r\n      icon: <Share2 className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onShare(group),\r\n      disabled: isFolder\r\n    },\r\n    {\r\n      label: t('menu.copyToClipboard'),\r\n      icon: <Copy className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onCopyToClipboard(group)\r\n    },\r\n    {\r\n      label: t('menu.createFolderAbove'),\r\n      icon: <FolderPlusIcon className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onCreateFolderAbove(group),\r\n      divider: true\r\n    },\r\n    {\r\n      label: t('menu.createFolderInside'),\r\n      icon: <FolderPlusIcon className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onCreateFolderInside(group),\r\n      disabled: !isFolder\r\n    },\r\n    {\r\n      label: t('menu.createFolderBelow'),\r\n      icon: <FolderPlusIcon className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onCreateFolderBelow(group)\r\n    },\r\n    {\r\n      label: t('menu.removeDuplicates'),\r\n      icon: <Copy className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onRemoveDuplicates(group),\r\n      disabled: isFolder,\r\n      divider: true\r\n    },\r\n    {\r\n      label: t('menu.move'),\r\n      icon: <Move className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onMove(group),\r\n      disabled: isLocked\r\n    },\r\n    {\r\n      label: t('menu.pinToTop'),\r\n      icon: <Pin className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onPinToTop(group)\r\n    },\r\n    {\r\n      label: isLocked ? t('menu.unlock') : t('menu.lock'),\r\n      icon: <Lock className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onLock(group)\r\n    },\r\n    {\r\n      label: t('menu.moveToTrash'),\r\n      icon: <Trash2 className=\"w-4 h-4\" />,\r\n      onClick: () => menuActions.onMoveToTrash(group),\r\n      disabled: isLocked,\r\n      danger: true,\r\n      divider: true\r\n    }\r\n  ]\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/tree/TreeNodeSimple.tsx",
    "content": "/**\r\n * 简化版树形节点 - 使用 CSS 伪元素绘制连接线\r\n * 参考 VSCode 的实现方式\r\n */\r\n\r\nimport { ChevronRight, ChevronDown, Folder, FolderOpen, Circle } from 'lucide-react'\r\nimport type { TabGroup } from '@/lib/types'\r\nimport './TreeNode.css'\r\n\r\ninterface TreeNodeSimpleProps {\r\n  group: TabGroup\r\n  level: number\r\n  isLast: boolean\r\n  parentHasMore: boolean[]\r\n  selectedGroupId: string | null\r\n  onSelectGroup: (groupId: string | null) => void\r\n  expandedGroups: Set<string>\r\n  onToggleExpand: (groupId: string) => void\r\n}\r\n\r\nexport function TreeNodeSimple({\r\n  group,\r\n  level,\r\n  isLast,\r\n  parentHasMore,\r\n  selectedGroupId,\r\n  onSelectGroup,\r\n  expandedGroups,\r\n  onToggleExpand,\r\n}: TreeNodeSimpleProps) {\r\n  const isSelected = selectedGroupId === group.id\r\n  const isExpanded = expandedGroups.has(group.id)\r\n  const hasChildren = (group.children?.length || 0) > 0\r\n  const isFolder = group.is_folder === 1\r\n\r\n  return (\r\n    <div className=\"tree-node\">\r\n      {/* 节点行 */}\r\n      <div\r\n        className={`tree-node-row ${isSelected ? 'selected' : ''}`}\r\n        onClick={() => onSelectGroup(group.id)}\r\n      >\r\n        {/* 缩进和连接线 */}\r\n        {Array.from({ length: level }).map((_, idx) => {\r\n          const isLastLevel = idx === level - 1\r\n          const hasVerticalLine = idx < level - 1 ? parentHasMore[idx] : !isLast\r\n          \r\n          return (\r\n            <span\r\n              key={idx}\r\n              className={`tree-indent ${hasVerticalLine ? 'has-vertical-line' : ''} ${isLastLevel ? 'has-corner' : ''}`}\r\n            />\r\n          )\r\n        })}\r\n\r\n        {/* 展开/折叠按钮 */}\r\n        {isFolder && hasChildren && (\r\n          <button\r\n            onClick={(e) => {\r\n              e.stopPropagation()\r\n              onToggleExpand(group.id)\r\n            }}\r\n            className=\"flex-shrink-0 p-0.5 hover:bg-muted rounded\"\r\n          >\r\n            {isExpanded ? (\r\n              <ChevronDown className=\"w-3.5 h-3.5\" />\r\n            ) : (\r\n              <ChevronRight className=\"w-3.5 h-3.5\" />\r\n            )}\r\n          </button>\r\n        )}\r\n\r\n        {/* 图标 */}\r\n        <div className=\"flex-shrink-0 ml-1\">\r\n          {isFolder ? (\r\n            isExpanded ? (\r\n              <FolderOpen className=\"w-3.5 h-3.5 text-primary\" />\r\n            ) : (\r\n              <Folder className=\"w-3.5 h-3.5 text-primary\" />\r\n            )\r\n          ) : (\r\n            <Circle\r\n              className={`w-2 h-2 ${isSelected ? 'fill-primary text-primary' : 'text-muted-foreground'}`}\r\n            />\r\n          )}\r\n        </div>\r\n\r\n        {/* 标题 */}\r\n        <span className={`text-sm flex-1 ml-2 truncate ${isSelected ? 'text-primary font-medium' : 'text-foreground'}`}>\r\n          {group.title}\r\n        </span>\r\n\r\n        {/* 数量 */}\r\n        <span className=\"text-xs text-muted-foreground flex-shrink-0 ml-2\">\r\n          {group.item_count || 0}\r\n        </span>\r\n      </div>\r\n\r\n      {/* 子节点 */}\r\n      {isExpanded && hasChildren && group.children && (\r\n        <div className=\"tree-children\">\r\n          {group.children.map((child, index) => (\r\n            <TreeNodeSimple\r\n              key={child.id}\r\n              group={child}\r\n              level={level + 1}\r\n              isLast={index === group.children!.length - 1}\r\n              parentHasMore={[...parentHasMore, !isLast]}\r\n              selectedGroupId={selectedGroupId}\r\n              onSelectGroup={onSelectGroup}\r\n              expandedGroups={expandedGroups}\r\n              onToggleExpand={onToggleExpand}\r\n            />\r\n          ))}\r\n        </div>\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/tree/TreeUtils.ts",
    "content": "import type { TabGroup } from '@/lib/types'\r\n\r\n/**\r\n * 递归计算分组及其所有子分组的标签页总数\r\n */\r\nexport function getTotalItemCount(group: TabGroup): number {\r\n  let total = 0\r\n  \r\n  // 如果是文件夹，不计算自己的 item_count\r\n  if (group.is_folder !== 1) {\r\n    total += group.item_count || 0\r\n  }\r\n  \r\n  // 递归计算所有子项的数量\r\n  if (group.children && group.children.length > 0) {\r\n    total += group.children.reduce((sum, child) => sum + getTotalItemCount(child), 0)\r\n  }\r\n  \r\n  return total\r\n}\r\n\r\n/**\r\n * 构建树形结构\r\n */\r\nexport function buildTree(groups: TabGroup[]): TabGroup[] {\r\n  const groupMap = new Map<string, TabGroup>()\r\n  const rootGroups: TabGroup[] = []\r\n\r\n  // 第一遍：创建映射并初始化 children\r\n  groups.forEach(group => {\r\n    groupMap.set(group.id, { ...group, children: [] })\r\n  })\r\n\r\n  // 第二遍：构建父子关系\r\n  groups.forEach(group => {\r\n    const node = groupMap.get(group.id)!\r\n    if (group.parent_id) {\r\n      const parent = groupMap.get(group.parent_id)\r\n      if (parent) {\r\n        parent.children = parent.children || []\r\n        parent.children.push(node)\r\n      } else {\r\n        // 父节点不存在，作为根节点\r\n        rootGroups.push(node)\r\n      }\r\n    } else {\r\n      rootGroups.push(node)\r\n    }\r\n  })\r\n\r\n  // 按 position 排序所有层级\r\n  const sortByPosition = (nodes: TabGroup[]) => {\r\n    nodes.sort((a, b) => (a.position || 0) - (b.position || 0))\r\n    nodes.forEach(node => {\r\n      if (node.children && node.children.length > 0) {\r\n        sortByPosition(node.children)\r\n      }\r\n    })\r\n  }\r\n\r\n  sortByPosition(rootGroups)\r\n  return rootGroups\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tab-groups/tree/useDragAndDrop.ts",
    "content": "import { useState, useCallback } from 'react'\r\nimport { useSensors, useSensor, PointerSensor, KeyboardSensor } from '@dnd-kit/core'\r\nimport type { CollisionDetection, DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core'\r\nimport { pointerWithin, closestCenter } from '@dnd-kit/core'\r\nimport type { TabGroup } from '@/lib/types'\r\nimport { logger } from '@/lib/logger'\r\n\r\ntype DropPosition = 'before' | 'inside' | 'after'\r\n\r\ninterface UseDragAndDropProps {\r\n  tabGroups: TabGroup[]\r\n  onMoveGroup?: (groupId: string, newParentId: string | null, newPosition: number) => Promise<void>\r\n}\r\n\r\nexport function useDragAndDrop({ tabGroups, onMoveGroup }: UseDragAndDropProps) {\r\n  const [activeId, setActiveId] = useState<string | null>(null)\r\n  const [overId, setOverId] = useState<string | null>(null)\r\n  const [dropPosition, setDropPosition] = useState<DropPosition | null>(null)\r\n\r\n  const sensors = useSensors(\r\n    useSensor(PointerSensor, {\r\n      activationConstraint: {\r\n        distance: 8,\r\n      },\r\n    }),\r\n    useSensor(KeyboardSensor)\r\n  )\r\n\r\n  const collisionDetection: CollisionDetection = (args) => {\r\n    const pointerCollisions = pointerWithin(args)\r\n    if (pointerCollisions && pointerCollisions.length > 0) {\r\n      return pointerCollisions\r\n    }\r\n    return closestCenter(args)\r\n  }\r\n\r\n  const handleDragStart = useCallback((event: DragStartEvent) => {\r\n    setActiveId(event.active.id as string)\r\n  }, [])\r\n\r\n  const handleDragOver = useCallback((event: DragOverEvent) => {\r\n    const { over, active } = event\r\n    const currentOverId = over?.id as string | null\r\n    setOverId(currentOverId)\r\n\r\n    if (!currentOverId || !over) {\r\n      setDropPosition(null)\r\n      return\r\n    }\r\n\r\n    const overGroup = tabGroups.find(g => g.id === currentOverId)\r\n    if (!overGroup) {\r\n      setDropPosition(null)\r\n      return\r\n    }\r\n\r\n    // 获取目标元素的矩形\r\n    const overRect = over.rect\r\n    if (!overRect || overRect.height === 0) {\r\n      setDropPosition(null)\r\n      return\r\n    }\r\n\r\n    // 使用 active.rect.current.translated 获取当前拖拽元素的位置\r\n    const activeTranslated = active.rect.current.translated\r\n    if (!activeTranslated) {\r\n      setDropPosition(null)\r\n      return\r\n    }\r\n\r\n    // 计算拖拽元素中心点相对于目标元素的位置\r\n    const activeCenterY = activeTranslated.top + activeTranslated.height / 2\r\n    const relativeY = activeCenterY - overRect.top\r\n    const relativeYPercent = relativeY / overRect.height\r\n\r\n    // 根据目标是否为文件夹，使用不同的判断逻辑\r\n    if (overGroup.is_folder === 1) {\r\n      // 文件夹：上方 25% = before，中间 50% = inside，下方 25% = after\r\n      if (relativeYPercent < 0.25) {\r\n        setDropPosition('before')\r\n      } else if (relativeYPercent > 0.75) {\r\n        setDropPosition('after')\r\n      } else {\r\n        setDropPosition('inside')\r\n      }\r\n    } else {\r\n      // 普通分组：上方 50% = before，下方 50% = after\r\n      if (relativeYPercent < 0.5) {\r\n        setDropPosition('before')\r\n      } else {\r\n        setDropPosition('after')\r\n      }\r\n    }\r\n  }, [tabGroups])\r\n\r\n  const handleDragEnd = useCallback(async (event: DragEndEvent) => {\r\n    const { active, over } = event\r\n    const currentDropPosition = dropPosition\r\n\r\n    // 清理状态\r\n    setActiveId(null)\r\n    setOverId(null)\r\n    setDropPosition(null)\r\n\r\n    if (!over || active.id === over.id || !onMoveGroup) return\r\n\r\n    const draggedGroup = tabGroups.find(g => g.id === active.id)\r\n    const targetGroup = tabGroups.find(g => g.id === over.id)\r\n\r\n    if (!draggedGroup || !targetGroup) return\r\n\r\n    logger.log('🎯 DragEnd:', {\r\n      dragged: draggedGroup.title,\r\n      target: targetGroup.title,\r\n      targetIsFolder: targetGroup.is_folder === 1,\r\n      dropPosition: currentDropPosition\r\n    })\r\n\r\n    // 拖拽到文件夹内部\r\n    if (currentDropPosition === 'inside' && targetGroup.is_folder === 1) {\r\n      // 检查循环嵌套（不能把文件夹拖到自己的子孙节点内）\r\n      if (draggedGroup.is_folder === 1) {\r\n        const isDescendant = (parentId: string, childId: string): boolean => {\r\n          const child = tabGroups.find(g => g.id === childId)\r\n          if (!child || !child.parent_id) return false\r\n          if (child.parent_id === parentId) return true\r\n          return isDescendant(parentId, child.parent_id)\r\n        }\r\n\r\n        if (isDescendant(draggedGroup.id, targetGroup.id)) {\r\n          logger.log('  ❌ Cannot move folder into its descendant')\r\n          return\r\n        }\r\n      }\r\n\r\n      logger.log('  → Moving inside folder:', targetGroup.title)\r\n      await onMoveGroup(draggedGroup.id, targetGroup.id, 0)\r\n      return\r\n    }\r\n\r\n    // 移动到同级（before 或 after）\r\n    const newParentId = targetGroup.parent_id || null\r\n    const siblings = tabGroups.filter(g => (g.parent_id || null) === newParentId)\r\n    \r\n    let targetIndex = siblings.findIndex(g => g.id === targetGroup.id)\r\n    if (currentDropPosition === 'after') {\r\n      targetIndex++\r\n    }\r\n\r\n    // 如果在同一父级内移动，需要调整索引\r\n    const currentIndex = siblings.findIndex(g => g.id === draggedGroup.id)\r\n    if (currentIndex !== -1 && currentIndex < targetIndex) {\r\n      targetIndex--\r\n    }\r\n\r\n    const newPosition = Math.max(0, targetIndex)\r\n    logger.log('  → Moving to position:', newPosition, 'under parent:', newParentId)\r\n    await onMoveGroup(draggedGroup.id, newParentId, newPosition)\r\n  }, [dropPosition, tabGroups, onMoveGroup])\r\n\r\n  const handleDragCancel = useCallback(() => {\r\n    setActiveId(null)\r\n    setOverId(null)\r\n    setDropPosition(null)\r\n  }, [])\r\n\r\n  return {\r\n    sensors,\r\n    collisionDetection,\r\n    activeId,\r\n    overId,\r\n    dropPosition,\r\n    handleDragStart,\r\n    handleDragOver,\r\n    handleDragEnd,\r\n    handleDragCancel\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tags/TagControls.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\n\r\ninterface TagControlsProps {\r\n  sortBy: 'usage' | 'name' | 'clicks'\r\n  onSortChange: (sortBy: 'usage' | 'name' | 'clicks') => void\r\n  layout: 'grid' | 'masonry'\r\n  onLayoutChange: (layout: 'grid' | 'masonry') => void\r\n  selectedCount: number\r\n  onClearSelection: () => void\r\n}\r\n\r\nexport function TagControls({\r\n  sortBy,\r\n  onSortChange,\r\n  layout,\r\n  onLayoutChange,\r\n  selectedCount,\r\n  onClearSelection,\r\n}: TagControlsProps) {\r\n  const { t } = useTranslation('tags')\r\n  \r\n  const handleSortToggle = () => {\r\n    if (sortBy === 'usage') {\r\n      onSortChange('clicks')\r\n    } else if (sortBy === 'clicks') {\r\n      onSortChange('name')\r\n    } else {\r\n      onSortChange('usage')\r\n    }\r\n  }\r\n\r\n  const getSortIcon = () => {\r\n    switch (sortBy) {\r\n      case 'usage':\r\n        return {\r\n          icon: (\r\n            <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z\" />\r\n            </svg>\r\n          ),\r\n          title: t('sort.byUsage'),\r\n        }\r\n      case 'clicks':\r\n        return {\r\n          icon: (\r\n            <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122\" />\r\n            </svg>\r\n          ),\r\n          title: t('sort.byClicks'),\r\n        }\r\n      case 'name':\r\n        return {\r\n          icon: (\r\n            <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12\" />\r\n            </svg>\r\n          ),\r\n          title: t('sort.byName'),\r\n        }\r\n    }\r\n  }\r\n\r\n  const sortConfig = getSortIcon()\r\n\r\n  return (\r\n    <div className=\"flex items-center gap-1\">\r\n      <button\r\n        onClick={handleSortToggle}\r\n        className=\"btn btn-sm btn-ghost p-2 flex-shrink-0\"\r\n        title={sortConfig.title}\r\n      >\r\n        {sortConfig.icon}\r\n      </button>\r\n\r\n      <button\r\n        onClick={() => onLayoutChange(layout === 'grid' ? 'masonry' : 'grid')}\r\n        className=\"btn btn-sm btn-ghost p-2 flex-shrink-0\"\r\n        title={layout === 'grid' ? t('layout.grid') : t('layout.masonry')}\r\n      >\r\n        {layout === 'grid' ? (\r\n          <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z\" />\r\n          </svg>\r\n        ) : (\r\n          <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n            <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4 5h6v4H4V5zM4 11h6v8H4v-8zM12 5h8v6h-8V5zM12 13h8v6h-8v-6z\" />\r\n          </svg>\r\n        )}\r\n      </button>\r\n\r\n      <button\r\n        onClick={onClearSelection}\r\n        disabled={selectedCount === 0}\r\n        className={`btn btn-sm p-2 flex-shrink-0 ${\r\n          selectedCount === 0\r\n            ? 'btn-disabled'\r\n            : 'btn-ghost hover:bg-error/10 hover:text-error'\r\n        }`}\r\n        title={selectedCount > 0 ? t('selection.clearWithCount', { count: selectedCount }) : t('selection.clear')}\r\n      >\r\n        <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\r\n        </svg>\r\n      </button>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tags/TagFormModal.tsx",
    "content": "import { useEffect, useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\n\r\ninterface TagFormModalProps {\r\n  isOpen: boolean\r\n  title: string\r\n  initialName: string\r\n  onConfirm: (name: string) => void\r\n  onCancel: () => void\r\n  confirmLabel?: string\r\n  isSubmitting?: boolean\r\n  onDelete?: () => void\r\n  isDeleting?: boolean\r\n}\r\n\r\nexport function TagFormModal({\r\n  isOpen,\r\n  title,\r\n  initialName,\r\n  onConfirm,\r\n  onCancel,\r\n  confirmLabel,\r\n  isSubmitting = false,\r\n  onDelete,\r\n  isDeleting = false,\r\n}: TagFormModalProps) {\r\n  const { t } = useTranslation('tags')\r\n  const { t: tc } = useTranslation('common')\r\n  const [name, setName] = useState(initialName)\r\n\r\n  const displayConfirmLabel = confirmLabel ?? t('action.save')\r\n\r\n  useEffect(() => {\r\n    if (isOpen) {\r\n      setName(initialName)\r\n    }\r\n  }, [initialName, isOpen])\r\n\r\n  if (!isOpen) return null\r\n\r\n  return (\r\n    <div className=\"fixed inset-0 flex items-center justify-center p-4\" style={{ zIndex: Z_INDEX.TAG_FORM_MODAL }}>\r\n      <div className=\"absolute inset-0 bg-background/80 backdrop-blur-sm\" onClick={onCancel} />\r\n      <div className=\"relative w-full max-w-sm card p-5 space-y-4 animate-scale-in border border-border shadow-2xl rounded-xl\" style={{ backgroundColor: 'var(--card)' }}>\r\n        <div>\r\n          <h3 className=\"text-base font-semibold mb-1\">{title}</h3>\r\n          <p className=\"text-xs text-muted-foreground\">{t('form.editHint')}</p>\r\n        </div>\r\n        <div className=\"space-y-1.5\">\r\n          <label className=\"text-xs font-medium text-muted-foreground\">{t('form.nameLabel')}</label>\r\n          <input\r\n            type=\"text\"\r\n            className=\"input w-full\"\r\n            value={name}\r\n            onChange={(e) => setName(e.target.value)}\r\n            placeholder={t('form.namePlaceholder')}\r\n            autoFocus\r\n            onKeyDown={(e) => {\r\n              if (e.key === 'Enter' && !isSubmitting) onConfirm(name.trim())\r\n              if (e.key === 'Escape') onCancel()\r\n            }}\r\n          />\r\n        </div>\r\n\r\n        <div className=\"flex items-center justify-between gap-3\">\r\n          {onDelete ? (\r\n            <button\r\n              type=\"button\"\r\n              className=\"btn btn-sm btn-error\"\r\n              onClick={onDelete}\r\n              disabled={isSubmitting || isDeleting}\r\n            >\r\n              {isDeleting ? t('action.deleting') : t('action.delete')}\r\n            </button>\r\n          ) : <span />}\r\n          <div className=\"flex gap-3\">\r\n            <button\r\n              type=\"button\"\r\n              className=\"btn btn-sm btn-outline\"\r\n              onClick={onCancel}\r\n              disabled={isSubmitting || isDeleting}\r\n            >\r\n              {tc('button.cancel')}\r\n            </button>\r\n            <button\r\n              type=\"button\"\r\n              className=\"btn btn-sm\"\r\n              onClick={() => onConfirm(name.trim())}\r\n              disabled={!name.trim() || isSubmitting || isDeleting}\r\n            >\r\n              {isSubmitting ? t('action.saving') : displayConfirmLabel}\r\n            </button>\r\n          </div>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tags/TagItem.tsx",
    "content": "/**\r\n * 标签项组件 - 单个标签的展示\r\n */\r\nimport type { Tag } from '@/lib/types'\r\n\r\ninterface TagItemProps {\r\n  tag: Tag\r\n  isSelected: boolean\r\n  isRelated: boolean\r\n  hasSelection: boolean\r\n  layout: 'grid' | 'masonry'\r\n  onToggle: () => void\r\n}\r\n\r\nexport function TagItem({ tag, isSelected, isRelated, hasSelection, layout, onToggle }: TagItemProps) {\r\n  const stateClasses = isSelected\r\n    ? 'border border-transparent bg-primary text-primary-content shadow-inner ring-1 ring-primary/40'\r\n    : isRelated\r\n      ? 'border border-transparent bg-accent/5 text-accent'\r\n      : hasSelection\r\n        ? 'border border-transparent bg-base-200/80 text-muted-foreground opacity-70 ring-1 ring-transparent'\r\n        : 'border border-border bg-card hover:border-primary/50 ring-1 ring-transparent'\r\n\r\n  const indicatorClasses = isSelected\r\n    ? 'bg-primary-content/20 border-2 border-primary-content'\r\n    : isRelated\r\n      ? 'bg-accent/20 border-2 border-accent'\r\n      : 'bg-transparent border-2 border-border'\r\n\r\n  const countClasses = isSelected\r\n    ? 'bg-primary-content/25 text-primary-content'\r\n    : isRelated\r\n      ? 'bg-accent/20 text-accent'\r\n      : hasSelection\r\n        ? 'bg-base-300 text-muted-foreground'\r\n        : 'bg-muted text-muted-foreground'\r\n\r\n  const layoutClasses = layout === 'masonry'\r\n    ? 'inline-flex items-center gap-2 px-3 py-2 rounded-lg'\r\n    : 'flex w-full items-center justify-between px-2.5 py-2'\r\n\r\n  const showMarquee = isSelected || isRelated\r\n  const marqueeStroke = isSelected ? 'var(--accent)' : 'var(--primary)'\r\n  const marqueeOpacity = isSelected ? 0.9 : 0.65\r\n  const marqueeDuration = isSelected ? '1s' : '3s'\r\n\r\n  return (\r\n    <div\r\n      className={`relative overflow-hidden rounded-lg cursor-pointer transition-all ${stateClasses}`}\r\n      onClick={onToggle}\r\n    >\r\n      {showMarquee && (\r\n        <div className=\"pointer-events-none absolute inset-0 z-0 rounded-lg overflow-hidden\">\r\n          <div\r\n            className=\"absolute top-0 left-0 right-0 h-0.5\"\r\n            style={{\r\n              background: `repeating-linear-gradient(90deg, ${marqueeStroke} 0px, ${marqueeStroke} 8px, transparent 8px, transparent 12px)`,\r\n              opacity: marqueeOpacity,\r\n              animation: `tag-marquee-move-right ${marqueeDuration} linear infinite`\r\n            }}\r\n          />\r\n          <div\r\n            className=\"absolute top-0 right-0 bottom-0 w-0.5\"\r\n            style={{\r\n              background: `repeating-linear-gradient(0deg, ${marqueeStroke} 0px, ${marqueeStroke} 8px, transparent 8px, transparent 12px)`,\r\n              opacity: marqueeOpacity,\r\n              animation: `tag-marquee-move-down ${marqueeDuration} linear infinite`\r\n            }}\r\n          />\r\n          <div\r\n            className=\"absolute bottom-0 left-0 right-0 h-0.5\"\r\n            style={{\r\n              background: `repeating-linear-gradient(-90deg, ${marqueeStroke} 0px, ${marqueeStroke} 8px, transparent 8px, transparent 12px)`,\r\n              opacity: marqueeOpacity,\r\n              animation: `tag-marquee-move-left ${marqueeDuration} linear infinite`\r\n            }}\r\n          />\r\n          <div\r\n            className=\"absolute top-0 left-0 bottom-0 w-0.5\"\r\n            style={{\r\n              background: `repeating-linear-gradient(180deg, ${marqueeStroke} 0px, ${marqueeStroke} 8px, transparent 8px, transparent 12px)`,\r\n              opacity: marqueeOpacity,\r\n              animation: `tag-marquee-move-up ${marqueeDuration} linear infinite`\r\n            }}\r\n          />\r\n        </div>\r\n      )}\r\n      <div className={`relative z-10 ${layoutClasses}`}>\r\n        <div className={`flex items-center gap-2 ${layout === 'masonry' ? '' : 'flex-1 min-w-0'}`}>\r\n          <div className={`w-3.5 h-3.5 rounded flex-shrink-0 flex items-center justify-center ${indicatorClasses}`}>\r\n            {isSelected && (\r\n              <svg className=\"w-2 h-2 text-primary-content\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={3.5} d=\"M5 13l4 4L19 7\" />\r\n              </svg>\r\n            )}\r\n          </div>\r\n\r\n          <span className={`text-xs ${layout === 'masonry' ? 'whitespace-nowrap' : 'truncate flex-1'} ${isSelected ? 'font-semibold' : 'font-medium'}`}>\r\n            {tag.name}\r\n          </span>\r\n\r\n          {tag.bookmark_count !== undefined && (\r\n            <span className={`text-[10px] font-bold px-1.5 py-0.5 rounded ${countClasses}`}>\r\n              {tag.bookmark_count}\r\n            </span>\r\n          )}\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tags/TagManageModal.tsx",
    "content": "import { useMemo, useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport type { Tag } from '@/lib/types'\r\nimport { useDeleteTag, useUpdateTag } from '@/hooks/useTags'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { AlertDialog } from '@/components/common/AlertDialog'\r\nimport { TagFormModal } from './TagFormModal'\r\nimport { logger } from '@/lib/logger'\r\nimport { Z_INDEX } from '@/lib/constants/z-index'\r\n\r\ninterface TagManageModalProps {\r\n  tags: Tag[]\r\n  onClose: () => void\r\n}\r\n\r\nexport function TagManageModal({ tags, onClose }: TagManageModalProps) {\r\n  const { t } = useTranslation('tags')\r\n  const { t: tc } = useTranslation('common')\r\n  const [editingTag, setEditingTag] = useState<Tag | null>(null)\r\n  const [editName, setEditName] = useState('')\r\n  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)\r\n  const [tagToDelete, setTagToDelete] = useState<Tag | null>(null)\r\n  const [showSuccessAlert, setShowSuccessAlert] = useState(false)\r\n  const [successMessage, setSuccessMessage] = useState('')\r\n  const [showErrorAlert, setShowErrorAlert] = useState(false)\r\n  const [errorMessage, setErrorMessage] = useState('')\r\n  const [isEditModalOpen, setIsEditModalOpen] = useState(false)\r\n\r\n  const deleteTag = useDeleteTag()\r\n  const updateTag = useUpdateTag()\r\n\r\n  const sortedTags = useMemo(() => {\r\n    return [...tags].sort((a, b) => (b.bookmark_count || 0) - (a.bookmark_count || 0))\r\n  }, [tags])\r\n\r\n  const handleEditClick = (tag: Tag) => {\r\n    setEditingTag(tag)\r\n    setEditName(tag.name)\r\n    setIsEditModalOpen(true)\r\n  }\r\n\r\n  const handleSaveEdit = async (value?: string) => {\r\n    if (!editingTag) return\r\n    const nextName = value?.trim() ?? editName.trim()\r\n    if (!nextName) return\r\n\r\n    try {\r\n      await updateTag.mutateAsync({\r\n        id: editingTag.id,\r\n        data: { name: nextName },\r\n      })\r\n      setEditingTag(null)\r\n      setEditName('')\r\n      setIsEditModalOpen(false)\r\n      setSuccessMessage(t('message.updateSuccess'))\r\n      setShowSuccessAlert(true)\r\n    } catch (error) {\r\n      logger.error('Failed to update tag:', error)\r\n      setErrorMessage(t('message.updateFailed'))\r\n      setShowErrorAlert(true)\r\n    }\r\n  }\r\n\r\n  const handleCancelEdit = () => {\r\n    setEditingTag(null)\r\n    setEditName('')\r\n    setIsEditModalOpen(false)\r\n  }\r\n\r\n  const openDeleteConfirm = (tag: Tag) => {\r\n    setTagToDelete(tag)\r\n    setShowDeleteConfirm(true)\r\n  }\r\n\r\n  const handleConfirmDelete = async () => {\r\n    if (!tagToDelete) return\r\n\r\n    setShowDeleteConfirm(false)\r\n    try {\r\n      await deleteTag.mutateAsync(tagToDelete.id)\r\n      if (editingTag?.id === tagToDelete.id) {\r\n        setEditingTag(null)\r\n        setIsEditModalOpen(false)\r\n        setEditName('')\r\n      }\r\n      setSuccessMessage(t('message.deleteSuccess'))\r\n      setShowSuccessAlert(true)\r\n    } catch (error) {\r\n      logger.error('Failed to delete tag:', error)\r\n      setErrorMessage(t('message.deleteFailed'))\r\n      setShowErrorAlert(true)\r\n    } finally {\r\n      setTagToDelete(null)\r\n    }\r\n  }\r\n\r\n  return (\r\n    <div className=\"fixed inset-0 flex items-center justify-center p-4 animate-fade-in bg-background/80 backdrop-blur-sm\" style={{ zIndex: Z_INDEX.TAG_MANAGE_MODAL }}>\r\n      <div className=\"absolute inset-0\" onClick={onClose} />\r\n\r\n      <div className=\"relative card rounded-2xl shadow-2xl w-full max-h-[68vh] flex flex-col border border-border animate-scale-in\" style={{padding: 0, maxWidth: '1200px', backgroundColor: 'var(--card)'}}>\r\n        <div className=\"flex items-center justify-between px-5 py-4 border-b border-border\">\r\n          <div className=\"flex items-center gap-2.5\">\r\n            <div className=\"w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-md\">\r\n              <svg className=\"w-5 h-5 text-primary-content\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z\" />\r\n              </svg>\r\n            </div>\r\n            <div>\r\n              <h2 className=\"text-lg font-bold text-foreground\">{t('manage.title')}</h2>\r\n              <p className=\"text-xs text-muted-foreground mt-0.5\">{t('manage.description')}</p>\r\n            </div>\r\n          </div>\r\n          <button\r\n            onClick={onClose}\r\n            className=\"w-8 h-8 rounded-lg hover:bg-muted flex items-center justify-center transition-colors text-muted-foreground/60 hover:text-foreground\"\r\n            title={tc('button.close')}\r\n          >\r\n            <svg className=\"w-4.5 h-4.5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\r\n            </svg>\r\n          </button>\r\n        </div>\r\n\r\n        <div className=\"flex-1 overflow-y-auto px-5 py-3\">\r\n          {sortedTags.length === 0 ? (\r\n            <div className=\"text-center py-12 text-muted-foreground/60\">\r\n              <div className=\"w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-3\">\r\n                <svg className=\"w-8 h-8 text-muted-foreground/30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n                  <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1.5} d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z\" />\r\n                </svg>\r\n              </div>\r\n              <p className=\"text-sm font-medium mb-1 text-foreground\">{t('manage.noTags')}</p>\r\n              <p className=\"text-xs text-muted-foreground/50\">{t('manage.noTagsHint')}</p>\r\n            </div>\r\n          ) : (\r\n            <div className=\"columns-1 sm:columns-2 lg:columns-3 gap-2.5 space-y-2.5\">\r\n              {sortedTags.map((tag, index) => (\r\n                <div\r\n                  key={tag.id}\r\n                  className=\"break-inside-avoid cursor-pointer group\"\r\n                  style={{ animationDelay: `${index * 30}ms` }}\r\n                  onClick={() => handleEditClick(tag)}\r\n                >\r\n                  <div className=\"relative rounded-xl border border-border bg-card/95 shadow-sm hover:shadow-md hover:shadow-primary/10 transition-all duration-200 hover:-translate-y-0.5\">\r\n                    <div className=\"absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity\">\r\n                      <button\r\n                        type=\"button\"\r\n                        className=\"w-7 h-7 rounded-full bg-primary/10 text-primary flex items-center justify-center\"\r\n                        title={t('action.edit')}\r\n                      >\r\n                        <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n                          <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z\" />\r\n                        </svg>\r\n                      </button>\r\n                    </div>\r\n\r\n                    <div className=\"p-3.5 space-y-2\">\r\n                      <div className=\"space-y-0.5\">\r\n                        <h3 className=\"text-base font-semibold text-foreground truncate\">{tag.name}</h3>\r\n                        {tag.bookmark_count !== undefined && (\r\n                          <p className=\"text-xs text-muted-foreground/70\">\r\n                            {tag.bookmark_count === 0 \r\n                              ? t('manage.noBookmarks') \r\n                              : t('manage.bookmarkCount', { count: tag.bookmark_count })}\r\n                          </p>\r\n                        )}\r\n                      </div>\r\n                    </div>\r\n                  </div>\r\n                </div>\r\n              ))}\r\n            </div>\r\n          )}\r\n        </div>\r\n\r\n        <div className=\"px-5 py-3 border-t border-border bg-muted/30\">\r\n          <button onClick={onClose} className=\"btn w-full\">\r\n            {t('action.done')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n\r\n      <ConfirmDialog\r\n        isOpen={showDeleteConfirm}\r\n        title={t('confirm.deleteTitle')}\r\n        message={t('confirm.deleteMessage', { name: tagToDelete?.name })}\r\n        type=\"warning\"\r\n        onConfirm={handleConfirmDelete}\r\n        onCancel={() => {\r\n          setShowDeleteConfirm(false)\r\n          setTagToDelete(null)\r\n        }}\r\n      />\r\n\r\n      <AlertDialog\r\n        isOpen={showSuccessAlert}\r\n        title={tc('dialog.successTitle')}\r\n        message={successMessage}\r\n        type=\"success\"\r\n        onConfirm={() => setShowSuccessAlert(false)}\r\n      />\r\n\r\n      <AlertDialog\r\n        isOpen={showErrorAlert}\r\n        title={tc('dialog.errorTitle')}\r\n        message={errorMessage}\r\n        type=\"error\"\r\n        onConfirm={() => setShowErrorAlert(false)}\r\n      />\r\n\r\n      <TagFormModal\r\n        isOpen={isEditModalOpen && Boolean(editingTag)}\r\n        title={t('action.edit')}\r\n        initialName={editingTag?.name ?? ''}\r\n        onConfirm={(value) => handleSaveEdit(value)}\r\n        onCancel={handleCancelEdit}\r\n        confirmLabel={t('action.save')}\r\n        isSubmitting={updateTag.isPending}\r\n        onDelete={() => {\r\n          if (editingTag) {\r\n            openDeleteConfirm(editingTag)\r\n          }\r\n        }}\r\n        isDeleting={deleteTag.isPending}\r\n      />\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tags/TagSidebar.tsx",
    "content": "import { useState, useMemo } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { useTags, useCreateTag } from '@/hooks/useTags'\r\nimport { tagsService } from '@/services/tags'\r\nimport type { Bookmark, Tag } from '@/lib/types'\r\nimport { TagManageModal } from './TagManageModal'\r\nimport { TagItem } from './TagItem'\r\nimport { useTagFiltering } from './useTagFiltering'\r\nimport { logger } from '@/lib/logger'\r\n\r\ninterface TagSidebarProps {\r\n  selectedTags: string[]\r\n  onTagsChange: (tags: string[]) => void\r\n  isLoadingBookmarks?: boolean\r\n  bookmarks: Bookmark[]\r\n  tagLayout: 'grid' | 'masonry'\r\n  onTagLayoutChange: (layout: 'grid' | 'masonry') => void\r\n  readOnly?: boolean\r\n  availableTags?: Tag[]\r\n  tagSortBy?: 'usage' | 'name' | 'clicks'\r\n  onTagSortChange?: (sortBy: 'usage' | 'name' | 'clicks') => void\r\n  searchQuery?: string // 外部搜索关键词\r\n  relatedTagIds?: string[] // 后端返回的相关标签ID\r\n}\r\n\r\nexport function TagSidebar({\r\n  selectedTags,\r\n  onTagsChange,\r\n  bookmarks,\r\n  tagLayout,\r\n  onTagLayoutChange,\r\n  readOnly = false,\r\n  availableTags,\r\n  tagSortBy: externalTagSortBy,\r\n  onTagSortChange,\r\n  searchQuery: externalSearchQuery = '',\r\n  relatedTagIds: serverRelatedTagIds,\r\n}: TagSidebarProps) {\r\n  const { t } = useTranslation('tags')\r\n  const [showCreateForm, setShowCreateForm] = useState(false)\r\n  const [showManageModal, setShowManageModal] = useState(false)\r\n  const [newTagName, setNewTagName] = useState('')\r\n  const [internalSortBy, setInternalSortBy] = useState<'usage' | 'name' | 'clicks'>('usage')\r\n\r\n  const sortBy = externalTagSortBy !== undefined ? externalTagSortBy : internalSortBy\r\n  const setSortBy = onTagSortChange || setInternalSortBy\r\n\r\n  const { data, isLoading } = useTags({ sort: sortBy }, { enabled: !availableTags })\r\n  const createTag = useCreateTag()\r\n\r\n  const tags = useMemo(() => availableTags || data?.tags || [], [availableTags, data?.tags])\r\n  const isTagLoading = availableTags ? false : isLoading\r\n\r\n  // 使用自定义 Hook 处理标签筛选逻辑\r\n  const { orderedTags, relatedTagIds } = useTagFiltering(\r\n    tags,\r\n    bookmarks,\r\n    selectedTags,\r\n    externalSearchQuery,\r\n    serverRelatedTagIds // 传递后端返回的相关标签ID\r\n  )\r\n\r\n  const handleToggleTag = async (tagId: string) => {\r\n    let newSelectedTags: string[]\r\n    if (selectedTags.includes(tagId)) {\r\n      newSelectedTags = selectedTags.filter((id) => id !== tagId)\r\n    } else {\r\n      newSelectedTags = [...selectedTags, tagId]\r\n      if (!readOnly) {\r\n        try {\r\n          await tagsService.incrementClick(tagId)\r\n        } catch (error) {\r\n          logger.error('Failed to increment tag click count:', error)\r\n        }\r\n      }\r\n    }\r\n    onTagsChange(newSelectedTags)\r\n  }\r\n\r\n  const handleCreateTag = async (e: React.FormEvent) => {\r\n    e.preventDefault()\r\n    if (readOnly) return\r\n    if (!newTagName.trim()) return\r\n\r\n    try {\r\n      await createTag.mutateAsync({ name: newTagName.trim() })\r\n      setNewTagName('')\r\n      setShowCreateForm(false)\r\n    } catch (error) {\r\n      logger.error('Failed to create tag:', error)\r\n    }\r\n  }\r\n\r\n  const getSortTitle = () => {\r\n    if (sortBy === 'usage') return t('sort.byUsage')\r\n    if (sortBy === 'clicks') return t('sort.byClicks')\r\n    return t('sort.byName')\r\n  }\r\n\r\n  const getLayoutTitle = () => {\r\n    return tagLayout === 'grid' ? t('layout.grid') : t('layout.masonry')\r\n  }\r\n\r\n  return (\r\n    <>\r\n      <div className=\"card flex flex-col shadow-lg h-full\">\r\n        {/* 标签头部 */}\r\n        <div className=\"flex items-center gap-2 mb-4 sm:mb-6 flex-shrink-0\">\r\n          <h3 className=\"text-base sm:text-lg font-bold text-primary flex-shrink-0\">\r\n            {t('title')}\r\n          </h3>\r\n\r\n          {/* 排序按钮 */}\r\n          <button\r\n            onClick={() => {\r\n              if (sortBy === 'usage') setSortBy('clicks')\r\n              else if (sortBy === 'clicks') setSortBy('name')\r\n              else setSortBy('usage')\r\n            }}\r\n            className=\"btn btn-sm btn-ghost p-2 flex-shrink-0\"\r\n            title={getSortTitle()}\r\n          >\r\n            {sortBy === 'usage' ? (\r\n              <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z\" />\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9 3h6\" opacity=\"0.5\" />\r\n              </svg>\r\n            ) : sortBy === 'clicks' ? (\r\n              <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z\" />\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z\" />\r\n              </svg>\r\n            ) : (\r\n              <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12\" />\r\n              </svg>\r\n            )}\r\n          </button>\r\n\r\n          {/* 布局切换按钮 */}\r\n          <button\r\n            onClick={() => onTagLayoutChange(tagLayout === 'grid' ? 'masonry' : 'grid')}\r\n            className=\"btn btn-sm btn-ghost p-2 flex-shrink-0\"\r\n            title={getLayoutTitle()}\r\n          >\r\n            {tagLayout === 'grid' ? (\r\n              <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z\" />\r\n              </svg>\r\n            ) : (\r\n              <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n                <rect x=\"3\" y=\"3\" width=\"7\" height=\"8\" rx=\"1\" />\r\n                <rect x=\"3\" y=\"13\" width=\"7\" height=\"8\" rx=\"1\" />\r\n                <rect x=\"14\" y=\"3\" width=\"7\" height=\"12\" rx=\"1\" />\r\n                <rect x=\"14\" y=\"17\" width=\"7\" height=\"4\" rx=\"1\" />\r\n              </svg>\r\n            )}\r\n          </button>\r\n\r\n          {/* 右侧按钮组 */}\r\n          {!readOnly && (\r\n            <>\r\n              <button\r\n                onClick={() => setShowManageModal(true)}\r\n                className=\"btn btn-sm btn-ghost p-2 flex-shrink-0 ml-auto\"\r\n                title={t('action.manage')}\r\n              >\r\n                <svg className=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\r\n                  <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\" />\r\n                  <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\r\n                </svg>\r\n              </button>\r\n\r\n              <button\r\n                onClick={() => setShowCreateForm(!showCreateForm)}\r\n                className=\"btn btn-sm p-2 flex-shrink-0 btn-ghost\"\r\n                title={showCreateForm ? t('action.cancel') : t('action.create')}\r\n              >\r\n                <svg\r\n                  className={`w-4 h-4 transition-transform ${showCreateForm ? 'rotate-45' : ''}`}\r\n                  fill=\"none\"\r\n                  stroke=\"currentColor\"\r\n                  viewBox=\"0 0 24 24\"\r\n                  strokeWidth={2}\r\n                >\r\n                  <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M12 4v16m8-8H4\" />\r\n                </svg>\r\n              </button>\r\n            </>\r\n          )}\r\n        </div>\r\n\r\n        {/* 创建标签表单 */}\r\n        {!readOnly && showCreateForm && (\r\n          <form onSubmit={handleCreateTag} className=\"mb-4 sm:mb-5 animate-fade-in flex-shrink-0\">\r\n            <div className=\"flex gap-2\">\r\n              <input\r\n                type=\"text\"\r\n                className=\"input flex-1 h-10 sm:h-auto text-sm sm:text-base\"\r\n                placeholder={t('form.placeholder')}\r\n                value={newTagName}\r\n                onChange={(e) => setNewTagName(e.target.value)}\r\n                autoFocus\r\n              />\r\n              <button type=\"submit\" className=\"btn btn-sm w-10 h-10 sm:w-auto sm:h-auto touch-manipulation\" disabled={createTag.isPending}>\r\n                {createTag.isPending ? '...' : '✓'}\r\n              </button>\r\n            </div>\r\n          </form>\r\n        )}\r\n\r\n        {/* 标签列表 */}\r\n        <div className=\"flex-1 overflow-y-auto scrollbar-hide p-1 min-h-0 overscroll-contain touch-auto\">\r\n          {isTagLoading && (\r\n            <div className=\"text-center py-8 text-muted-foreground/60 text-sm\">\r\n              <svg className=\"animate-spin h-6 w-6 mx-auto mb-2\" viewBox=\"0 0 24 24\" fill=\"none\">\r\n                <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\"></circle>\r\n                <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\r\n              </svg>\r\n              {t('status.loading')}\r\n            </div>\r\n          )}\r\n\r\n          {!isTagLoading && orderedTags.length === 0 && (\r\n            <div className=\"text-center py-12 text-muted-foreground/60\">\r\n              <svg className=\"w-12 h-12 mx-auto mb-3 text-muted-foreground/30\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n                <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={1.5} d=\"M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z\" />\r\n              </svg>\r\n              <p className=\"text-sm\">\r\n                {externalSearchQuery\r\n                  ? t('empty.noMatch')\r\n                  : readOnly\r\n                    ? t('empty.readOnly')\r\n                    : t('empty.description')}\r\n              </p>\r\n            </div>\r\n          )}\r\n\r\n          {!isTagLoading && orderedTags.length > 0 && (\r\n            <div\r\n              className={`${tagLayout === 'masonry'\r\n                ? 'flex flex-wrap items-start gap-2 justify-between'\r\n                : 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 justify-center'\r\n                }`}\r\n            >\r\n              {orderedTags.map((tag) => (\r\n                <TagItem\r\n                  key={tag.id}\r\n                  tag={tag}\r\n                  isSelected={selectedTags.includes(tag.id)}\r\n                  isRelated={relatedTagIds.has(tag.id)}\r\n                  hasSelection={selectedTags.length > 0}\r\n                  layout={tagLayout}\r\n                  onToggle={() => handleToggleTag(tag.id)}\r\n                />\r\n              ))}\r\n            </div>\r\n          )}\r\n        </div>\r\n      </div>\r\n\r\n      {!readOnly && showManageModal && (\r\n        <TagManageModal\r\n          tags={tags}\r\n          onClose={() => setShowManageModal(false)}\r\n        />\r\n      )}\r\n    </>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/components/tags/useTagFiltering.ts",
    "content": "/**\r\n * 标签筛选逻辑 Hook\r\n */\r\nimport { useMemo } from 'react'\r\nimport type { Bookmark, Tag } from '@/lib/types'\r\n\r\nexport function useTagFiltering(\r\n  tags: Tag[],\r\n  bookmarks: Bookmark[],\r\n  selectedTags: string[],\r\n  searchQuery: string,\r\n  serverRelatedTagIds?: string[] // 后端返回的相关标签ID（基于所有书签）\r\n) {\r\n  // 计算标签共现关系\r\n  const coOccurrenceMap = useMemo(() => {\r\n    const map = new Map<string, Set<string>>()\r\n\r\n    for (const bookmark of bookmarks) {\r\n      if (!bookmark.tags || bookmark.tags.length < 2) continue\r\n\r\n      const ids = bookmark.tags.reduce<string[]>((acc, tag) => {\r\n        if (tag.id) acc.push(tag.id)\r\n        return acc\r\n      }, [])\r\n\r\n      if (ids.length < 2) continue\r\n\r\n      for (let i = 0; i < ids.length; i++) {\r\n        const sourceId = ids[i]!\r\n        if (!map.has(sourceId)) {\r\n          map.set(sourceId, new Set())\r\n        }\r\n\r\n        for (let j = 0; j < ids.length; j++) {\r\n          if (i === j) continue\r\n          const targetId = ids[j]\r\n          if (!targetId) continue\r\n          \r\n          const sourceSet = map.get(sourceId)\r\n          if (sourceSet) {\r\n            sourceSet.add(targetId)\r\n          }\r\n        }\r\n      }\r\n    }\r\n\r\n    return map\r\n  }, [bookmarks])\r\n\r\n  // 计算相关标签\r\n  const relatedTagIds = useMemo(() => {\r\n    if (selectedTags.length === 0) return new Set<string>()\r\n\r\n    // 优先使用后端返回的相关标签（基于所有书签，更准确）\r\n    if (serverRelatedTagIds && serverRelatedTagIds.length > 0) {\r\n      return new Set(serverRelatedTagIds)\r\n    }\r\n\r\n    // 降级方案：基于当前已加载的书签计算（可能不完整）\r\n    if (selectedTags.length === 1) {\r\n      const neighbors = coOccurrenceMap.get(selectedTags[0]!)\r\n      if (!neighbors) return new Set<string>()\r\n      return new Set([...neighbors].filter(id => !selectedTags.includes(id)))\r\n    }\r\n\r\n    const firstTagNeighbors = coOccurrenceMap.get(selectedTags[0]!)\r\n    if (!firstTagNeighbors) return new Set<string>()\r\n\r\n    const related = new Set<string>()\r\n\r\n    firstTagNeighbors.forEach((neighborId) => {\r\n      if (selectedTags.includes(neighborId)) return\r\n\r\n      const isRelatedToAll = selectedTags.every((tagId) => {\r\n        const neighbors = coOccurrenceMap.get(tagId)\r\n        return neighbors && neighbors.has(neighborId)\r\n      })\r\n\r\n      if (isRelatedToAll) {\r\n        related.add(neighborId)\r\n      }\r\n    })\r\n\r\n    return related\r\n  }, [selectedTags, coOccurrenceMap, serverRelatedTagIds])\r\n\r\n  // 搜索筛选\r\n  const filteredTags = useMemo(() => {\r\n    if (!searchQuery.trim()) return tags\r\n\r\n    const query = searchQuery.toLowerCase()\r\n    return tags.filter((tag) => tag.name.toLowerCase().includes(query))\r\n  }, [tags, searchQuery])\r\n\r\n  // 排序：已选中、相关、其他\r\n  const orderedTags = useMemo(() => {\r\n    const selected: Tag[] = []\r\n    const related: Tag[] = []\r\n    const others: Tag[] = []\r\n\r\n    for (const tag of filteredTags) {\r\n      if (selectedTags.includes(tag.id)) {\r\n        selected.push(tag)\r\n      } else if (relatedTagIds.has(tag.id)) {\r\n        related.push(tag)\r\n      } else {\r\n        others.push(tag)\r\n      }\r\n    }\r\n\r\n    return [...selected, ...related, ...others]\r\n  }, [filteredTags, selectedTags, relatedTagIds])\r\n\r\n  return { orderedTags, relatedTagIds }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/hooks/buildTabOpenerHtml.ts",
    "content": "/**\n * Generates the HTML for the tab opener popup window.\n */\n\ninterface ThemeColors {\n  primary: string\n  accent: string\n  card: string\n  muted: string\n  success: string\n  destructive: string\n  foreground: string\n}\n\ninterface I18nStrings {\n  title: string\n  heading: string\n  preparing: string\n  opening: string\n  successPartial: string\n  successAll: string\n  closeWindow: string\n}\n\ninterface TabItem {\n  url: string\n  title: string\n}\n\nfunction escapeHtml(str: string): string {\n  return str\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#39;')\n}\n\nfunction escapeJsString(str: string): string {\n  return str\n    .replace(/\\\\/g, '\\\\\\\\')\n    .replace(/'/g, \"\\\\'\")\n    .replace(/</g, '\\\\x3c')\n    .replace(/>/g, '\\\\x3e')\n    .replace(/\\n/g, '\\\\n')\n    .replace(/\\r/g, '\\\\r')\n}\n\nfunction sanitizeCssValue(str: string): string {\n  return str.replace(/[^a-zA-Z0-9#%(),.\\-\\s/]/g, '')\n}\n\nexport function getThemeColors(): ThemeColors {\n  const root = document.documentElement\n  const get = (v: string) => getComputedStyle(root).getPropertyValue(v).trim()\n  return {\n    primary: get('--primary'),\n    accent: get('--accent'),\n    card: get('--card'),\n    muted: get('--muted'),\n    success: get('--success'),\n    destructive: get('--destructive'),\n    foreground: get('--foreground'),\n  }\n}\n\nexport function buildTabOpenerHtml(\n  items: TabItem[],\n  colors: ThemeColors,\n  i18n: I18nStrings\n): string {\n  const urlsJson = JSON.stringify(items)\n  const c = {\n    primary: sanitizeCssValue(colors.primary),\n    accent: sanitizeCssValue(colors.accent),\n    card: sanitizeCssValue(colors.card),\n    muted: sanitizeCssValue(colors.muted),\n    success: sanitizeCssValue(colors.success),\n    destructive: sanitizeCssValue(colors.destructive),\n    foreground: sanitizeCssValue(colors.foreground),\n  }\n\n  return `<!DOCTYPE html>\n<html>\n<head>\n  <meta charset=\"UTF-8\">\n  <title>${escapeHtml(i18n.title)}</title>\n  <style>\n    body {\n      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n      display: flex;\n      flex-direction: column;\n      align-items: center;\n      justify-content: center;\n      min-height: 100vh;\n      margin: 0;\n      background: linear-gradient(135deg, ${c.primary} 0%, ${c.accent} 100%);\n      color: ${c.foreground};\n    }\n    .container {\n      text-align: center;\n      padding: 2rem;\n      background: ${c.card};\n      backdrop-filter: blur(10px);\n      border-radius: 1rem;\n      box-shadow: 0 8px 32px hsl(0 0% 0% / 0.15);\n      max-width: 600px;\n    }\n    h1 { margin: 0 0 1rem 0; font-size: 2rem; }\n    .progress {\n      margin: 2rem 0;\n      font-size: 1.5rem;\n      font-weight: bold;\n    }\n    .status {\n      margin: 1rem 0;\n      padding: 1rem;\n      background: ${c.muted};\n      border-radius: 0.5rem;\n      font-size: 0.9rem;\n    }\n    .links {\n      margin-top: 2rem;\n      text-align: left;\n      max-height: 300px;\n      overflow-y: auto;\n      padding: 1rem;\n      background: ${c.muted};\n      border-radius: 0.5rem;\n    }\n    .link-item {\n      padding: 0.5rem;\n      margin: 0.25rem 0;\n      background: ${c.card};\n      border-radius: 0.25rem;\n      font-size: 0.85rem;\n      word-break: break-all;\n    }\n    .link-item.opened {\n      background: color-mix(in srgb, ${c.success} 30%, transparent);\n    }\n    .link-item.failed {\n      background: color-mix(in srgb, ${c.destructive} 30%, transparent);\n    }\n    button {\n      margin-top: 1rem;\n      padding: 0.75rem 2rem;\n      font-size: 1rem;\n      background: ${c.primary};\n      color: ${c.foreground};\n      border: none;\n      border-radius: 0.5rem;\n      cursor: pointer;\n      font-weight: bold;\n      transition: transform 0.2s;\n    }\n    button:hover {\n      transform: scale(1.05);\n    }\n  </style>\n</head>\n<body>\n  <div class=\"container\">\n    <h1>${escapeHtml(i18n.heading)}</h1>\n    <div class=\"progress\">\n      <span id=\"current\">0</span> / <span id=\"total\">${items.length}</span>\n    </div>\n    <div class=\"status\" id=\"status\">${escapeHtml(i18n.preparing)}</div>\n    <div class=\"links\" id=\"links\"></div>\n    <button onclick=\"window.close()\" style=\"display:none\" id=\"closeBtn\">${escapeHtml(i18n.closeWindow)}</button>\n  </div>\n  <script>\n    const urls = ${urlsJson};\n    const i18nOpening = '${escapeJsString(i18n.opening)}';\n    let opened = 0;\n    let failed = 0;\n\n    const linksContainer = document.getElementById('links');\n    const statusEl = document.getElementById('status');\n    const currentEl = document.getElementById('current');\n    const closeBtnEl = document.getElementById('closeBtn');\n\n    urls.forEach((item, index) => {\n      const div = document.createElement('div');\n      div.className = 'link-item';\n      div.id = 'link-' + index;\n      div.textContent = (index + 1) + '. ' + item.title;\n      linksContainer.appendChild(div);\n    });\n\n    async function openTabs() {\n      for (let i = 0; i < urls.length; i++) {\n        const item = urls[i];\n        const linkEl = document.getElementById('link-' + i);\n\n        try {\n          statusEl.textContent = i18nOpening + item.title;\n          const newWindow = window.open(item.url, '_blank', 'noopener,noreferrer');\n\n          if (newWindow) {\n            opened++;\n            linkEl.className = 'link-item opened';\n          } else {\n            failed++;\n            linkEl.className = 'link-item failed';\n          }\n        } catch (error) {\n          console.error('Failed to open:', item.url, error);\n          failed++;\n          linkEl.className = 'link-item failed';\n        }\n\n        currentEl.textContent = (i + 1);\n\n        if (i < urls.length - 1) {\n          await new Promise(resolve => setTimeout(resolve, 100));\n        }\n      }\n\n      if (failed > 0) {\n        statusEl.textContent = '${escapeJsString(i18n.successPartial)}';\n        statusEl.style.background = 'var(--warning)';\n        statusEl.style.opacity = '0.3';\n      } else {\n        statusEl.textContent = '${escapeJsString(i18n.successAll)}';\n        statusEl.style.background = 'var(--success)';\n        statusEl.style.opacity = '0.3';\n      }\n\n      closeBtnEl.style.display = 'block';\n    }\n\n    setTimeout(openTabs, 500);\n  </script>\n</body>\n</html>`\n}\n"
  },
  {
    "path": "tmarks/src/hooks/index.ts",
    "content": "// ============ Hooks Exports ============\r\n\r\nexport * from './useApiKeys'\r\nexport * from './useBatchActions'\r\nexport * from './useBookmarks'\r\nexport * from './useLocalPreferences'\r\nexport * from './usePreferences'\r\nexport * from './useShare'\r\nexport * from './useStorage'\r\nexport * from './useTags'\r\n"
  },
  {
    "path": "tmarks/src/hooks/useAnimatedProgress.ts",
    "content": "import { useState, useEffect } from 'react'\n\nexport function useAnimatedProgress(percentage: number) {\n  const [animatedPercentage, setAnimatedPercentage] = useState(0)\n  const [isComplete, setIsComplete] = useState(false)\n\n  // 动画效果\n  useEffect(() => {\n    const timer = setTimeout(() => {\n      setAnimatedPercentage(percentage)\n    }, 100)\n\n    return () => clearTimeout(timer)\n  }, [percentage])\n\n  // 完成状态检测\n  useEffect(() => {\n    if (percentage >= 100) {\n      const timer = setTimeout(() => {\n        setIsComplete(true)\n      }, 500)\n      return () => clearTimeout(timer)\n    } else {\n      setIsComplete(false)\n    }\n  }, [percentage])\n\n  return { animatedPercentage, isComplete }\n}\n"
  },
  {
    "path": "tmarks/src/hooks/useApiKeys.ts",
    "content": "/**\n * API Keys React Query Hooks\n * 使用 React Query 管理 API Keys 数据\n */\n\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport {\n  getApiKeys,\n  getApiKey,\n  createApiKey,\n  updateApiKey,\n  revokeApiKey,\n  deleteApiKey,\n  getApiKeyLogs,\n  type UpdateApiKeyRequest,\n} from '@/services/api-keys'\n\n// Query Keys\nexport const apiKeysKeys = {\n  all: ['api-keys'] as const,\n  lists: () => [...apiKeysKeys.all, 'list'] as const,\n  list: () => [...apiKeysKeys.lists()] as const,\n  details: () => [...apiKeysKeys.all, 'detail'] as const,\n  detail: (id: string) => [...apiKeysKeys.details(), id] as const,\n  logs: (id: string) => [...apiKeysKeys.all, 'logs', id] as const,\n}\n\n/**\n * 获取 API Keys 列表\n */\nexport function useApiKeys() {\n  return useQuery({\n    queryKey: apiKeysKeys.list(),\n    queryFn: getApiKeys,\n  })\n}\n\n/**\n * 获取单个 API Key 详情\n */\nexport function useApiKey(id: string) {\n  return useQuery({\n    queryKey: apiKeysKeys.detail(id),\n    queryFn: () => getApiKey(id),\n    enabled: !!id,\n  })\n}\n\n/**\n * 获取 API Key 日志\n */\nexport function useApiKeyLogs(id: string, limit: number = 10) {\n  return useQuery({\n    queryKey: apiKeysKeys.logs(id),\n    queryFn: () => getApiKeyLogs(id, limit),\n    enabled: !!id,\n  })\n}\n\n/**\n * 创建 API Key\n */\nexport function useCreateApiKey() {\n  const queryClient = useQueryClient()\n\n  return useMutation({\n    mutationFn: createApiKey,\n    onSuccess: () => {\n      // 刷新列表\n      queryClient.invalidateQueries({ queryKey: apiKeysKeys.lists() })\n    },\n  })\n}\n\n/**\n * 更新 API Key\n */\nexport function useUpdateApiKey() {\n  const queryClient = useQueryClient()\n\n  return useMutation({\n    mutationFn: ({ id, data }: { id: string; data: UpdateApiKeyRequest }) =>\n      updateApiKey(id, data),\n    onSuccess: (_, variables) => {\n      // 刷新列表和详情\n      queryClient.invalidateQueries({ queryKey: apiKeysKeys.lists() })\n      queryClient.invalidateQueries({ queryKey: apiKeysKeys.detail(variables.id) })\n    },\n  })\n}\n\n/**\n * 撤销 API Key\n */\nexport function useRevokeApiKey() {\n  const queryClient = useQueryClient()\n\n  return useMutation({\n    mutationFn: revokeApiKey,\n    onSuccess: () => {\n      // 刷新列表\n      queryClient.invalidateQueries({ queryKey: apiKeysKeys.lists() })\n    },\n  })\n}\n\n/**\n * 删除 API Key\n */\nexport function useDeleteApiKey() {\n  const queryClient = useQueryClient()\n\n  return useMutation({\n    mutationFn: deleteApiKey,\n    onSuccess: () => {\n      queryClient.invalidateQueries({ queryKey: apiKeysKeys.lists() })\n    },\n  })\n}\n"
  },
  {
    "path": "tmarks/src/hooks/useBatchActions.ts",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { tabGroupsService } from '@/services/tab-groups'\r\nimport type { TabGroup, TabGroupItem } from '@/lib/types'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { useInvalidateTabGroups } from './useTabGroupsQuery'\r\nimport { logger } from '@/lib/logger'\r\n\r\ninterface UseBatchActionsProps {\r\n  tabGroups: TabGroup[]\r\n  setTabGroups: React.Dispatch<React.SetStateAction<TabGroup[]>>\r\n  selectedItems: Set<string>\r\n  setSelectedItems: React.Dispatch<React.SetStateAction<Set<string>>>\r\n  setConfirmDialog: React.Dispatch<React.SetStateAction<{\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n  }>>\r\n  confirmDialog: {\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n  }\r\n}\r\n\r\nexport function useBatchActions({\r\n  tabGroups,\r\n  setTabGroups,\r\n  selectedItems,\r\n  setSelectedItems,\r\n  setConfirmDialog,\r\n  confirmDialog,\r\n}: UseBatchActionsProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const { success, error: showError } = useToastStore()\r\n  const invalidateTabGroups = useInvalidateTabGroups()\r\n\r\n  const handleBatchDelete = () => {\r\n    if (selectedItems.size === 0) return\r\n\r\n    setConfirmDialog({\r\n      isOpen: true,\r\n      title: t('confirm.batchDelete'),\r\n      message: t('confirm.batchDeleteMessage', { count: selectedItems.size }),\r\n      onConfirm: async () => {\r\n        setConfirmDialog({ ...confirmDialog, isOpen: false })\r\n        try {\r\n          await Promise.all(\r\n            Array.from(selectedItems).map((itemId) =>\r\n              tabGroupsService.deleteTabGroupItem(itemId)\r\n            )\r\n          )\r\n\r\n          setTabGroups((prev) =>\r\n            prev.map((group) => ({\r\n              ...group,\r\n              items: group.items?.filter((item) => !selectedItems.has(item.id)),\r\n              item_count: (group.item_count || 0) - Array.from(selectedItems).filter((id) =>\r\n                group.items?.some((item) => item.id === id)\r\n              ).length,\r\n            }))\r\n          )\r\n\r\n          await invalidateTabGroups()\r\n          setSelectedItems(new Set())\r\n          success(t('message.batchDeleteSuccess'))\r\n        } catch (err) {\r\n          logger.error('Failed to batch delete:', err)\r\n          showError(t('message.batchDeleteFailed'))\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handleBatchPin = async () => {\r\n    if (selectedItems.size === 0) return\r\n\r\n    try {\r\n      await Promise.all(\r\n        Array.from(selectedItems).map((itemId) =>\r\n          tabGroupsService.updateTabGroupItem(itemId, { is_pinned: true })\r\n        )\r\n      )\r\n\r\n      setTabGroups((prev) =>\r\n        prev.map((group) => ({\r\n          ...group,\r\n          items: group.items?.map((item) =>\r\n            selectedItems.has(item.id) ? { ...item, is_pinned: true } : item\r\n          ),\r\n        }))\r\n      )\r\n\r\n      await invalidateTabGroups()\r\n      setSelectedItems(new Set())\r\n      success(t('message.batchPinSuccess'))\r\n    } catch (err) {\r\n      logger.error('Failed to batch pin:', err)\r\n      showError(t('message.batchPinFailed'))\r\n    }\r\n  }\r\n\r\n  const handleBatchTodo = async () => {\r\n    if (selectedItems.size === 0) return\r\n\r\n    try {\r\n      await Promise.all(\r\n        Array.from(selectedItems).map((itemId) =>\r\n          tabGroupsService.updateTabGroupItem(itemId, { is_todo: true })\r\n        )\r\n      )\r\n\r\n      setTabGroups((prev) =>\r\n        prev.map((group) => ({\r\n          ...group,\r\n          items: group.items?.map((item) =>\r\n            selectedItems.has(item.id) ? { ...item, is_todo: true } : item\r\n          ),\r\n        }))\r\n      )\r\n\r\n      await invalidateTabGroups()\r\n      setSelectedItems(new Set())\r\n      success(t('message.batchTodoSuccess'))\r\n    } catch (err) {\r\n      logger.error('Failed to batch todo:', err)\r\n      showError(t('message.batchTodoFailed'))\r\n    }\r\n  }\r\n\r\n  const handleBatchExport = () => {\r\n    if (selectedItems.size === 0) return\r\n\r\n    // Get all selected items from all groups\r\n    const selectedItemsData: TabGroupItem[] = []\r\n    tabGroups.forEach((group) => {\r\n      group.items?.forEach((item) => {\r\n        if (selectedItems.has(item.id)) {\r\n          selectedItemsData.push(item)\r\n        }\r\n      })\r\n    })\r\n\r\n    // Generate markdown\r\n    let markdown = `# ${t('export.title')}\\n\\n`\r\n    markdown += `${t('export.exportTime')}: ${new Date().toLocaleString()}\\n`\r\n    markdown += `${t('export.tabCount')}: ${selectedItemsData.length}\\n\\n`\r\n    markdown += `---\\n\\n`\r\n\r\n    selectedItemsData.forEach((item, index) => {\r\n      markdown += `${index + 1}. [${item.title}](${item.url})\\n`\r\n      if (item.is_pinned) markdown += `   - 📌 ${t('item.pinned')}\\n`\r\n      if (item.is_todo) markdown += `   - ✅ ${t('item.todo')}\\n`\r\n      markdown += '\\n'\r\n    })\r\n\r\n    // Download\r\n    const blob = new Blob([markdown], { type: 'text/markdown' })\r\n    const url = URL.createObjectURL(blob)\r\n    const a = document.createElement('a')\r\n    a.href = url\r\n    a.download = `batch-export-${Date.now()}.md`\r\n    document.body.appendChild(a)\r\n    a.click()\r\n    document.body.removeChild(a)\r\n    URL.revokeObjectURL(url)\r\n\r\n    success(t('message.exportSuccess'))\r\n  }\r\n\r\n  const handleDeselectAll = () => {\r\n    setSelectedItems(new Set())\r\n  }\r\n\r\n  return {\r\n    handleBatchDelete,\r\n    handleBatchPin,\r\n    handleBatchTodo,\r\n    handleBatchExport,\r\n    handleDeselectAll,\r\n  }\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/src/hooks/useBookmarkFilters.ts",
    "content": "import { useState, useRef, useEffect, useCallback } from 'react'\nimport type { SortOption } from '@/components/common/SortSelector'\nimport {\n  VIEW_MODES,\n  ViewMode,\n  VisibilityFilter,\n  VISIBILITY_FILTERS,\n  SORT_OPTIONS,\n  VIEW_MODE_STORAGE_KEY,\n  VIEW_MODE_UPDATED_AT_STORAGE_KEY\n} from '@/lib/constants/bookmarks'\n\nfunction isValidViewMode(value: string | null): value is ViewMode {\n  return !!value && (VIEW_MODES as readonly string[]).includes(value)\n}\n\nfunction getStoredViewMode(): ViewMode | null {\n  if (typeof window === 'undefined') return null\n  const stored = window.localStorage.getItem(VIEW_MODE_STORAGE_KEY)\n  return isValidViewMode(stored) ? stored : null\n}\n\nexport function getStoredViewModeUpdatedAt(): number {\n  if (typeof window === 'undefined') return 0\n  const stored = window.localStorage.getItem(VIEW_MODE_UPDATED_AT_STORAGE_KEY)\n  const timestamp = stored ? Number(stored) : 0\n  return Number.isFinite(timestamp) ? timestamp : 0\n}\n\nexport function setStoredViewMode(mode: ViewMode, updatedAt?: number) {\n  if (typeof window === 'undefined') return\n  window.localStorage.setItem(VIEW_MODE_STORAGE_KEY, mode)\n  window.localStorage.setItem(\n    VIEW_MODE_UPDATED_AT_STORAGE_KEY,\n    String(typeof updatedAt === 'number' && Number.isFinite(updatedAt) ? updatedAt : Date.now()),\n  )\n}\n\nexport function useBookmarkFilters() {\n  const [selectedTags, setSelectedTags] = useState<string[]>([])\n  const [debouncedSelectedTags, setDebouncedSelectedTags] = useState<string[]>([])\n  const [searchKeyword, setSearchKeyword] = useState('')\n  const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('')\n  const [searchMode, setSearchMode] = useState<'bookmark' | 'tag'>('bookmark')\n  const [sortBy, setSortBy] = useState<SortOption>('created')\n  const [viewMode, setViewMode] = useState<ViewMode>(() => getStoredViewMode() ?? 'card')\n  const [visibilityFilter, setVisibilityFilter] = useState<VisibilityFilter>('all')\n  const [tagLayout, setTagLayout] = useState<'grid' | 'masonry'>('grid')\n\n  const tagDebounceTimerRef = useRef<NodeJS.Timeout | null>(null)\n  const searchCleanupTimerRef = useRef<NodeJS.Timeout | null>(null)\n\n  // 标签搜索防抖\n  useEffect(() => {\n    if (tagDebounceTimerRef.current) clearTimeout(tagDebounceTimerRef.current)\n    tagDebounceTimerRef.current = setTimeout(() => {\n      setDebouncedSelectedTags(selectedTags)\n    }, 300)\n    return () => {\n      if (tagDebounceTimerRef.current) clearTimeout(tagDebounceTimerRef.current)\n    }\n  }, [selectedTags])\n\n  // 关键词搜索防抖\n  useEffect(() => {\n    if (searchCleanupTimerRef.current) clearTimeout(searchCleanupTimerRef.current)\n    searchCleanupTimerRef.current = setTimeout(() => {\n      setDebouncedSearchKeyword(searchKeyword)\n    }, 400)\n    return () => {\n      if (searchCleanupTimerRef.current) clearTimeout(searchCleanupTimerRef.current)\n    }\n  }, [searchKeyword])\n\n  const handleViewModeChange = useCallback(() => {\n    const currentIndex = VIEW_MODES.indexOf(viewMode)\n    const nextMode = VIEW_MODES[(currentIndex + 1) % VIEW_MODES.length]!\n    setViewMode(nextMode)\n    setStoredViewMode(nextMode)\n    return nextMode\n  }, [viewMode])\n\n  const handleSortChange = useCallback(() => {\n    const currentIndex = SORT_OPTIONS.indexOf(sortBy)\n    const nextSort = SORT_OPTIONS[(currentIndex + 1) % SORT_OPTIONS.length]!\n    setSortBy(nextSort)\n    return nextSort\n  }, [sortBy])\n\n  const handleVisibilityChange = useCallback(() => {\n    const currentIndex = VISIBILITY_FILTERS.indexOf(visibilityFilter)\n    const nextFilter = VISIBILITY_FILTERS[(currentIndex + 1) % VISIBILITY_FILTERS.length]!\n    setVisibilityFilter(nextFilter)\n    return nextFilter\n  }, [visibilityFilter])\n\n  const handleTagLayoutChange = useCallback((layout: 'grid' | 'masonry') => {\n    setTagLayout(layout)\n  }, [])\n\n  return {\n    selectedTags,\n    setSelectedTags,\n    debouncedSelectedTags,\n    searchKeyword,\n    setSearchKeyword,\n    debouncedSearchKeyword,\n    searchMode,\n    setSearchMode,\n    sortBy,\n    setSortBy,\n    handleSortChange,\n    viewMode,\n    setViewMode,\n    handleViewModeChange,\n    visibilityFilter,\n    setVisibilityFilter,\n    handleVisibilityChange,\n    tagLayout,\n    setTagLayout,\n    handleTagLayoutChange,\n  }\n}\n"
  },
  {
    "path": "tmarks/src/hooks/useBookmarks.ts",
    "content": "import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\r\nimport { bookmarksService } from '@/services/bookmarks'\r\nimport type {\r\n  BookmarkQueryParams,\r\n  CreateBookmarkRequest,\r\n  UpdateBookmarkRequest,\r\n  BatchActionRequest,\r\n} from '@/lib/types'\r\n\r\nexport const BOOKMARKS_QUERY_KEY = 'bookmarks'\r\n\r\n/**\r\n * 获取书签列表\r\n * \r\n * 优化说明:\r\n * - staleTime: 30分钟 (书签变化不频繁)\r\n * - gcTime: 24小时 (持久化缓存)\r\n * - refetchOnWindowFocus: true (窗口聚焦时刷新)\r\n */\r\nexport function useBookmarks(params?: BookmarkQueryParams, options?: { staleTime?: number; gcTime?: number }) {\r\n  return useQuery({\r\n    queryKey: [BOOKMARKS_QUERY_KEY, params],\r\n    queryFn: () => bookmarksService.getBookmarks(params),\r\n    staleTime: options?.staleTime || 30 * 60 * 1000, // 30分钟\r\n    gcTime: options?.gcTime || 24 * 60 * 60 * 1000, // 24小时\r\n    refetchOnWindowFocus: 'always', // 窗口聚焦时刷新，保持数据同步\r\n  })\r\n}\r\n\r\n/**\r\n * 无限滚动获取书签列表\r\n * \r\n * 优化说明:\r\n * - staleTime: 30分钟\r\n * - gcTime: 24小时\r\n */\r\nexport function useInfiniteBookmarks(\r\n  params?: BookmarkQueryParams,\r\n  options?: { staleTime?: number; cacheTime?: number }\r\n) {\r\n  return useInfiniteQuery({\r\n    queryKey: [BOOKMARKS_QUERY_KEY, params],\r\n    queryFn: ({ pageParam }) =>\r\n      bookmarksService.getBookmarks({\r\n        ...params,\r\n        page_cursor: typeof pageParam === 'string' ? pageParam : undefined,\r\n      }),\r\n    initialPageParam: undefined as string | undefined,\r\n    getNextPageParam: (lastPage) => (lastPage.meta?.has_more ? lastPage.meta.next_cursor : undefined),\r\n    staleTime: options?.staleTime ?? 30 * 60 * 1000, // 30分钟\r\n    gcTime: options?.cacheTime ?? 24 * 60 * 60 * 1000, // 24小时\r\n    refetchOnWindowFocus: 'always', // 窗口聚焦时刷新，保持数据同步\r\n  })\r\n}\r\n\r\n/**\r\n * 创建书签\r\n * \r\n * 成功后自动刷新书签和标签缓存\r\n */\r\nexport function useCreateBookmark() {\r\n  const queryClient = useQueryClient()\r\n\r\n  return useMutation({\r\n    mutationFn: (data: CreateBookmarkRequest) => bookmarksService.createBookmark(data),\r\n    onSuccess: async () => {\r\n      // 成功后刷新所有书签查询\r\n      try {\r\n        await queryClient.invalidateQueries({ queryKey: [BOOKMARKS_QUERY_KEY] })\r\n        await queryClient.invalidateQueries({ queryKey: ['tags'] })\r\n      } catch (error) {\r\n        console.error('Failed to invalidate queries:', error)\r\n      }\r\n    },\r\n  })\r\n}\r\n\r\n/**\r\n * 更新书签\r\n */\r\nexport function useUpdateBookmark() {\r\n  const queryClient = useQueryClient()\r\n\r\n  return useMutation({\r\n    mutationFn: ({ id, data }: { id: string; data: UpdateBookmarkRequest }) =>\r\n      bookmarksService.updateBookmark(id, data),\r\n    onSuccess: async () => {\r\n      try {\r\n        await queryClient.invalidateQueries({ queryKey: [BOOKMARKS_QUERY_KEY] })\r\n        await queryClient.invalidateQueries({ queryKey: ['tags'] })\r\n      } catch (error) {\r\n        console.error('Failed to invalidate queries:', error)\r\n      }\r\n    },\r\n  })\r\n}\r\n\r\n/**\r\n * 删除书签\r\n */\r\nexport function useDeleteBookmark() {\r\n  const queryClient = useQueryClient()\r\n\r\n  return useMutation({\r\n    mutationFn: (id: string) => bookmarksService.deleteBookmark(id),\r\n    onSuccess: async () => {\r\n      try {\r\n        await queryClient.invalidateQueries({ queryKey: [BOOKMARKS_QUERY_KEY] })\r\n        await queryClient.invalidateQueries({ queryKey: ['tags'] })\r\n      } catch (error) {\r\n        console.error('Failed to invalidate queries:', error)\r\n      }\r\n    },\r\n  })\r\n}\r\n\r\n/**\r\n * 恢复书签\r\n */\r\nexport function useRestoreBookmark() {\r\n  const queryClient = useQueryClient()\r\n\r\n  return useMutation({\r\n    mutationFn: (id: string) => bookmarksService.restoreBookmark(id),\r\n    onSuccess: async () => {\r\n      try {\r\n        await queryClient.invalidateQueries({ queryKey: [BOOKMARKS_QUERY_KEY] })\r\n        await queryClient.invalidateQueries({ queryKey: ['tags'] })\r\n      } catch (error) {\r\n        console.error('Failed to invalidate queries:', error)\r\n      }\r\n    },\r\n  })\r\n}\r\n\r\n/**\r\n * 记录书签点击\r\n */\r\nexport function useRecordClick() {\r\n  return useMutation({\r\n    mutationFn: (id: string) => bookmarksService.recordClick(id),\r\n  })\r\n}\r\n\r\n/**\r\n * 批量操作书签\r\n */\r\nexport function useBatchAction() {\r\n  const queryClient = useQueryClient()\r\n\r\n  return useMutation({\r\n    mutationFn: (data: BatchActionRequest) => bookmarksService.batchAction(data),\r\n    onSuccess: async () => {\r\n      // 使用 catch 来防止 invalidateQueries 的错误影响 mutation 结果\r\n      try {\r\n        await queryClient.invalidateQueries({ queryKey: [BOOKMARKS_QUERY_KEY] })\r\n        await queryClient.invalidateQueries({ queryKey: ['tags'] })\r\n      } catch (error) {\r\n        console.error('Failed to invalidate queries:', error)\r\n        // 即使缓存失效失败也不应该让操作显示为失败\r\n      }\r\n    },\r\n  })\r\n}\r\n"
  },
  {
    "path": "tmarks/src/hooks/useClientSideFilter.ts",
    "content": "/**\n * 客户端过滤/排序/分页 hook\n * 用于公开分享页等一次性加载全量数据后在前端处理的场景\n */\n\nimport { useMemo, useState, useCallback } from 'react'\nimport type { Bookmark } from '@/lib/types'\nimport type { SortOption } from '@/components/common/SortSelector'\nimport type { VisibilityFilter } from '@/lib/constants/bookmarks'\n\ninterface UseClientSideFilterOptions {\n  bookmarks: Bookmark[]\n  selectedTags: string[]\n  searchKeyword: string\n  sortBy: SortOption\n  visibilityFilter: VisibilityFilter\n  pageSize?: number\n}\n\ninterface UseClientSideFilterResult {\n  /** 经过筛选+排序的完整列表 */\n  sortedBookmarks: Bookmark[]\n  /** 当前页显示的切片 */\n  displayedBookmarks: Bookmark[]\n  /** 经过标签过滤后的列表（供 TagSidebar 统计用） */\n  tagFilteredBookmarks: Bookmark[]\n  hasMore: boolean\n  currentPage: number\n  loadMore: () => void\n  resetPage: () => void\n}\n\nexport function useClientSideFilter({\n  bookmarks,\n  selectedTags,\n  searchKeyword,\n  sortBy,\n  visibilityFilter,\n  pageSize = 30,\n}: UseClientSideFilterOptions): UseClientSideFilterResult {\n  const [currentPage, setCurrentPage] = useState(1)\n\n  // 1. Tag filter\n  const tagFilteredBookmarks = useMemo(() => {\n    if (selectedTags.length === 0) return bookmarks\n    return bookmarks.filter((b) =>\n      selectedTags.every((tagId) => b.tags?.some((t) => t.id === tagId))\n    )\n  }, [bookmarks, selectedTags])\n\n  // 2. Keyword search\n  const searchFiltered = useMemo(() => {\n    const kw = searchKeyword.trim().toLowerCase()\n    if (!kw) return tagFilteredBookmarks\n    return tagFilteredBookmarks.filter((b) => {\n      return (\n        b.title.toLowerCase().includes(kw) ||\n        b.url.toLowerCase().includes(kw) ||\n        b.description?.toLowerCase().includes(kw) ||\n        b.ai_summary?.toLowerCase().includes(kw) ||\n        b.tags?.some((tag) => tag.name.toLowerCase().includes(kw))\n      )\n    })\n  }, [tagFilteredBookmarks, searchKeyword])\n\n  // 3. Visibility filter\n  const visFiltered = useMemo(() => {\n    if (visibilityFilter === 'all') return searchFiltered\n    return searchFiltered.filter((b) =>\n      visibilityFilter === 'public' ? b.is_public : !b.is_public\n    )\n  }, [searchFiltered, visibilityFilter])\n\n  // 4. Sort\n  const sortedBookmarks = useMemo(() => {\n    const result = [...visFiltered]\n    result.sort((a, b) => {\n      switch (sortBy) {\n        case 'created':\n          return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()\n        case 'updated':\n          return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()\n        case 'pinned':\n          if (a.is_pinned && !b.is_pinned) return -1\n          if (!a.is_pinned && b.is_pinned) return 1\n          return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()\n        case 'popular':\n          return (b.click_count || 0) - (a.click_count || 0)\n        default:\n          return 0\n      }\n    })\n    return result\n  }, [visFiltered, sortBy])\n\n  // 5. Paginate\n  const displayedBookmarks = useMemo(() => {\n    return sortedBookmarks.slice(0, currentPage * pageSize)\n  }, [sortedBookmarks, currentPage, pageSize])\n\n  const hasMore = sortedBookmarks.length > displayedBookmarks.length\n\n  const loadMore = useCallback(() => setCurrentPage((p) => p + 1), [])\n  const resetPage = useCallback(() => setCurrentPage(1), [])\n\n  return {\n    sortedBookmarks,\n    displayedBookmarks,\n    tagFilteredBookmarks,\n    hasMore,\n    currentPage,\n    loadMore,\n    resetPage,\n  }\n}\n"
  },
  {
    "path": "tmarks/src/hooks/useLanguage.ts",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { useCallback } from 'react'\r\nimport { supportedLanguages, type LanguageCode } from '@/i18n'\r\n\r\n/**\r\n * 语言切换 hook\r\n * 提供当前语言、切换语言、支持的语言列表等功能\r\n */\r\nexport function useLanguage() {\r\n  const { i18n } = useTranslation()\r\n\r\n  const currentLanguage = i18n.language as LanguageCode\r\n\r\n  const changeLanguage = useCallback(\r\n    async (lang: LanguageCode) => {\r\n      await i18n.changeLanguage(lang)\r\n      // i18next-browser-languagedetector 会自动保存到 localStorage\r\n    },\r\n    [i18n]\r\n  )\r\n\r\n  const getCurrentLanguageInfo = useCallback(() => {\r\n    return supportedLanguages.find((l) => l.code === currentLanguage) || supportedLanguages[0]\r\n  }, [currentLanguage])\r\n\r\n  return {\r\n    currentLanguage,\r\n    changeLanguage,\r\n    supportedLanguages,\r\n    getCurrentLanguageInfo\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/hooks/useLocalPreferences.ts",
    "content": "import { useState, useEffect, useCallback } from 'react'\r\nimport type { UserPreferences } from '@/lib/types'\r\nimport { logger } from '@/lib/logger'\r\n\r\nconst PREFERENCES_STORAGE_KEY = 'tmarks:preferences'\r\n\r\n// 默认偏好设置\r\nconst DEFAULT_PREFERENCES: Partial<UserPreferences> = {\r\n  theme: 'light',\r\n  page_size: 30,\r\n  view_mode: 'list',\r\n  density: 'normal',\r\n  tag_layout: 'grid',\r\n  sort_by: 'created',\r\n  search_auto_clear_seconds: 15,\r\n  tag_selection_auto_clear_seconds: 30,\r\n  enable_search_auto_clear: true,\r\n  enable_tag_selection_auto_clear: false,\r\n  default_bookmark_icon: 'orbital-spinner',\r\n  snapshot_retention_count: 5,\r\n}\r\n\r\n/**\r\n * 从 localStorage 加载偏好设置\r\n */\r\nfunction loadPreferencesFromStorage(): Partial<UserPreferences> {\r\n  if (typeof window === 'undefined') return DEFAULT_PREFERENCES\r\n\r\n  try {\r\n    const stored = window.localStorage.getItem(PREFERENCES_STORAGE_KEY)\r\n    if (stored) {\r\n      const parsed = JSON.parse(stored)\r\n      return { ...DEFAULT_PREFERENCES, ...parsed }\r\n    }\r\n  } catch (error) {\r\n    logger.error('Failed to load preferences from localStorage:', error)\r\n  }\r\n\r\n  return DEFAULT_PREFERENCES\r\n}\r\n\r\n/**\r\n * 保存偏好设置到 localStorage\r\n */\r\nfunction savePreferencesToStorage(preferences: Partial<UserPreferences>): void {\r\n  if (typeof window === 'undefined') return\r\n\r\n  try {\r\n    window.localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(preferences))\r\n  } catch (error) {\r\n    logger.error('Failed to save preferences to localStorage:', error)\r\n  }\r\n}\r\n\r\n/**\r\n * 使用本地偏好设置的 Hook\r\n * 优先使用 localStorage，后台异步同步到服务器\r\n */\r\nexport function useLocalPreferences() {\r\n  const [preferences, setPreferences] = useState<Partial<UserPreferences>>(loadPreferencesFromStorage)\r\n\r\n  // 初始化时从 localStorage 加载\r\n  useEffect(() => {\r\n    const stored = loadPreferencesFromStorage()\r\n    setPreferences(stored)\r\n  }, [])\r\n\r\n  // 更新偏好设置\r\n  const updatePreferences = useCallback((updates: Partial<UserPreferences>) => {\r\n    setPreferences((prev) => {\r\n      const newPreferences = { ...prev, ...updates }\r\n      savePreferencesToStorage(newPreferences)\r\n      return newPreferences\r\n    })\r\n  }, [])\r\n\r\n  // 重置为默认值\r\n  const resetPreferences = useCallback(() => {\r\n    setPreferences(DEFAULT_PREFERENCES)\r\n    savePreferencesToStorage(DEFAULT_PREFERENCES)\r\n  }, [])\r\n\r\n  return {\r\n    preferences,\r\n    updatePreferences,\r\n    resetPreferences,\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/hooks/useMediaQuery.ts",
    "content": "import { useState, useEffect } from 'react'\n\n/**\n * 响应式媒体查询 Hook\n * 用于检测当前屏幕尺寸\n */\nexport function useMediaQuery(query: string): boolean {\n  const [matches, setMatches] = useState(false)\n\n  useEffect(() => {\n    const media = window.matchMedia(query)\n    \n    // 初始化\n    setMatches(media.matches)\n\n    // 监听变化\n    const listener = (e: MediaQueryListEvent) => {\n      setMatches(e.matches)\n    }\n\n    // 兼容旧版浏览器\n    if (media.addEventListener) {\n      media.addEventListener('change', listener)\n    } else {\n      media.addListener(listener)\n    }\n\n    return () => {\n      if (media.removeEventListener) {\n        media.removeEventListener('change', listener)\n      } else {\n        media.removeListener(listener)\n      }\n    }\n  }, [query])\n\n  return matches\n}\n\n/**\n * 预定义的断点 Hooks\n */\nexport function useIsMobile() {\n  return useMediaQuery('(max-width: 767px)')\n}\n\nexport function useIsTablet() {\n  return useMediaQuery('(min-width: 768px) and (max-width: 1023px)')\n}\n\nexport function useIsDesktop() {\n  return useMediaQuery('(min-width: 1024px)')\n}\n\n/**\n * 获取当前设备类型\n */\nexport function useDeviceType(): 'mobile' | 'tablet' | 'desktop' {\n  const isMobile = useIsMobile()\n  const isTablet = useIsTablet()\n  \n  if (isMobile) return 'mobile'\n  if (isTablet) return 'tablet'\n  return 'desktop'\n}\n\n"
  },
  {
    "path": "tmarks/src/hooks/usePreferences.ts",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\r\nimport { preferencesService } from '@/services/preferences'\r\nimport type { UpdatePreferencesRequest, UserPreferences } from '@/lib/types'\r\nimport { ApiError } from '@/lib/api-client'\r\nimport { logger } from '@/lib/logger'\r\n\r\nexport const PREFERENCES_QUERY_KEY = 'preferences'\r\nconst PREFERENCES_STORAGE_KEY = 'tmarks:preferences'\r\n\r\n// 从 localStorage 获取完整偏好设置\r\nfunction getStoredPreferences(): UserPreferences | null {\r\n  if (typeof window === 'undefined') return null\r\n  \r\n  try {\r\n    const stored = window.localStorage.getItem(PREFERENCES_STORAGE_KEY)\r\n    if (stored) {\r\n      return JSON.parse(stored)\r\n    }\r\n  } catch (error) {\r\n    logger.error('Failed to parse stored preferences:', error)\r\n  }\r\n  \r\n  return null\r\n}\r\n\r\n// 保存偏好设置到 localStorage\r\nfunction saveStoredPreferences(preferences: UserPreferences): void {\r\n  if (typeof window === 'undefined') return\r\n  \r\n  try {\r\n    window.localStorage.setItem(PREFERENCES_STORAGE_KEY, JSON.stringify(preferences))\r\n  } catch (error) {\r\n    logger.error('Failed to save preferences to localStorage:', error)\r\n  }\r\n}\r\n\r\n// 从 localStorage 获取视图模式（向后兼容）\r\nfunction getStoredViewMode(): 'list' | 'card' | 'minimal' | 'title' | null {\r\n  if (typeof window === 'undefined') return null\r\n  const stored = window.localStorage.getItem('tmarks:view_mode')\r\n  const validModes: string[] = ['list', 'card', 'minimal', 'title']\r\n  return stored && validModes.includes(stored) ? (stored as 'list' | 'card' | 'minimal' | 'title') : null\r\n}\r\n\r\n// 默认偏好设置\r\nfunction getDefaultPreferences(): UserPreferences {\r\n  // 优先使用 localStorage 中的完整偏好设置\r\n  const storedPreferences = getStoredPreferences()\r\n  if (storedPreferences) {\r\n    return storedPreferences\r\n  }\r\n  \r\n  // 向后兼容：使用旧的视图模式存储\r\n  const storedViewMode = getStoredViewMode()\r\n\r\n  return {\r\n    user_id: '',\r\n    theme: 'light',\r\n    page_size: 30,\r\n    view_mode: storedViewMode || 'list',\r\n    density: 'normal',\r\n    tag_layout: 'grid',\r\n    sort_by: 'created',\r\n    search_auto_clear_seconds: 15,\r\n    tag_selection_auto_clear_seconds: 30,\r\n    enable_search_auto_clear: true,\r\n    enable_tag_selection_auto_clear: false,\r\n    default_bookmark_icon: 'orbital-spinner',\r\n    snapshot_retention_count: 5,\r\n    updated_at: new Date().toISOString(),\r\n  }\r\n}\r\n\r\n/**\r\n * 获取用户偏好设置\r\n * 优先使用 localStorage，后台异步从服务器同步\r\n */\r\nexport function usePreferences() {\r\n  return useQuery({\r\n    queryKey: [PREFERENCES_QUERY_KEY],\r\n    queryFn: async () => {\r\n      // 1. 立即返回 localStorage 中的偏好设置\r\n      const localPreferences = getDefaultPreferences()\r\n      \r\n      // 2. 后台异步从服务器获取最新设置\r\n      try {\r\n        const serverPreferences = await preferencesService.getPreferences()\r\n        \r\n        // 3. 如果服务器设置更新，保存到 localStorage\r\n        if (serverPreferences.updated_at && serverPreferences.updated_at > localPreferences.updated_at) {\r\n          saveStoredPreferences(serverPreferences)\r\n          return serverPreferences\r\n        }\r\n      } catch (error) {\r\n        // 服务器获取失败，使用本地设置\r\n        if (error instanceof ApiError && error.status === 404) {\r\n          logger.warn('Preferences API not found, using localStorage preferences')\r\n        } else {\r\n          logger.warn('Failed to fetch server preferences, using localStorage:', error)\r\n        }\r\n      }\r\n      \r\n      return localPreferences\r\n    },\r\n    // 减少重试次数,避免频繁请求不存在的接口\r\n    retry: false,\r\n    // 增加缓存时间,减少请求频率\r\n    staleTime: 24 * 60 * 60 * 1000, // 24小时 (偏好很少变化)\r\n    gcTime: 7 * 24 * 60 * 60 * 1000, // 7天\r\n    // 立即返回缓存数据，后台更新\r\n    placeholderData: getDefaultPreferences(),\r\n  })\r\n}\r\n\r\n/**\r\n * 更新用户偏好设置\r\n * 立即更新 localStorage，后台异步同步到服务器\r\n */\r\nexport function useUpdatePreferences() {\r\n  const queryClient = useQueryClient()\r\n\r\n  return useMutation({\r\n    mutationFn: async (data: UpdatePreferencesRequest) => {\r\n      // 1. 立即更新 localStorage\r\n      const currentPreferences = getDefaultPreferences()\r\n      const updatedPreferences: UserPreferences = {\r\n        ...currentPreferences,\r\n        ...data,\r\n        updated_at: new Date().toISOString(),\r\n      }\r\n      saveStoredPreferences(updatedPreferences)\r\n      \r\n      // 2. 立即更新 React Query 缓存\r\n      queryClient.setQueryData([PREFERENCES_QUERY_KEY], updatedPreferences)\r\n      \r\n      // 3. 后台异步同步到服务器\r\n      try {\r\n        return await preferencesService.updatePreferences(data)\r\n      } catch (error) {\r\n        // 服务器同步失败不影响本地使用\r\n        logger.warn('Failed to sync preferences to server, but local changes are saved:', error)\r\n        return updatedPreferences\r\n      }\r\n    },\r\n    onSuccess: (serverPreferences) => {\r\n      // 如果服务器返回了更新的设置，保存到 localStorage\r\n      if (serverPreferences) {\r\n        saveStoredPreferences(serverPreferences)\r\n        queryClient.setQueryData([PREFERENCES_QUERY_KEY], serverPreferences)\r\n      }\r\n    },\r\n    onError: (error) => {\r\n      // 静默处理错误,不影响用户体验\r\n      logger.warn('Preferences update error (local changes are still saved):', error)\r\n    },\r\n    // 失败时不重试,避免频繁请求\r\n    retry: false,\r\n  })\r\n}\r\n"
  },
  {
    "path": "tmarks/src/hooks/useShare.ts",
    "content": "import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from '@tanstack/react-query'\r\nimport { shareService } from '@/services/share'\r\nimport type { ShareSettings, UpdateShareSettingsRequest, PublicSharePayload, PublicSharePaginatedPayload } from '@/lib/types'\r\n\r\nexport const SHARE_SETTINGS_QUERY_KEY = 'share-settings'\r\n\r\nexport function useShareSettings() {\r\n  return useQuery<ShareSettings>({\r\n    queryKey: [SHARE_SETTINGS_QUERY_KEY],\r\n    queryFn: () => shareService.getSettings(),\r\n    staleTime: 5 * 60 * 1000,\r\n  })\r\n}\r\n\r\nexport function useUpdateShareSettings() {\r\n  const queryClient = useQueryClient()\r\n  return useMutation({\r\n    mutationFn: (payload: UpdateShareSettingsRequest) => shareService.updateSettings(payload),\r\n    onSuccess: (data: ShareSettings) => {\r\n      queryClient.setQueryData([SHARE_SETTINGS_QUERY_KEY], data)\r\n    },\r\n  })\r\n}\r\n\r\nexport function usePublicShare(slug: string, enabled: boolean) {\r\n  return useQuery<PublicSharePayload>({\r\n    queryKey: ['public-share', slug],\r\n    queryFn: () => shareService.getPublicShare(slug),\r\n    enabled: enabled && Boolean(slug),\r\n    staleTime: 60 * 1000,\r\n  })\r\n}\r\n\r\nexport function useInfinitePublicShare(slug: string, enabled: boolean, pageSize = 30) {\r\n  return useInfiniteQuery<PublicSharePaginatedPayload>({\r\n    queryKey: ['public-share-infinite', slug, pageSize],\r\n    queryFn: ({ pageParam }: { pageParam: unknown }) =>\r\n      shareService.getPublicSharePaginated(slug, {\r\n        page_size: pageSize,\r\n        page_cursor: pageParam as string | undefined,\r\n      }),\r\n    enabled: enabled && Boolean(slug),\r\n    staleTime: 60 * 1000,\r\n    initialPageParam: undefined,\r\n    getNextPageParam: (lastPage: PublicSharePaginatedPayload) => {\r\n      return lastPage.meta.has_more ? lastPage.meta.next_cursor : undefined\r\n    },\r\n  })\r\n}\r\n"
  },
  {
    "path": "tmarks/src/hooks/useStorage.ts",
    "content": "import { useQuery } from '@tanstack/react-query'\r\nimport { storageService } from '@/services/storage'\r\nimport type { R2StorageQuota } from '@/lib/types'\r\n\r\nexport const storageKeys = {\r\n  r2Quota: ['storage', 'r2-quota'] as const,\r\n}\r\n\r\nexport function useR2StorageQuota() {\r\n  return useQuery<R2StorageQuota>({\r\n    queryKey: storageKeys.r2Quota,\r\n    queryFn: () => storageService.getR2Quota(),\r\n    staleTime: 60 * 1000, // 1 分钟内视为新鲜\r\n  })\r\n}\r\n\r\n"
  },
  {
    "path": "tmarks/src/hooks/useTabGroupActions.ts",
    "content": "import { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { tabGroupsService } from '@/services/tab-groups'\r\nimport type { TabGroup, TabGroupItem } from '@/lib/types'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { useInvalidateTabGroups } from './useTabGroupsQuery'\r\nimport { formatDistanceToNow } from 'date-fns'\r\nimport { zhCN, enUS } from 'date-fns/locale'\r\nimport { logger } from '@/lib/logger'\r\nimport { useTabGroupItemActions } from './useTabGroupItemActions'\r\nimport { buildTabOpenerHtml, getThemeColors } from './buildTabOpenerHtml'\r\n\r\ninterface UseTabGroupActionsProps {\r\n  setTabGroups: React.Dispatch<React.SetStateAction<TabGroup[]>>\r\n  setDeletingId: React.Dispatch<React.SetStateAction<string | null>>\r\n  setConfirmDialog: React.Dispatch<React.SetStateAction<{\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n  }>>\r\n  confirmDialog: {\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n  }\r\n}\r\n\r\nexport function useTabGroupActions({\r\n  setTabGroups,\r\n  setDeletingId,\r\n  setConfirmDialog,\r\n  confirmDialog,\r\n}: UseTabGroupActionsProps) {\r\n  const { t, i18n } = useTranslation('tabGroups')\r\n  const { success, error: showError } = useToastStore()\r\n  const invalidateTabGroups = useInvalidateTabGroups()\r\n  const [editingGroupId, setEditingGroupId] = useState<string | null>(null)\r\n  const [editingGroupTitle, setEditingGroupTitle] = useState('')\r\n\r\n  // Include item actions\r\n  const itemActions = useTabGroupItemActions({\r\n    setTabGroups,\r\n    setConfirmDialog,\r\n    confirmDialog,\r\n  })\r\n\r\n  const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS\r\n\r\n  const formatDate = (dateStr: string) => {\r\n    try {\r\n      return formatDistanceToNow(new Date(dateStr), {\r\n        addSuffix: true,\r\n        locale: dateLocale,\r\n      })\r\n    } catch {\r\n      return dateStr\r\n    }\r\n  }\r\n\r\n  const handleDelete = (id: string, title: string) => {\r\n    setConfirmDialog({\r\n      isOpen: true,\r\n      title: t('confirm.deleteGroup'),\r\n      message: t('confirm.deleteGroupMessage', { title }),\r\n      onConfirm: async () => {\r\n        setConfirmDialog({ ...confirmDialog, isOpen: false })\r\n        setDeletingId(id)\r\n        try {\r\n          await tabGroupsService.deleteTabGroup(id)\r\n          setTabGroups((prev) => prev.filter((g) => g.id !== id))\r\n          await invalidateTabGroups()\r\n          success(t('message.movedToTrash'))\r\n        } catch (err) {\r\n          logger.error('Failed to delete tab group:', err)\r\n          showError(t('message.deleteFailed'))\r\n        } finally {\r\n          setDeletingId(null)\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handleOpenAll = (items: TabGroupItem[]) => {\r\n    if (!items || items.length === 0) {\r\n      showError(t('message.noTabsToOpen'))\r\n      return\r\n    }\r\n\r\n    const itemCount = items.length\r\n\r\n    // 提示用户\r\n    const message =\r\n      itemCount > 10\r\n        ? t('confirm.openTabsWarning', { count: itemCount })\r\n        : t('confirm.openTabsMessage', { mode: t('openMode.newWindow'), count: itemCount })\r\n\r\n    setConfirmDialog({\r\n      isOpen: true,\r\n      title: t('confirm.openMultipleTabs'),\r\n      message,\r\n      onConfirm: () => {\r\n        setConfirmDialog({ ...confirmDialog, isOpen: false })\r\n\r\n        // Use tab opener popup to avoid browser popup blocker\r\n        const colors = getThemeColors()\r\n        const i18nSuccessPartial = t('tabOpener.successPartial', { opened: '__OPENED__', failed: '__FAILED__' } as Record<string, unknown>)\r\n          .replace('__OPENED__', \"' + opened + '\")\r\n          .replace('__FAILED__', \"' + failed + '\")\r\n        const i18nSuccessAll = t('tabOpener.successAll', { count: '__COUNT__' } as Record<string, unknown>)\r\n          .replace('__COUNT__', \"' + opened + '\")\r\n        const html = buildTabOpenerHtml(\r\n          items.map(item => ({ url: item.url, title: item.title })),\r\n          colors,\r\n          {\r\n            title: t('tabOpener.title', { defaultValue: 'Opening Tabs' }),\r\n            heading: t('tabOpener.heading', { defaultValue: 'Opening Tabs...' }),\r\n            preparing: t('tabOpener.preparing', { defaultValue: 'Preparing...' }),\r\n            opening: t('tabOpener.opening', { defaultValue: 'Opening: ' }),\r\n            successPartial: i18nSuccessPartial,\r\n            successAll: i18nSuccessAll,\r\n            closeWindow: t('tabOpener.closeWindow', { defaultValue: 'Close Window' }),\r\n          }\r\n        )\r\n        const blob = new Blob([html], { type: 'text/html' })\r\n        const url = URL.createObjectURL(blob)\r\n        const newWindow = window.open(url, '_blank', 'width=800,height=600')\r\n\r\n        if (newWindow) {\r\n          setTimeout(() => URL.revokeObjectURL(url), 5000)\r\n          success(t('message.openingTabs', { count: itemCount }))\r\n        } else {\r\n          URL.revokeObjectURL(url)\r\n          showError(t('message.cannotOpenWindow', { defaultValue: 'Popup was blocked. Please allow popups for this site.' }))\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handleExportMarkdown = (group: TabGroup) => {\r\n    const items = group.items || []\r\n    let markdown = `# ${group.title}\\n\\n`\r\n    markdown += `${t('export.createdTime')}: ${formatDate(group.created_at)}\\n`\r\n    markdown += `${t('export.tabCount')}: ${items.length}\\n\\n`\r\n\r\n    if (group.tags && group.tags.length > 0) {\r\n      markdown += `${t('export.tags')}: ${group.tags.join(', ')}\\n\\n`\r\n    }\r\n\r\n    markdown += `---\\n\\n`\r\n\r\n    items.forEach((item, index) => {\r\n      markdown += `${index + 1}. [${item.title}](${item.url})\\n`\r\n      if (item.is_pinned) markdown += `   - 📌 ${t('item.pinned')}\\n`\r\n      if (item.is_todo) markdown += `   - ✅ ${t('item.todo')}\\n`\r\n      markdown += '\\n'\r\n    })\r\n\r\n    const blob = new Blob([markdown], { type: 'text/markdown' })\r\n    const url = URL.createObjectURL(blob)\r\n    const a = document.createElement('a')\r\n    a.href = url\r\n    a.download = `${group.title}-${Date.now()}.md`\r\n    document.body.appendChild(a)\r\n    a.click()\r\n    document.body.removeChild(a)\r\n    URL.revokeObjectURL(url)\r\n\r\n    success(t('message.exportSuccess'))\r\n  }\r\n\r\n  const handleEditGroup = (group: TabGroup) => {\r\n    setEditingGroupId(group.id)\r\n    setEditingGroupTitle(group.title)\r\n  }\r\n\r\n  const handleSaveGroupEdit = async (groupId: string) => {\r\n    if (!editingGroupTitle.trim()) {\r\n      showError(t('message.titleRequired'))\r\n      return\r\n    }\r\n\r\n    try {\r\n      await tabGroupsService.updateTabGroup(groupId, { title: editingGroupTitle })\r\n      setTabGroups((prev) =>\r\n        prev.map((g) => (g.id === groupId ? { ...g, title: editingGroupTitle } : g))\r\n      )\r\n      await invalidateTabGroups()\r\n      setEditingGroupId(null)\r\n      setEditingGroupTitle('')\r\n      success(t('message.renameSuccess'))\r\n    } catch (err) {\r\n      logger.error('Failed to update group title:', err)\r\n      showError(t('message.renameFailed'))\r\n    }\r\n  }\r\n\r\n  return {\r\n    ...itemActions,\r\n    editingGroupId,\r\n    setEditingGroupId,\r\n    editingGroupTitle,\r\n    setEditingGroupTitle,\r\n    formatDate,\r\n    handleDelete,\r\n    handleOpenAll,\r\n    handleExportMarkdown,\r\n    handleEditGroup,\r\n    handleSaveGroupEdit,\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/hooks/useTabGroupItemActions.ts",
    "content": "import { useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { tabGroupsService } from '@/services/tab-groups'\nimport type { TabGroup, TabGroupItem } from '@/lib/types'\nimport { useToastStore } from '@/stores/toastStore'\nimport { useInvalidateTabGroups } from './useTabGroupsQuery'\nimport { logger } from '@/lib/logger'\n\ninterface UseTabGroupItemActionsProps {\n  setTabGroups: React.Dispatch<React.SetStateAction<TabGroup[]>>\n  setConfirmDialog: React.Dispatch<React.SetStateAction<{\n    isOpen: boolean\n    title: string\n    message: string\n    onConfirm: () => void\n  }>>\n  confirmDialog: {\n    isOpen: boolean\n    title: string\n    message: string\n    onConfirm: () => void\n  }\n}\n\nexport function useTabGroupItemActions({\n  setTabGroups,\n  setConfirmDialog,\n  confirmDialog,\n}: UseTabGroupItemActionsProps) {\n  const { t } = useTranslation('tabGroups')\n  const { success, error: showError } = useToastStore()\n  const invalidateTabGroups = useInvalidateTabGroups()\n  const [editingItemId, setEditingItemId] = useState<string | null>(null)\n  const [editingTitle, setEditingTitle] = useState('')\n\n  const handleEditItem = (item: TabGroupItem) => {\n    setEditingItemId(item.id)\n    setEditingTitle(item.title)\n  }\n\n  const handleSaveEdit = async (groupId: string, itemId: string) => {\n    if (!editingTitle.trim()) {\n      showError(t('message.titleRequired'))\n      return\n    }\n\n    try {\n      await tabGroupsService.updateTabGroupItem(itemId, { title: editingTitle })\n      setTabGroups((prev) =>\n        prev.map((group) =>\n          group.id === groupId\n            ? {\n              ...group,\n              items: group.items?.map((item) =>\n                item.id === itemId ? { ...item, title: editingTitle } : item\n              ),\n            }\n            : group\n        )\n      )\n      await invalidateTabGroups()\n      setEditingItemId(null)\n      setEditingTitle('')\n      success(t('message.editSuccess'))\n    } catch (err) {\n      logger.error('Failed to update item:', err)\n      showError(t('message.editFailed'))\n    }\n  }\n\n  const handleTogglePin = async (groupId: string, itemId: string, currentPinned: boolean) => {\n    const newPinned = !currentPinned\n    try {\n      await tabGroupsService.updateTabGroupItem(itemId, { is_pinned: newPinned })\n      setTabGroups((prev) =>\n        prev.map((group) =>\n          group.id === groupId\n            ? {\n              ...group,\n              items: group.items?.map((item) =>\n                item.id === itemId ? { ...item, is_pinned: newPinned } : item\n              ),\n            }\n            : group\n        )\n      )\n      await invalidateTabGroups()\n      success(newPinned ? t('message.pinSuccess') : t('message.unpinSuccess'))\n    } catch (err) {\n      logger.error('Failed to toggle pin:', err)\n      showError(t('message.operationFailed'))\n    }\n  }\n\n  const handleToggleTodo = async (groupId: string, itemId: string, currentTodo: boolean) => {\n    const newTodo = !currentTodo\n    try {\n      await tabGroupsService.updateTabGroupItem(itemId, { is_todo: newTodo })\n      setTabGroups((prev) =>\n        prev.map((group) =>\n          group.id === groupId\n            ? {\n              ...group,\n              items: group.items?.map((item) =>\n                item.id === itemId ? { ...item, is_todo: newTodo } : item\n              ),\n            }\n            : group\n        )\n      )\n      await invalidateTabGroups()\n      success(newTodo ? t('message.todoSuccess') : t('message.untodoSuccess'))\n    } catch (err) {\n      logger.error('Failed to toggle todo:', err)\n      showError(t('message.operationFailed'))\n    }\n  }\n\n  const handleDeleteItem = (groupId: string, itemId: string, title: string) => {\n    setConfirmDialog({\n      isOpen: true,\n      title: t('confirm.deleteItem'),\n      message: t('confirm.deleteItemMessage', { title }),\n      onConfirm: async () => {\n        setConfirmDialog({ ...confirmDialog, isOpen: false })\n        try {\n          await tabGroupsService.deleteTabGroupItem(itemId)\n          setTabGroups((prev) =>\n            prev.map((group) =>\n              group.id === groupId\n                ? {\n                  ...group,\n                  items: group.items?.filter((item) => item.id !== itemId),\n                  item_count: (group.item_count || 0) - 1,\n                }\n                : group\n            )\n          )\n          await invalidateTabGroups()\n          success(t('message.deleteSuccess'))\n        } catch (err) {\n          logger.error('Failed to delete item:', err)\n          showError(t('message.deleteFailed'))\n        }\n      },\n    })\n  }\n\n  return {\n    editingItemId,\n    setEditingItemId,\n    editingTitle,\n    setEditingTitle,\n    handleEditItem,\n    handleSaveEdit,\n    handleTogglePin,\n    handleToggleTodo,\n    handleDeleteItem,\n  }\n}\n"
  },
  {
    "path": "tmarks/src/hooks/useTabGroupMenu.ts",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { buildTabOpenerHtml, getThemeColors } from './buildTabOpenerHtml'\r\nimport { tabGroupsService } from '@/services/tab-groups'\r\nimport type { TabGroup } from '@/lib/types'\r\nimport { useDialogStore } from '@/stores/dialogStore'\r\n\r\nexport interface TabGroupMenuActions {\r\n  onOpenInNewWindow: (group: TabGroup) => void\r\n  onOpenInCurrentWindow: (group: TabGroup) => void\r\n  onOpenInIncognito: (group: TabGroup) => void\r\n  onRename: (group: TabGroup) => void\r\n  onShare: (group: TabGroup) => void\r\n  onCopyToClipboard: (group: TabGroup) => void\r\n  onCreateFolderAbove: (group: TabGroup) => void\r\n  onCreateFolderInside: (group: TabGroup) => void\r\n  onCreateFolderBelow: (group: TabGroup) => void\r\n  onPinToTop: (group: TabGroup) => void\r\n  onRemoveDuplicates: (group: TabGroup) => void\r\n  onLock: (group: TabGroup) => void\r\n  onMove: (group: TabGroup) => Promise<void>\r\n  onMoveToTrash: (group: TabGroup) => void\r\n}\r\n\r\ninterface UseTabGroupMenuProps {\r\n  onRefresh?: () => Promise<void>\r\n  onStartRename: (groupId: string, title: string) => void\r\n  onOpenMoveDialog?: (group: TabGroup) => void\r\n}\r\n\r\nexport function useTabGroupMenu({ onRefresh, onStartRename, onOpenMoveDialog }: UseTabGroupMenuProps): TabGroupMenuActions {\r\n  const { t } = useTranslation('tabGroups')\r\n  const dialog = useDialogStore.getState()\r\n\r\n  // 打开所有标签页\r\n  const openAllTabs = async (group: TabGroup, mode: 'new' | 'current' | 'incognito') => {\r\n    if (!group.items || group.items.length === 0) {\r\n      await dialog.alert({ message: t('message.noTabsToOpen'), type: 'info' })\r\n      return\r\n    }\r\n\r\n    const modeText = t(`openMode.${mode === 'new' ? 'newWindow' : mode === 'current' ? 'currentWindow' : 'incognito'}`)\r\n    \r\n    // 确认打开多个标签页\r\n    if (group.items.length > 5) {\r\n      const confirmed = await dialog.confirm({\r\n        title: t('confirm.openMultipleTabs'),\r\n        message: t('confirm.openTabsMessage', { mode: modeText, count: group.items.length }),\r\n        type: 'warning',\r\n      })\r\n      if (!confirmed) {\r\n        return\r\n      }\r\n    }\r\n\r\n    // 对于\"当前窗口\"模式，使用传统方法\r\n    if (mode === 'current' && group.items && group.items.length > 0) {\r\n      const firstItem = group.items[0]\r\n      if (firstItem) {\r\n        window.location.href = firstItem.url\r\n      }\r\n      return\r\n    }\r\n\r\n    try {\r\n      const colors = getThemeColors()\r\n      const i18nSuccessPartial = t('tabOpener.successPartial', { opened: '__OPENED__', failed: '__FAILED__' } as Record<string, unknown>)\r\n        .replace('__OPENED__', \"' + opened + '\")\r\n        .replace('__FAILED__', \"' + failed + '\")\r\n      const i18nSuccessAll = t('tabOpener.successAll', { count: '__COUNT__' } as Record<string, unknown>)\r\n        .replace('__COUNT__', \"' + opened + '\")\r\n\r\n      const html = buildTabOpenerHtml(\r\n        group.items.map(item => ({ url: item.url, title: item.title })),\r\n        colors,\r\n        {\r\n          title: t('tabOpener.title'),\r\n          heading: t('tabOpener.heading'),\r\n          preparing: t('tabOpener.preparing'),\r\n          opening: t('tabOpener.opening'),\r\n          successPartial: i18nSuccessPartial,\r\n          successAll: i18nSuccessAll,\r\n          closeWindow: t('tabOpener.closeWindow'),\r\n        }\r\n      )\r\n\r\n      const blob = new Blob([html], { type: 'text/html' })\r\n      const url = URL.createObjectURL(blob)\r\n      const newWindow = window.open(url, '_blank', 'width=800,height=600')\r\n\r\n      if (newWindow) {\r\n        await dialog.alert({ message: t('message.tabManagerOpened', { mode: modeText }), type: 'success' })\r\n        setTimeout(() => URL.revokeObjectURL(url), 5000)\r\n      } else {\r\n        await dialog.alert({ message: t('message.cannotOpenWindow'), type: 'error' })\r\n      }\r\n    } catch (error) {\r\n      console.error('Failed to open tabs:', error)\r\n      await dialog.alert({ message: t('message.openTabsFailed'), type: 'error' })\r\n    }\r\n  }\r\n\r\n  const onOpenInNewWindow = (group: TabGroup) => openAllTabs(group, 'new')\r\n  const onOpenInCurrentWindow = (group: TabGroup) => openAllTabs(group, 'current')\r\n  const onOpenInIncognito = (group: TabGroup) => openAllTabs(group, 'incognito')\r\n\r\n  const onRename = (group: TabGroup) => {\r\n    onStartRename(group.id, group.title)\r\n  }\r\n\r\n  const onShare = async (group: TabGroup) => {\r\n    try {\r\n      const shareData = await tabGroupsService.createShare(group.id, {\r\n        is_public: true,\r\n        expires_in_days: 30\r\n      })\r\n\r\n      const shareUrl = shareData.share_url\r\n\r\n      // 复制到剪贴板\r\n      try {\r\n        await navigator.clipboard.writeText(shareUrl)\r\n        await dialog.alert({\r\n          title: t('share.linkCreated'),\r\n          message: t('share.linkCreatedMessage', { url: shareUrl }),\r\n          type: 'success',\r\n        })\r\n      } catch {\r\n        await dialog.alert({\r\n          title: t('share.linkCreated'),\r\n          message: t('share.linkCreatedManualCopy', { url: shareUrl }),\r\n          type: 'warning',\r\n        })\r\n      }\r\n    } catch (error) {\r\n      console.error('Failed to create share:', error)\r\n      await dialog.alert({ message: t('share.createFailed'), type: 'error' })\r\n    }\r\n  }\r\n\r\n  const onCopyToClipboard = async (group: TabGroup) => {\r\n    if (!group.items || group.items.length === 0) {\r\n      await dialog.alert({ message: t('message.noTabsInGroup'), type: 'info' })\r\n      return\r\n    }\r\n\r\n    const text = group.items.map(item => `${item.title}\\n${item.url}`).join('\\n\\n')\r\n    try {\r\n      await navigator.clipboard.writeText(text)\r\n      await dialog.alert({ message: t('message.copiedToClipboard'), type: 'success' })\r\n    } catch (err) {\r\n      console.error('Failed to copy:', err)\r\n      await dialog.alert({ message: t('message.copyFailed'), type: 'error' })\r\n    }\r\n  }\r\n\r\n  const createFolderAt = async (parentId?: string | null) => {\r\n    try {\r\n      await tabGroupsService.createFolder(t('folder.newFolder'), parentId)\r\n      await onRefresh?.()\r\n    } catch (err) {\r\n      console.error('Failed to create folder:', err)\r\n      await dialog.alert({ message: t('message.createFolderFailed'), type: 'error' })\r\n    }\r\n  }\r\n  const onCreateFolderAbove = (group: TabGroup) => createFolderAt(group.parent_id)\r\n  const onCreateFolderInside = async (group: TabGroup) => {\r\n    if (group.is_folder !== 1) return\r\n    await createFolderAt(group.id)\r\n  }\r\n  const onCreateFolderBelow = (group: TabGroup) => createFolderAt(group.parent_id)\r\n\r\n  const onPinToTop = async (group: TabGroup) => {\r\n    try {\r\n      // 将该项的 position 设置为 -1（最小值），这样排序时会在最前面\r\n      await tabGroupsService.updateTabGroup(group.id, {\r\n        position: -1\r\n      })\r\n      await onRefresh?.()\r\n    } catch (err) {\r\n      console.error('Failed to pin to top:', err)\r\n      await dialog.alert({ message: t('message.pinFailed'), type: 'error' })\r\n    }\r\n  }\r\n\r\n  const onRemoveDuplicates = async (group: TabGroup) => {\r\n    if (!group.items || group.items.length === 0) return\r\n\r\n    const seen = new Set<string>()\r\n    const duplicates: string[] = []\r\n\r\n    group.items.forEach(item => {\r\n      if (seen.has(item.url)) {\r\n        duplicates.push(item.id)\r\n      } else {\r\n        seen.add(item.url)\r\n      }\r\n    })\r\n\r\n    if (duplicates.length === 0) {\r\n      await dialog.alert({ message: t('message.noDuplicates'), type: 'info' })\r\n      return\r\n    }\r\n\r\n    const confirmed = await dialog.confirm({\r\n      title: t('confirm.removeDuplicates'),\r\n      message: t('confirm.removeDuplicatesMessage', { count: duplicates.length }),\r\n      type: 'warning',\r\n    })\r\n\r\n    if (confirmed) {\r\n      try {\r\n        for (const id of duplicates) {\r\n          await tabGroupsService.deleteTabGroupItem(id)\r\n        }\r\n        await onRefresh?.()\r\n        await dialog.alert({ message: t('message.duplicatesRemoved', { count: duplicates.length }), type: 'success' })\r\n      } catch (err) {\r\n        console.error('Failed to remove duplicates:', err)\r\n        await dialog.alert({ message: t('message.deleteFailed'), type: 'error' })\r\n      }\r\n    }\r\n  }\r\n\r\n  const onLock = async (group: TabGroup) => {\r\n    // 锁定功能：使用 tags 字段存储锁定状态\r\n    try {\r\n      const currentTags = group.tags || []\r\n      const isLocked = currentTags.includes('__locked__')\r\n\r\n      let newTags: string[]\r\n      if (isLocked) {\r\n        // 解锁：移除 __locked__ 标签\r\n        newTags = currentTags.filter(tag => tag !== '__locked__')\r\n      } else {\r\n        // 锁定：添加 __locked__ 标签\r\n        newTags = [...currentTags, '__locked__']\r\n      }\r\n\r\n      await tabGroupsService.updateTabGroup(group.id, {\r\n        tags: newTags\r\n      })\r\n      await onRefresh?.()\r\n    } catch (err) {\r\n      console.error('Failed to lock/unlock:', err)\r\n      await dialog.alert({ message: t('message.operationFailed'), type: 'error' })\r\n    }\r\n  }\r\n\r\n  const onMove = async (group: TabGroup) => {\r\n    if (onOpenMoveDialog) {\r\n      onOpenMoveDialog(group)\r\n    } else {\r\n      await dialog.alert({ message: t('message.moveFunctionDeveloping'), type: 'info' })\r\n    }\r\n  }\r\n\r\n  const onMoveToTrash = async (group: TabGroup) => {\r\n    const confirmed = await dialog.confirm({\r\n      title: t('confirm.deleteGroup'),\r\n      message: t('confirm.deleteGroupMessage', { title: group.title }),\r\n      type: 'warning',\r\n    })\r\n    if (!confirmed) return\r\n\r\n    try {\r\n      await tabGroupsService.deleteTabGroup(group.id)\r\n      await onRefresh?.()\r\n    } catch (err) {\r\n      console.error('Failed to delete:', err)\r\n      await dialog.alert({ message: t('message.deleteFailed'), type: 'error' })\r\n    }\r\n  }\r\n\r\n  return {\r\n    onOpenInNewWindow,\r\n    onOpenInCurrentWindow,\r\n    onOpenInIncognito,\r\n    onRename,\r\n    onShare,\r\n    onCopyToClipboard,\r\n    onCreateFolderAbove,\r\n    onCreateFolderInside,\r\n    onCreateFolderBelow,\r\n    onPinToTop,\r\n    onRemoveDuplicates,\r\n    onLock,\r\n    onMove,\r\n    onMoveToTrash,\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/hooks/useTabGroupsQuery.ts",
    "content": "import { useQuery, useQueryClient } from '@tanstack/react-query'\r\nimport { tabGroupsService } from '@/services/tab-groups'\r\n\r\nexport const TAB_GROUPS_QUERY_KEY = ['tab-groups', 'all'] as const\r\nexport const TAB_GROUPS_TRASH_QUERY_KEY = ['tab-groups', 'trash'] as const\r\nexport const TAB_GROUP_DETAIL_QUERY_KEY = 'tab-group-detail'\r\nexport const TAB_GROUPS_STATISTICS_QUERY_KEY = 'tab-groups-statistics'\r\n\r\nexport function useTabGroupsQuery() {\r\n  return useQuery({\r\n    queryKey: TAB_GROUPS_QUERY_KEY,\r\n    queryFn: async () => {\r\n      const groups = await tabGroupsService.listAllTabGroups()\r\n      return groups.filter((group) => !group.is_deleted)\r\n    },\r\n    staleTime: 5 * 60 * 1000,\r\n    gcTime: 24 * 60 * 60 * 1000,\r\n    refetchOnWindowFocus: 'always',\r\n  })\r\n}\r\n\r\nexport function useTabGroupsTrashQuery() {\r\n  return useQuery({\r\n    queryKey: TAB_GROUPS_TRASH_QUERY_KEY,\r\n    queryFn: async () => {\r\n      const response = await tabGroupsService.getTrash()\r\n      return response.tab_groups\r\n    },\r\n    staleTime: 5 * 60 * 1000,\r\n    gcTime: 24 * 60 * 60 * 1000,\r\n    refetchOnWindowFocus: 'always',\r\n  })\r\n}\r\n\r\nexport function useTabGroupDetailQuery(groupId?: string) {\r\n  return useQuery({\r\n    queryKey: [TAB_GROUP_DETAIL_QUERY_KEY, groupId],\r\n    queryFn: async () => {\r\n      if (!groupId) {\r\n        throw new Error('Missing group id')\r\n      }\r\n      return tabGroupsService.getTabGroup(groupId)\r\n    },\r\n    enabled: Boolean(groupId),\r\n    staleTime: 5 * 60 * 1000,\r\n    gcTime: 24 * 60 * 60 * 1000,\r\n    refetchOnWindowFocus: 'always',\r\n  })\r\n}\r\n\r\nexport function useTabGroupsStatisticsQuery(days: number) {\r\n  return useQuery({\r\n    queryKey: [TAB_GROUPS_STATISTICS_QUERY_KEY, days],\r\n    queryFn: async () => tabGroupsService.getStatistics(days),\r\n    staleTime: 5 * 60 * 1000,\r\n    gcTime: 24 * 60 * 60 * 1000,\r\n    refetchOnWindowFocus: 'always',\r\n  })\r\n}\r\n\r\nexport function useInvalidateTabGroups() {\r\n  const queryClient = useQueryClient()\r\n\r\n  return async () => {\r\n    await Promise.all([\r\n      queryClient.invalidateQueries({ queryKey: TAB_GROUPS_QUERY_KEY }),\r\n      queryClient.invalidateQueries({ queryKey: TAB_GROUPS_TRASH_QUERY_KEY }),\r\n      queryClient.invalidateQueries({ queryKey: [TAB_GROUP_DETAIL_QUERY_KEY] }),\r\n      queryClient.invalidateQueries({ queryKey: [TAB_GROUPS_STATISTICS_QUERY_KEY] }),\r\n    ])\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/hooks/useTags.ts",
    "content": "import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\r\nimport { tagsService } from '@/services/tags'\r\nimport type { CreateTagRequest, UpdateTagRequest, TagQueryParams } from '@/lib/types'\r\n\r\nexport const TAGS_QUERY_KEY = 'tags'\r\n\r\n/**\r\n * 获取标签列表\r\n */\r\nexport function useTags(\r\n  params?: TagQueryParams,\r\n  options?: { staleTime?: number; gcTime?: number; enabled?: boolean }\r\n) {\r\n  return useQuery({\r\n    queryKey: [TAGS_QUERY_KEY, params],\r\n    queryFn: () => tagsService.getTags(params),\r\n    staleTime: options?.staleTime || 60 * 60 * 1000, // 1小时 (标签很少变化)\r\n    gcTime: options?.gcTime || 24 * 60 * 60 * 1000, // 24小时\r\n    refetchOnWindowFocus: 'always', // 窗口聚焦时刷新，保持数据同步\r\n    enabled: options?.enabled ?? true,\r\n  })\r\n}\r\n\r\n/**\r\n * 创建标签\r\n */\r\nexport function useCreateTag() {\r\n  const queryClient = useQueryClient()\r\n\r\n  return useMutation({\r\n    mutationFn: (data: CreateTagRequest) => tagsService.createTag(data),\r\n    onSuccess: () => {\r\n      queryClient.invalidateQueries({ queryKey: [TAGS_QUERY_KEY] })\r\n    },\r\n  })\r\n}\r\n\r\n/**\r\n * 更新标签\r\n */\r\nexport function useUpdateTag() {\r\n  const queryClient = useQueryClient()\r\n\r\n  return useMutation({\r\n    mutationFn: ({ id, data }: { id: string; data: UpdateTagRequest }) =>\r\n      tagsService.updateTag(id, data),\r\n    onSuccess: () => {\r\n      queryClient.invalidateQueries({ queryKey: [TAGS_QUERY_KEY] })\r\n    },\r\n  })\r\n}\r\n\r\n/**\r\n * 删除标签\r\n */\r\nexport function useDeleteTag() {\r\n  const queryClient = useQueryClient()\r\n\r\n  return useMutation({\r\n    mutationFn: (id: string) => tagsService.deleteTag(id),\r\n    onSuccess: () => {\r\n      queryClient.invalidateQueries({ queryKey: [TAGS_QUERY_KEY] })\r\n      // 同时刷新书签列表（因为标签被删除）\r\n      queryClient.invalidateQueries({ queryKey: ['bookmarks'] })\r\n    },\r\n  })\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/index.ts",
    "content": "import i18n from 'i18next'\r\nimport { initReactI18next } from 'react-i18next'\r\nimport LanguageDetector from 'i18next-browser-languagedetector'\r\n\r\n// 导入语言包\r\nimport zhCNCommon from './locales/zh-CN/common.json'\r\nimport zhCNAuth from './locales/zh-CN/auth.json'\r\nimport zhCNErrors from './locales/zh-CN/errors.json'\r\nimport zhCNTabGroups from './locales/zh-CN/tabGroups.json'\r\nimport zhCNBookmarks from './locales/zh-CN/bookmarks.json'\r\nimport zhCNTags from './locales/zh-CN/tags.json'\r\nimport zhCNSettings from './locales/zh-CN/settings.json'\r\nimport zhCNImport from './locales/zh-CN/import.json'\r\nimport zhCNInfo from './locales/zh-CN/info.json'\r\nimport zhCNShare from './locales/zh-CN/share.json'\r\n\r\nimport enCommon from './locales/en/common.json'\r\nimport enAuth from './locales/en/auth.json'\r\nimport enErrors from './locales/en/errors.json'\r\nimport enTabGroups from './locales/en/tabGroups.json'\r\nimport enBookmarks from './locales/en/bookmarks.json'\r\nimport enTags from './locales/en/tags.json'\r\nimport enSettings from './locales/en/settings.json'\r\nimport enImport from './locales/en/import.json'\r\nimport enInfo from './locales/en/info.json'\r\nimport enShare from './locales/en/share.json'\r\n\r\n// 支持的语言列表\r\nexport const supportedLanguages = [\r\n  { code: 'zh-CN', name: '简体中文', nativeName: '简体中文' },\r\n  { code: 'en', name: 'English', nativeName: 'English' }\r\n] as const\r\n\r\nexport type LanguageCode = (typeof supportedLanguages)[number]['code']\r\n\r\n// 资源配置\r\nconst resources = {\r\n  'zh-CN': {\r\n    common: zhCNCommon,\r\n    auth: zhCNAuth,\r\n    errors: zhCNErrors,\r\n    tabGroups: zhCNTabGroups,\r\n    bookmarks: zhCNBookmarks,\r\n    tags: zhCNTags,\r\n    settings: zhCNSettings,\r\n    import: zhCNImport,\r\n    info: zhCNInfo,\r\n    share: zhCNShare\r\n  },\r\n  en: {\r\n    common: enCommon,\r\n    auth: enAuth,\r\n    errors: enErrors,\r\n    tabGroups: enTabGroups,\r\n    bookmarks: enBookmarks,\r\n    tags: enTags,\r\n    settings: enSettings,\r\n    import: enImport,\r\n    info: enInfo,\r\n    share: enShare\r\n  }\r\n}\r\n\r\n// 初始化 i18n\r\ni18n\r\n  .use(LanguageDetector)\r\n  .use(initReactI18next)\r\n  .init({\r\n    resources,\r\n    fallbackLng: 'zh-CN',\r\n    defaultNS: 'common',\r\n    ns: ['common', 'auth', 'errors', 'tabGroups', 'bookmarks', 'tags', 'settings', 'import', 'info', 'share'],\r\n\r\n    detection: {\r\n      // 语言检测顺序：localStorage -> 浏览器语言\r\n      order: ['localStorage', 'navigator'],\r\n      lookupLocalStorage: 'tmarks-language',\r\n      caches: ['localStorage']\r\n    },\r\n\r\n    interpolation: {\r\n      escapeValue: false // React 已经处理了 XSS\r\n    },\r\n\r\n    react: {\r\n      useSuspense: false\r\n    }\r\n  })\r\n\r\nexport default i18n\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/en/auth.json",
    "content": "{\r\n  \"login\": {\r\n    \"title\": \"Welcome Back\",\r\n    \"subtitle\": \"Sign in to TMarks Bookmark Manager\",\r\n    \"username\": \"Username or Email\",\r\n    \"usernamePlaceholder\": \"Enter your username or email\",\r\n    \"password\": \"Password\",\r\n    \"passwordPlaceholder\": \"Enter your password\",\r\n    \"rememberMe\": \"Remember me for 30 days\",\r\n    \"submit\": \"Sign In\",\r\n    \"submitting\": \"Signing in...\",\r\n    \"noAccount\": \"Don't have an account?\",\r\n    \"register\": \"Sign up →\"\r\n  },\r\n  \"register\": {\r\n    \"title\": \"Join TMarks\",\r\n    \"subtitle\": \"Start your smart bookmark management journey\",\r\n    \"username\": \"Username\",\r\n    \"usernamePlaceholder\": \"3-20 characters, letters, numbers, underscores\",\r\n    \"email\": \"Email\",\r\n    \"emailOptional\": \"(optional, for password recovery)\",\r\n    \"emailPlaceholder\": \"your@email.com\",\r\n    \"password\": \"Password\",\r\n    \"passwordPlaceholder\": \"At least 8 characters\",\r\n    \"confirmPassword\": \"Confirm Password\",\r\n    \"confirmPasswordPlaceholder\": \"Enter again\",\r\n    \"submit\": \"Sign Up\",\r\n    \"submitting\": \"Signing up...\",\r\n    \"hasAccount\": \"Already have an account?\",\r\n    \"login\": \"Sign in →\",\r\n    \"successTitle\": \"Registration Successful!\",\r\n    \"successMessage\": \"Redirecting to login page...\"\r\n  },\r\n  \"validation\": {\r\n    \"usernameRequired\": \"Please enter username and password\",\r\n    \"passwordRequired\": \"Please enter password\",\r\n    \"emailRequired\": \"Please enter email\",\r\n    \"passwordMismatch\": \"Passwords do not match\",\r\n    \"usernameLength\": \"Username must be 3-20 characters\",\r\n    \"usernameFormat\": \"Username can only contain letters, numbers and underscores\",\r\n    \"passwordLength\": \"Password must be at least 8 characters\",\r\n    \"emailFormat\": \"Invalid email format\"\r\n  },\r\n  \"error\": {\r\n    \"loginFailed\": \"Login failed, please try again later\",\r\n    \"registerFailed\": \"Registration failed, please try again later\",\r\n    \"userExists\": \"Username or email already registered\",\r\n    \"serverErrorMaySuccess\": \"Server error, but your account may have been created. Please try logging in\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/en/bookmarks.json",
    "content": "{\r\n  \"title\": \"Bookmarks\",\r\n  \"search\": {\r\n    \"placeholder\": \"Search bookmarks...\",\r\n    \"tagPlaceholder\": \"Search tags...\",\r\n    \"noResults\": \"No matching bookmarks found\"\r\n  },\r\n  \"filter\": {\r\n    \"all\": \"All bookmarks\",\r\n    \"public\": \"Public only\",\r\n    \"private\": \"Private only\",\r\n    \"clickToSwitch\": \"(click to switch)\"\r\n  },\r\n  \"sort\": {\r\n    \"created\": \"By created time\",\r\n    \"updated\": \"By updated time\",\r\n    \"pinned\": \"Pinned first\",\r\n    \"popular\": \"By popularity\",\r\n    \"clickToSwitch\": \"(click to switch)\"\r\n  },\r\n  \"viewMode\": {\r\n    \"list\": \"List view\",\r\n    \"card\": \"Card view\",\r\n    \"minimal\": \"Minimal list\",\r\n    \"title\": \"Title waterfall\",\r\n    \"clickToSwitch\": \"(click to switch)\"\r\n  },\r\n  \"toolbar\": {\r\n    \"openTags\": \"Open tags\",\r\n    \"switchToTagSearch\": \"Switch to tag search\",\r\n    \"switchToBookmarkSearch\": \"Switch to bookmark search\",\r\n    \"batchMode\": \"Batch mode\",\r\n    \"exitBatchMode\": \"Exit batch mode\",\r\n    \"trash\": \"Trash\",\r\n    \"addBookmark\": \"Add bookmark\"\r\n  },\r\n  \"empty\": {\r\n    \"title\": \"No bookmarks yet\",\r\n    \"description\": \"Click \\\"Add Bookmark\\\" button to start collecting\",\r\n    \"readOnlyDescription\": \"No public bookmarks available\"\r\n  },\r\n  \"loading\": \"Loading...\",\r\n  \"status\": {\r\n    \"pinned\": \"Pinned\",\r\n    \"archived\": \"Archived\"\r\n  },\r\n  \"view\": {\r\n    \"header\": {\r\n      \"title\": \"Title\",\r\n      \"url\": \"URL\",\r\n      \"note\": \"Note\"\r\n    },\r\n    \"noDescription\": \"—\"\r\n  },\r\n  \"snapshot\": {\r\n    \"title\": \"Snapshots\",\r\n    \"count\": \"{{count}} snapshots\",\r\n    \"viewCount\": \"View {{count}} snapshots\",\r\n    \"loading\": \"Loading snapshots...\",\r\n    \"empty\": \"No snapshots\",\r\n    \"emptyHint\": \"Use browser extension to save page snapshots\",\r\n    \"version\": \"Version {{version}}\",\r\n    \"close\": \"Close\",\r\n    \"delete\": \"Delete snapshot\",\r\n    \"deleteTitle\": \"Delete Snapshot\",\r\n    \"deleteMessage\": \"Are you sure you want to delete version {{version}} snapshot?\\n\\nThis action cannot be undone.\",\r\n    \"deleteConfirm\": \"Are you sure you want to delete this snapshot?\",\r\n    \"deleteSuccess\": \"Snapshot deleted\",\r\n    \"deleteFailed\": \"Failed to delete snapshot\"\r\n  },\r\n  \"batch\": {\r\n    \"selected\": \"{{count}} bookmarks selected\",\r\n    \"selectedCount\": \"{{count}} selected\",\r\n    \"pleaseSelect\": \"Please select bookmarks\",\r\n    \"selectAll\": \"Select All ({{count}})\",\r\n    \"delete\": \"Delete\",\r\n    \"deleteTitle\": \"Batch Delete\",\r\n    \"deleteMessage\": \"Are you sure you want to delete these {{count}} bookmarks?\",\r\n    \"deleteSuccess\": \"Successfully deleted {{count}} bookmarks\",\r\n    \"pin\": \"Pin\",\r\n    \"pinTitle\": \"Batch Pin\",\r\n    \"pinMessage\": \"Are you sure you want to pin these {{count}} bookmarks?\",\r\n    \"pinSuccess\": \"Successfully pinned {{count}} bookmarks\",\r\n    \"archive\": \"Archive\",\r\n    \"archiveTitle\": \"Batch Archive\",\r\n    \"archiveMessage\": \"Are you sure you want to archive these {{count}} bookmarks?\",\r\n    \"archiveSuccess\": \"Successfully archived {{count}} bookmarks\",\r\n    \"tags\": \"Tags\",\r\n    \"selectTags\": \"Select tags\",\r\n    \"addTags\": \"Add\",\r\n    \"removeTags\": \"Remove\",\r\n    \"addTagsSuccess\": \"Successfully added tags to {{count}} bookmarks\",\r\n    \"removeTagsSuccess\": \"Successfully removed tags from {{count}} bookmarks\",\r\n    \"cancel\": \"Cancel\",\r\n    \"confirmAction\": \"Confirm Action\",\r\n    \"confirmMessage\": \"Are you sure you want to perform this action?\",\r\n    \"select\": \"Select\",\r\n    \"deselect\": \"Deselect\"\r\n  },\r\n  \"action\": {\r\n    \"success\": \"Operation successful\",\r\n    \"failed\": \"Operation failed, please try again\",\r\n    \"edit\": \"Edit\",\r\n    \"open\": \"Open bookmark {{title}}\"\r\n  },\r\n  \"form\": {\r\n    \"addTitle\": \"Add Bookmark\",\r\n    \"editTitle\": \"Edit Bookmark\",\r\n    \"title\": \"Title\",\r\n    \"titleRequired\": \"Title\",\r\n    \"titlePlaceholder\": \"Bookmark title\",\r\n    \"url\": \"URL\",\r\n    \"urlRequired\": \"URL\",\r\n    \"urlPlaceholder\": \"https://example.com\",\r\n    \"description\": \"Description\",\r\n    \"descriptionPlaceholder\": \"Bookmark description (optional)\",\r\n    \"coverImage\": \"Cover Image URL\",\r\n    \"coverImagePlaceholder\": \"https://example.com/image.jpg\",\r\n    \"tags\": \"Tags\",\r\n    \"tagsBatchHint\": \"(comma-separated for batch)\",\r\n    \"tagsSelected\": \"{{count}} selected\",\r\n    \"tagsInputPlaceholder\": \"Enter tag names (comma-separated), press Enter to add...\",\r\n    \"noTags\": \"No tags yet, type above and press Enter to create\",\r\n    \"pinned\": \"Pinned\",\r\n    \"archived\": \"Archived\",\r\n    \"public\": \"Public\",\r\n    \"save\": \"Save\",\r\n    \"create\": \"Create\",\r\n    \"saving\": \"Saving...\",\r\n    \"delete\": \"Delete\",\r\n    \"deleting\": \"Deleting...\",\r\n    \"cancel\": \"Cancel\",\r\n    \"deleteTitle\": \"Delete Bookmark\",\r\n    \"deleteMessage\": \"Are you sure you want to delete this bookmark? This action cannot be undone.\",\r\n    \"deleteFailed\": \"Delete failed, please try again\",\r\n    \"validation\": {\r\n      \"titleRequired\": \"Please enter bookmark title\",\r\n      \"urlRequired\": \"Please enter bookmark URL\",\r\n      \"urlInvalid\": \"Invalid URL format\",\r\n      \"urlExists\": \"This URL already exists in bookmarks\"\r\n    },\r\n    \"urlWarning\": {\r\n      \"title\": \"URL already exists\",\r\n      \"bookmark\": \"Bookmark: {{title}}\"\r\n    },\r\n    \"createTagFailed\": \"Failed to create tag \\\"{{name}}\\\", please try again\",\r\n    \"operationFailed\": \"Operation failed, please try again\"\r\n  },\r\n  \"statistics\": {\r\n    \"title\": \"Bookmark Statistics\",\r\n    \"backToBookmarks\": \"Back to Bookmarks\",\r\n    \"loading\": \"Loading...\",\r\n    \"loadFailed\": \"Failed to load\",\r\n    \"retry\": \"Retry\",\r\n    \"granularity\": {\r\n      \"day\": \"Daily\",\r\n      \"week\": \"Weekly\",\r\n      \"month\": \"Monthly\",\r\n      \"year\": \"Yearly\"\r\n    },\r\n    \"navigation\": {\r\n      \"prevDay\": \"Previous Day\",\r\n      \"nextDay\": \"Next Day\",\r\n      \"prevWeek\": \"Previous Week\",\r\n      \"nextWeek\": \"Next Week\",\r\n      \"prevMonth\": \"Previous Month\",\r\n      \"nextMonth\": \"Next Month\",\r\n      \"prevYear\": \"Previous Year\",\r\n      \"nextYear\": \"Next Year\",\r\n      \"today\": \"Today\"\r\n    },\r\n    \"summary\": {\r\n      \"totalBookmarks\": \"Total Bookmarks\",\r\n      \"totalTags\": \"Total Tags\",\r\n      \"totalClicks\": \"Total Clicks\",\r\n      \"publicBookmarks\": \"Public Bookmarks\",\r\n      \"archived\": \"Archived\"\r\n    },\r\n    \"charts\": {\r\n      \"topBookmarks\": \"Top 10 Bookmarks\",\r\n      \"topTags\": \"Top 10 Tags\",\r\n      \"topDomains\": \"Top 10 Domains\",\r\n      \"currentRangeClicks\": \"Clicks in Current Range\",\r\n      \"recentVisits\": \"Recent Visits\",\r\n      \"creationTrend\": \"Bookmark Creation Trend\",\r\n      \"visitTrend\": \"Bookmark Visit Trend\"\r\n    },\r\n    \"noData\": \"No data\",\r\n    \"noClicksInRange\": \"No clicks in current range\",\r\n    \"times\": \"{{count}} times\",\r\n    \"items\": \"{{count}} items\",\r\n    \"dateFormat\": {\r\n      \"year\": \"{{year}}\",\r\n      \"month\": \"{{month}}/{{year}}\",\r\n      \"week\": \"Week {{week}}, {{year}}\"\r\n    }\r\n  },\r\n  \"trash\": {\r\n    \"title\": \"Bookmark Trash\",\r\n    \"backToBookmarks\": \"Back to Bookmarks\",\r\n    \"loading\": \"Loading...\",\r\n    \"loadFailed\": \"Failed to load trash\",\r\n    \"retry\": \"Retry\",\r\n    \"empty\": \"Empty\",\r\n    \"emptyTrash\": \"Empty Trash\",\r\n    \"emptyState\": {\r\n      \"title\": \"Trash is empty\",\r\n      \"description\": \"No deleted bookmarks\"\r\n    },\r\n    \"warning\": \"Bookmarks in trash will be permanently deleted after 30 days.\",\r\n    \"deletedAt\": \"Deleted {{time}}\",\r\n    \"restore\": \"Restore\",\r\n    \"delete\": \"Delete\",\r\n    \"restoreTitle\": \"Restore Bookmark\",\r\n    \"restoreMessage\": \"Are you sure you want to restore \\\"{{title}}\\\"?\",\r\n    \"restoreSuccess\": \"Bookmark restored\",\r\n    \"restoreFailed\": \"Restore failed, please try again\",\r\n    \"permanentDeleteTitle\": \"Permanent Delete\",\r\n    \"permanentDeleteMessage\": \"Are you sure you want to permanently delete \\\"{{title}}\\\"? This action cannot be undone!\",\r\n    \"permanentDeleteSuccess\": \"Bookmark permanently deleted\",\r\n    \"permanentDeleteFailed\": \"Delete failed, please try again\",\r\n    \"emptyTrashTitle\": \"Empty Trash\",\r\n    \"emptyTrashMessage\": \"Are you sure you want to permanently delete {{count}} bookmarks in trash? This action cannot be undone!\",\r\n    \"emptyTrashSuccess\": \"Trash emptied, {{count}} bookmarks deleted\",\r\n    \"emptyTrashFailed\": \"Failed to empty trash, please try again\",\r\n    \"confirmDelete\": \"Confirm Delete\",\r\n    \"confirm\": \"Confirm\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/en/common.json",
    "content": "{\r\n  \"button\": {\r\n    \"confirm\": \"Confirm\",\r\n    \"cancel\": \"Cancel\",\r\n    \"save\": \"Save\",\r\n    \"delete\": \"Delete\",\r\n    \"edit\": \"Edit\",\r\n    \"add\": \"Add\",\r\n    \"close\": \"Close\",\r\n    \"back\": \"Back\",\r\n    \"next\": \"Next\",\r\n    \"retry\": \"Retry\",\r\n    \"copy\": \"Copy\",\r\n    \"copied\": \"Copied\",\r\n    \"loadMore\": \"Load more\"\r\n  },\r\n  \"dialog\": {\r\n    \"confirmTitle\": \"Confirm\",\r\n    \"warningTitle\": \"Warning\",\r\n    \"errorTitle\": \"Error\",\r\n    \"successTitle\": \"Success\",\r\n    \"infoTitle\": \"Info\",\r\n    \"operationFailed\": \"Operation failed\",\r\n    \"operationSuccess\": \"Operation successful\"\r\n  },\r\n  \"status\": {\r\n    \"loading\": \"Loading...\",\r\n    \"processing\": \"Processing...\",\r\n    \"saving\": \"Saving...\",\r\n    \"deleting\": \"Deleting...\",\r\n    \"loadFailed\": \"Load failed\",\r\n    \"copied\": \"Copied\"\r\n  },\r\n  \"message\": {\r\n    \"operationSuccess\": \"Operation successful\",\r\n    \"operationFailed\": \"Operation failed\",\r\n    \"saveSuccess\": \"Saved successfully\",\r\n    \"saveFailed\": \"Failed to save\",\r\n    \"deleteSuccess\": \"Deleted successfully\",\r\n    \"deleteFailed\": \"Failed to delete\",\r\n    \"copySuccess\": \"Copied to clipboard\",\r\n    \"copyFailed\": \"Failed to copy, please copy manually\"\r\n  },\r\n  \"pagination\": {\r\n    \"prev\": \"Previous\",\r\n    \"next\": \"Next\",\r\n    \"page\": \"Page {{current}} / {{total}}\",\r\n    \"total\": \"{{count}} items\"\r\n  },\r\n  \"empty\": {\r\n    \"noData\": \"No data\",\r\n    \"noResults\": \"No matching results found\"\r\n  },\r\n  \"time\": {\r\n    \"justNow\": \"Just now\",\r\n    \"minutesAgo\": \"{{count}} minutes ago\",\r\n    \"hoursAgo\": \"{{count}} hours ago\",\r\n    \"daysAgo\": \"{{count}} days ago\",\r\n    \"seconds\": \"{{count}}s\",\r\n    \"minutes\": \"{{count}}min\",\r\n    \"hours\": \"{{count}}h\",\r\n    \"minutesSeconds\": \"{{minutes}}m {{seconds}}s\",\r\n    \"hoursMinutes\": \"{{hours}}h {{minutes}}m\"\r\n  },\r\n  \"progress\": {\r\n    \"itemsPerSecond\": \"{{count}} items/s\",\r\n    \"remaining\": \"{{time}} remaining\",\r\n    \"processed\": \"{{count}} items processed\"\r\n  },\r\n  \"error\": {\r\n    \"error\": \"Error\",\r\n    \"code\": \"Code\",\r\n    \"field\": \"Field\",\r\n    \"details\": \"Details\",\r\n    \"time\": \"Time\",\r\n    \"occurred\": \"An error occurred\",\r\n    \"occurredCount\": \"{{count}} errors occurred\",\r\n    \"warning\": \"Warning\",\r\n    \"warningCount\": \"{{count}} warnings\",\r\n    \"info\": \"Info\",\r\n    \"infoCount\": \"{{count}} messages\",\r\n    \"success\": \"Success\",\r\n    \"successCount\": \"{{count}} successful operations\",\r\n    \"notification\": \"Notification\",\r\n    \"showMore\": \"Show more ({{count}})\",\r\n    \"more\": \"More ({{count}})\"\r\n  },\r\n  \"sort\": {\r\n    \"byCreated\": \"By created time\",\r\n    \"byCreatedDesc\": \"Newest first\",\r\n    \"byUpdated\": \"By updated time\",\r\n    \"byUpdatedDesc\": \"Recently updated first\",\r\n    \"pinnedFirst\": \"Pinned first\",\r\n    \"pinnedFirstDesc\": \"Pinned bookmarks first\",\r\n    \"byPopular\": \"By popularity\",\r\n    \"byPopularDesc\": \"Most clicked first\",\r\n    \"options\": \"Sort options\",\r\n    \"selectSort\": \"Select sort order\"\r\n  },\r\n  \"nav\": {\r\n    \"home\": \"Go to Home\",\r\n    \"bookmarks\": \"Bookmarks\",\r\n    \"tabGroups\": \"Tab Groups\",\r\n    \"data\": \"Data\",\r\n    \"extension\": \"Extension\",\r\n    \"settings\": \"Settings\",\r\n    \"smartBookmarkManagement\": \"Smart Bookmark Management\",\r\n    \"manageTabGroups\": \"Manage Tab Groups\",\r\n    \"switchToBookmarks\": \"Switch to Bookmarks\",\r\n    \"switchToTabGroups\": \"Switch to Tab Groups\",\r\n    \"toggleDarkMode\": \"Switch to Dark Mode\",\r\n    \"toggleLightMode\": \"Switch to Light Mode\",\r\n    \"switchToOrange\": \"Switch to Orange Theme\",\r\n    \"switchToDefault\": \"Switch to Default Theme\",\r\n    \"userSettings\": \"{{username}} - Go to Settings\",\r\n    \"all\": \"All\",\r\n    \"todo\": \"Todo\",\r\n    \"trash\": \"Trash\",\r\n    \"toggleColorTheme\": \"Toggle color theme\"\r\n  },\r\n  \"upload\": {\r\n    \"dropToUpload\": \"Drop to upload\",\r\n    \"formatNotSupported\": \"Format not supported\",\r\n    \"dragOrClick\": \"Drag file here or click to select\",\r\n    \"supportedFormats\": \"Supported formats: {{formats}}\",\r\n    \"selectFile\": \"Select File\",\r\n    \"fileSizeExceeded\": \"File size exceeds limit ({{size}})\",\r\n    \"fileTypeNotSupported\": \"File type not supported, please select: {{types}}\",\r\n    \"close\": \"Close\"\r\n  },\r\n  \"action\": {\r\n    \"save\": \"Save\",\r\n    \"cancel\": \"Cancel\",\r\n    \"delete\": \"Delete\",\r\n    \"edit\": \"Edit\",\r\n    \"add\": \"Add\",\r\n    \"remove\": \"Remove\",\r\n    \"confirm\": \"Confirm\",\r\n    \"close\": \"Close\",\r\n    \"open\": \"Open\",\r\n    \"back\": \"Back\",\r\n    \"next\": \"Next\",\r\n    \"previous\": \"Previous\",\r\n    \"submit\": \"Submit\",\r\n    \"reset\": \"Reset\",\r\n    \"clear\": \"Clear\",\r\n    \"search\": \"Search\",\r\n    \"filter\": \"Filter\",\r\n    \"sort\": \"Sort\",\r\n    \"refresh\": \"Refresh\",\r\n    \"loading\": \"Loading...\",\r\n    \"saving\": \"Saving...\",\r\n    \"deleting\": \"Deleting...\",\r\n    \"uploading\": \"Uploading...\",\r\n    \"downloading\": \"Downloading...\",\r\n    \"processing\": \"Processing...\",\r\n    \"retry\": \"Retry\",\r\n    \"skip\": \"Skip\",\r\n    \"continue\": \"Continue\",\r\n    \"finish\": \"Finish\",\r\n    \"done\": \"Done\",\r\n    \"yes\": \"Yes\",\r\n    \"no\": \"No\",\r\n    \"ok\": \"OK\",\r\n    \"view\": \"View\",\r\n    \"hide\": \"Hide\",\r\n    \"show\": \"Show\",\r\n    \"expand\": \"Expand\",\r\n    \"collapse\": \"Collapse\",\r\n    \"select\": \"Select\",\r\n    \"selectAll\": \"Select All\",\r\n    \"deselectAll\": \"Deselect All\",\r\n    \"copy\": \"Copy\",\r\n    \"paste\": \"Paste\",\r\n    \"cut\": \"Cut\",\r\n    \"undo\": \"Undo\",\r\n    \"redo\": \"Redo\",\r\n    \"export\": \"Export\",\r\n    \"download\": \"Download\",\r\n    \"upload\": \"Upload\",\r\n    \"share\": \"Share\",\r\n    \"print\": \"Print\",\r\n    \"help\": \"Help\",\r\n    \"about\": \"About\",\r\n    \"settings\": \"Settings\",\r\n    \"preferences\": \"Preferences\",\r\n    \"profile\": \"Profile\",\r\n    \"logout\": \"Logout\",\r\n    \"login\": \"Login\",\r\n    \"register\": \"Register\",\r\n    \"viewDetails\": \"View Details\",\r\n    \"resetWidth\": \"Reset Width\",\r\n    \"copyError\": \"Copy Error\",\r\n    \"openMenu\": \"Open Menu\",\r\n    \"more\": \"More\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/en/errors.json",
    "content": "{\r\n  \"network\": {\r\n    \"connectionFailed\": \"Network connection failed, please check your network\",\r\n    \"timeout\": \"Request timed out, please try again later\",\r\n    \"serverError\": \"Server error, please try again later\"\r\n  },\r\n  \"auth\": {\r\n    \"invalidCredentials\": \"Invalid username or password\",\r\n    \"sessionExpired\": \"Session expired, please sign in again\",\r\n    \"unauthorized\": \"Unauthorized access\"\r\n  },\r\n  \"validation\": {\r\n    \"required\": \"This field is required\",\r\n    \"invalidFormat\": \"Invalid format\",\r\n    \"tooLong\": \"Content is too long\",\r\n    \"tooShort\": \"Content is too short\"\r\n  },\r\n  \"share\": {\r\n    \"loadFailed\": \"Failed to load public share content\",\r\n    \"notFound\": \"Public content not found\"\r\n  },\r\n  \"ai\": {\r\n    \"modelListFailed\": \"Failed to fetch model list\",\r\n    \"modelListInvalid\": \"Invalid model list response format\",\r\n    \"modelListEmpty\": \"Model list is empty\",\r\n    \"providerNotSupported\": \"Current AI service does not support automatic model list fetching\",\r\n    \"missingApiKey\": \"Missing API Key\",\r\n    \"missingApiUrl\": \"Missing API URL\"\r\n  },\r\n  \"unknown\": \"Unknown error\"\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/en/import.json",
    "content": "{\r\n  \"title\": \"Data Export\",\r\n  \"page\": {\r\n    \"title\": \"Data Management\",\r\n    \"description\": \"Export your bookmark data\",\r\n    \"exportTab\": \"Export Data\",\r\n    \"exportDesc\": \"Export bookmark data to file\",\r\n    \"recentOperation\": \"Recent Operation\",\r\n    \"exportOperation\": \"Export\",\r\n    \"dataExport\": \"Data Export\",\r\n    \"exportDetails\": \"Exported as {{format}} format{{tags}}{{metadata}}\",\r\n    \"withTags\": \", with tags\",\r\n    \"withMetadata\": \", with metadata\"\r\n  },\r\n  \"help\": {\r\n    \"title\": \"Instructions\",\n    \"exportTitle\": \"Export Features\",\n    \"exportTip1\": \"Exports in JSON format only\",\n    \"exportTip2\": \"JSON format contains complete data, ideal for backup and migration\",\n    \"exportTip3\": \"JSON preserves full structure and state (bookmarks, tags, tab groups) for strict restoration\",\n    \"exportTip4\": \"Optionally include tags, metadata, etc.\",\n    \"notesTitle\": \"Notes\",\n    \"notesTip1\": \"Recommend exporting data regularly as backup\",\n    \"notesTip2\": \"Large exports may take longer, please be patient\",\n    \"notesTip3\": \"Do not close the page during export\",\r\n    \"notesTip4\": \"If you encounter issues, check error details for troubleshooting\"\r\n  },\r\n  \"export\": {\n    \"title\": \"Export Data\",\n    \"description\": \"Export your data to a JSON file for backup and migration\",\n    \"selectFormat\": \"Export Format\",\n    \"options\": \"Export Options\",\n    \"scope\": \"Export Scope\",\n    \"scopeAll\": \"All data\",\n    \"scopeBookmarks\": \"Bookmarks only\",\n    \"scopeTabGroups\": \"Tab groups only\",\n    \"includeDeleted\": \"Include deleted items (trash)\",\n    \"includeTags\": \"Include tag information\",\n    \"includeMetadata\": \"Include metadata\",\n    \"prettyPrint\": \"Format JSON (for readability)\",\n    \"includeStats\": \"Include click statistics\",\n    \"preview\": \"Preview Info\",\r\n    \"previewTitle\": \"Export Preview\",\r\n    \"bookmarkCount\": \"Bookmarks\",\n    \"tagCount\": \"Tags\",\n    \"pinnedCount\": \"Pinned\",\n    \"tabGroupCount\": \"Tab Groups\",\n    \"estimatedSize\": \"Est. Size\",\n    \"startExport\": \"Start Export\",\n    \"exporting\": \"Exporting...\",\n    \"preparing\": \"Preparing export...\",\n    \"generating\": \"Generating export data...\",\r\n    \"downloading\": \"Downloading file...\",\r\n    \"complete\": \"Export complete\",\r\n    \"failed\": \"Export failed\",\r\n    \"failedRetry\": \"Export failed, please try again\"\r\n  },\r\n  \"format\": {\r\n    \"json\": \"JSON\",\r\n    \"jsonDesc\": \"TMarks standard format with complete data\",\r\n    \"html\": \"HTML\",\r\n    \"htmlDesc\": \"Browser bookmark format, good compatibility\",\r\n    \"recommended\": \"Recommended\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/en/info.json",
    "content": "{\r\n  \"extension\": {\r\n    \"title\": \"TMarks Browser Extension\",\r\n    \"subtitle\": \"Save tab groups with one click for efficient bookmark management\",\r\n    \"download\": {\n      \"title\": \"Download Chrome / Chromium Version\",\n      \"button\": \"Download\"\n    },\n    \"browsers\": {\r\n      \"title\": \"Supported Browsers\",\r\n      \"speedMode\": \"Speed Mode\"\r\n    },\r\n    \"version\": \"Version: {{version}} | Size: ~{{size}} | Updated: {{date}}\",\r\n    \"tip\": \"💡 Only the Chrome/Chromium universal package is provided (works on Chromium-based browsers like Chrome, Edge, Brave, Opera)\",\n    \"features\": {\r\n      \"title\": \"✨ Key Features\",\r\n      \"saveTabGroups\": {\r\n        \"title\": \"One-click Tab Group Saving\",\r\n        \"description\": \"Save all open tabs to TMarks with one click, including titles, URLs, and favicons\"\r\n      },\r\n      \"restoreTabs\": {\r\n        \"title\": \"Quick Tab Restoration\",\r\n        \"description\": \"Restore previously saved tab groups from TMarks website to continue your work\"\r\n      },\r\n      \"autoSync\": {\r\n        \"title\": \"Auto Sync\",\r\n        \"description\": \"Tab groups automatically sync to cloud for seamless multi-device access\"\r\n      }\r\n    },\r\n    \"install\": {\r\n      \"title\": \"📦 Installation Steps\",\r\n      \"step1\": {\n        \"title\": \"Download Extension Package\",\n        \"description\": \"Click the download button above to get the tmarks-extension-chrome.zip file\"\n      },\n      \"step2\": {\r\n        \"title\": \"Extract Files\",\r\n        \"description\": \"Extract the downloaded zip file to any folder (preferably a permanent location)\"\r\n      },\r\n      \"step3\": {\r\n        \"title\": \"Open Extension Management Page\",\r\n        \"description\": \"Enter in browser address bar:\"\r\n      },\r\n      \"step4\": {\r\n        \"title\": \"Enable Developer Mode\",\r\n        \"description\": \"Toggle on 'Developer mode' in the top right corner of the extension management page\"\r\n      },\r\n      \"step5\": {\r\n        \"title\": \"Load Extension\",\r\n        \"description\": \"Click 'Load unpacked' and select the extracted folder\"\r\n      },\r\n      \"step6\": {\r\n        \"title\": \"Installation Complete\",\r\n        \"description\": \"The extension icon will appear in the browser toolbar, click to use\"\r\n      }\r\n    },\r\n    \"tips\": {\r\n      \"title\": \"💡 Usage Tips\",\r\n      \"tip1\": \"First-time use requires configuring TMarks website URL and API Key in the extension\",\r\n      \"tip2\": \"API Key can be created on the website's 'API Keys' page\",\r\n      \"tip3\": \"Pin the extension icon to toolbar for quick access\",\r\n      \"tip4\": \"Extension automatically saves tab titles, URLs, and favicons\"\r\n    },\r\n    \"faq\": {\r\n      \"title\": \"❓ FAQ\",\r\n      \"q1\": \"Can't find the extension icon after installation?\",\r\n      \"a1\": \"Click the puzzle icon on the right side of the browser toolbar, find TMarks extension and click the pin button to show it in the toolbar.\",\r\n      \"q2\": \"How to get an API Key?\",\r\n      \"a2\": \"After logging into TMarks website, click 'API Keys' in the user menu at the top right, create a new API Key and copy it to the extension settings.\",\r\n      \"q3\": \"Which browsers are supported?\",\n      \"a3\": \"Only the Chrome/Chromium version is provided: works on Chromium-based browsers like Chrome, Edge, Brave and Opera. Firefox is not supported for the extension at the moment.\",\n      \"q4\": \"Can Chrome and Edge use the same version?\",\n      \"a4\": \"Yes! All Chromium-based browsers use the same Chrome/Chromium version.\",\n      \"q5\": \"Where to view saved tab groups?\",\n      \"a5\": \"View and manage all saved tab groups on the 'Tab Groups' page of TMarks website.\"\n    }\n  },\n  \"privacy\": {\r\n    \"title\": \"Privacy Policy\",\r\n    \"lastUpdated\": \"Last updated: {{date}}\",\r\n    \"commitment\": {\r\n      \"title\": \"Our Commitment\",\r\n      \"description\": \"TMarks is committed to protecting user privacy and data security. This privacy policy explains how we collect, use, store, and protect your personal information.\"\r\n    },\r\n    \"collection\": {\r\n      \"title\": \"Information We Collect\",\r\n      \"account\": {\r\n        \"title\": \"Account Information\",\r\n        \"description\": \"When you register a TMarks account, we collect your username, email address, and encrypted password.\"\r\n      },\r\n      \"bookmarks\": {\r\n        \"title\": \"Bookmark Data\",\r\n        \"description\": \"Bookmarks, tags, tab groups and other content you create, used only to provide service functionality.\"\r\n      },\r\n      \"usage\": {\r\n        \"title\": \"Usage Data\",\r\n        \"description\": \"We may collect information about your use of the service, such as access time, IP address, browser type, etc., to improve service quality.\"\r\n      }\r\n    },\r\n    \"usage\": {\r\n      \"title\": \"How We Use Information\",\r\n      \"item1\": \"Provide, maintain, and improve TMarks services\",\r\n      \"item2\": \"Process your requests and transactions\",\r\n      \"item3\": \"Send service-related notifications and updates\",\r\n      \"item4\": \"Detect, prevent, and resolve technical issues\",\r\n      \"item5\": \"Comply with legal obligations\"\r\n    },\r\n    \"security\": {\r\n      \"title\": \"Data Security\",\r\n      \"description\": \"We take multiple security measures to protect your personal information:\",\r\n      \"item1\": \"All data transmission uses HTTPS encryption\",\r\n      \"item2\": \"Passwords are stored using industry-standard hashing algorithms\",\r\n      \"item3\": \"JWT tokens are used for authentication\",\r\n      \"item4\": \"Data is stored on Cloudflare's secure infrastructure\",\r\n      \"item5\": \"Regular security audits and updates\"\r\n    },\r\n    \"rights\": {\r\n      \"title\": \"Your Rights\",\r\n      \"access\": \"Access: You can access and view your personal information at any time\",\r\n      \"modify\": \"Modify: You can update or modify your personal information\",\r\n      \"delete\": \"Delete: You can request deletion of your account and all related data\",\r\n      \"export\": \"Export: You can export your bookmark data\"\r\n    },\r\n    \"cookies\": {\r\n      \"title\": \"Cookies and Similar Technologies\",\r\n      \"description\": \"We use cookies and similar technologies to:\",\r\n      \"item1\": \"Keep you logged in\",\r\n      \"item2\": \"Remember your preferences\",\r\n      \"item3\": \"Analyze service usage\"\r\n    },\r\n    \"thirdParty\": {\r\n      \"title\": \"Third-Party Services\",\r\n      \"description\": \"TMarks uses the following third-party services:\",\r\n      \"cloudflare\": \"Cloudflare: Provides hosting, database, and CDN services\",\r\n      \"note\": \"These service providers have their own privacy policies, we recommend reviewing them.\"\r\n    },\r\n    \"updates\": {\r\n      \"title\": \"Policy Updates\",\r\n      \"description\": \"We may update this privacy policy from time to time. For significant changes, we will notify you via email or in-service notification. Continued use of the service indicates acceptance of the updated policy.\"\r\n    },\r\n    \"contact\": {\r\n      \"title\": \"Contact Us\",\r\n      \"description\": \"If you have any questions or suggestions about this privacy policy, please contact us:\",\r\n      \"email\": \"Email:\"\r\n    }\r\n  },\r\n  \"terms\": {\r\n    \"title\": \"Terms of Service\",\r\n    \"lastUpdated\": \"Last updated: {{date}}\",\r\n    \"welcome\": {\r\n      \"title\": \"Welcome to TMarks\",\r\n      \"description\": \"Thank you for using TMarks bookmark management service. By using our service, you agree to comply with the following terms of service. Please read these terms carefully. If you do not agree, please do not use our service.\"\r\n    },\r\n    \"service\": {\r\n      \"title\": \"1. Service Description\",\r\n      \"description\": \"TMarks provides online bookmark management services, including but not limited to:\",\r\n      \"item1\": \"Creating, editing, deleting, and organizing bookmarks\",\r\n      \"item2\": \"Tag system and tab group management\",\r\n      \"item3\": \"Bookmark export and sharing\",\r\n      \"item4\": \"Browser extension and API access\"\r\n    },\r\n    \"responsibility\": {\r\n      \"title\": \"2. User Responsibilities\",\r\n      \"description\": \"When using TMarks services, you agree to:\",\r\n      \"item1\": \"Provide accurate and complete registration information\",\r\n      \"item2\": \"Protect your account security and not share login credentials\",\r\n      \"item3\": \"Be responsible for all activities under your account\",\r\n      \"item4\": \"Comply with all applicable laws and regulations\",\r\n      \"item5\": \"Not abuse the service or interfere with other users\"\r\n    },\r\n    \"prohibited\": {\r\n      \"title\": \"3. Prohibited Activities\",\r\n      \"description\": \"When using TMarks, you must not:\",\r\n      \"item1\": \"Upload or share illegal, harmful, threatening, abusive, or infringing content\",\r\n      \"item2\": \"Attempt unauthorized access to other users' accounts or data\",\r\n      \"item3\": \"Interfere with or disrupt normal service operation\",\r\n      \"item4\": \"Use automated tools to excessively access the service\",\r\n      \"item5\": \"Copy, modify, or distribute any part of the service\"\r\n    },\r\n    \"ip\": {\r\n      \"title\": \"4. Intellectual Property\",\r\n      \"description1\": \"TMarks service and its original content, features, and functionality are owned by TMarks and its licensors, protected by international copyright, trademark, and other intellectual property laws.\",\r\n      \"description2\": \"You retain all rights to content you create and upload. By using the service, you grant TMarks a license to use, store, and display your content to provide the service.\"\r\n    },\r\n    \"changes\": {\r\n      \"title\": \"5. Service Changes and Termination\",\r\n      \"description1\": \"We reserve the right to modify, suspend, or terminate the service (or any part thereof) at any time, with or without notice. We are not liable to you or any third party for any modification, suspension, or termination of the service.\",\r\n      \"description2\": \"You may stop using the service and delete your account at any time. We may also suspend or terminate your account for violating these terms.\"\r\n    },\r\n    \"disclaimer\": {\r\n      \"title\": \"6. Disclaimer\",\r\n      \"description\": \"TMarks service is provided on an 'as is' and 'as available' basis without any express or implied warranties, including but not limited to:\",\r\n      \"item1\": \"The service will be uninterrupted, timely, secure, or error-free\",\r\n      \"item2\": \"Results obtained through the service will be accurate or reliable\",\r\n      \"item3\": \"Any errors in the service will be corrected\"\r\n    },\r\n    \"liability\": {\r\n      \"title\": \"7. Limitation of Liability\",\r\n      \"description\": \"To the maximum extent permitted by law, TMarks shall not be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits, revenue, data, or use, whether based on contract, tort (including negligence), strict liability, or any other theory, even if we have been advised of the possibility of such damages.\"\r\n    },\r\n    \"termChanges\": {\r\n      \"title\": \"8. Changes to Terms\",\r\n      \"description\": \"We reserve the right to modify these terms at any time. For significant changes, we will notify you via email or in-service notification. Continued use of the service after changes take effect indicates acceptance of the modified terms.\"\r\n    },\r\n    \"law\": {\r\n      \"title\": \"9. Governing Law\",\r\n      \"description\": \"These terms are governed by and construed in accordance with the laws of the People's Republic of China, without regard to its conflict of law provisions.\"\r\n    },\r\n    \"contact\": {\r\n      \"title\": \"Contact Us\",\r\n      \"description\": \"If you have any questions about these terms of service, please contact us:\",\r\n      \"email\": \"Email:\"\r\n    }\r\n  },\r\n  \"share\": {\r\n    \"loading\": \"Loading public bookmarks...\",\r\n    \"error\": \"Share link is invalid or content has been taken offline.\",\r\n    \"defaultTitle\": \"{{username}}'s Bookmark Collection\",\r\n    \"totalBookmarks\": \"{{count}} bookmarks\",\r\n    \"filteredBookmarks\": \"Filtered {{filtered}} / {{total}} bookmarks\",\r\n    \"noResults\": \"No matching bookmarks found\",\r\n    \"noResultsHint\": \"Try adjusting filters or search keywords\",\r\n    \"openTags\": \"Open Tags\",\r\n    \"searchBookmarks\": \"Search bookmarks...\",\r\n    \"searchTags\": \"Search tags...\",\r\n    \"switchToTagSearch\": \"Switch to tag search\",\r\n    \"switchToBookmarkSearch\": \"Switch to bookmark search\",\r\n    \"sort\": {\r\n      \"created\": \"By creation time\",\r\n      \"updated\": \"By update time\",\r\n      \"pinned\": \"Pinned first\",\r\n      \"popular\": \"By popularity\"\r\n    },\r\n    \"visibility\": {\r\n      \"all\": \"All bookmarks\",\r\n      \"public\": \"Public only\",\r\n      \"private\": \"Private only\"\r\n    },\r\n    \"viewMode\": {\r\n      \"list\": \"List view\",\r\n      \"card\": \"Card view\",\r\n      \"minimal\": \"Minimal list\",\r\n      \"title\": \"Title waterfall\"\r\n    }\r\n  },\r\n  \"about\": {\r\n    \"title\": \"About TMarks\",\r\n    \"subtitle\": \"A modern smart bookmark management system to keep your bookmarks organized\",\r\n    \"version\": \"Version\",\r\n    \"releaseNote\": \"Migration Automation Release\",\r\n    \"features\": {\r\n      \"title\": \"Core Features\",\r\n      \"fast\": {\r\n        \"title\": \"Fast & Efficient\",\r\n        \"description\": \"Powered by Cloudflare's global network for lightning-fast access\"\r\n      },\r\n      \"secure\": {\r\n        \"title\": \"Secure & Reliable\",\r\n        \"description\": \"Encrypted data storage with JWT authentication to protect your privacy\"\r\n      },\r\n      \"sync\": {\r\n        \"title\": \"Multi-device Sync\",\r\n        \"description\": \"Browser extension support for seamless bookmark sync across devices\"\r\n      },\r\n      \"opensource\": {\r\n        \"title\": \"Open Source\",\r\n        \"description\": \"MIT licensed, fully open source, contributions welcome\"\r\n      }\r\n    },\r\n    \"techStack\": {\r\n      \"title\": \"Tech Stack\",\r\n      \"frontend\": \"Frontend\",\r\n      \"backend\": \"Backend\"\r\n    },\r\n    \"opensource\": {\r\n      \"title\": \"Open Source Project\",\r\n      \"description\": \"TMarks is an open source project under MIT license. We welcome all forms of contributions, including but not limited to:\",\r\n      \"contribute1\": \"Bug reports and feature suggestions\",\r\n      \"contribute2\": \"Documentation and translation improvements\",\r\n      \"contribute3\": \"Code contributions and bug fixes\",\r\n      \"contribute4\": \"Sharing experiences and best practices\",\r\n      \"visitGithub\": \"Visit GitHub Repository\"\r\n    },\r\n    \"thanks\": {\r\n      \"title\": \"Acknowledgments\",\r\n      \"description\": \"Thanks to all developers and users who contributed to TMarks, and the following excellent open source projects and services:\"\r\n    }\r\n  },\r\n  \"help\": {\r\n    \"title\": \"Help Center\",\r\n    \"subtitle\": \"Find answers to common questions or browse user guides\",\r\n    \"guides\": {\r\n      \"title\": \"Quick Guides\",\r\n      \"quickStart\": {\r\n        \"title\": \"Quick Start\",\r\n        \"description\": \"Learn how to create your first bookmark and use basic features\"\r\n      },\r\n      \"extension\": {\r\n        \"title\": \"Browser Extension\",\r\n        \"description\": \"Install and configure browser extension for quick bookmark saving\"\r\n      },\r\n      \"export\": {\r\n        \"title\": \"Data Export\",\r\n        \"description\": \"Export bookmark data for backup\"\r\n      },\r\n      \"share\": {\r\n        \"title\": \"Public Sharing\",\r\n        \"description\": \"Create public links to share your bookmark collection\"\r\n      }\r\n    },\r\n    \"faq\": {\r\n      \"title\": \"FAQ\",\r\n      \"q1\": \"How to create a bookmark?\",\r\n      \"a1\": \"Click the 'Add Bookmark' button in the top right corner, fill in the bookmark information and save. You can also use the browser extension to quickly save the current page.\",\r\n      \"q2\": \"How to use tags?\",\r\n      \"a2\": \"When creating or editing a bookmark, you can add tags to it. Click on tags in the sidebar to filter corresponding bookmarks.\",\r\n      \"q3\": \"How to share my bookmarks?\",\r\n      \"a3\": \"Go to 'General Settings' → 'Share' tab, enable public sharing, and the system will generate a public link for others to access.\",\r\n      \"q4\": \"How to get an API Key?\",\r\n      \"a4\": \"Go to 'General Settings' → 'API' tab, click 'Create' button to generate a new API Key for browser extension or third-party apps.\",\r\n      \"q5\": \"How to install the browser extension?\",\r\n      \"a5\": \"Visit the 'Browser' page, download the extension file for your browser, and follow the instructions to install.\",\r\n      \"q6\": \"How to switch themes?\",\r\n      \"a6\": \"Go to 'General Settings' → 'Basic' tab, choose light, dark, or system theme.\",\r\n      \"q7\": \"Is my data secure?\",\r\n      \"a7\": \"All data is encrypted and stored in Cloudflare D1 database, using JWT for authentication to ensure data security.\"\r\n    },\r\n    \"contact\": {\r\n      \"title\": \"Need More Help?\",\r\n      \"description\": \"If you haven't found the answer to your question, you can contact us through:\",\r\n      \"submitIssue\": \"Submit Issue\",\r\n      \"contactSupport\": \"Contact Support\"\r\n    }\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/en/settings.json",
    "content": "{\r\n  \"title\": \"General Settings\",\r\n  \"description\": \"Configure app behavior and user experience\",\r\n  \"tabs\": {\r\n    \"basic\": \"Basic\",\r\n    \"automation\": \"Automation\",\r\n    \"appearance\": \"Appearance\",\r\n    \"snapshot\": \"Snapshot\",\r\n    \"ai\": \"AI\",\r\n    \"browser\": \"Browser\",\r\n    \"api\": \"API\",\r\n    \"share\": \"Share\",\r\n    \"data\": \"Data\",\r\n    \"statistics\": \"Statistics\",\r\n    \"language\": \"Language\"\r\n  },\r\n  \"action\": {\r\n    \"save\": \"Save Settings\",\r\n    \"saving\": \"Saving...\",\r\n    \"reset\": \"Reset\",\r\n    \"logout\": \"Logout\",\r\n    \"discard\": \"Discard\"\r\n  },\r\n  \"message\": {\r\n    \"saveSuccess\": \"Settings saved\",\r\n    \"saveFailed\": \"Failed to save\",\r\n    \"saveFailedWithError\": \"Failed to save: {{error}}\",\r\n    \"resetSuccess\": \"Reset to last saved settings\",\r\n    \"logoutFailed\": \"Failed to logout\",\r\n    \"unsavedChanges\": \"You have unsaved changes\"\r\n  },\r\n  \"language\": {\r\n    \"title\": \"Language Settings\",\r\n    \"description\": \"Select interface language\",\r\n    \"label\": \"Interface Language\",\r\n    \"hint\": \"Interface will switch immediately after changing language\"\r\n  },\r\n  \"permissions\": {\r\n    \"bookmarks\": \"Bookmarks\",\r\n    \"tags\": \"Tags\",\r\n    \"tabGroups\": \"Tab Groups\",\r\n    \"other\": \"Other\",\r\n    \"bookmarksCreate\": \"Create bookmarks\",\r\n    \"bookmarksRead\": \"Read bookmarks\",\r\n    \"bookmarksUpdate\": \"Update bookmarks\",\r\n    \"bookmarksDelete\": \"Delete bookmarks\",\r\n    \"bookmarksAll\": \"All bookmark permissions\",\r\n    \"tagsCreate\": \"Create tags\",\r\n    \"tagsRead\": \"Read tags\",\r\n    \"tagsUpdate\": \"Update tags\",\r\n    \"tagsDelete\": \"Delete tags\",\r\n    \"tagsAssign\": \"Assign tags\",\r\n    \"tagsAll\": \"All tag permissions\",\r\n    \"tabGroupsCreate\": \"Create tab groups\",\r\n    \"tabGroupsRead\": \"Read tab groups\",\r\n    \"tabGroupsUpdate\": \"Update tab groups\",\r\n    \"tabGroupsDelete\": \"Delete tab groups\",\r\n    \"tabGroupsAll\": \"All tab group permissions\",\r\n    \"aiSuggest\": \"AI suggestions\",\r\n    \"userRead\": \"Read user info\",\r\n    \"userPreferencesRead\": \"Read user preferences\",\r\n    \"templates\": {\r\n      \"readOnly\": \"Read Only\",\r\n      \"readOnlyDesc\": \"View data only, cannot modify\",\r\n      \"basic\": \"Basic\",\r\n      \"basicDesc\": \"Can add bookmarks and tags, but cannot delete\",\r\n      \"full\": \"Full Access\",\r\n      \"fullDesc\": \"Has all operation permissions\"\r\n    }\r\n  },\r\n  \"browserPermissions\": {\r\n    \"title\": \"Browser Permissions\",\r\n    \"description\": \"Configure browser permissions required by TMarks for the best experience\",\r\n    \"popup\": {\r\n      \"title\": \"Popup Permission\",\r\n      \"description\": \"Allow TMarks to open popups to use the \\\"Open All Tabs\\\" feature.\"\r\n    },\r\n    \"howTo\": {\r\n      \"title\": \"How to allow popups?\",\r\n      \"step1\": \"On the tab group detail page, click the \\\"Restore All\\\" button\",\r\n      \"step2\": \"A popup blocked icon will appear in the browser address bar (usually on the right)\",\r\n      \"step3\": \"Click the icon and select \\\"Always allow popups from [site]\\\"\",\r\n      \"step4\": \"Refresh the page and click \\\"Restore All\\\" again to use normally\"\r\n    },\r\n    \"browsers\": {\r\n      \"title\": \"Browser-specific instructions\",\r\n      \"chrome\": {\r\n        \"title\": \"Chrome / Edge\",\r\n        \"description\": \"A 🚫 icon will appear on the right side of the address bar. Click it and select \\\"Always allow pop-ups and redirects\\\"\"\r\n      },\r\n      \"firefox\": {\r\n        \"title\": \"Firefox\",\r\n        \"description\": \"A popup blocked notification will appear on the left side of the address bar. Click \\\"Options\\\" → \\\"Allow pop-ups for [site]\\\"\"\r\n      },\r\n      \"safari\": {\r\n        \"title\": \"Safari\",\r\n        \"description\": \"Menu bar: Safari → Settings → Websites → Pop-up Windows → Find current site → Select \\\"Allow\\\"\"\r\n      }\r\n    },\r\n    \"why\": {\r\n      \"title\": \"💡 Why is popup permission needed?\",\r\n      \"description\": \"The \\\"Open All Tabs\\\" feature needs to open multiple web pages simultaneously. Browsers block batch popups by default for security. After allowing TMarks popup permission, you can open all pages in a tab group at once, greatly improving efficiency.\"\r\n    }\r\n  },\r\n  \"ai\": {\r\n    \"custom\": \"Custom\",\r\n    \"title\": \"AI Service Configuration\",\r\n    \"description\": \"Configure AI service for tag suggestions, smart categorization, etc.\",\r\n    \"enable\": \"Enable AI Features\",\r\n    \"enabled\": \"Enabled\",\r\n    \"enableHint\": \"Enable to use AI smart organization features\",\r\n    \"provider\": \"Provider\",\r\n    \"apiKey\": \"API Key\",\r\n    \"getApiKey\": \"Get API Key\",\r\n    \"apiKeyPlaceholder\": \"Enter {{provider}} API Key\",\r\n    \"model\": \"Model\",\r\n    \"selectModel\": \"Select\",\r\n    \"refreshModels\": \"Refresh Models\",\r\n    \"refreshingModels\": \"Fetching...\",\r\n    \"modelsFetched\": \"Fetched {{count}} models, select or enter manually\",\r\n    \"modelsFetchError\": \"Failed to load models: {{error}}\",\r\n    \"modelsHint\": \"Enter API Key to auto-fetch available models, recommended: {{model}}\",\r\n    \"modelsRecommend\": \"Recommended: {{model}}, good value\",\r\n    \"apiUrl\": \"API URL\",\r\n    \"apiUrlOptional\": \"API URL (optional)\",\r\n    \"apiUrlHint\": \"Leave empty for default URL, or enter custom API endpoint\",\r\n    \"testConnection\": \"Test Connection\",\r\n    \"testSuccess\": \"Connected ({{latency}}ms)\",\r\n    \"testFailed\": \"Connection failed\",\r\n    \"enterApiKeyFirst\": \"Please enter a complete API Key first\",\r\n    \"saveSuccess\": \"AI settings saved\",\r\n    \"infoBox\": {\r\n      \"title\": \"Usage Guide\",\r\n      \"tip1\": \"API Key is encrypted and stored on server, will not be leaked\",\r\n      \"tip2\": \"AI calls are made directly from browser, data doesn't pass through TMarks server\",\r\n      \"tip3\": \"Recommended: DeepSeek or gpt-4o-mini for best value\",\r\n      \"tip4\": \"Processing 100 bookmarks costs about $0.01-0.05\"\r\n    }\r\n  },\r\n  \"apiKey\": {\r\n    \"page\": {\r\n      \"title\": \"API Keys Management\",\r\n      \"description\": \"Manage your API keys for third-party app access\",\r\n      \"createNew\": \"Create New API Key\",\r\n      \"info\": \"API Keys are used by third-party apps (like browser extensions) to securely access your TMarks data. You can revoke unused keys anytime.\",\r\n      \"currentUsage\": \"Current usage\",\r\n      \"unlimited\": \"Unlimited\",\r\n      \"empty\": \"No API Keys created yet\",\r\n      \"createFirst\": \"Create your first API Key\",\r\n      \"tipsTitle\": \"💡 Tips:\",\r\n      \"tip1\": \"Each account can create up to {{limit}} API Keys\",\r\n      \"tip2\": \"API Key is only shown once after creation, please save it securely\",\r\n      \"tip3\": \"If a Key is compromised, revoke it immediately\",\r\n      \"revokeTitle\": \"Revoke API Key\",\r\n      \"revokeMessage\": \"Are you sure you want to revoke this API Key? This action cannot be undone.\",\r\n      \"revokeSuccess\": \"API Key revoked\",\r\n      \"revokeFailed\": \"Failed to revoke, please try again\",\r\n      \"deleteTitle\": \"Delete API Key\",\r\n      \"deleteMessage\": \"Are you sure you want to permanently delete this API Key? This action cannot be undone and will clear all usage records.\",\r\n      \"deleteSuccess\": \"API Key permanently deleted\",\r\n      \"deleteFailed\": \"Failed to delete, please try again\"\r\n    },\r\n    \"status\": {\r\n      \"label\": \"Status\",\r\n      \"active\": \"Active\",\r\n      \"revoked\": \"Revoked\",\r\n      \"expired\": \"Expired\"\r\n    },\r\n    \"permissions\": \"Permissions\",\r\n    \"permissionsCount\": \"{{count}} items\",\r\n    \"lastUsed\": \"Last used\",\r\n    \"neverUsed\": \"Never used\",\r\n    \"createdAt\": \"Created at\",\r\n    \"expiresAt\": \"Expires at\",\r\n    \"viewDetails\": \"View Details\",\r\n    \"revoke\": \"Revoke\",\r\n    \"delete\": \"Delete\",\r\n    \"create\": {\r\n      \"title\": \"Create API Key\",\r\n      \"step\": \"Step {{current}}/{{total}}\",\r\n      \"name\": \"Name\",\r\n      \"nameRequired\": \"Name *\",\r\n      \"namePlaceholder\": \"e.g., Chrome Extension - Work PC\",\r\n      \"nameHint\": \"Used to identify the purpose of this Key\",\r\n      \"description\": \"Description (optional)\",\r\n      \"descriptionPlaceholder\": \"e.g., For browser extension access\",\r\n      \"quickSelect\": \"Quick Select:\",\r\n      \"recommended\": \"Recommended\",\r\n      \"includedPermissions\": \"Included Permissions:\",\r\n      \"expiration\": \"Expiration:\",\r\n      \"neverExpire\": \"Never expire\",\r\n      \"expireIn30Days\": \"30 days\",\r\n      \"expireIn90Days\": \"90 days\",\r\n      \"cancel\": \"Cancel\",\r\n      \"prev\": \"← Previous\",\r\n      \"next\": \"Next →\",\r\n      \"creating\": \"Creating...\",\r\n      \"createButton\": \"Create API Key\",\r\n      \"failed\": \"Creation Failed\",\r\n      \"failedMessage\": \"Failed to create, please try again\"\r\n    },\r\n    \"success\": {\r\n      \"title\": \"Created! Please save this Key securely\",\r\n      \"yourKey\": \"Your API Key:\",\r\n      \"copy\": \"📋 Copy\",\r\n      \"copied\": \"✓ Copied\",\r\n      \"warning\": \"⚠️ Important:\",\r\n      \"warningList\": {\r\n        \"showOnce\": \"This Key is only shown once and cannot be viewed after closing\",\r\n        \"saveNow\": \"Please copy and save it to a secure location immediately\",\r\n        \"prefixOnly\": \"You will only see the prefix later: {{prefix}}...\"\r\n      },\r\n      \"close\": \"I've saved it, close\"\r\n    },\r\n    \"detail\": {\r\n      \"close\": \"Close\",\r\n      \"basicInfo\": \"Basic Information:\",\r\n      \"keyPrefix\": \"Key Prefix:\",\r\n      \"createdAt\": \"Created at:\",\r\n      \"expiresAt\": \"Expires at:\",\r\n      \"neverExpire\": \"Never expires\",\r\n      \"description\": \"Description:\",\r\n      \"permissions\": \"Permissions:\",\r\n      \"usage\": \"Usage:\",\r\n      \"lastUsed\": \"Last used:\",\r\n      \"neverUsed\": \"Never used\",\r\n      \"totalRequests\": \"Total requests:\",\r\n      \"requestCount\": \"{{count}} times\",\r\n      \"lastIp\": \"Last IP:\",\r\n      \"recentActivity\": \"Recent Activity:\",\r\n      \"recentActivityLimit\": \"(showing up to {{count}} entries)\",\r\n      \"tableTime\": \"Time\",\r\n      \"tableMethod\": \"Method\",\r\n      \"tableEndpoint\": \"Endpoint\",\r\n      \"tableStatus\": \"Status\",\r\n      \"noLogs\": \"No usage records\"\r\n    },\r\n    \"copyPrefix\": \"Copy key prefix\",\r\n    \"infoBox\": {\r\n      \"usageTitle\": \"Usage Guide\",\r\n      \"usageTip1\": \"API Keys are used by third-party apps (like browser extensions) to securely access your data\",\r\n      \"usageTip2\": \"Each Key can have different permission scopes\",\r\n      \"usageTip3\": \"Recommended to create different Keys for different purposes\",\r\n      \"securityTitle\": \"Security Tips\",\r\n      \"securityTip1\": \"Do not share your API Key publicly\",\r\n      \"securityTip2\": \"Revoke immediately if a Key is compromised\",\r\n      \"securityTip3\": \"Regularly check Key usage records\"\r\n    }\r\n  },\r\n  \"snapshot\": {\r\n    \"title\": \"Snapshot Settings\",\r\n    \"retention\": {\r\n      \"title\": \"Snapshot Retention Policy\",\r\n      \"description\": \"Control the number of snapshots retained per bookmark\",\r\n      \"count\": \"Retention Count\",\r\n      \"countHint\": \"Maximum snapshot versions per bookmark (-1 for unlimited)\",\r\n      \"unit\": \"\",\r\n      \"tip\": \"💡 When snapshot count exceeds the limit, oldest snapshots will be automatically deleted. Set to -1 for unlimited.\"\r\n    },\r\n    \"maxCount\": {\r\n      \"title\": \"Maximum Snapshots\",\r\n      \"description\": \"Maximum number of snapshots per bookmark\",\r\n      \"hint\": \"When snapshot count exceeds the limit, oldest snapshots will be automatically deleted. Set to -1 for unlimited.\"\r\n    },\r\n    \"autoCreate\": {\r\n      \"title\": \"Auto-create Snapshots\",\r\n      \"description\": \"Automatically create webpage snapshot when adding bookmark\",\r\n      \"enable\": \"Enable Auto-create\",\r\n      \"enableHint\": \"Automatically save webpage snapshot when adding new bookmark (requires browser extension)\"\r\n    },\r\n    \"dedup\": {\r\n      \"title\": \"Smart Deduplication\",\r\n      \"description\": \"Avoid saving duplicate snapshot content\",\r\n      \"enable\": \"Enable Smart Deduplication\",\r\n      \"tip1\": \"Detect duplicate snapshots via content hash\",\r\n      \"tip2\": \"Won't create new snapshot if content is identical\",\r\n      \"tip3\": \"Save storage space and improve efficiency\"\r\n    },\r\n    \"autoClean\": {\r\n      \"title\": \"Auto Cleanup\",\r\n      \"description\": \"Periodically clean up expired snapshots\",\r\n      \"days\": \"Auto-cleanup Days\",\r\n      \"daysHint\": \"Automatically delete snapshots older than specified days (0 to disable)\",\r\n      \"unit\": \"days\",\r\n      \"warning\": \"⚠️ Auto-cleanup will permanently delete snapshots, please set carefully. Set to 0 to disable.\"\r\n    },\r\n    \"automation\": {\r\n      \"title\": \"Automation Options\",\r\n      \"description\": \"Configure auto-create and deduplication for snapshots\"\r\n    },\r\n    \"infoBox\": {\r\n      \"title\": \"Snapshot Feature Guide\",\r\n      \"tip1\": \"Snapshot feature saves complete webpage content, viewable even if original page is deleted\",\r\n      \"tip2\": \"Enable smart deduplication to avoid duplicate content and save storage space\"\r\n    }\r\n  },\r\n  \"share\": {\r\n    \"title\": \"Share Settings\",\r\n    \"publicShare\": {\r\n      \"title\": \"Public Share\",\r\n      \"description\": \"Create a public link for others to view your bookmarks\",\r\n      \"enable\": \"Enable Public Share\",\r\n      \"enableHint\": \"When enabled, others can access your public bookmarks via link\"\r\n    },\r\n    \"slug\": {\r\n      \"label\": \"Share Link Suffix\",\r\n      \"placeholder\": \"e.g., my-bookmarks\",\r\n      \"hint\": \"Only letters, numbers, and hyphens allowed. Leave empty to auto-generate\",\r\n      \"regenerate\": \"Regenerate\"\r\n    },\r\n    \"pageTitle\": {\r\n      \"label\": \"Page Title\",\r\n      \"placeholder\": \"Public page title to introduce to visitors\"\r\n    },\r\n    \"pageDescription\": {\r\n      \"label\": \"Page Description\",\r\n      \"placeholder\": \"Optional description to explain the bookmark collection\"\r\n    },\r\n    \"shareLink\": {\r\n      \"label\": \"Share Link\",\r\n      \"placeholder\": \"Link will appear after generation\",\r\n      \"copy\": \"Copy\",\r\n      \"copied\": \"Copied\"\r\n    },\r\n    \"reset\": \"Reset\",\r\n    \"saveSuccess\": \"Share settings saved\",\r\n    \"saveFailed\": \"Failed to save\",\r\n    \"regenerateSuccess\": \"Link regenerated\",\r\n    \"regenerateFailed\": \"Failed to generate\",\r\n    \"copySuccess\": \"Link copied\",\r\n    \"copyFailed\": \"Failed to copy\",\r\n    \"resetSuccess\": \"Reset to last saved settings\",\r\n    \"infoBox\": {\r\n      \"title\": \"Share Feature Guide\",\r\n      \"tip1\": \"Only bookmarks marked as \\\"public\\\" will be shown on the share page\",\r\n      \"tip2\": \"You can modify the share link or disable sharing anytime\",\r\n      \"tip3\": \"Share page can be accessed without login\"\r\n    }\r\n  },\r\n  \"data\": {\r\n    \"title\": \"Data Management\",\r\n    \"r2Storage\": {\r\n      \"title\": \"R2 Storage Usage (Global)\",\r\n      \"description\": \"Total R2 storage used by all snapshots and cover images\",\r\n      \"currentUsage\": \"Current usage\",\r\n      \"unlimited\": \"Unlimited\"\r\n    },\r\n    \"export\": {\r\n      \"title\": \"Export Data\",\r\n      \"description\": \"Export bookmark data to file for backup\",\r\n      \"tip1\": \"Contains complete data, suitable for backup and migration\",\r\n      \"tip2\": \"Can choose to include tags, metadata, etc.\",\r\n      \"htmlTip1\": \"Compatible with browser standard format\",\r\n      \"htmlTip2\": \"Can be exported as browser bookmark format\",\r\n      \"htmlTip3\": \"Preserve folder structure and bookmark hierarchy\"\r\n    },\r\n    \"exportSuccess\": \"Export successful: {{details}}\",\r\n    \"lastOperation\": {\r\n      \"export\": \"Data Export\",\r\n      \"cleanup\": \"Snapshot Cleanup\"\r\n    },\r\n    \"snapshotManagement\": {\r\n      \"title\": \"Snapshot Management\",\r\n      \"description\": \"Clean up and maintain bookmark snapshot data\",\r\n      \"cleanOrphan\": \"Clean Orphan Snapshot Records\",\r\n      \"cleaning\": \"Cleaning...\",\r\n      \"cleanButton\": \"Clean Orphan Records\"\r\n    },\r\n    \"exportFeature\": {\r\n      \"title\": \"Export Features\",\r\n      \"description\": \"Multiple formats to meet different needs\",\r\n      \"jsonTitle\": \"JSON Format\",\r\n      \"htmlTitle\": \"HTML Format\"\r\n    },\r\n    \"fetchBookmarksFailed\": \"Failed to fetch bookmark list\",\r\n    \"cleanSnapshotsFailed\": \"Failed to clean snapshots\",\r\n    \"cleanSnapshotsSuccess\": \"Successfully cleaned {{count}} orphan snapshot records\",\r\n    \"cleanSnapshotsNone\": \"No orphan snapshot records found\",\r\n    \"cleanSnapshotsConfirmTitle\": \"Clean Snapshot Records\",\r\n    \"cleanSnapshotsConfirmMessage\": \"Are you sure you want to clean all orphan snapshot records?\\n\\nThis will check all snapshot records and delete those whose R2 files no longer exist.\",\r\n    \"loading\": \"Loading...\",\r\n    \"cleanSnapshots\": {\r\n      \"title\": \"Clean Orphan Snapshots\",\r\n      \"tip1\": \"Check all snapshot records and verify R2 file existence\",\r\n      \"tip2\": \"Delete database records for non-existent R2 files\",\r\n      \"tip3\": \"Automatically update bookmark snapshot counts\",\r\n      \"tip4\": \"Useful for data repair after manually deleting R2 files\"\r\n    }\r\n  },\r\n  \"browser\": {\r\n    \"title\": \"Browser Settings\",\r\n    \"download\": {\r\n      \"title\": \"Browser Extension Download\",\r\n      \"description\": \"Download the Chrome/Chromium universal extension (for Chromium-based browsers)\",\r\n      \"clickToDownload\": \"Click to download\",\r\n      \"tip\": \"💡 Only the Chrome/Chromium universal package is provided (Chrome, Edge, Brave, Opera, 360, QQ, Sogou, etc.)\"\r\n    },\r\n    \"permissions\": {\r\n      \"title\": \"Browser Permissions\",\r\n      \"description\": \"Configure browser permissions required by TMarks for the best experience\",\r\n      \"bookmarks\": {\r\n        \"title\": \"Bookmark Access Permission\",\r\n        \"description\": \"Allow extension to read and save browser bookmarks\"\r\n      },\r\n      \"tabs\": {\r\n        \"title\": \"Tab Access Permission\",\r\n        \"description\": \"Allow extension to access current open tab information\"\r\n      },\r\n      \"storage\": {\r\n        \"title\": \"Storage Permission\",\r\n        \"description\": \"Allow extension to store data locally\"\r\n      }\r\n    },\r\n    \"popup\": {\r\n      \"title\": \"Popup Permission\",\r\n      \"description\": \"Allow TMarks to open popups to use the \\\"Open All Tabs\\\" feature.\",\r\n      \"howTo\": \"How to allow popups?\",\r\n      \"step1\": \"On the tab group detail page, click the \\\"Restore All\\\" button\",\r\n      \"step2\": \"A popup blocked icon will appear in the browser address bar (usually on the right)\",\r\n      \"step3\": \"Click the icon and select \\\"Always allow popups\\\"\",\r\n      \"step4\": \"Refresh the page and click \\\"Restore All\\\" again to use normally\",\r\n      \"chromeTitle\": \"Chrome / Edge\",\r\n      \"chromeDesc\": \"A 🚫 icon will appear on the right side of the address bar. Click it and select \\\"Always allow pop-ups and redirects\\\"\",\r\n      \"firefoxTitle\": \"Firefox\",\r\n      \"firefoxDesc\": \"A popup blocked notification will appear on the left side of the address bar. Click \\\"Options\\\" → \\\"Allow pop-ups\\\"\",\r\n      \"safariTitle\": \"Safari\",\r\n      \"safariDesc\": \"Menu bar: Safari → Settings → Websites → Pop-up Windows → Find current site → Select \\\"Allow\\\"\",\r\n      \"whyTitle\": \"💡 Why is popup permission needed?\",\r\n      \"whyDesc\": \"The \\\"Open All Tabs\\\" feature needs to open multiple web pages simultaneously. Browsers block batch popups by default for security. After allowing TMarks popup permission, you can open all pages in a tab group at once, greatly improving efficiency.\"\r\n    },\r\n    \"install\": {\r\n      \"title\": \"Installation Steps\",\r\n      \"description\": \"Follow these steps to install the browser extension\",\r\n      \"step1Title\": \"Download extension package\",\r\n      \"step1Desc\": \"Click the download button above to get the extension file for your browser\",\r\n      \"step2Title\": \"Extract files\",\r\n      \"step2Desc\": \"Extract the downloaded zip file to any folder (preferably a permanent location)\",\r\n      \"step3Title\": \"Open extension management page\",\r\n      \"step3Desc\": \"Chrome: chrome://extensions/ | Edge: edge://extensions/\",\r\n      \"step4Title\": \"Enable developer mode\",\r\n      \"step4Desc\": \"In the extension management page, turn on \\\"Developer mode\\\" switch in the top right\",\r\n      \"step5Title\": \"Load extension\",\r\n      \"step5Desc\": \"Click \\\"Load unpacked\\\" and select the extracted folder\",\r\n      \"step6Title\": \"Installation complete\",\r\n      \"step6Desc\": \"Extension icon will appear in browser toolbar, click to use\"\r\n    },\r\n    \"faq\": {\r\n      \"title\": \"FAQ\",\r\n      \"iconNotFound\": \"Q: Can't find the extension icon after installation?\",\r\n      \"iconNotFoundAnswer\": \"A: Click the puzzle icon on the right side of browser toolbar, find TMarks extension and click the pin button to show it in toolbar.\",\r\n      \"howToGetApiKey\": \"Q: How to get API Key?\",\r\n      \"howToGetApiKeyAnswer\": \"A: Create a new API Key in the \\\"API\\\" tab of General Settings and copy it to the extension configuration.\",\r\n      \"supportedBrowsers\": \"Q: Which browsers are supported?\",\r\n      \"supportedBrowsersAnswer\": \"A: Only the Chrome/Chromium version is provided. It works on Chromium-based browsers like Chrome, Edge, Brave, Opera, 360, QQ and Sogou.\",\r\n      \"whereToView\": \"Where to view saved tab groups?\",\r\n      \"whereToViewAnswer\": \"View and manage all saved tab groups on the \\\"Tab Groups\\\" page of TMarks website.\"\r\n    },\r\n    \"infoBox\": {\r\n      \"title\": \"Usage Tips\",\r\n      \"tip1\": \"First-time use requires configuring TMarks website URL and API Key in the extension\",\r\n      \"tip2\": \"Recommended to pin the extension icon to toolbar for quick access\",\r\n      \"tip3\": \"Extension automatically saves tab titles, URLs, and favicons\",\r\n      \"tip4\": \"All data automatically syncs to cloud for seamless multi-device access\"\r\n    }\r\n  },\r\n  \"basic\": {\r\n    \"accountInfo\": {\r\n      \"title\": \"Account Information\",\r\n      \"description\": \"View your account details\"\r\n    },\r\n    \"username\": \"Username\",\r\n    \"email\": \"Email\",\r\n    \"registeredAt\": \"Registered at\",\r\n    \"role\": \"Account Role\",\r\n    \"roleAdmin\": \"Administrator\",\r\n    \"roleUser\": \"Regular User\",\r\n    \"notSet\": \"Not set\",\r\n    \"unknown\": \"Unknown\",\r\n    \"security\": {\r\n      \"title\": \"Security Settings\",\r\n      \"description\": \"Change your login password\"\r\n    },\r\n    \"password\": {\r\n      \"change\": \"Change Password\",\r\n      \"current\": \"Current Password\",\r\n      \"currentPlaceholder\": \"Enter current password\",\r\n      \"new\": \"New Password\",\r\n      \"newPlaceholder\": \"Enter new password (at least 6 characters)\",\r\n      \"confirm\": \"Confirm New Password\",\r\n      \"confirmPlaceholder\": \"Enter new password again\",\r\n      \"cancel\": \"Cancel\",\r\n      \"submit\": \"Confirm Change\",\r\n      \"changing\": \"Changing...\",\r\n      \"changeSuccess\": \"Password changed successfully\",\r\n      \"changeFailed\": \"Failed to change password\",\r\n      \"fillAllFields\": \"Please fill in all fields\",\r\n      \"mismatch\": \"New passwords do not match\",\r\n      \"tooShort\": \"New password must be at least 6 characters\",\r\n      \"infoBoxTitle\": \"Account Security Tips\",\r\n      \"tip1\": \"Please change your password regularly to protect account security\",\r\n      \"tip2\": \"Password must be at least 6 characters\",\r\n      \"tip3\": \"Recommended to use a combination of letters, numbers, and symbols\",\r\n      \"tip4\": \"Do not share your password with others\"\r\n    }\r\n  },\r\n  \"automation\": {\r\n    \"search\": {\r\n      \"title\": \"Search Auto-clear\",\r\n      \"description\": \"Set the time for search box to auto-clear after inactivity\",\r\n      \"enable\": \"Enable search auto-clear\",\r\n      \"enableHint\": \"Search box will auto-clear after specified time of inactivity\",\r\n      \"delay\": \"Delay time\",\r\n      \"unit\": \"sec\"\r\n    },\r\n    \"tag\": {\r\n      \"title\": \"Tag Selection Auto-clear\",\r\n      \"description\": \"Set the time for tag selection to auto-clear after inactivity\",\r\n      \"enable\": \"Enable tag selection auto-clear\",\r\n      \"enableHint\": \"Tag selection will auto-clear after specified time of inactivity\",\r\n      \"delay\": \"Delay time\",\r\n      \"unit\": \"sec\"\r\n    },\r\n    \"infoBox\": {\r\n      \"title\": \"Automation Feature Guide\",\r\n      \"tip1\": \"Auto-clear feature helps you quickly return to initial state, improving efficiency\"\r\n    }\r\n  },\r\n  \"appearance\": {\r\n    \"icon\": {\r\n      \"title\": \"Default Bookmark Icon\",\r\n      \"description\": \"Choose the default icon when bookmark has no cover image or favicon\",\r\n      \"favicon\": \"Favicon\",\r\n      \"letter\": \"Letter\",\r\n      \"hash\": \"Hash Icon\",\r\n      \"none\": \"No Icon\"\r\n    },\r\n    \"view\": {\r\n      \"title\": \"Default View\",\r\n      \"description\": \"Choose the default display mode for bookmark list\",\r\n      \"list\": \"List\",\r\n      \"grid\": \"Grid\",\r\n      \"card\": \"Card\"\r\n    },\r\n    \"density\": {\r\n      \"title\": \"List Density\",\r\n      \"description\": \"Adjust the display density of bookmark list\",\r\n      \"compact\": \"Compact\",\r\n      \"normal\": \"Normal\",\r\n      \"comfortable\": \"Comfortable\"\r\n    },\r\n    \"infoBox\": {\r\n      \"title\": \"Appearance Customization Guide\",\r\n      \"tip1\": \"These settings affect how the bookmark list is displayed, adjust according to your preference\"\r\n    }\r\n  },\r\n  \"tips\": {\r\n    \"title\": \"💡 Tips\"\r\n  },\r\n  \"navGroup\": {\r\n    \"account\": \"Account & Security\",\r\n    \"features\": \"Features\",\r\n    \"integration\": \"Integration\",\r\n    \"dataAndShare\": \"Data & Sharing\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/en/share.json",
    "content": "{\r\n  \"title\": \"Public Share\",\r\n  \"loading\": \"Loading public bookmarks...\",\r\n  \"error\": \"Share link is invalid or content has been removed.\",\r\n  \"defaultTitle\": \"{{username}}'s Bookmark Collection\",\r\n  \"guestTitle\": \"Guest's Bookmark Collection\",\r\n  \"settings\": {\r\n    \"title\": \"Public Share Settings\",\r\n    \"enableTitle\": \"Enable Public Share\",\r\n    \"enableDescription\": \"When enabled, your bookmarks can be viewed by anyone via a read-only page.\",\r\n    \"enableLabel\": \"Enable public share\",\r\n    \"slugLabel\": \"Share Link Suffix\",\r\n    \"slugPlaceholder\": \"e.g., my-bookmarks\",\r\n    \"slugHint\": \"Only letters, numbers and hyphens allowed. Leave empty to auto-generate.\",\r\n    \"regenerate\": \"Regenerate\",\r\n    \"pageTitleLabel\": \"Page Title\",\r\n    \"pageTitlePlaceholder\": \"Public page title for visitors\",\r\n    \"descriptionLabel\": \"Page Description\",\r\n    \"descriptionPlaceholder\": \"Optional description for visitors\",\r\n    \"linkLabel\": \"Share Link\",\r\n    \"linkPlaceholder\": \"Share link will appear after generation\",\r\n    \"copy\": \"Copy\",\r\n    \"copied\": \"Copied\",\r\n    \"reset\": \"Reset\",\r\n    \"save\": \"Save Settings\",\r\n    \"saving\": \"Saving...\"\r\n  },\r\n  \"stats\": {\r\n    \"total\": \"{{count}} bookmarks\",\r\n    \"filtered\": \"{{filtered}} / {{total}} bookmarks filtered\"\r\n  },\r\n  \"search\": {\r\n    \"bookmarkPlaceholder\": \"Search bookmarks...\",\r\n    \"tagPlaceholder\": \"Search tags...\",\r\n    \"switchToTag\": \"Switch to tag search\",\r\n    \"switchToBookmark\": \"Switch to bookmark search\"\r\n  },\r\n  \"filter\": {\r\n    \"all\": \"All Bookmarks\",\r\n    \"public\": \"Public Only\",\r\n    \"private\": \"Private Only\",\r\n    \"openTags\": \"Open Tags\",\r\n    \"tagFilter\": \"Tag Filter\",\r\n    \"closeTagDrawer\": \"Close tag drawer\"\r\n  },\r\n  \"sort\": {\r\n    \"created\": \"By Created Time\",\r\n    \"updated\": \"By Updated Time\",\r\n    \"pinned\": \"Pinned First\",\r\n    \"popular\": \"By Popularity\"\r\n  },\r\n  \"view\": {\r\n    \"list\": \"List View\",\r\n    \"card\": \"Card View\",\r\n    \"minimal\": \"Minimal List\",\r\n    \"title\": \"Title Waterfall\"\r\n  },\r\n  \"empty\": {\r\n    \"title\": \"No matching bookmarks found\",\r\n    \"hint\": \"Try adjusting filters or search keywords\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/en/tabGroups.json",
    "content": "{\r\n  \"title\": \"Tab Groups\",\r\n  \"page\": {\r\n    \"loading\": \"Loading...\",\r\n    \"loadFailed\": \"Failed to load tab groups\",\r\n    \"refreshFailed\": \"Refresh failed\",\r\n    \"createFolderFailed\": \"Failed to create folder\",\r\n    \"renameFailed\": \"Rename failed\",\r\n    \"moveFailed\": \"Move failed\"\r\n  },\r\n  \"menu\": {\r\n    \"openInNewWindow\": \"Open in new window\",\r\n    \"openInCurrentWindow\": \"Open in current window\",\r\n    \"openInIncognito\": \"Open in incognito window\",\r\n    \"rename\": \"Rename\",\r\n    \"shareAsPage\": \"Share as webpage\",\r\n    \"copyToClipboard\": \"Copy to clipboard\",\r\n    \"createFolderAbove\": \"Create folder above\",\r\n    \"createFolderInside\": \"Create folder inside\",\r\n    \"createFolderBelow\": \"Create folder below\",\r\n    \"removeDuplicates\": \"Remove duplicates\",\r\n    \"move\": \"Move\",\r\n    \"pinToTop\": \"Pin to top\",\r\n    \"lock\": \"Lock\",\r\n    \"unlock\": \"Unlock\",\r\n    \"moveToTrash\": \"Move to trash\",\r\n    \"openLink\": \"Open link\",\r\n    \"pin\": \"Pin\",\r\n    \"unpin\": \"Unpin\",\r\n    \"markTodo\": \"Mark as todo\",\r\n    \"unmarkTodo\": \"Unmark todo\",\r\n    \"moveToOtherGroup\": \"Move to other group\",\r\n    \"setColor\": \"Set color\",\r\n    \"createFolder\": \"Create folder\"\r\n  },\r\n  \"item\": {\r\n    \"save\": \"Save\",\r\n    \"cancel\": \"Cancel\",\r\n    \"edit\": \"Edit\",\r\n    \"delete\": \"Delete\",\r\n    \"pinned\": \"Pinned\",\r\n    \"todo\": \"Todo\"\r\n  },\r\n  \"message\": {\r\n    \"noTabsToOpen\": \"No tabs to open\",\r\n    \"noTabsInGroup\": \"No tabs in this group\",\r\n    \"copiedToClipboard\": \"Copied to clipboard\",\r\n    \"copyFailed\": \"Copy failed\",\r\n    \"createFolderFailed\": \"Failed to create folder\",\r\n    \"pinFailed\": \"Pin failed\",\r\n    \"noDuplicates\": \"No duplicates found\",\r\n    \"duplicatesRemoved\": \"Removed {{count}} duplicates\",\r\n    \"deleteFailed\": \"Delete failed\",\r\n    \"operationFailed\": \"Operation failed\",\r\n    \"movedToTrash\": \"Moved to trash\",\r\n    \"titleRequired\": \"Title cannot be empty\",\r\n    \"renameSuccess\": \"Renamed successfully\",\r\n    \"renameFailed\": \"Rename failed, please try again\",\r\n    \"editSuccess\": \"Edit successful\",\r\n    \"editFailed\": \"Edit failed, please try again\",\r\n    \"pinSuccess\": \"Pinned\",\r\n    \"unpinSuccess\": \"Unpinned\",\r\n    \"todoSuccess\": \"Marked as todo\",\r\n    \"untodoSuccess\": \"Unmarked todo\",\r\n    \"deleteSuccess\": \"Deleted successfully\",\r\n    \"exportSuccess\": \"Export successful\",\r\n    \"openingTabs\": \"Opening {{count}} tabs...\",\r\n    \"openTabsFailed\": \"Failed to open tabs, please try again\",\r\n    \"cannotOpenWindow\": \"Cannot open new window, please check browser popup settings\",\r\n    \"tabManagerOpened\": \"Tab manager opened in {{mode}}\",\r\n    \"moveFunctionDeveloping\": \"Move function is under development (please use drag and drop)\",\r\n    \"batchDeleteSuccess\": \"Batch delete successful\",\r\n    \"batchDeleteFailed\": \"Batch delete failed, please try again\",\r\n    \"batchPinSuccess\": \"Batch pin successful\",\r\n    \"batchPinFailed\": \"Batch pin failed, please try again\",\r\n    \"batchTodoSuccess\": \"Batch mark todo successful\",\r\n    \"batchTodoFailed\": \"Batch mark todo failed, please try again\"\r\n  },\r\n  \"confirm\": {\r\n    \"openMultipleTabs\": \"Open multiple tabs\",\r\n    \"openTabsMessage\": \"Are you sure you want to open {{count}} tabs in {{mode}}?\",\r\n    \"openTabsWarning\": \"About to open {{count}} tabs.\\n\\n⚠️ If the browser blocks popups, please click \\\"Allow popups\\\" in the address bar.\\n\\nContinue?\",\r\n    \"deleteGroup\": \"Delete tab group\",\r\n    \"deleteGroupMessage\": \"Are you sure you want to delete tab group \\\"{{title}}\\\"? It will be moved to trash.\",\r\n    \"deleteItem\": \"Delete tab\",\r\n    \"deleteItemMessage\": \"Are you sure you want to delete \\\"{{title}}\\\"? This action cannot be undone.\",\r\n    \"removeDuplicates\": \"Remove duplicates\",\r\n    \"removeDuplicatesMessage\": \"Found {{count}} duplicates, remove them?\",\r\n    \"batchDelete\": \"Batch delete\",\r\n    \"batchDeleteMessage\": \"Are you sure you want to delete {{count}} selected tabs?\",\r\n    \"restoreGroup\": \"Restore tab group\",\r\n    \"restoreGroupMessage\": \"Are you sure you want to restore \\\"{{title}}\\\"?\",\r\n    \"permanentDelete\": \"Permanent delete\",\r\n    \"permanentDeleteMessage\": \"Are you sure you want to permanently delete \\\"{{title}}\\\"? This action cannot be undone!\"\r\n  },\r\n  \"share\": {\r\n    \"linkCreated\": \"Share link created\",\r\n    \"linkCreatedMessage\": \"Share link created and copied to clipboard:\\n\\n{{url}}\\n\\nValid for: 30 days\",\r\n    \"linkCreatedManualCopy\": \"Share link created:\\n\\n{{url}}\\n\\nValid for: 30 days\\n\\n(Failed to copy to clipboard, please copy manually)\",\r\n    \"createFailed\": \"Failed to create share link\"\r\n  },\r\n  \"export\": {\r\n    \"title\": \"Batch exported tabs\",\r\n    \"exportTime\": \"Export time\",\r\n    \"tabCount\": \"Tab count\",\r\n    \"createdTime\": \"Created time\",\r\n    \"tags\": \"Tags\"\r\n  },\r\n  \"openMode\": {\r\n    \"newWindow\": \"new window\",\r\n    \"currentWindow\": \"current window\",\r\n    \"incognito\": \"incognito window\"\r\n  },\r\n  \"tabOpener\": {\r\n    \"title\": \"Opening tabs...\",\r\n    \"heading\": \"🚀 Opening Tabs\",\r\n    \"preparing\": \"Preparing...\",\r\n    \"opening\": \"Opening: \",\r\n    \"successPartial\": \"✅ Opened {{opened}}, ❌ Failed {{failed}}\",\r\n    \"successAll\": \"✅ All opened successfully! {{count}} tabs\",\r\n    \"closeWindow\": \"Close this window\"\r\n  },\r\n  \"statistics\": {\r\n    \"title\": \"Usage Statistics\",\r\n    \"tabGroups\": \"Tab Groups\",\r\n    \"tabs\": \"Tabs\",\r\n    \"shares\": \"Shares\",\r\n    \"trash\": \"Trash\",\r\n    \"topDomains\": \"Top 10 Domains\",\r\n    \"noData\": \"No data\",\r\n    \"count\": \"{{count}}\",\r\n    \"groupSizeDistribution\": \"Tab Group Size Distribution\",\r\n    \"groupCreationTrend\": \"Tab Group Creation Trend\",\r\n    \"tabAdditionTrend\": \"Tab Addition Trend\",\r\n    \"last7Days\": \"Last 7 days\",\r\n    \"last30Days\": \"Last 30 days\",\r\n    \"last90Days\": \"Last 90 days\",\r\n    \"backToTabGroups\": \"Back to Tab Groups\"\r\n  },\r\n  \"trashPage\": {\r\n    \"title\": \"Trash\",\r\n    \"description\": \"Deleted tab groups are kept here and can be restored or permanently deleted\",\r\n    \"empty\": \"Trash is empty\",\r\n    \"emptyDescription\": \"No deleted tab groups\",\r\n    \"restore\": \"Restore\",\r\n    \"permanentDelete\": \"Delete permanently\",\r\n    \"deletedAt\": \"Deleted\",\r\n    \"restoreSuccess\": \"Restored successfully\",\r\n    \"restoreFailed\": \"Restore failed, please try again\",\r\n    \"deleteSuccess\": \"Deleted successfully\",\r\n    \"deleteFailed\": \"Delete failed, please try again\",\r\n    \"loadFailed\": \"Failed to load trash\"\r\n  },\r\n  \"todo\": {\r\n    \"title\": \"Todo\",\r\n    \"empty\": \"No todo items\",\r\n    \"emptyTip\": \"Click the \\\"Todo\\\" button on a tab to add\",\r\n    \"todoMarked\": \"Marked as todo\",\r\n    \"todoUnmarked\": \"Unmarked todo\",\r\n    \"tabDeleted\": \"Tab deleted\",\r\n    \"incognitoNotSupported\": \"Opening in incognito mode requires browser extension support\",\r\n    \"renameSuccess\": \"Renamed successfully\",\r\n    \"noGroupsToMove\": \"No groups available to move to\",\r\n    \"moveTab\": \"Move tab\",\r\n    \"moveTabTo\": \"Move \\\"{{title}}\\\" to:\",\r\n    \"moveTabMessage\": \"Are you sure you want to move this tab to \\\"{{title}}\\\"?\",\r\n    \"archiveTab\": \"Archive tab\",\r\n    \"archiveTabMessage\": \"Are you sure you want to archive this tab? It can be viewed in the archive view.\",\r\n    \"tabMoved\": \"Moved to \\\"{{title}}\\\"\",\r\n    \"tabArchived\": \"Tab archived\",\r\n    \"cancelTaskMark\": \"Cancel task mark\",\r\n    \"markAsCompleted\": \"Mark as completed\",\r\n    \"moveToOtherGroup\": \"Move to other group\",\r\n    \"markAsArchived\": \"Mark as archived\"\r\n  },\r\n  \"detail\": {\r\n    \"backToList\": \"Back to list\",\r\n    \"editTitle\": \"Edit title\",\r\n    \"tabCount\": \"{{count}} tabs\",\r\n    \"noTabs\": \"No tabs in this tab group\",\r\n    \"tabsCleared\": \"Tab group has been cleared\",\r\n    \"totalTabs\": \"Total {{count}} tabs\",\r\n    \"restoreAll\": \"Restore all\",\r\n    \"openAllTabs\": \"Open all tabs\",\r\n    \"openAllMessage\": \"Are you sure you want to open {{count}} links in new tabs?\",\r\n    \"openAllWarning\": \"About to open {{count}} tabs, will open in batches to avoid browser blocking.\\n\\n10 per batch, 1 second interval.\\n\\nContinue?\",\r\n    \"openingBatch\": \"Opening batch {{current}}/{{total}}...\",\r\n    \"allOpened\": \"Successfully opened {{count}} tabs!\",\r\n    \"titleUpdateSuccess\": \"Title updated successfully\",\r\n    \"titleUpdateFailed\": \"Failed to update title, please try again\",\r\n    \"updateSuccess\": \"Update successful\",\r\n    \"updateFailed\": \"Update failed, please try again\",\r\n    \"groupNotFound\": \"Tab group not found\"\r\n  },\r\n  \"folder\": {\r\n    \"newFolder\": \"New Folder\",\r\n    \"tabsInFolder\": \"{{count}} tabs\",\r\n    \"dropHere\": \"Drop here\"\r\n  },\r\n  \"search\": {\r\n    \"placeholder\": \"Search tab groups...\",\r\n    \"noResults\": \"No matching tab groups found\",\r\n    \"tryDifferent\": \"Try searching with different keywords \\\"{{query}}\\\"\"\r\n  },\r\n  \"empty\": {\r\n    \"title\": \"No tab groups yet\",\r\n    \"description\": \"Use the browser extension to collect tabs, or create a new tab group here to start managing your tabs\",\r\n    \"tip\": \"💡 Tip: Install the browser extension to quickly collect all tabs from the current window\"\r\n  },\r\n  \"sort\": {\r\n    \"label\": \"Sort\",\r\n    \"created\": \"By created time\",\r\n    \"title\": \"By title\",\r\n    \"count\": \"By tab count\",\r\n    \"byCreated\": \"By created time\",\r\n    \"byTitle\": \"By title\",\r\n    \"byCount\": \"By tab count\"\r\n  },\r\n  \"batch\": {\r\n    \"enter\": \"Batch mode\",\r\n    \"exit\": \"Exit batch mode\",\r\n    \"selected\": \"{{count}} selected\",\r\n    \"selectAll\": \"Select all\",\r\n    \"deselectAll\": \"Deselect\",\r\n    \"delete\": \"Delete\",\r\n    \"pin\": \"Pin\",\r\n    \"todo\": \"Todo\",\r\n    \"export\": \"Export\",\r\n    \"cancel\": \"Cancel\"\r\n  },\r\n  \"trash\": {\r\n    \"title\": \"Trash\",\r\n    \"empty\": \"Trash is empty\",\r\n    \"restore\": \"Restore\",\r\n    \"deletePermanently\": \"Delete permanently\"\r\n  },\r\n  \"share\": {\r\n    \"title\": \"Share Tab Group\",\r\n    \"groupName\": \"Tab group name\",\r\n    \"link\": \"Share link\",\r\n    \"copy\": \"Copy\",\r\n    \"copied\": \"Copied\",\r\n    \"viewCount\": \"View count\",\r\n    \"tip\": \"💡 Anyone can view your tab group via this link, but cannot edit it.\",\r\n    \"delete\": \"Delete share\",\r\n    \"close\": \"Close\",\r\n    \"generating\": \"Generating share link...\",\r\n    \"createFailed\": \"Failed to create share link\",\r\n    \"deleteFailed\": \"Failed to delete\",\r\n    \"copyFailed\": \"Failed to copy to clipboard, please copy manually.\",\r\n    \"confirmDelete\": \"Are you sure you want to delete the share link? The link will become invalid.\"\r\n  },\r\n  \"action\": {\r\n    \"create\": \"New tab group\",\r\n    \"edit\": \"Edit\",\r\n    \"delete\": \"Delete\",\r\n    \"deleting\": \"Deleting...\",\r\n    \"share\": \"Share\",\r\n    \"open\": \"Open\",\r\n    \"openAll\": \"Open all\",\r\n    \"export\": \"Export Markdown\",\r\n    \"rename\": \"Rename\",\r\n    \"save\": \"Save\",\r\n    \"cancel\": \"Cancel\"\r\n  },\r\n  \"header\": {\r\n    \"tabCount\": \"{{count}} tabs\"\r\n  },\r\n  \"moveToFolder\": {\r\n    \"title\": \"Move to Folder\",\r\n    \"description\": \"Select target folder to move \\\"{{title}}\\\" to\",\r\n    \"rootFolder\": \"Root\",\r\n    \"noFolders\": \"No available folders\",\r\n    \"confirm\": \"Move\"\r\n  },\r\n  \"sidebar\": {\r\n    \"title\": \"Tab Groups\",\r\n    \"all\": \"All\",\r\n    \"noGroups\": \"No groups\",\r\n    \"moreItems\": \"{{count}} more...\"\r\n  },\r\n  \"color\": {\r\n    \"none\": \"None\",\r\n    \"red\": \"Red\",\r\n    \"orange\": \"Orange\",\r\n    \"yellow\": \"Yellow\",\r\n    \"green\": \"Green\",\r\n    \"blue\": \"Blue\",\r\n    \"purple\": \"Purple\",\r\n    \"pink\": \"Pink\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/en/tags.json",
    "content": "{\r\n  \"title\": \"Tags\",\r\n  \"filter\": {\r\n    \"title\": \"Tag Filter\",\r\n    \"close\": \"Close tag drawer\"\r\n  },\r\n  \"empty\": {\r\n    \"title\": \"No tags yet\",\r\n    \"description\": \"Click + to create your first tag\",\r\n    \"noMatch\": \"No matching tags found\",\r\n    \"readOnly\": \"Publisher has not shared any tags\"\r\n  },\r\n  \"sort\": {\r\n    \"byUsage\": \"Sort by bookmark count (click to switch to popularity)\",\r\n    \"byClicks\": \"Sort by popularity (click to switch to name)\",\r\n    \"byName\": \"Sort by name (click to switch to bookmark count)\"\r\n  },\r\n  \"layout\": {\r\n    \"grid\": \"Grid layout (click to switch to masonry)\",\r\n    \"masonry\": \"Masonry layout (click to switch to grid)\"\r\n  },\r\n  \"selection\": {\r\n    \"clear\": \"Clear selection\",\r\n    \"clearWithCount\": \"Clear selection ({{count}})\"\r\n  },\r\n  \"action\": {\r\n    \"manage\": \"Manage tags\",\r\n    \"create\": \"New tag\",\r\n    \"cancel\": \"Cancel\",\r\n    \"edit\": \"Edit tag\",\r\n    \"delete\": \"Delete tag\",\r\n    \"deleting\": \"Deleting...\",\r\n    \"save\": \"Save\",\r\n    \"saving\": \"Saving...\",\r\n    \"done\": \"Done\"\r\n  },\r\n  \"form\": {\r\n    \"placeholder\": \"Enter tag name...\",\r\n    \"namePlaceholder\": \"Enter tag name\",\r\n    \"nameLabel\": \"Tag name\",\r\n    \"editHint\": \"Adjust tag name, only affects this tag.\"\r\n  },\r\n  \"manage\": {\r\n    \"title\": \"Tag Management\",\r\n    \"description\": \"Edit tag names or delete unwanted tags\",\r\n    \"noTags\": \"No tags yet\",\r\n    \"noTagsHint\": \"Click + button to create your first tag\",\r\n    \"bookmarkCount\": \"{{count}} bookmarks\",\r\n    \"noBookmarks\": \"No bookmarks\"\r\n  },\r\n  \"confirm\": {\r\n    \"deleteTitle\": \"Delete Tag\",\r\n    \"deleteMessage\": \"Are you sure you want to delete tag \\\"{{name}}\\\"? Associated bookmarks will not be deleted.\"\r\n  },\r\n  \"message\": {\r\n    \"updateSuccess\": \"Tag updated successfully\",\r\n    \"updateFailed\": \"Update failed, please try again\",\r\n    \"deleteSuccess\": \"Tag deleted successfully\",\r\n    \"deleteFailed\": \"Delete failed, please try again\"\r\n  },\r\n  \"status\": {\r\n    \"loading\": \"Loading...\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/zh-CN/auth.json",
    "content": "{\r\n  \"login\": {\r\n    \"title\": \"欢迎回来\",\r\n    \"subtitle\": \"登录到 TMarks 书签管理系统\",\r\n    \"username\": \"用户名或邮箱\",\r\n    \"usernamePlaceholder\": \"请输入用户名或邮箱\",\r\n    \"password\": \"密码\",\r\n    \"passwordPlaceholder\": \"请输入密码\",\r\n    \"rememberMe\": \"记住我 30 天\",\r\n    \"submit\": \"立即登录\",\r\n    \"submitting\": \"登录中...\",\r\n    \"noAccount\": \"还没有账号？\",\r\n    \"register\": \"立即注册 →\"\r\n  },\r\n  \"register\": {\r\n    \"title\": \"加入 TMarks\",\r\n    \"subtitle\": \"开始你的智能书签管理之旅\",\r\n    \"username\": \"用户名\",\r\n    \"usernamePlaceholder\": \"3-20个字符，字母数字下划线\",\r\n    \"email\": \"邮箱\",\r\n    \"emailOptional\": \"(可选，用于密码找回)\",\r\n    \"emailPlaceholder\": \"your@email.com\",\r\n    \"password\": \"密码\",\r\n    \"passwordPlaceholder\": \"至少8个字符\",\r\n    \"confirmPassword\": \"确认密码\",\r\n    \"confirmPasswordPlaceholder\": \"再次输入\",\r\n    \"submit\": \"立即注册\",\r\n    \"submitting\": \"注册中...\",\r\n    \"hasAccount\": \"已有账号？\",\r\n    \"login\": \"立即登录 →\",\r\n    \"successTitle\": \"注册成功！\",\r\n    \"successMessage\": \"即将跳转到登录页面...\"\r\n  },\r\n  \"validation\": {\r\n    \"usernameRequired\": \"请输入用户名和密码\",\r\n    \"passwordRequired\": \"请输入密码\",\r\n    \"emailRequired\": \"请输入邮箱\",\r\n    \"passwordMismatch\": \"两次输入的密码不一致\",\r\n    \"usernameLength\": \"用户名长度应为 3-20 个字符\",\r\n    \"usernameFormat\": \"用户名只能包含字母、数字和下划线\",\r\n    \"passwordLength\": \"密码至少需要 8 个字符\",\r\n    \"emailFormat\": \"邮箱格式不正确\"\r\n  },\r\n  \"error\": {\r\n    \"loginFailed\": \"登录失败，请稍后重试\",\r\n    \"registerFailed\": \"注册失败，请稍后重试\",\r\n    \"userExists\": \"用户名或邮箱已被注册\",\r\n    \"serverErrorMaySuccess\": \"服务器错误，但您的账号可能已创建成功，请尝试登录\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/zh-CN/bookmarks.json",
    "content": "{\r\n  \"title\": \"书签\",\r\n  \"search\": {\r\n    \"placeholder\": \"搜索书签...\",\r\n    \"tagPlaceholder\": \"搜索标签...\",\r\n    \"noResults\": \"没有找到匹配的书签\"\r\n  },\r\n  \"filter\": {\r\n    \"all\": \"全部书签\",\r\n    \"public\": \"仅公开\",\r\n    \"private\": \"仅私密\",\r\n    \"clickToSwitch\": \"(点击切换)\"\r\n  },\r\n  \"sort\": {\r\n    \"created\": \"按创建时间\",\r\n    \"updated\": \"按更新时间\",\r\n    \"pinned\": \"置顶优先\",\r\n    \"popular\": \"按热门程度\",\r\n    \"clickToSwitch\": \"(点击切换)\"\r\n  },\r\n  \"viewMode\": {\r\n    \"list\": \"列表视图\",\r\n    \"card\": \"卡片视图\",\r\n    \"minimal\": \"极简列表\",\r\n    \"title\": \"标题瀑布\",\r\n    \"clickToSwitch\": \"(点击切换)\"\r\n  },\r\n  \"toolbar\": {\r\n    \"openTags\": \"打开标签\",\r\n    \"switchToTagSearch\": \"切换到标签搜索\",\r\n    \"switchToBookmarkSearch\": \"切换到书签搜索\",\r\n    \"batchMode\": \"批量操作\",\r\n    \"exitBatchMode\": \"退出批量操作\",\r\n    \"trash\": \"回收站\",\r\n    \"addBookmark\": \"新增书签\"\r\n  },\r\n  \"empty\": {\r\n    \"title\": \"暂无书签\",\r\n    \"description\": \"点击右上角 \\\"新增书签\\\" 按钮开始收藏精彩内容\",\r\n    \"readOnlyDescription\": \"发布者尚未公开任何书签，或公开内容已取消\"\r\n  },\r\n  \"loading\": \"加载中...\",\r\n  \"status\": {\r\n    \"pinned\": \"置顶\",\r\n    \"archived\": \"归档\"\r\n  },\r\n  \"view\": {\r\n    \"header\": {\r\n      \"title\": \"标题\",\r\n      \"url\": \"网址\",\r\n      \"note\": \"备注\",\r\n      \"aiSummary\": \"AI 摘要\"\r\n    },\r\n    \"noDescription\": \"—\",\r\n    \"aiSummaryTitle\": \"AI 核心摘要\"\r\n  },\r\n  \"snapshot\": {\r\n    \"title\": \"快照\",\r\n    \"count\": \"共 {{count}} 个快照\",\r\n    \"viewCount\": \"查看 {{count}} 个快照\",\r\n    \"loading\": \"加载快照中...\",\r\n    \"empty\": \"暂无快照\",\r\n    \"emptyHint\": \"使用浏览器插件可以保存网页快照\",\r\n    \"version\": \"版本 {{version}}\",\r\n    \"close\": \"关闭\",\r\n    \"delete\": \"删除快照\",\r\n    \"deleteTitle\": \"删除快照\",\r\n    \"deleteMessage\": \"确定要删除版本 {{version}} 的快照吗？\\n\\n删除后将无法恢复。\",\r\n    \"deleteConfirm\": \"确定要删除该快照吗？\",\r\n    \"deleteSuccess\": \"快照已删除\",\r\n    \"deleteFailed\": \"删除快照失败\"\r\n  },\r\n  \"batch\": {\r\n    \"selected\": \"已选 {{count}} 个书签\",\r\n    \"selectedCount\": \"已选择 {{count}} 个\",\r\n    \"pleaseSelect\": \"请选择书签\",\r\n    \"selectAll\": \"全选 ({{count}})\",\r\n    \"delete\": \"删除\",\r\n    \"deleteTitle\": \"批量删除\",\r\n    \"deleteMessage\": \"确定要删除这 {{count}} 个书签吗？\",\r\n    \"deleteSuccess\": \"成功删除 {{count}} 个书签\",\r\n    \"pin\": \"置顶\",\r\n    \"pinTitle\": \"批量置顶\",\r\n    \"pinMessage\": \"确定要置顶这 {{count}} 个书签吗？\",\r\n    \"pinSuccess\": \"成功置顶 {{count}} 个书签\",\r\n    \"archive\": \"归档\",\r\n    \"archiveTitle\": \"批量归档\",\r\n    \"archiveMessage\": \"确定要归档这 {{count}} 个书签吗？\",\r\n    \"archiveSuccess\": \"成功归档 {{count}} 个书签\",\r\n    \"tags\": \"标签\",\r\n    \"selectTags\": \"选择标签\",\r\n    \"addTags\": \"添加\",\r\n    \"removeTags\": \"移除\",\r\n    \"addTagsSuccess\": \"成功为 {{count}} 个书签添加标签\",\r\n    \"removeTagsSuccess\": \"成功为 {{count}} 个书签移除标签\",\r\n    \"cancel\": \"取消\",\r\n    \"confirmAction\": \"确认操作\",\r\n    \"confirmMessage\": \"确定要执行此操作吗?\",\r\n    \"select\": \"选择\",\r\n    \"deselect\": \"取消选择\"\r\n  },\r\n  \"action\": {\r\n    \"success\": \"操作成功\",\r\n    \"failed\": \"操作失败，请重试\",\r\n    \"edit\": \"编辑\",\r\n    \"open\": \"打开书签 {{title}}\"\r\n  },\r\n  \"form\": {\r\n    \"addTitle\": \"新增书签\",\r\n    \"editTitle\": \"编辑书签\",\r\n    \"title\": \"标题\",\r\n    \"titleRequired\": \"标题\",\r\n    \"titlePlaceholder\": \"书签标题\",\r\n    \"url\": \"URL\",\r\n    \"urlRequired\": \"URL\",\r\n    \"urlPlaceholder\": \"https://example.com\",\r\n    \"description\": \"描述\",\r\n    \"descriptionPlaceholder\": \"书签描述（可选）\",\r\n    \"coverImage\": \"封面图 URL\",\r\n    \"coverImagePlaceholder\": \"https://example.com/image.jpg\",\r\n    \"tags\": \"标签\",\r\n    \"tagsBatchHint\": \"（支持逗号批量添加）\",\r\n    \"tagsSelected\": \"已选 {{count}} 个\",\r\n    \"tagsInputPlaceholder\": \"输入标签名称（多个用逗号分隔），按 Enter 添加...\",\r\n    \"noTags\": \"暂无标签，在上方输入框输入后按 Enter 创建\",\r\n    \"pinned\": \"置顶\",\r\n    \"archived\": \"归档\",\r\n    \"public\": \"公开分享\",\r\n    \"save\": \"保存\",\r\n    \"create\": \"创建\",\r\n    \"saving\": \"保存中...\",\r\n    \"delete\": \"删除\",\r\n    \"deleting\": \"删除中...\",\r\n    \"cancel\": \"取消\",\r\n    \"deleteTitle\": \"删除书签\",\r\n    \"deleteMessage\": \"确定要删除这个书签吗？此操作无法撤销。\",\r\n    \"deleteFailed\": \"删除失败，请重试\",\r\n    \"validation\": {\r\n      \"titleRequired\": \"请输入书签标题\",\r\n      \"urlRequired\": \"请输入书签URL\",\r\n      \"urlInvalid\": \"URL 格式不正确\",\r\n      \"urlExists\": \"该 URL 已存在于书签中\"\r\n    },\r\n    \"urlWarning\": {\r\n      \"title\": \"该 URL 已存在\",\r\n      \"bookmark\": \"书签：{{title}}\"\r\n    },\r\n    \"createTagFailed\": \"创建标签\\\"{{name}}\\\"失败，请重试\",\r\n    \"operationFailed\": \"操作失败，请重试\"\r\n  },\r\n  \"statistics\": {\r\n    \"title\": \"书签统计\",\r\n    \"backToBookmarks\": \"返回书签\",\r\n    \"loading\": \"加载中...\",\r\n    \"loadFailed\": \"加载失败\",\r\n    \"retry\": \"重试\",\r\n    \"granularity\": {\r\n      \"day\": \"按日\",\r\n      \"week\": \"按周\",\r\n      \"month\": \"按月\",\r\n      \"year\": \"按年\"\r\n    },\r\n    \"navigation\": {\r\n      \"prevDay\": \"上一天\",\r\n      \"nextDay\": \"下一天\",\r\n      \"prevWeek\": \"上一周\",\r\n      \"nextWeek\": \"下一周\",\r\n      \"prevMonth\": \"上一月\",\r\n      \"nextMonth\": \"下一月\",\r\n      \"prevYear\": \"上一年\",\r\n      \"nextYear\": \"下一年\",\r\n      \"today\": \"今天\"\r\n    },\r\n    \"summary\": {\r\n      \"totalBookmarks\": \"书签总数\",\r\n      \"totalTags\": \"标签数量\",\r\n      \"totalClicks\": \"总点击数\",\r\n      \"publicBookmarks\": \"公开书签\",\r\n      \"archived\": \"已归档\"\r\n    },\r\n    \"charts\": {\r\n      \"topBookmarks\": \"热门书签 Top 10\",\r\n      \"topTags\": \"热门标签 Top 10\",\r\n      \"topDomains\": \"热门域名 Top 10\",\r\n      \"currentRangeClicks\": \"当前时间范围内书签点击统计\",\r\n      \"recentVisits\": \"最近访问\",\r\n      \"creationTrend\": \"书签创建趋势\",\r\n      \"visitTrend\": \"书签访问趋势\"\r\n    },\r\n    \"noData\": \"暂无数据\",\r\n    \"noClicksInRange\": \"当前时间范围内暂无点击数据\",\r\n    \"times\": \"{{count}} 次\",\r\n    \"items\": \"{{count}} 个\",\r\n    \"dateFormat\": {\r\n      \"year\": \"{{year}} 年\",\r\n      \"month\": \"{{year}} 年 {{month}} 月\",\r\n      \"week\": \"{{year}} 年第 {{week}} 周\"\r\n    }\r\n  },\r\n  \"trash\": {\r\n    \"title\": \"书签回收站\",\r\n    \"backToBookmarks\": \"返回书签\",\r\n    \"loading\": \"加载中...\",\r\n    \"loadFailed\": \"加载回收站失败\",\r\n    \"retry\": \"重试\",\r\n    \"empty\": \"清空\",\r\n    \"emptyTrash\": \"清空回收站\",\r\n    \"emptyState\": {\r\n      \"title\": \"回收站是空的\",\r\n      \"description\": \"没有已删除的书签\"\r\n    },\r\n    \"warning\": \"回收站中的书签将在 30 天后自动永久删除。\",\r\n    \"deletedAt\": \"删除于 {{time}}\",\r\n    \"restore\": \"恢复\",\r\n    \"delete\": \"删除\",\r\n    \"restoreTitle\": \"恢复书签\",\r\n    \"restoreMessage\": \"确定要恢复\\\"{{title}}\\\"吗？\",\r\n    \"restoreSuccess\": \"书签已恢复\",\r\n    \"restoreFailed\": \"恢复失败，请重试\",\r\n    \"permanentDeleteTitle\": \"永久删除\",\r\n    \"permanentDeleteMessage\": \"确定要永久删除\\\"{{title}}\\\"吗？此操作不可撤销！\",\r\n    \"permanentDeleteSuccess\": \"书签已永久删除\",\r\n    \"permanentDeleteFailed\": \"删除失败，请重试\",\r\n    \"emptyTrashTitle\": \"清空回收站\",\r\n    \"emptyTrashMessage\": \"确定要永久删除回收站中的 {{count}} 个书签吗？此操作不可撤销！\",\r\n    \"emptyTrashSuccess\": \"已清空回收站，删除了 {{count}} 个书签\",\r\n    \"emptyTrashFailed\": \"清空回收站失败，请重试\",\r\n    \"confirmDelete\": \"确认删除\",\r\n    \"confirm\": \"确认\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/zh-CN/common.json",
    "content": "{\r\n  \"button\": {\r\n    \"confirm\": \"确定\",\r\n    \"cancel\": \"取消\",\r\n    \"save\": \"保存\",\r\n    \"delete\": \"删除\",\r\n    \"edit\": \"编辑\",\r\n    \"add\": \"添加\",\r\n    \"close\": \"关闭\",\r\n    \"back\": \"返回\",\r\n    \"next\": \"下一步\",\r\n    \"retry\": \"重试\",\r\n    \"copy\": \"复制\",\r\n    \"copied\": \"已复制\",\r\n    \"loadMore\": \"加载更多\"\r\n  },\r\n  \"dialog\": {\r\n    \"confirmTitle\": \"确认\",\r\n    \"warningTitle\": \"警告\",\r\n    \"errorTitle\": \"错误\",\r\n    \"successTitle\": \"成功\",\r\n    \"infoTitle\": \"提示\",\r\n    \"operationFailed\": \"操作失败\",\r\n    \"operationSuccess\": \"操作成功\"\r\n  },\r\n  \"status\": {\r\n    \"loading\": \"加载中...\",\r\n    \"processing\": \"正在处理...\",\r\n    \"saving\": \"保存中...\",\r\n    \"deleting\": \"删除中...\",\r\n    \"loadFailed\": \"加载失败\",\r\n    \"copied\": \"已复制\"\r\n  },\r\n  \"message\": {\r\n    \"operationSuccess\": \"操作成功\",\r\n    \"operationFailed\": \"操作失败\",\r\n    \"saveSuccess\": \"保存成功\",\r\n    \"saveFailed\": \"保存失败\",\r\n    \"deleteSuccess\": \"删除成功\",\r\n    \"deleteFailed\": \"删除失败\",\r\n    \"copySuccess\": \"复制成功\",\r\n    \"copyFailed\": \"复制失败，请手动复制\"\r\n  },\r\n  \"pagination\": {\r\n    \"prev\": \"上一页\",\r\n    \"next\": \"下一页\",\r\n    \"page\": \"第 {{current}} / {{total}} 页\",\r\n    \"total\": \"共 {{count}} 条\"\r\n  },\r\n  \"empty\": {\r\n    \"noData\": \"暂无数据\",\r\n    \"noResults\": \"没有找到匹配的结果\"\r\n  },\r\n  \"time\": {\r\n    \"justNow\": \"刚刚\",\r\n    \"minutesAgo\": \"{{count}} 分钟前\",\r\n    \"hoursAgo\": \"{{count}} 小时前\",\r\n    \"daysAgo\": \"{{count}} 天前\",\r\n    \"seconds\": \"{{count}}秒\",\r\n    \"minutes\": \"{{count}}分钟\",\r\n    \"hours\": \"{{count}}小时\",\r\n    \"minutesSeconds\": \"{{minutes}}分{{seconds}}秒\",\r\n    \"hoursMinutes\": \"{{hours}}小时{{minutes}}分钟\"\r\n  },\r\n  \"progress\": {\r\n    \"itemsPerSecond\": \"{{count}} 项/秒\",\r\n    \"remaining\": \"剩余 {{time}}\",\r\n    \"processed\": \"已处理 {{count}} 项\"\r\n  },\r\n  \"error\": {\r\n    \"error\": \"错误\",\r\n    \"code\": \"代码\",\r\n    \"field\": \"字段\",\r\n    \"details\": \"详情\",\r\n    \"time\": \"时间\",\r\n    \"occurred\": \"发生错误\",\r\n    \"occurredCount\": \"发生 {{count}} 个错误\",\r\n    \"warning\": \"警告\",\r\n    \"warningCount\": \"{{count}} 个警告\",\r\n    \"info\": \"信息\",\r\n    \"infoCount\": \"{{count}} 条信息\",\r\n    \"success\": \"成功\",\r\n    \"successCount\": \"{{count}} 个成功操作\",\r\n    \"notification\": \"通知\",\r\n    \"showMore\": \"显示更多 ({{count}} 个)\",\r\n    \"more\": \"更多 ({{count}})\"\r\n  },\r\n  \"sort\": {\r\n    \"byCreated\": \"按创建时间\",\r\n    \"byCreatedDesc\": \"最新创建的在前\",\r\n    \"byUpdated\": \"按更新时间\",\r\n    \"byUpdatedDesc\": \"最近更新的在前\",\r\n    \"pinnedFirst\": \"置顶优先\",\r\n    \"pinnedFirstDesc\": \"置顶书签优先显示\",\r\n    \"byPopular\": \"按热门程度\",\r\n    \"byPopularDesc\": \"点击次数多的在前\",\r\n    \"options\": \"排序选项\",\r\n    \"selectSort\": \"选择排序方式\"\r\n  },\r\n  \"nav\": {\r\n    \"home\": \"回到首页\",\r\n    \"bookmarks\": \"书签\",\r\n    \"tabGroups\": \"标签页\",\r\n    \"data\": \"数据\",\r\n    \"extension\": \"插件\",\r\n    \"settings\": \"设置\",\r\n    \"smartBookmarkManagement\": \"智能书签管理\",\r\n    \"manageTabGroups\": \"管理收纳的标签页组\",\r\n    \"switchToBookmarks\": \"切换到书签\",\r\n    \"switchToTabGroups\": \"切换到标签页组\",\r\n    \"toggleDarkMode\": \"切换到暗色模式\",\r\n    \"toggleLightMode\": \"切换到亮色模式\",\r\n    \"switchToOrange\": \"切换到活力橙\",\r\n    \"switchToDefault\": \"切换到中性灰\",\r\n    \"userSettings\": \"{{username}} - 点击进入设置\",\r\n    \"all\": \"全部\",\r\n    \"todo\": \"待办\",\r\n    \"trash\": \"回收站\",\r\n    \"toggleColorTheme\": \"切换颜色主题\"\r\n  },\r\n  \"upload\": {\r\n    \"dropToUpload\": \"松开以上传文件\",\r\n    \"formatNotSupported\": \"文件格式不支持\",\r\n    \"dragOrClick\": \"拖拽文件到此处或点击选择\",\r\n    \"supportedFormats\": \"支持格式: {{formats}}\",\r\n    \"selectFile\": \"选择文件\",\r\n    \"fileSizeExceeded\": \"文件大小超过限制 ({{size}})\",\r\n    \"fileTypeNotSupported\": \"不支持的文件类型，请选择: {{types}}\",\r\n    \"close\": \"关闭\"\r\n  },\r\n  \"action\": {\r\n    \"save\": \"保存\",\r\n    \"cancel\": \"取消\",\r\n    \"delete\": \"删除\",\r\n    \"edit\": \"编辑\",\r\n    \"add\": \"添加\",\r\n    \"remove\": \"移除\",\r\n    \"confirm\": \"确认\",\r\n    \"close\": \"关闭\",\r\n    \"open\": \"打开\",\r\n    \"back\": \"返回\",\r\n    \"next\": \"下一步\",\r\n    \"previous\": \"上一步\",\r\n    \"submit\": \"提交\",\r\n    \"reset\": \"重置\",\r\n    \"clear\": \"清空\",\r\n    \"search\": \"搜索\",\r\n    \"filter\": \"筛选\",\r\n    \"sort\": \"排序\",\r\n    \"refresh\": \"刷新\",\r\n    \"loading\": \"加载中...\",\r\n    \"saving\": \"保存中...\",\r\n    \"deleting\": \"删除中...\",\r\n    \"uploading\": \"上传中...\",\r\n    \"downloading\": \"下载中...\",\r\n    \"processing\": \"处理中...\",\r\n    \"retry\": \"重试\",\r\n    \"skip\": \"跳过\",\r\n    \"continue\": \"继续\",\r\n    \"finish\": \"完成\",\r\n    \"done\": \"完成\",\r\n    \"yes\": \"是\",\r\n    \"no\": \"否\",\r\n    \"ok\": \"确定\",\r\n    \"view\": \"查看\",\r\n    \"hide\": \"隐藏\",\r\n    \"show\": \"显示\",\r\n    \"expand\": \"展开\",\r\n    \"collapse\": \"收起\",\r\n    \"select\": \"选择\",\r\n    \"selectAll\": \"全选\",\r\n    \"deselectAll\": \"取消全选\",\r\n    \"copy\": \"复制\",\r\n    \"paste\": \"粘贴\",\r\n    \"cut\": \"剪切\",\r\n    \"undo\": \"撤销\",\r\n    \"redo\": \"重做\",\r\n    \"export\": \"导出\",\r\n    \"download\": \"下载\",\r\n    \"upload\": \"上传\",\r\n    \"share\": \"分享\",\r\n    \"print\": \"打印\",\r\n    \"help\": \"帮助\",\r\n    \"about\": \"关于\",\r\n    \"settings\": \"设置\",\r\n    \"preferences\": \"偏好设置\",\r\n    \"profile\": \"个人资料\",\r\n    \"logout\": \"退出登录\",\r\n    \"login\": \"登录\",\r\n    \"register\": \"注册\",\r\n    \"viewDetails\": \"查看详情\",\r\n    \"resetWidth\": \"恢复默认宽度\",\r\n    \"copyError\": \"复制错误信息\",\r\n    \"openMenu\": \"打开菜单\",\r\n    \"more\": \"更多\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/zh-CN/errors.json",
    "content": "{\r\n  \"network\": {\r\n    \"connectionFailed\": \"网络连接失败，请检查网络\",\r\n    \"timeout\": \"请求超时，请稍后重试\",\r\n    \"serverError\": \"服务器错误，请稍后重试\"\r\n  },\r\n  \"auth\": {\r\n    \"invalidCredentials\": \"用户名或密码错误\",\r\n    \"sessionExpired\": \"登录已过期，请重新登录\",\r\n    \"unauthorized\": \"未授权访问\"\r\n  },\r\n  \"validation\": {\r\n    \"required\": \"此字段为必填项\",\r\n    \"invalidFormat\": \"格式不正确\",\r\n    \"tooLong\": \"内容过长\",\r\n    \"tooShort\": \"内容过短\"\r\n  },\r\n  \"share\": {\r\n    \"loadFailed\": \"无法加载公开分享内容\",\r\n    \"notFound\": \"公开内容不存在\"\r\n  },\r\n  \"ai\": {\r\n    \"modelListFailed\": \"获取模型列表失败\",\r\n    \"modelListInvalid\": \"模型列表响应格式无效\",\r\n    \"modelListEmpty\": \"模型列表为空\",\r\n    \"providerNotSupported\": \"当前 AI 服务暂不支持自动获取模型列表\",\r\n    \"missingApiKey\": \"缺少 API Key\",\r\n    \"missingApiUrl\": \"缺少 API 地址\"\r\n  },\r\n  \"unknown\": \"未知错误\"\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/zh-CN/import.json",
    "content": "{\n  \"title\": \"数据导出\",\n  \"page\": {\n    \"title\": \"数据管理\",\n    \"description\": \"导出您的书签数据\",\n    \"exportTab\": \"导出数据\",\n    \"exportDesc\": \"将书签数据导出为文件\",\n    \"recentOperation\": \"最近操作\",\n    \"exportOperation\": \"导出\",\n    \"dataExport\": \"数据导出\",\n    \"exportDetails\": \"导出为 {{format}} 格式{{tags}}{{metadata}}\",\n    \"withTags\": \"，包含标签\",\n    \"withMetadata\": \"，包含元数据\"\n  },\n  \"help\": {\n    \"title\": \"使用说明\",\n    \"exportTitle\": \"导出功能\",\n    \"exportTip1\": \"仅支持 JSON 格式导出\",\n    \"exportTip2\": \"JSON 格式包含完整数据，适合备份和迁移\",\n    \"exportTip3\": \"JSON 可完整保留书签、标签与标签页组结构，适合严格还原\",\n    \"exportTip4\": \"可选择包含标签、元数据等信息\",\n    \"notesTitle\": \"注意事项\",\n    \"notesTip1\": \"建议定期导出数据作为备份\",\n    \"notesTip2\": \"大量书签导出可能需要较长时间，请耐心等待\",\n    \"notesTip3\": \"导出过程中请勿关闭页面\",\n    \"notesTip4\": \"如遇到问题，可查看错误详情进行排查\"\n  },\n  \"export\": {\n    \"title\": \"导出数据\",\n    \"description\": \"将您的数据导出为 JSON 文件，用于备份与迁移\",\n    \"selectFormat\": \"导出格式\",\n    \"options\": \"导出选项\",\n    \"scope\": \"导出范围\",\n    \"scopeAll\": \"全部数据\",\n    \"scopeBookmarks\": \"仅书签\",\n    \"scopeTabGroups\": \"仅收纳箱/标签页组\",\n    \"includeDeleted\": \"包含回收站（已删除内容）\",\n    \"includeTags\": \"包含标签信息\",\n    \"includeMetadata\": \"包含元数据\",\n    \"prettyPrint\": \"格式化 JSON（便于阅读）\",\n    \"includeStats\": \"包含点击统计\",\n    \"preview\": \"预览信息\",\n    \"previewTitle\": \"导出预览\",\n    \"bookmarkCount\": \"书签数量\",\n    \"tagCount\": \"标签数量\",\n    \"pinnedCount\": \"置顶书签\",\n    \"tabGroupCount\": \"收纳箱（标签页组）\",\n    \"estimatedSize\": \"预计大小\",\n    \"startExport\": \"开始导出\",\n    \"exporting\": \"导出中...\",\n    \"preparing\": \"准备导出...\",\n    \"generating\": \"正在生成导出数据...\",\n    \"downloading\": \"正在下载文件...\",\n    \"complete\": \"导出完成\",\n    \"failed\": \"导出失败\",\n    \"failedRetry\": \"导出失败，请重试\"\n  },\n  \"format\": {\n    \"json\": \"JSON\",\n    \"jsonDesc\": \"TMarks 标准格式，包含完整数据\",\n    \"html\": \"HTML\",\n    \"htmlDesc\": \"浏览器书签格式，兼容性好\",\n    \"recommended\": \"推荐\"\n  }\n}\n\n"
  },
  {
    "path": "tmarks/src/i18n/locales/zh-CN/info.json",
    "content": "{\r\n  \"extension\": {\r\n    \"title\": \"TMarks 浏览器插件\",\r\n    \"subtitle\": \"一键保存标签页组，让书签管理更高效\",\r\n    \"download\": {\n      \"title\": \"下载 Chrome / Chromium 版本\",\n      \"button\": \"下载\"\n    },\n    \"browsers\": {\r\n      \"title\": \"支持的浏览器\",\r\n      \"speedMode\": \"极速模式\"\r\n    },\r\n    \"version\": \"版本：{{version}} | 大小：约 {{size}} | 更新时间：{{date}}\",\r\n    \"tip\": \"💡 目前仅提供 Chrome/Chromium 通用版本（适用于 Chrome、Edge、Brave、Opera 等 Chromium 内核浏览器）\",\n    \"features\": {\r\n      \"title\": \"✨ 主要功能\",\r\n      \"saveTabGroups\": {\r\n        \"title\": \"一键保存标签页组\",\r\n        \"description\": \"将当前浏览器打开的所有标签页一键保存到 TMarks，包括标题、URL 和网站图标\"\r\n      },\r\n      \"restoreTabs\": {\r\n        \"title\": \"快速恢复标签页\",\r\n        \"description\": \"从 TMarks 网站一键恢复之前保存的标签页组，继续之前的工作\"\r\n      },\r\n      \"autoSync\": {\r\n        \"title\": \"自动同步\",\r\n        \"description\": \"标签页组自动同步到云端，多设备无缝切换\"\r\n      }\r\n    },\r\n    \"install\": {\r\n      \"title\": \"📦 安装步骤\",\r\n      \"step1\": {\n        \"title\": \"下载插件压缩包\",\n        \"description\": \"点击上方下载按钮，获取 tmarks-extension-chrome.zip 文件\"\n      },\n      \"step2\": {\r\n        \"title\": \"解压文件\",\r\n        \"description\": \"将下载的 zip 文件解压到任意文件夹（建议放在不会删除的位置）\"\r\n      },\r\n      \"step3\": {\r\n        \"title\": \"打开扩展管理页面\",\r\n        \"description\": \"在浏览器地址栏输入：\"\r\n      },\r\n      \"step4\": {\r\n        \"title\": \"启用开发者模式\",\r\n        \"description\": \"在扩展管理页面右上角，打开「开发者模式」开关\"\r\n      },\r\n      \"step5\": {\r\n        \"title\": \"加载插件\",\r\n        \"description\": \"点击「加载已解压的扩展程序」，选择刚才解压的文件夹\"\r\n      },\r\n      \"step6\": {\r\n        \"title\": \"完成安装\",\r\n        \"description\": \"插件图标会出现在浏览器工具栏，点击即可使用\"\r\n      }\r\n    },\r\n    \"tips\": {\r\n      \"title\": \"💡 使用提示\",\r\n      \"tip1\": \"首次使用需要在插件中配置 TMarks 网站地址和 API Key\",\r\n      \"tip2\": \"API Key 可以在网站的「API Keys」页面创建\",\r\n      \"tip3\": \"建议将插件图标固定到工具栏，方便快速访问\",\r\n      \"tip4\": \"插件会自动保存标签页的标题、URL 和网站图标\"\r\n    },\r\n    \"faq\": {\r\n      \"title\": \"❓ 常见问题\",\r\n      \"q1\": \"插件安装后找不到图标？\",\r\n      \"a1\": \"点击浏览器工具栏右侧的拼图图标，找到 TMarks 插件并点击固定按钮，图标就会显示在工具栏上。\",\r\n      \"q2\": \"如何获取 API Key？\",\r\n      \"a2\": \"登录 TMarks 网站后，点击右上角用户菜单中的「API Keys」，创建一个新的 API Key 并复制到插件配置中。\",\r\n      \"q3\": \"插件支持哪些浏览器？\",\n      \"a3\": \"目前仅提供 Chrome/Chromium 版本：支持 Chrome、Edge、Brave、Opera 等 Chromium 内核浏览器。Firefox 暂不支持插件版本。\",\n      \"q4\": \"Chrome 和 Edge 可以用同一个版本吗？\",\n      \"a4\": \"是的！所有 Chromium 内核浏览器都使用同一个 Chrome/Chromium 版本。\",\n      \"q5\": \"保存的标签页组在哪里查看？\",\n      \"a5\": \"在 TMarks 网站的「标签页」页面可以查看和管理所有保存的标签页组。\"\n    }\n  },\n  \"privacy\": {\r\n    \"title\": \"隐私政策\",\r\n    \"lastUpdated\": \"最后更新：{{date}}\",\r\n    \"commitment\": {\r\n      \"title\": \"我们的承诺\",\r\n      \"description\": \"TMarks 致力于保护用户的隐私和数据安全。本隐私政策说明了我们如何收集、使用、存储和保护您的个人信息。\"\r\n    },\r\n    \"collection\": {\r\n      \"title\": \"我们收集的信息\",\r\n      \"account\": {\r\n        \"title\": \"账户信息\",\r\n        \"description\": \"当您注册 TMarks 账户时，我们会收集您的用户名、电子邮件地址和加密后的密码。\"\r\n      },\r\n      \"bookmarks\": {\r\n        \"title\": \"书签数据\",\r\n        \"description\": \"您创建的书签、标签、标签页组等内容数据，这些数据仅用于提供服务功能。\"\r\n      },\r\n      \"usage\": {\r\n        \"title\": \"使用数据\",\r\n        \"description\": \"我们可能收集您使用服务的相关信息，如访问时间、IP 地址、浏览器类型等，用于改进服务质量。\"\r\n      }\r\n    },\r\n    \"usage\": {\r\n      \"title\": \"信息使用方式\",\r\n      \"item1\": \"提供、维护和改进 TMarks 服务\",\r\n      \"item2\": \"处理您的请求和交易\",\r\n      \"item3\": \"发送服务相关的通知和更新\",\r\n      \"item4\": \"检测、预防和解决技术问题\",\r\n      \"item5\": \"遵守法律义务\"\r\n    },\r\n    \"security\": {\r\n      \"title\": \"数据安全\",\r\n      \"description\": \"我们采取多种安全措施来保护您的个人信息：\",\r\n      \"item1\": \"所有数据传输使用 HTTPS 加密\",\r\n      \"item2\": \"密码使用行业标准的哈希算法加密存储\",\r\n      \"item3\": \"使用 JWT 令牌进行身份认证\",\r\n      \"item4\": \"数据存储在 Cloudflare 的安全基础设施上\",\r\n      \"item5\": \"定期进行安全审计和更新\"\r\n    },\r\n    \"rights\": {\r\n      \"title\": \"您的权利\",\r\n      \"access\": \"访问权：您可以随时访问和查看您的个人信息\",\r\n      \"modify\": \"修改权：您可以更新或修改您的个人信息\",\r\n      \"delete\": \"删除权：您可以请求删除您的账户和所有相关数据\",\r\n      \"export\": \"导出权：您可以导出您的书签数据\"\r\n    },\r\n    \"cookies\": {\r\n      \"title\": \"Cookie 和类似技术\",\r\n      \"description\": \"我们使用 Cookie 和类似技术来：\",\r\n      \"item1\": \"保持您的登录状态\",\r\n      \"item2\": \"记住您的偏好设置\",\r\n      \"item3\": \"分析服务使用情况\"\r\n    },\r\n    \"thirdParty\": {\r\n      \"title\": \"第三方服务\",\r\n      \"description\": \"TMarks 使用以下第三方服务：\",\r\n      \"cloudflare\": \"Cloudflare：提供托管、数据库和 CDN 服务\",\r\n      \"note\": \"这些服务提供商有自己的隐私政策，我们建议您查阅它们的政策。\"\r\n    },\r\n    \"updates\": {\r\n      \"title\": \"政策更新\",\r\n      \"description\": \"我们可能会不时更新本隐私政策。重大变更时，我们会通过电子邮件或服务内通知告知您。继续使用服务即表示您接受更新后的政策。\"\r\n    },\r\n    \"contact\": {\r\n      \"title\": \"联系我们\",\r\n      \"description\": \"如果您对本隐私政策有任何疑问或建议，请通过以下方式联系我们：\",\r\n      \"email\": \"电子邮件：\"\r\n    }\r\n  },\r\n  \"terms\": {\r\n    \"title\": \"服务条款\",\r\n    \"lastUpdated\": \"最后更新：{{date}}\",\r\n    \"welcome\": {\r\n      \"title\": \"欢迎使用 TMarks\",\r\n      \"description\": \"感谢您使用 TMarks 书签管理服务。使用我们的服务即表示您同意遵守以下服务条款。请仔细阅读这些条款，如果您不同意，请不要使用我们的服务。\"\r\n    },\r\n    \"service\": {\r\n      \"title\": \"1. 服务说明\",\r\n      \"description\": \"TMarks 提供在线书签管理服务，包括但不限于：\",\r\n      \"item1\": \"书签的创建、编辑、删除和组织\",\r\n      \"item2\": \"标签系统和标签页组管理\",\r\n      \"item3\": \"书签的导出和分享\",\r\n      \"item4\": \"浏览器扩展和 API 访问\"\r\n    },\r\n    \"responsibility\": {\r\n      \"title\": \"2. 用户责任\",\r\n      \"description\": \"使用 TMarks 服务时，您同意：\",\r\n      \"item1\": \"提供准确、完整的注册信息\",\r\n      \"item2\": \"保护您的账户安全，不与他人分享登录凭证\",\r\n      \"item3\": \"对您账户下的所有活动负责\",\r\n      \"item4\": \"遵守所有适用的法律法规\",\r\n      \"item5\": \"不滥用服务或干扰其他用户的使用\"\r\n    },\r\n    \"prohibited\": {\r\n      \"title\": \"3. 禁止行为\",\r\n      \"description\": \"使用 TMarks 时，您不得：\",\r\n      \"item1\": \"上传或分享非法、有害、威胁性、辱骂性或侵权的内容\",\r\n      \"item2\": \"尝试未经授权访问其他用户的账户或数据\",\r\n      \"item3\": \"干扰或破坏服务的正常运行\",\r\n      \"item4\": \"使用自动化工具过度访问服务\",\r\n      \"item5\": \"复制、修改或分发服务的任何部分\"\r\n    },\r\n    \"ip\": {\r\n      \"title\": \"4. 知识产权\",\r\n      \"description1\": \"TMarks 服务及其原创内容、功能和特性归 TMarks 及其许可方所有，受国际版权、商标和其他知识产权法律保护。\",\r\n      \"description2\": \"您保留对您创建和上传的内容的所有权利。通过使用服务，您授予 TMarks 使用、存储和展示您的内容以提供服务的许可。\"\r\n    },\r\n    \"changes\": {\r\n      \"title\": \"5. 服务变更和终止\",\r\n      \"description1\": \"我们保留随时修改、暂停或终止服务（或其任何部分）的权利，无论是否通知。我们不对您或任何第三方因服务的修改、暂停或终止承担责任。\",\r\n      \"description2\": \"您可以随时停止使用服务并删除您的账户。我们也可能因违反这些条款而暂停或终止您的账户。\"\r\n    },\r\n    \"disclaimer\": {\r\n      \"title\": \"6. 免责声明\",\r\n      \"description\": \"TMarks 服务按「现状」和「可用」基础提供，不提供任何明示或暗示的保证，包括但不限于：\",\r\n      \"item1\": \"服务将不间断、及时、安全或无错误\",\r\n      \"item2\": \"通过服务获得的结果将准确或可靠\",\r\n      \"item3\": \"服务中的任何错误都将被纠正\"\r\n    },\r\n    \"liability\": {\r\n      \"title\": \"7. 责任限制\",\r\n      \"description\": \"在法律允许的最大范围内，TMarks 不对任何间接、偶然、特殊、后果性或惩罚性损害，或任何利润、收入、数据或使用损失承担责任，无论是基于合同、侵权（包括过失）、严格责任还是其他理论，即使我们已被告知此类损害的可能性。\"\r\n    },\r\n    \"termChanges\": {\r\n      \"title\": \"8. 条款变更\",\r\n      \"description\": \"我们保留随时修改这些条款的权利。重大变更时，我们会通过电子邮件或服务内通知告知您。在变更生效后继续使用服务即表示您接受修改后的条款。\"\r\n    },\r\n    \"law\": {\r\n      \"title\": \"9. 适用法律\",\r\n      \"description\": \"这些条款受中华人民共和国法律管辖并按其解释，不考虑其法律冲突规定。\"\r\n    },\r\n    \"contact\": {\r\n      \"title\": \"联系我们\",\r\n      \"description\": \"如果您对这些服务条款有任何疑问，请通过以下方式联系我们：\",\r\n      \"email\": \"电子邮件：\"\r\n    }\r\n  },\r\n  \"share\": {\r\n    \"loading\": \"正在加载公开书签...\",\r\n    \"error\": \"分享链接无效或内容已下线。\",\r\n    \"defaultTitle\": \"{{username}}的书签精选\",\r\n    \"totalBookmarks\": \"共 {{count}} 个书签\",\r\n    \"filteredBookmarks\": \"筛选出 {{filtered}} / {{total}} 个书签\",\r\n    \"noResults\": \"没有找到匹配的书签\",\r\n    \"noResultsHint\": \"尝试调整筛选条件或搜索关键词\",\r\n    \"openTags\": \"打开标签\",\r\n    \"searchBookmarks\": \"搜索书签...\",\r\n    \"searchTags\": \"搜索标签...\",\r\n    \"switchToTagSearch\": \"切换到标签搜索\",\r\n    \"switchToBookmarkSearch\": \"切换到书签搜索\",\r\n    \"sort\": {\r\n      \"created\": \"按创建时间\",\r\n      \"updated\": \"按更新时间\",\r\n      \"pinned\": \"置顶优先\",\r\n      \"popular\": \"按热门程度\"\r\n    },\r\n    \"visibility\": {\r\n      \"all\": \"全部书签\",\r\n      \"public\": \"仅公开\",\r\n      \"private\": \"仅私密\"\r\n    },\r\n    \"viewMode\": {\r\n      \"list\": \"列表视图\",\r\n      \"card\": \"卡片视图\",\r\n      \"minimal\": \"极简列表\",\r\n      \"title\": \"标题瀑布\"\r\n    }\r\n  },\r\n  \"about\": {\r\n    \"title\": \"关于 TMarks\",\r\n    \"subtitle\": \"现代化的智能书签管理系统，让你的书签井井有条\",\r\n    \"version\": \"版本\",\r\n    \"releaseNote\": \"Migration Automation Release\",\r\n    \"features\": {\r\n      \"title\": \"核心特性\",\r\n      \"fast\": {\r\n        \"title\": \"快速高效\",\r\n        \"description\": \"基于 Cloudflare 全球网络，提供极速访问体验\"\r\n      },\r\n      \"secure\": {\r\n        \"title\": \"安全可靠\",\r\n        \"description\": \"数据加密存储，支持 JWT 认证，保护你的隐私\"\r\n      },\r\n      \"sync\": {\r\n        \"title\": \"多端同步\",\r\n        \"description\": \"支持浏览器扩展，多设备无缝同步你的书签\"\r\n      },\r\n      \"opensource\": {\r\n        \"title\": \"开源免费\",\r\n        \"description\": \"MIT 许可证，完全开源，欢迎贡献代码\"\r\n      }\r\n    },\r\n    \"techStack\": {\r\n      \"title\": \"技术栈\",\r\n      \"frontend\": \"前端\",\r\n      \"backend\": \"后端\"\r\n    },\r\n    \"opensource\": {\r\n      \"title\": \"开源项目\",\r\n      \"description\": \"TMarks 是一个开源项目，采用 MIT 许可证。我们欢迎任何形式的贡献，包括但不限于：\",\r\n      \"contribute1\": \"提交 Bug 报告和功能建议\",\r\n      \"contribute2\": \"改进文档和翻译\",\r\n      \"contribute3\": \"贡献代码和修复问题\",\r\n      \"contribute4\": \"分享使用经验和最佳实践\",\r\n      \"visitGithub\": \"访问 GitHub 仓库\"\r\n    },\r\n    \"thanks\": {\r\n      \"title\": \"致谢\",\r\n      \"description\": \"感谢所有为 TMarks 做出贡献的开发者和用户，以及以下优秀的开源项目和服务：\"\r\n    }\r\n  },\r\n  \"help\": {\r\n    \"title\": \"帮助中心\",\r\n    \"subtitle\": \"查找常见问题的答案，或浏览使用指南\",\r\n    \"guides\": {\r\n      \"title\": \"快速指南\",\r\n      \"quickStart\": {\r\n        \"title\": \"快速开始\",\r\n        \"description\": \"了解如何创建第一个书签和使用基本功能\"\r\n      },\r\n      \"extension\": {\r\n        \"title\": \"浏览器扩展\",\r\n        \"description\": \"安装和配置浏览器扩展，快速保存书签\"\r\n      },\r\n      \"export\": {\r\n        \"title\": \"数据导出\",\r\n        \"description\": \"导出书签数据进行备份\"\r\n      },\r\n      \"share\": {\r\n        \"title\": \"公开分享\",\r\n        \"description\": \"创建公开链接，分享你的书签集合\"\r\n      }\r\n    },\r\n    \"faq\": {\r\n      \"title\": \"常见问题\",\r\n      \"q1\": \"如何创建书签？\",\r\n      \"a1\": \"点击页面右上角的「添加书签」按钮，填写书签信息后保存即可。你也可以使用浏览器扩展快速保存当前页面。\",\r\n      \"q2\": \"如何使用标签？\",\r\n      \"a2\": \"在创建或编辑书签时，可以为书签添加标签。点击侧边栏的标签可以筛选对应的书签。\",\r\n      \"q3\": \"如何分享我的书签？\",\r\n      \"a3\": \"进入「通用设置」→「分享」标签页，启用公开分享功能，系统会生成一个公开链接供他人访问。\",\r\n      \"q4\": \"如何获取 API Key？\",\r\n      \"a4\": \"进入「通用设置」→「API」标签页，点击「创建」按钮生成新的 API Key，用于浏览器扩展或第三方应用。\",\r\n      \"q5\": \"浏览器扩展如何安装？\",\r\n      \"a5\": \"访问「浏览器」页面，下载对应浏览器的扩展文件，按照说明安装即可。\",\r\n      \"q6\": \"如何切换主题？\",\r\n      \"a6\": \"进入「通用设置」→「基础」标签页，选择浅色、深色或跟随系统主题。\",\r\n      \"q7\": \"数据安全吗？\",\r\n      \"a7\": \"所有数据都加密存储在 Cloudflare D1 数据库中，使用 JWT 进行身份认证，确保数据安全。\"\r\n    },\r\n    \"contact\": {\r\n      \"title\": \"需要更多帮助？\",\r\n      \"description\": \"如果你没有找到问题的答案，可以通过以下方式联系我们：\",\r\n      \"submitIssue\": \"提交问题\",\r\n      \"contactSupport\": \"联系支持\"\r\n    }\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/zh-CN/settings.json",
    "content": "{\r\n  \"title\": \"通用设置\",\r\n  \"description\": \"配置应用的通用行为和用户体验\",\r\n  \"tabs\": {\r\n    \"basic\": \"基础\",\r\n    \"automation\": \"自动化\",\r\n    \"appearance\": \"外观\",\r\n    \"snapshot\": \"快照\",\r\n    \"ai\": \"AI\",\r\n    \"browser\": \"浏览器\",\r\n    \"api\": \"API\",\r\n    \"share\": \"分享\",\r\n    \"data\": \"数据\",\r\n    \"statistics\": \"数据分析\",\r\n    \"language\": \"语言\"\r\n  },\r\n  \"navGroup\": {\r\n    \"account\": \"账户与安全\",\r\n    \"features\": \"功能\",\r\n    \"integration\": \"集成\",\r\n    \"dataAndShare\": \"数据与分享\"\r\n  },\r\n  \"action\": {\r\n    \"save\": \"保存设置\",\r\n    \"saving\": \"保存中...\",\r\n    \"reset\": \"重置\",\r\n    \"logout\": \"登出\",\r\n    \"discard\": \"放弃\"\r\n  },\r\n  \"message\": {\r\n    \"saveSuccess\": \"设置已保存\",\r\n    \"saveFailed\": \"保存失败\",\r\n    \"saveFailedWithError\": \"保存失败：{{error}}\",\r\n    \"resetSuccess\": \"已重置为上次保存的设置\",\r\n    \"logoutFailed\": \"登出失败\",\r\n    \"unsavedChanges\": \"有未保存的更改\"\r\n  },\r\n  \"language\": {\r\n    \"title\": \"语言设置\",\r\n    \"description\": \"选择界面显示语言\",\r\n    \"label\": \"界面语言\",\r\n    \"hint\": \"更改语言后界面将立即切换\"\r\n  },\r\n  \"permissions\": {\r\n    \"bookmarks\": \"书签\",\r\n    \"tags\": \"标签\",\r\n    \"tabGroups\": \"收纳\",\r\n    \"other\": \"其他\",\r\n    \"bookmarksCreate\": \"创建书签\",\r\n    \"bookmarksRead\": \"读取书签\",\r\n    \"bookmarksUpdate\": \"更新书签\",\r\n    \"bookmarksDelete\": \"删除书签\",\r\n    \"bookmarksAll\": \"所有书签权限\",\r\n    \"tagsCreate\": \"创建标签\",\r\n    \"tagsRead\": \"读取标签\",\r\n    \"tagsUpdate\": \"更新标签\",\r\n    \"tagsDelete\": \"删除标签\",\r\n    \"tagsAssign\": \"分配标签\",\r\n    \"tagsAll\": \"所有标签权限\",\r\n    \"tabGroupsCreate\": \"创建收纳\",\r\n    \"tabGroupsRead\": \"读取收纳\",\r\n    \"tabGroupsUpdate\": \"更新收纳\",\r\n    \"tabGroupsDelete\": \"删除收纳\",\r\n    \"tabGroupsAll\": \"所有收纳权限\",\r\n    \"aiSuggest\": \"AI 智能建议\",\r\n    \"userRead\": \"读取用户信息\",\r\n    \"userPreferencesRead\": \"读取用户偏好\",\r\n    \"templates\": {\r\n      \"readOnly\": \"只读\",\r\n      \"readOnlyDesc\": \"仅查看数据，不能修改\",\r\n      \"basic\": \"基础使用\",\r\n      \"basicDesc\": \"可以添加书签和标签，但不能删除\",\r\n      \"full\": \"完整权限\",\r\n      \"fullDesc\": \"拥有所有操作权限\"\r\n    }\r\n  },\r\n  \"browserPermissions\": {\r\n    \"title\": \"浏览器权限设置\",\r\n    \"description\": \"配置 TMarks 所需的浏览器权限，以获得最佳使用体验\",\r\n    \"popup\": {\r\n      \"title\": \"弹窗权限\",\r\n      \"description\": \"允许 TMarks 打开弹窗，以便使用\\\"一键打开全部标签页\\\"功能。\"\r\n    },\r\n    \"howTo\": {\r\n      \"title\": \"如何允许弹窗？\",\r\n      \"step1\": \"在标签页组详情页面，点击\\\"全部恢复\\\"按钮\",\r\n      \"step2\": \"浏览器地址栏会出现弹窗拦截图标（通常在右侧）\",\r\n      \"step3\": \"点击该图标，选择\\\"始终允许 [网站] 显示弹出式窗口\\\"\",\r\n      \"step4\": \"刷新页面后，再次点击\\\"全部恢复\\\"即可正常使用\"\r\n    },\r\n    \"browsers\": {\r\n      \"title\": \"各浏览器设置方法\",\r\n      \"chrome\": {\r\n        \"title\": \"Chrome / Edge\",\r\n        \"description\": \"地址栏右侧会出现 🚫 图标，点击后选择\\\"始终允许弹出式窗口和重定向\\\"\"\r\n      },\r\n      \"firefox\": {\r\n        \"title\": \"Firefox\",\r\n        \"description\": \"地址栏左侧会出现弹窗拦截提示，点击\\\"选项\\\" → \\\"允许 [网站] 的弹出式窗口\\\"\"\r\n      },\r\n      \"safari\": {\r\n        \"title\": \"Safari\",\r\n        \"description\": \"菜单栏：Safari → 设置 → 网站 → 弹出式窗口 → 找到当前网站 → 选择\\\"允许\\\"\"\r\n      }\r\n    },\r\n    \"why\": {\r\n      \"title\": \"💡 为什么需要弹窗权限？\",\r\n      \"description\": \"\\\"一键打开全部标签页\\\"功能需要同时打开多个网页。浏览器为了安全考虑，默认会拦截批量打开的弹窗。允许 TMarks 的弹窗权限后，您就可以一次性打开标签页组中的所有网页，大大提高工作效率。\"\r\n    }\r\n  },\r\n  \"ai\": {\r\n    \"custom\": \"自定义\"\r\n  },\r\n  \"apiKey\": {\r\n    \"page\": {\r\n      \"title\": \"API Keys 管理\",\r\n      \"description\": \"管理您的 API 密钥，用于第三方应用访问\",\r\n      \"createNew\": \"创建新的 API Key\",\r\n      \"info\": \"API Keys 用于第三方应用（如浏览器插件）安全访问您的 TMarks 数据。您可以随时撤销不需要的 Key。\",\r\n      \"currentUsage\": \"当前使用\",\r\n      \"unlimited\": \"无限制\",\r\n      \"empty\": \"还没有创建任何 API Key\",\r\n      \"createFirst\": \"创建第一个 API Key\",\r\n      \"tipsTitle\": \"💡 提示：\",\r\n      \"tip1\": \"每个账户最多创建 {{limit}} 个 API Key\",\r\n      \"tip2\": \"API Key 创建后仅显示一次，请妥善保存\",\r\n      \"tip3\": \"如果 Key 泄露，请立即撤销\",\r\n      \"revokeTitle\": \"撤销 API Key\",\r\n      \"revokeMessage\": \"确定要撤销此 API Key 吗？撤销后无法恢复。\",\r\n      \"revokeSuccess\": \"API Key 已撤销\",\r\n      \"revokeFailed\": \"撤销失败，请重试\",\r\n      \"deleteTitle\": \"删除 API Key\",\r\n      \"deleteMessage\": \"确定要彻底删除此 API Key 吗？该操作不可恢复，并会清除所有使用记录。\",\r\n      \"deleteSuccess\": \"API Key 已永久删除\",\r\n      \"deleteFailed\": \"删除失败，请重试\"\r\n    },\r\n    \"status\": {\r\n      \"label\": \"状态\",\r\n      \"active\": \"活跃\",\r\n      \"revoked\": \"已撤销\",\r\n      \"expired\": \"已过期\"\r\n    },\r\n    \"permissions\": \"权限\",\r\n    \"permissionsCount\": \"{{count}} 项\",\r\n    \"lastUsed\": \"最后使用\",\r\n    \"neverUsed\": \"从未使用\",\r\n    \"createdAt\": \"创建于\",\r\n    \"expiresAt\": \"过期时间\",\r\n    \"viewDetails\": \"查看详情\",\r\n    \"revoke\": \"撤销\",\r\n    \"delete\": \"删除\",\r\n    \"copyPrefix\": \"复制密钥前缀\",\r\n    \"create\": {\r\n      \"title\": \"创建 API Key\",\r\n      \"step\": \"步骤 {{current}}/{{total}}\",\r\n      \"name\": \"名称\",\r\n      \"nameRequired\": \"名称 *\",\r\n      \"namePlaceholder\": \"例如：Chrome 插件 - 工作电脑\",\r\n      \"nameHint\": \"用于识别此 Key 的用途\",\r\n      \"description\": \"描述 (可选)\",\r\n      \"descriptionPlaceholder\": \"例如：用于浏览器插件访问\",\r\n      \"quickSelect\": \"快速选择:\",\r\n      \"recommended\": \"推荐\",\r\n      \"includedPermissions\": \"包含的权限:\",\r\n      \"expiration\": \"过期时间:\",\r\n      \"neverExpire\": \"永不过期\",\r\n      \"expireIn30Days\": \"30 天后\",\r\n      \"expireIn90Days\": \"90 天后\",\r\n      \"cancel\": \"取消\",\r\n      \"prev\": \"← 上一步\",\r\n      \"next\": \"下一步 →\",\r\n      \"creating\": \"创建中...\",\r\n      \"createButton\": \"创建 API Key\",\r\n      \"failed\": \"创建失败\",\r\n      \"failedMessage\": \"创建失败，请重试\"\r\n    },\r\n    \"success\": {\r\n      \"title\": \"创建成功！请妥善保存此 Key\",\r\n      \"yourKey\": \"您的 API Key:\",\r\n      \"copy\": \"📋 复制\",\r\n      \"copied\": \"✓ 已复制\",\r\n      \"warning\": \"⚠️ 重要提示：\",\r\n      \"warningList\": {\r\n        \"showOnce\": \"此 Key 仅显示一次，关闭后无法再查看\",\r\n        \"saveNow\": \"请立即复制并保存到安全的地方\",\r\n        \"prefixOnly\": \"后续您只能看到前缀: {{prefix}}...\"\r\n      },\r\n      \"close\": \"我已保存，关闭\"\r\n    },\r\n    \"detail\": {\r\n      \"close\": \"关闭\",\r\n      \"basicInfo\": \"基本信息:\",\r\n      \"keyPrefix\": \"Key 前缀:\",\r\n      \"createdAt\": \"创建时间:\",\r\n      \"expiresAt\": \"过期时间:\",\r\n      \"neverExpire\": \"永不过期\",\r\n      \"description\": \"描述:\",\r\n      \"permissions\": \"权限:\",\r\n      \"usage\": \"使用情况:\",\r\n      \"lastUsed\": \"最后使用:\",\r\n      \"neverUsed\": \"从未使用\",\r\n      \"totalRequests\": \"使用次数:\",\r\n      \"requestCount\": \"{{count}} 次\",\r\n      \"lastIp\": \"最后 IP:\",\r\n      \"recentActivity\": \"最近活动:\",\r\n      \"recentActivityLimit\": \"(最多显示 {{count}} 条)\",\r\n      \"tableTime\": \"时间\",\r\n      \"tableMethod\": \"方法\",\r\n      \"tableEndpoint\": \"端点\",\r\n      \"tableStatus\": \"状态\",\r\n      \"noLogs\": \"暂无使用记录\"\r\n    },\r\n    \"infoBox\": {\r\n      \"usageTitle\": \"使用说明\",\r\n      \"usageTip1\": \"API Keys 用于第三方应用（如浏览器插件）安全访问您的数据\",\r\n      \"usageTip2\": \"每个 Key 可以设置不同的权限范围\",\r\n      \"usageTip3\": \"建议为不同用途创建不同的 Key\",\r\n      \"securityTitle\": \"安全提示\",\r\n      \"securityTip1\": \"不要在公开场合分享你的 API Key\",\r\n      \"securityTip2\": \"如果 Key 泄露，请立即撤销\",\r\n      \"securityTip3\": \"定期检查 Key 的使用记录\"\r\n    }\r\n  },\r\n  \"snapshot\": {\r\n    \"title\": \"快照设置\",\r\n    \"retention\": {\r\n      \"title\": \"快照保留策略\",\r\n      \"description\": \"控制每个书签保留的快照数量\",\r\n      \"count\": \"保留快照数量\",\r\n      \"countHint\": \"每个书签最多保留的快照版本数（-1 表示无限制）\",\r\n      \"unit\": \"个\",\r\n      \"tip\": \"💡 当快照数量超过限制时，会自动删除最旧的快照。设置为 -1 表示不限制数量。\"\r\n    },\r\n    \"maxCount\": {\r\n      \"title\": \"最大快照数量\",\r\n      \"description\": \"每个书签最多保存的快照数量\",\r\n      \"hint\": \"当快照数量超过限制时，会自动删除最旧的快照。设置为 -1 表示不限制数量。\"\r\n    },\r\n    \"autoCreate\": {\r\n      \"title\": \"自动创建快照\",\r\n      \"description\": \"添加书签时自动创建网页快照\",\r\n      \"enable\": \"启用自动创建\",\r\n      \"enableHint\": \"添加新书签时自动保存网页快照（需要浏览器扩展支持）\"\r\n    },\r\n    \"dedup\": {\r\n      \"title\": \"智能去重\",\r\n      \"description\": \"避免保存重复的快照内容\",\r\n      \"enable\": \"启用智能去重\",\r\n      \"tip1\": \"通过内容哈希检测重复快照\",\r\n      \"tip2\": \"如果内容相同，不会创建新快照\",\r\n      \"tip3\": \"节省存储空间，提高效率\"\r\n    },\r\n    \"autoClean\": {\r\n      \"title\": \"自动清理\",\r\n      \"description\": \"定期清理过期的快照\",\r\n      \"days\": \"自动清理天数\",\r\n      \"daysHint\": \"自动删除超过指定天数的快照（0 表示不自动清理）\",\r\n      \"unit\": \"天\",\r\n      \"warning\": \"⚠️ 自动清理功能会永久删除快照，请谨慎设置。设置为 0 表示不自动清理。\"\r\n    },\r\n    \"automation\": {\r\n      \"title\": \"自动化选项\",\r\n      \"description\": \"配置快照的自动创建和去重功能\"\r\n    },\r\n    \"infoBox\": {\r\n      \"title\": \"快照功能说明\",\r\n      \"tip1\": \"快照功能可以保存网页的完整内容，即使原网页被删除也可查看\",\r\n      \"tip2\": \"建议启用智能去重，避免保存重复内容，节省存储空间\"\r\n    }\r\n  },\r\n  \"share\": {\r\n    \"title\": \"分享设置\",\r\n    \"publicShare\": {\r\n      \"title\": \"公开分享\",\r\n      \"description\": \"创建公开链接，让其他人查看你的书签\",\r\n      \"enable\": \"启用公开分享\",\r\n      \"enableHint\": \"开启后，其他人可以通过链接访问你的公开书签\"\r\n    },\r\n    \"slug\": {\r\n      \"label\": \"分享链接后缀\",\r\n      \"placeholder\": \"例如：my-bookmarks\",\r\n      \"hint\": \"仅支持字母、数字与短横线，留空将自动生成\",\r\n      \"regenerate\": \"重新生成\"\r\n    },\r\n    \"pageTitle\": {\r\n      \"label\": \"页面标题\",\r\n      \"placeholder\": \"公开页面标题，用于向访客介绍\"\r\n    },\r\n    \"pageDescription\": {\r\n      \"label\": \"页面描述\",\r\n      \"placeholder\": \"可选描述，向访客说明书签集合内容\"\r\n    },\r\n    \"shareLink\": {\r\n      \"label\": \"分享链接\",\r\n      \"placeholder\": \"生成后显示分享链接\",\r\n      \"copy\": \"复制\",\r\n      \"copied\": \"已复制\"\r\n    },\r\n    \"reset\": \"重置\",\r\n    \"saveSuccess\": \"分享设置已保存\",\r\n    \"saveFailed\": \"保存失败\",\r\n    \"regenerateSuccess\": \"链接已重新生成\",\r\n    \"regenerateFailed\": \"生成失败\",\r\n    \"copySuccess\": \"链接已复制\",\r\n    \"copyFailed\": \"复制失败\",\r\n    \"resetSuccess\": \"已重置为上次保存的设置\",\r\n    \"infoBox\": {\r\n      \"title\": \"分享功能说明\",\r\n      \"tip1\": \"只有标记为\\\"公开\\\"的书签才会在分享页面显示\",\r\n      \"tip2\": \"你可以随时修改分享链接或关闭分享功能\",\r\n      \"tip3\": \"分享页面不需要登录即可访问\"\r\n    }\r\n  },\r\n  \"data\": {\r\n    \"title\": \"数据管理\",\r\n    \"r2Storage\": {\r\n      \"title\": \"R2 存储使用情况（全局）\",\r\n      \"description\": \"包含所有快照和封面图在 R2 中的总占用\",\r\n      \"currentUsage\": \"当前使用\",\r\n      \"unlimited\": \"无限制\"\r\n    },\r\n    \"export\": {\r\n      \"title\": \"导出数据\",\r\n      \"description\": \"将书签数据导出为文件进行备份\",\r\n      \"tip1\": \"包含完整数据，适合备份和迁移\",\r\n      \"tip2\": \"可选择包含标签、元数据等信息\",\r\n      \"htmlTip1\": \"兼容浏览器标准格式\",\r\n      \"htmlTip2\": \"可直接导出为浏览器书签格式\",\r\n      \"htmlTip3\": \"保留文件夹结构和书签层级\"\r\n    },\r\n    \"exportSuccess\": \"导出成功：{{details}}\",\r\n    \"lastOperation\": {\r\n      \"export\": \"数据导出\",\r\n      \"cleanup\": \"快照清理\"\r\n    },\r\n    \"snapshotManagement\": {\r\n      \"title\": \"快照管理\",\r\n      \"description\": \"清理和维护书签快照数据\",\r\n      \"cleanOrphan\": \"清理孤立快照记录\",\r\n      \"cleaning\": \"清理中...\",\r\n      \"cleanButton\": \"清理孤立记录\"\r\n    },\r\n    \"exportFeature\": {\r\n      \"title\": \"导出功能\",\r\n      \"description\": \"支持多种格式，满足不同需求\",\r\n      \"jsonTitle\": \"JSON 格式\",\r\n      \"htmlTitle\": \"HTML 格式\"\r\n    },\r\n    \"fetchBookmarksFailed\": \"获取书签列表失败\",\r\n    \"cleanSnapshotsFailed\": \"清理快照失败\",\r\n    \"cleanSnapshotsSuccess\": \"成功清理 {{count}} 条孤立快照记录\",\r\n    \"cleanSnapshotsNone\": \"没有发现孤立的快照记录\",\r\n    \"cleanSnapshotsConfirmTitle\": \"清理快照记录\",\r\n    \"cleanSnapshotsConfirmMessage\": \"确定要清理所有书签的孤立快照记录吗？\\n\\n这将检查所有快照记录，删除 R2 文件不存在的记录。\",\r\n    \"loading\": \"加载中...\",\r\n    \"cleanSnapshots\": {\r\n      \"title\": \"清理孤立快照\",\r\n      \"tip1\": \"检查所有快照记录，验证 R2 文件是否存在\",\r\n      \"tip2\": \"删除 R2 文件不存在的数据库记录\",\r\n      \"tip3\": \"自动更新书签的快照计数\",\r\n      \"tip4\": \"适用于手动删除 R2 文件后的数据修复\"\r\n    }\r\n  },\r\n  \"browser\": {\r\n    \"title\": \"浏览器设置\",\r\n    \"download\": {\r\n      \"title\": \"浏览器插件下载\",\r\n      \"description\": \"下载 Chrome/Chromium 通用扩展（适用于 Chromium 内核浏览器）\",\r\n      \"clickToDownload\": \"点击下载\",\r\n      \"tip\": \"💡 目前仅提供 Chrome/Chromium 通用版本（Chrome、Edge、Brave、Opera、360、QQ、搜狗等）\"\r\n    },\r\n    \"permissions\": {\r\n      \"title\": \"浏览器权限设置\",\r\n      \"description\": \"配置 TMarks 所需的浏览器权限，以获得最佳使用体验\",\r\n      \"bookmarks\": {\r\n        \"title\": \"书签访问权限\",\r\n        \"description\": \"允许扩展读取和保存浏览器书签\"\r\n      },\r\n      \"tabs\": {\r\n        \"title\": \"标签页访问权限\",\r\n        \"description\": \"允许扩展访问当前打开的标签页信息\"\r\n      },\r\n      \"storage\": {\r\n        \"title\": \"存储权限\",\r\n        \"description\": \"允许扩展在本地存储数据\"\r\n      }\r\n    },\r\n    \"popup\": {\r\n      \"title\": \"弹窗权限\",\r\n      \"description\": \"允许 TMarks 打开弹窗，以便使用\\\"一键打开全部标签页\\\"功能。\",\r\n      \"howTo\": \"如何允许弹窗？\",\r\n      \"step1\": \"在标签页组详情页面，点击\\\"全部恢复\\\"按钮\",\r\n      \"step2\": \"浏览器地址栏会出现弹窗拦截图标（通常在右侧）\",\r\n      \"step3\": \"点击该图标，选择\\\"始终允许显示弹出式窗口\\\"\",\r\n      \"step4\": \"刷新页面后，再次点击\\\"全部恢复\\\"即可正常使用\",\r\n      \"chromeTitle\": \"Chrome / Edge\",\r\n      \"chromeDesc\": \"地址栏右侧会出现 🚫 图标，点击后选择\\\"始终允许弹出式窗口和重定向\\\"\",\r\n      \"firefoxTitle\": \"Firefox\",\r\n      \"firefoxDesc\": \"地址栏左侧会出现弹窗拦截提示，点击\\\"选项\\\" → \\\"允许弹出式窗口\\\"\",\r\n      \"safariTitle\": \"Safari\",\r\n      \"safariDesc\": \"菜单栏：Safari → 设置 → 网站 → 弹出式窗口 → 找到当前网站 → 选择\\\"允许\\\"\",\r\n      \"whyTitle\": \"💡 为什么需要弹窗权限？\",\r\n      \"whyDesc\": \"\\\"一键打开全部标签页\\\"功能需要同时打开多个网页。浏览器为了安全考虑，默认会拦截批量打开的弹窗。允许 TMarks 的弹窗权限后，您就可以一次性打开标签页组中的所有网页，大大提高工作效率。\"\r\n    },\r\n    \"install\": {\r\n      \"title\": \"安装步骤\",\r\n      \"description\": \"按照以下步骤安装浏览器扩展\",\r\n      \"step1Title\": \"下载插件压缩包\",\r\n      \"step1Desc\": \"点击上方下载按钮，获取对应浏览器的扩展文件\",\r\n      \"step2Title\": \"解压文件\",\r\n      \"step2Desc\": \"将下载的 zip 文件解压到任意文件夹（建议放在不会删除的位置）\",\r\n      \"step3Title\": \"打开扩展管理页面\",\r\n      \"step3Desc\": \"Chrome: chrome://extensions/ | Edge: edge://extensions/\",\r\n      \"step4Title\": \"启用开发者模式\",\r\n      \"step4Desc\": \"在扩展管理页面右上角，打开\\\"开发者模式\\\"开关\",\r\n      \"step5Title\": \"加载插件\",\r\n      \"step5Desc\": \"点击\\\"加载已解压的扩展程序\\\"，选择刚才解压的文件夹\",\r\n      \"step6Title\": \"完成安装\",\r\n      \"step6Desc\": \"插件图标会出现在浏览器工具栏，点击即可使用\"\r\n    },\r\n    \"faq\": {\r\n      \"title\": \"常见问题\",\r\n      \"iconNotFound\": \"Q: 插件安装后找不到图标？\",\r\n      \"iconNotFoundAnswer\": \"A: 点击浏览器工具栏右侧的拼图图标，找到 TMarks 插件并点击固定按钮，图标就会显示在工具栏上。\",\r\n      \"howToGetApiKey\": \"Q: 如何获取 API Key？\",\r\n      \"howToGetApiKeyAnswer\": \"A: 在通用设置的\\\"API\\\"标签页中创建一个新的 API Key 并复制到插件配置中。\",\r\n      \"supportedBrowsers\": \"Q: 插件支持哪些浏览器？\",\r\n      \"supportedBrowsersAnswer\": \"A: 目前仅提供 Chrome/Chromium 版本，支持 Chrome、Edge、Brave、Opera、360、QQ、搜狗等 Chromium 内核浏览器。\",\r\n      \"whereToView\": \"保存的标签页组在哪里查看？\",\r\n      \"whereToViewAnswer\": \"在 TMarks 网站的\\\"标签页\\\"页面可以查看和管理所有保存的标签页组。\"\r\n    },\r\n    \"infoBox\": {\r\n      \"title\": \"使用提示\",\r\n      \"tip1\": \"首次使用需要在插件中配置 TMarks 网站地址和 API Key\",\r\n      \"tip2\": \"建议将插件图标固定到工具栏，方便快速访问\",\r\n      \"tip3\": \"插件会自动保存标签页的标题、URL 和网站图标\",\r\n      \"tip4\": \"所有数据自动同步到云端，多设备无缝切换\"\r\n    }\r\n  },\r\n  \"basic\": {\r\n    \"accountInfo\": {\r\n      \"title\": \"账户信息\",\r\n      \"description\": \"查看您的账户基本信息\"\r\n    },\r\n    \"username\": \"用户名\",\r\n    \"email\": \"邮箱\",\r\n    \"registeredAt\": \"注册时间\",\r\n    \"role\": \"账户角色\",\r\n    \"roleAdmin\": \"管理员\",\r\n    \"roleUser\": \"普通用户\",\r\n    \"notSet\": \"未设置\",\r\n    \"unknown\": \"未知\",\r\n    \"security\": {\r\n      \"title\": \"安全设置\",\r\n      \"description\": \"修改您的登录密码\"\r\n    },\r\n    \"password\": {\r\n      \"change\": \"修改密码\",\r\n      \"current\": \"当前密码\",\r\n      \"currentPlaceholder\": \"请输入当前密码\",\r\n      \"new\": \"新密码\",\r\n      \"newPlaceholder\": \"请输入新密码（至少 6 个字符）\",\r\n      \"confirm\": \"确认新密码\",\r\n      \"confirmPlaceholder\": \"请再次输入新密码\",\r\n      \"cancel\": \"取消\",\r\n      \"submit\": \"确认修改\",\r\n      \"changing\": \"修改中...\",\r\n      \"changeSuccess\": \"密码修改成功\",\r\n      \"changeFailed\": \"密码修改失败\",\r\n      \"fillAllFields\": \"请填写所有字段\",\r\n      \"mismatch\": \"两次输入的新密码不一致\",\r\n      \"tooShort\": \"新密码至少需要 6 个字符\",\r\n      \"infoBoxTitle\": \"账户安全提示\",\r\n      \"tip1\": \"请定期修改密码以保护账户安全\",\r\n      \"tip2\": \"密码至少需要 6 个字符\",\r\n      \"tip3\": \"建议使用字母、数字和符号的组合\",\r\n      \"tip4\": \"不要与他人分享您的密码\"\r\n    }\r\n  },\r\n  \"automation\": {\r\n    \"search\": {\r\n      \"title\": \"搜索框自动清空\",\r\n      \"description\": \"设置搜索框在无操作后自动清空的时间\",\r\n      \"enable\": \"启用搜索框自动清空\",\r\n      \"enableHint\": \"搜索框在指定时间无操作后会自动清空\",\r\n      \"delay\": \"延迟时间\",\r\n      \"unit\": \"秒\"\r\n    },\r\n    \"tag\": {\r\n      \"title\": \"标签选中自动清空\",\r\n      \"description\": \"设置标签选中状态在无操作后自动清空的时间\",\r\n      \"enable\": \"启用标签选中自动清空\",\r\n      \"enableHint\": \"标签选中状态在指定时间无操作后会自动清空\",\r\n      \"delay\": \"延迟时间\",\r\n      \"unit\": \"秒\"\r\n    },\r\n    \"infoBox\": {\r\n      \"title\": \"自动化功能说明\",\r\n      \"tip1\": \"自动清空功能可以帮助你快速回到初始状态，提高操作效率\"\r\n    }\r\n  },\r\n  \"appearance\": {\r\n    \"icon\": {\r\n      \"title\": \"默认书签图标\",\r\n      \"description\": \"选择当书签没有封面图和网站图标时显示的默认图标\",\r\n      \"favicon\": \"网站图标\",\r\n      \"letter\": \"首字母\",\r\n      \"hash\": \"哈希图标\",\r\n      \"none\": \"无图标\"\r\n    },\r\n    \"view\": {\r\n      \"title\": \"默认视图\",\r\n      \"description\": \"选择书签列表的默认显示方式\",\r\n      \"list\": \"列表\",\r\n      \"grid\": \"网格\",\r\n      \"card\": \"卡片\"\r\n    },\r\n    \"density\": {\r\n      \"title\": \"列表密度\",\r\n      \"description\": \"调整书签列表的显示密度\",\r\n      \"compact\": \"紧凑\",\r\n      \"normal\": \"标准\",\r\n      \"comfortable\": \"宽松\"\r\n    },\r\n    \"infoBox\": {\r\n      \"title\": \"外观定制说明\",\r\n      \"tip1\": \"这些设置会影响书签列表的显示效果，可以根据个人喜好调整\"\r\n    }\r\n  },\r\n  \"tips\": {\r\n    \"title\": \"💡 使用提示\"\r\n  },\r\n  \"ai\": {\r\n    \"custom\": \"自定义\",\r\n    \"title\": \"AI 服务配置\",\r\n    \"description\": \"配置 AI 服务用于标签推荐、智能分类等功能\",\r\n    \"enable\": \"启用 AI 功能\",\r\n    \"enabled\": \"已开启\",\r\n    \"enableHint\": \"开启后可使用 AI 智能整理等功能\",\r\n    \"provider\": \"服务商\",\r\n    \"apiKey\": \"API Key\",\r\n    \"getApiKey\": \"获取 API Key\",\r\n    \"apiKeyPlaceholder\": \"输入 {{provider}} API Key\",\r\n    \"model\": \"模型\",\r\n    \"selectModel\": \"选择\",\r\n    \"refreshModels\": \"刷新模型\",\r\n    \"refreshingModels\": \"获取中...\",\r\n    \"modelsFetched\": \"已获取 {{count}} 个模型，可直接选择或手动输入\",\r\n    \"modelsFetchError\": \"模型列表加载失败：{{error}}\",\r\n    \"modelsHint\": \"输入 API Key 后可自动获取可用模型列表，推荐使用 {{model}}\",\r\n    \"modelsRecommend\": \"推荐使用 {{model}}，性价比较高\",\r\n    \"apiUrl\": \"API 地址\",\r\n    \"apiUrlOptional\": \"API 地址 (可选)\",\r\n    \"apiUrlHint\": \"留空使用默认地址，或输入自定义 API 端点\",\r\n    \"testConnection\": \"测试连接\",\r\n    \"testSuccess\": \"连接成功 ({{latency}}ms)\",\r\n    \"testFailed\": \"连接失败\",\r\n    \"enterApiKeyFirst\": \"请先输入完整的 API Key\",\r\n    \"saveSuccess\": \"AI 设置已保存\",\r\n    \"infoBox\": {\r\n      \"title\": \"使用说明\",\r\n      \"tip1\": \"API Key 会加密存储在服务器，不会泄露\",\r\n      \"tip2\": \"AI 调用直接从浏览器发起，数据不经过 TMarks 服务器\",\r\n      \"tip3\": \"推荐使用 DeepSeek 或 gpt-4o-mini，性价比高\",\r\n      \"tip4\": \"处理 100 个书签约消耗 0.01-0.05 美元\"\r\n    }\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/zh-CN/share.json",
    "content": "{\r\n  \"title\": \"公开分享\",\r\n  \"loading\": \"正在加载公开书签...\",\r\n  \"error\": \"分享链接无效或内容已下线。\",\r\n  \"defaultTitle\": \"{{username}}的书签精选\",\r\n  \"guestTitle\": \"访客的书签精选\",\r\n  \"settings\": {\r\n    \"title\": \"公开分享设置\",\r\n    \"enableTitle\": \"启用公开分享\",\r\n    \"enableDescription\": \"开启后可将书签以只读页面公开给任何人访问。\",\r\n    \"enableLabel\": \"开启公开分享\",\r\n    \"slugLabel\": \"分享链接后缀\",\r\n    \"slugPlaceholder\": \"例如：my-bookmarks\",\r\n    \"slugHint\": \"仅支持字母、数字与短横线，留空将自动生成。\",\r\n    \"regenerate\": \"重新生成\",\r\n    \"pageTitleLabel\": \"页面标题\",\r\n    \"pageTitlePlaceholder\": \"公开页面标题，用于向访客介绍\",\r\n    \"descriptionLabel\": \"页面描述\",\r\n    \"descriptionPlaceholder\": \"可选描述，向访客说明书签集合内容\",\r\n    \"linkLabel\": \"分享链接\",\r\n    \"linkPlaceholder\": \"生成后显示分享链接\",\r\n    \"copy\": \"复制\",\r\n    \"copied\": \"已复制\",\r\n    \"reset\": \"重置\",\r\n    \"save\": \"保存设置\",\r\n    \"saving\": \"保存中...\"\r\n  },\r\n  \"stats\": {\r\n    \"total\": \"共 {{count}} 个书签\",\r\n    \"filtered\": \"筛选出 {{filtered}} / {{total}} 个书签\"\r\n  },\r\n  \"search\": {\r\n    \"bookmarkPlaceholder\": \"搜索书签...\",\r\n    \"tagPlaceholder\": \"搜索标签...\",\r\n    \"switchToTag\": \"切换到标签搜索\",\r\n    \"switchToBookmark\": \"切换到书签搜索\"\r\n  },\r\n  \"filter\": {\r\n    \"all\": \"全部书签\",\r\n    \"public\": \"仅公开\",\r\n    \"private\": \"仅私密\",\r\n    \"openTags\": \"打开标签\",\r\n    \"tagFilter\": \"标签筛选\",\r\n    \"closeTagDrawer\": \"关闭标签抽屉\"\r\n  },\r\n  \"sort\": {\r\n    \"created\": \"按创建时间\",\r\n    \"updated\": \"按更新时间\",\r\n    \"pinned\": \"置顶优先\",\r\n    \"popular\": \"按热门程度\"\r\n  },\r\n  \"view\": {\r\n    \"list\": \"列表视图\",\r\n    \"card\": \"卡片视图\",\r\n    \"minimal\": \"极简列表\",\r\n    \"title\": \"标题瀑布\"\r\n  },\r\n  \"empty\": {\r\n    \"title\": \"没有找到匹配的书签\",\r\n    \"hint\": \"尝试调整筛选条件或搜索关键词\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/zh-CN/tabGroups.json",
    "content": "{\r\n  \"title\": \"标签页组\",\r\n  \"page\": {\r\n    \"loading\": \"加载中...\",\r\n    \"loadFailed\": \"加载标签页组失败\",\r\n    \"refreshFailed\": \"刷新失败\",\r\n    \"createFolderFailed\": \"创建文件夹失败\",\r\n    \"renameFailed\": \"重命名失败\",\r\n    \"moveFailed\": \"移动失败\"\r\n  },\r\n  \"menu\": {\r\n    \"openInNewWindow\": \"在新窗口中打开\",\r\n    \"openInCurrentWindow\": \"在此窗口中打开\",\r\n    \"openInIncognito\": \"在新的隐身窗口中打开\",\r\n    \"rename\": \"重命名\",\r\n    \"shareAsPage\": \"共享为网页\",\r\n    \"copyToClipboard\": \"复制到剪贴板\",\r\n    \"createFolderAbove\": \"在上方创建文件夹\",\r\n    \"createFolderInside\": \"在内部创建文件夹\",\r\n    \"createFolderBelow\": \"在下方创建文件夹\",\r\n    \"removeDuplicates\": \"移除重复项\",\r\n    \"move\": \"移动\",\r\n    \"pinToTop\": \"固定到顶部\",\r\n    \"lock\": \"锁定\",\r\n    \"unlock\": \"解锁\",\r\n    \"moveToTrash\": \"移至回收站\",\r\n    \"openLink\": \"打开链接\",\r\n    \"pin\": \"固定\",\r\n    \"unpin\": \"取消固定\",\r\n    \"markTodo\": \"标记待办\",\r\n    \"unmarkTodo\": \"取消待办\",\r\n    \"moveToOtherGroup\": \"移动到其他组\",\r\n    \"setColor\": \"设置颜色\",\r\n    \"createFolder\": \"创建文件夹\"\r\n  },\r\n  \"item\": {\r\n    \"save\": \"保存\",\r\n    \"cancel\": \"取消\",\r\n    \"edit\": \"编辑\",\r\n    \"delete\": \"删除\",\r\n    \"pinned\": \"已固定\",\r\n    \"todo\": \"待办\"\r\n  },\r\n  \"message\": {\r\n    \"noTabsToOpen\": \"没有可打开的标签页\",\r\n    \"noTabsInGroup\": \"此分组没有标签页\",\r\n    \"copiedToClipboard\": \"已复制到剪贴板\",\r\n    \"copyFailed\": \"复制失败\",\r\n    \"createFolderFailed\": \"创建文件夹失败\",\r\n    \"pinFailed\": \"固定失败\",\r\n    \"noDuplicates\": \"没有找到重复项\",\r\n    \"duplicatesRemoved\": \"已删除 {{count}} 个重复项\",\r\n    \"deleteFailed\": \"删除失败\",\r\n    \"operationFailed\": \"操作失败\",\r\n    \"movedToTrash\": \"已移至回收站\",\r\n    \"titleRequired\": \"标题不能为空\",\r\n    \"renameSuccess\": \"重命名成功\",\r\n    \"renameFailed\": \"重命名失败，请重试\",\r\n    \"editSuccess\": \"编辑成功\",\r\n    \"editFailed\": \"编辑失败，请重试\",\r\n    \"pinSuccess\": \"已固定\",\r\n    \"unpinSuccess\": \"已取消固定\",\r\n    \"todoSuccess\": \"已标记待办\",\r\n    \"untodoSuccess\": \"已取消待办\",\r\n    \"deleteSuccess\": \"删除成功\",\r\n    \"exportSuccess\": \"导出成功\",\r\n    \"openingTabs\": \"正在打开 {{count}} 个标签页...\",\r\n    \"openTabsFailed\": \"打开标签页失败，请重试\",\r\n    \"cannotOpenWindow\": \"无法打开新窗口，请检查浏览器弹窗设置\",\r\n    \"tabManagerOpened\": \"已在{{mode}}中打开标签页管理器\",\r\n    \"moveFunctionDeveloping\": \"移动功能开发中（请使用拖拽）\",\r\n    \"batchDeleteSuccess\": \"批量删除成功\",\r\n    \"batchDeleteFailed\": \"批量删除失败，请重试\",\r\n    \"batchPinSuccess\": \"批量固定成功\",\r\n    \"batchPinFailed\": \"批量固定失败，请重试\",\r\n    \"batchTodoSuccess\": \"批量标记待办成功\",\r\n    \"batchTodoFailed\": \"批量标记待办失败，请重试\"\r\n  },\r\n  \"confirm\": {\r\n    \"openMultipleTabs\": \"打开多个标签页\",\r\n    \"openTabsMessage\": \"确定要在{{mode}}中打开 {{count}} 个标签页吗？\",\r\n    \"openTabsWarning\": \"即将打开 {{count}} 个标签页。\\n\\n⚠️ 如果浏览器拦截弹窗，请在地址栏点击\\\"允许弹窗\\\"。\\n\\n是否继续？\",\r\n    \"deleteGroup\": \"删除标签页组\",\r\n    \"deleteGroupMessage\": \"确定要删除标签页组\\\"{{title}}\\\"吗？此操作将移至回收站。\",\r\n    \"deleteItem\": \"删除标签页\",\r\n    \"deleteItemMessage\": \"确定要删除\\\"{{title}}\\\"吗？此操作不可撤销。\",\r\n    \"removeDuplicates\": \"删除重复项\",\r\n    \"removeDuplicatesMessage\": \"找到 {{count}} 个重复项，是否删除？\",\r\n    \"batchDelete\": \"批量删除\",\r\n    \"batchDeleteMessage\": \"确定要删除选中的 {{count}} 个标签页吗？\",\r\n    \"restoreGroup\": \"恢复标签页组\",\r\n    \"restoreGroupMessage\": \"确定要恢复\\\"{{title}}\\\"吗？\",\r\n    \"permanentDelete\": \"永久删除\",\r\n    \"permanentDeleteMessage\": \"确定要永久删除\\\"{{title}}\\\"吗？此操作不可撤销！\"\r\n  },\r\n  \"share\": {\r\n    \"linkCreated\": \"分享链接已创建\",\r\n    \"linkCreatedMessage\": \"分享链接已创建并复制到剪贴板：\\n\\n{{url}}\\n\\n有效期：30天\",\r\n    \"linkCreatedManualCopy\": \"分享链接已创建：\\n\\n{{url}}\\n\\n有效期：30天\\n\\n（复制到剪贴板失败，请手动复制）\",\r\n    \"createFailed\": \"创建分享链接失败\"\r\n  },\r\n  \"export\": {\r\n    \"title\": \"批量导出的标签页\",\r\n    \"exportTime\": \"导出时间\",\r\n    \"tabCount\": \"标签页数量\",\r\n    \"createdTime\": \"创建时间\",\r\n    \"tags\": \"标签\"\r\n  },\r\n  \"openMode\": {\r\n    \"newWindow\": \"新窗口\",\r\n    \"currentWindow\": \"当前窗口\",\r\n    \"incognito\": \"隐身窗口\"\r\n  },\r\n  \"tabOpener\": {\r\n    \"title\": \"正在打开标签页...\",\r\n    \"heading\": \"🚀 正在打开标签页\",\r\n    \"preparing\": \"准备打开...\",\r\n    \"opening\": \"正在打开: \",\r\n    \"successPartial\": \"✅ 成功打开 {{opened}} 个，❌ 失败 {{failed}} 个\",\r\n    \"successAll\": \"✅ 全部打开成功！共 {{count}} 个标签页\",\r\n    \"closeWindow\": \"关闭此窗口\"\r\n  },\r\n  \"statistics\": {\r\n    \"title\": \"使用统计\",\r\n    \"tabGroups\": \"标签页组\",\r\n    \"tabs\": \"标签页\",\r\n    \"shares\": \"分享\",\r\n    \"trash\": \"回收站\",\r\n    \"topDomains\": \"热门域名 Top 10\",\r\n    \"noData\": \"暂无数据\",\r\n    \"count\": \"{{count}} 个\",\r\n    \"groupSizeDistribution\": \"标签页组大小分布\",\r\n    \"groupCreationTrend\": \"标签页组创建趋势\",\r\n    \"tabAdditionTrend\": \"标签页添加趋势\",\r\n    \"last7Days\": \"最近 7 天\",\r\n    \"last30Days\": \"最近 30 天\",\r\n    \"last90Days\": \"最近 90 天\",\r\n    \"backToTabGroups\": \"返回标签页组\"\r\n  },\r\n  \"trashPage\": {\r\n    \"title\": \"回收站\",\r\n    \"description\": \"已删除的标签页组将保留在这里，可以恢复或永久删除\",\r\n    \"empty\": \"回收站是空的\",\r\n    \"emptyDescription\": \"没有已删除的标签页组\",\r\n    \"restore\": \"恢复\",\r\n    \"permanentDelete\": \"永久删除\",\r\n    \"deletedAt\": \"删除于\",\r\n    \"restoreSuccess\": \"恢复成功\",\r\n    \"restoreFailed\": \"恢复失败，请重试\",\r\n    \"deleteSuccess\": \"删除成功\",\r\n    \"deleteFailed\": \"删除失败，请重试\",\r\n    \"loadFailed\": \"加载回收站失败\"\r\n  },\r\n  \"todo\": {\r\n    \"title\": \"待办事项\",\r\n    \"empty\": \"暂无待办事项\",\r\n    \"emptyTip\": \"在标签页上点击\\\"待办\\\"按钮添加\",\r\n    \"todoMarked\": \"已标记为待办\",\r\n    \"todoUnmarked\": \"已取消待办\",\r\n    \"tabDeleted\": \"标签页已删除\",\r\n    \"incognitoNotSupported\": \"隐身模式打开需要浏览器扩展支持\",\r\n    \"renameSuccess\": \"重命名成功\",\r\n    \"noGroupsToMove\": \"没有可移动到的分组\",\r\n    \"moveTab\": \"移动标签页\",\r\n    \"moveTabTo\": \"将 \\\"{{title}}\\\" 移动到：\",\r\n    \"moveTabMessage\": \"确定要将此标签页移动到\\\"{{title}}\\\"吗？\",\r\n    \"archiveTab\": \"归档标签页\",\r\n    \"archiveTabMessage\": \"确定要归档这个标签页吗？归档后可以在归档视图中查看。\",\r\n    \"tabMoved\": \"已移至\\\"{{title}}\\\"\",\r\n    \"tabArchived\": \"标签页已归档\",\r\n    \"cancelTaskMark\": \"取消任务标记\",\r\n    \"markAsCompleted\": \"标记为已完成任务\",\r\n    \"moveToOtherGroup\": \"移动到其他分组\",\r\n    \"markAsArchived\": \"标记为已归档\"\r\n  },\r\n  \"detail\": {\r\n    \"backToList\": \"返回列表\",\r\n    \"editTitle\": \"编辑标题\",\r\n    \"tabCount\": \"{{count}} 个标签页\",\r\n    \"noTabs\": \"此标签页组没有标签页\",\r\n    \"tabsCleared\": \"标签页组已被清空\",\r\n    \"totalTabs\": \"共 {{count}} 个标签页\",\r\n    \"restoreAll\": \"全部恢复\",\r\n    \"openAllTabs\": \"打开所有标签页\",\r\n    \"openAllMessage\": \"确定要在新标签页中打开 {{count}} 个链接吗？\",\r\n    \"openAllWarning\": \"即将打开 {{count}} 个标签页，将分批打开以避免浏览器拦截。\\n\\n每批 10 个，间隔 1 秒。\\n\\n是否继续？\",\r\n    \"openingBatch\": \"正在打开第 {{current}}/{{total}} 批...\",\r\n    \"allOpened\": \"已成功打开 {{count}} 个标签页！\",\r\n    \"titleUpdateSuccess\": \"标题更新成功\",\r\n    \"titleUpdateFailed\": \"更新标题失败，请重试\",\r\n    \"updateSuccess\": \"更新成功\",\r\n    \"updateFailed\": \"更新失败，请重试\",\r\n    \"groupNotFound\": \"标签页组不存在\"\r\n  },\r\n  \"folder\": {\r\n    \"newFolder\": \"新文件夹\",\r\n    \"tabsInFolder\": \"{{count}} 个标签页\",\r\n    \"dropHere\": \"放置到此文件夹\"\r\n  },\r\n  \"search\": {\r\n    \"placeholder\": \"搜索标签页组...\",\r\n    \"noResults\": \"没有找到匹配的标签页组\",\r\n    \"tryDifferent\": \"尝试使用不同的关键词搜索 \\\"{{query}}\\\"\"\r\n  },\r\n  \"empty\": {\r\n    \"title\": \"还没有标签页组\",\r\n    \"description\": \"使用浏览器扩展收集标签页，或者在这里创建新的标签页组来开始管理您的标签页\",\r\n    \"tip\": \"💡 提示：安装浏览器扩展可以快速收集当前窗口的所有标签页\"\r\n  },\r\n  \"sort\": {\r\n    \"label\": \"排序\",\r\n    \"created\": \"按创建时间\",\r\n    \"title\": \"按标题\",\r\n    \"count\": \"按标签页数量\",\r\n    \"byCreated\": \"按创建时间\",\r\n    \"byTitle\": \"按标题\",\r\n    \"byCount\": \"按标签页数量\"\r\n  },\r\n  \"batch\": {\r\n    \"enter\": \"批量操作\",\r\n    \"exit\": \"退出批量操作\",\r\n    \"selected\": \"已选择 {{count}} 个\",\r\n    \"selectAll\": \"全选\",\r\n    \"deselectAll\": \"取消\",\r\n    \"delete\": \"删除\",\r\n    \"pin\": \"固定\",\r\n    \"todo\": \"待办\",\r\n    \"export\": \"导出\",\r\n    \"cancel\": \"取消\"\r\n  },\r\n  \"trash\": {\r\n    \"title\": \"回收站\",\r\n    \"empty\": \"回收站是空的\",\r\n    \"restore\": \"恢复\",\r\n    \"deletePermanently\": \"永久删除\"\r\n  },\r\n  \"share\": {\r\n    \"title\": \"分享标签页组\",\r\n    \"groupName\": \"标签页组名称\",\r\n    \"link\": \"分享链接\",\r\n    \"copy\": \"复制\",\r\n    \"copied\": \"已复制\",\r\n    \"viewCount\": \"浏览次数\",\r\n    \"tip\": \"💡 任何人都可以通过此链接查看您的标签页组，但无法编辑。\",\r\n    \"delete\": \"删除分享\",\r\n    \"close\": \"关闭\",\r\n    \"generating\": \"生成分享链接中...\",\r\n    \"createFailed\": \"创建分享链接失败\",\r\n    \"deleteFailed\": \"删除失败\",\r\n    \"copyFailed\": \"无法复制到剪贴板，请手动复制链接。\",\r\n    \"confirmDelete\": \"确定要删除分享链接吗？删除后链接将失效。\"\r\n  },\r\n  \"action\": {\r\n    \"create\": \"新建标签页组\",\r\n    \"edit\": \"编辑\",\r\n    \"delete\": \"删除\",\r\n    \"deleting\": \"删除中...\",\r\n    \"share\": \"分享\",\r\n    \"open\": \"打开\",\r\n    \"openAll\": \"打开全部\",\r\n    \"export\": \"导出 Markdown\",\r\n    \"rename\": \"重命名\",\r\n    \"save\": \"保存\",\r\n    \"cancel\": \"取消\"\r\n  },\r\n  \"header\": {\r\n    \"tabCount\": \"{{count}} 个标签页\"\r\n  },\r\n  \"moveToFolder\": {\r\n    \"title\": \"移动到文件夹\",\r\n    \"description\": \"选择目标文件夹，将\\\"{{title}}\\\"移动到该位置\",\r\n    \"rootFolder\": \"根目录\",\r\n    \"noFolders\": \"没有可用的文件夹\",\r\n    \"confirm\": \"确定移动\"\r\n  },\r\n  \"sidebar\": {\r\n    \"title\": \"标签页组\",\r\n    \"all\": \"全部\",\r\n    \"noGroups\": \"暂无分组\",\r\n    \"moreItems\": \"还有 {{count}} 项...\"\r\n  },\r\n  \"color\": {\r\n    \"none\": \"无\",\r\n    \"red\": \"红色\",\r\n    \"orange\": \"橙色\",\r\n    \"yellow\": \"黄色\",\r\n    \"green\": \"绿色\",\r\n    \"blue\": \"蓝色\",\r\n    \"purple\": \"紫色\",\r\n    \"pink\": \"粉色\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/i18n/locales/zh-CN/tags.json",
    "content": "{\r\n  \"title\": \"标签\",\r\n  \"filter\": {\r\n    \"title\": \"标签筛选\",\r\n    \"close\": \"关闭标签抽屉\"\r\n  },\r\n  \"empty\": {\r\n    \"title\": \"暂无标签\",\r\n    \"description\": \"点击 + 创建第一个标签\",\r\n    \"noMatch\": \"没有找到匹配的标签\",\r\n    \"readOnly\": \"发布者尚未公开任何标签\"\r\n  },\r\n  \"sort\": {\r\n    \"byUsage\": \"按书签数排序 (点击切换到按热度)\",\r\n    \"byClicks\": \"按热度排序 (点击切换到按名称)\",\r\n    \"byName\": \"按名称排序 (点击切换到按书签数)\"\r\n  },\r\n  \"layout\": {\r\n    \"grid\": \"网格布局 (点击切换到瀑布流)\",\r\n    \"masonry\": \"瀑布流布局 (点击切换到网格)\"\r\n  },\r\n  \"selection\": {\r\n    \"clear\": \"清空选中\",\r\n    \"clearWithCount\": \"清空选中 ({{count}})\"\r\n  },\r\n  \"action\": {\r\n    \"manage\": \"管理标签\",\r\n    \"create\": \"新建标签\",\r\n    \"cancel\": \"取消\",\r\n    \"edit\": \"编辑标签\",\r\n    \"delete\": \"删除标签\",\r\n    \"deleting\": \"删除中...\",\r\n    \"save\": \"保存\",\r\n    \"saving\": \"保存中...\",\r\n    \"done\": \"完成\"\r\n  },\r\n  \"form\": {\r\n    \"placeholder\": \"输入标签名称...\",\r\n    \"namePlaceholder\": \"输入标签名称\",\r\n    \"nameLabel\": \"标签名称\",\r\n    \"editHint\": \"调整标签名称，仅影响当前标签。\"\r\n  },\r\n  \"manage\": {\r\n    \"title\": \"标签管理\",\r\n    \"description\": \"编辑标签名称或删除不需要的标签\",\r\n    \"noTags\": \"暂无标签\",\r\n    \"noTagsHint\": \"点击 + 按钮创建第一个标签\",\r\n    \"bookmarkCount\": \"{{count}} 个关联书签\",\r\n    \"noBookmarks\": \"暂无关联书签\"\r\n  },\r\n  \"confirm\": {\r\n    \"deleteTitle\": \"删除标签\",\r\n    \"deleteMessage\": \"确定要删除标签\\\"{{name}}\\\"吗？关联的书签不会被删除。\"\r\n  },\r\n  \"message\": {\r\n    \"updateSuccess\": \"标签已成功更新\",\r\n    \"updateFailed\": \"更新失败，请重试\",\r\n    \"deleteSuccess\": \"标签已成功删除\",\r\n    \"deleteFailed\": \"删除失败，请重试\"\r\n  },\r\n  \"status\": {\r\n    \"loading\": \"加载中...\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/lib/ai/client.ts",
    "content": "/**\r\n * AI 服务客户端\r\n * 支持多种 AI 服务商的统一调用接口\r\n */\r\n\r\nimport { AI_SERVICE_URLS, AI_DEFAULT_MODELS, AI_TIMEOUT, type AIProvider } from './constants'\r\n\r\n// 系统提示词\r\nconst SYSTEM_PROMPT = '你是一个智能书签整理助手。请根据用户的要求整理书签数据。返回格式必须是 JSON。'\r\n\r\n// 调用参数\r\nexport interface AICallParams {\r\n  provider: AIProvider\r\n  apiKey: string\r\n  apiUrl?: string\r\n  model?: string\r\n  prompt: string\r\n  systemPrompt?: string\r\n  temperature?: number\r\n  maxTokens?: number\r\n}\r\n\r\n// 调用结果\r\nexport interface AICallResult {\r\n  content: string\r\n  raw: unknown\r\n}\r\n\r\n/**\r\n * 解析 API URL，确保正确拼接端点\r\n */\r\nfunction resolveEndpoint(baseUrl: string, endpoint: string): string {\r\n  const trimmed = baseUrl.trim()\r\n  if (!trimmed) return endpoint\r\n  if (trimmed.includes(endpoint)) return trimmed\r\n  \r\n  const normalizedBase = trimmed.replace(/\\/$/, '')\r\n  const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`\r\n  return `${normalizedBase}${normalizedEndpoint}`\r\n}\r\n\r\n/**\r\n * 构建 OpenAI 兼容格式的请求\r\n */\r\nfunction buildOpenAIRequest(params: AICallParams, options?: {\r\n  includeJsonResponseFormat?: boolean\r\n  additionalBody?: Record<string, unknown>\r\n}) {\r\n  const { apiKey, apiUrl, model, prompt, systemPrompt, temperature, maxTokens, provider } = params\r\n  \r\n  const baseUrl = apiUrl?.trim() || AI_SERVICE_URLS[provider]\r\n  const url = resolveEndpoint(baseUrl, '/chat/completions')\r\n  \r\n  const headers: Record<string, string> = {\r\n    'Content-Type': 'application/json',\r\n    'Authorization': `Bearer ${apiKey}`\r\n  }\r\n  \r\n  const body: Record<string, unknown> = {\r\n    model: model || AI_DEFAULT_MODELS[provider],\r\n    messages: [\r\n      { role: 'system', content: systemPrompt || SYSTEM_PROMPT },\r\n      { role: 'user', content: prompt }\r\n    ],\r\n    temperature: temperature ?? 0.7,\r\n    max_tokens: maxTokens ?? 2000\r\n  }\r\n  \r\n  if (options?.includeJsonResponseFormat) {\r\n    body.response_format = { type: 'json_object' }\r\n  }\r\n  \r\n  if (options?.additionalBody) {\r\n    Object.assign(body, options.additionalBody)\r\n  }\r\n  \r\n  return { url, headers, body }\r\n}\r\n\r\n/**\r\n * 构建 Claude 格式的请求\r\n */\r\nfunction buildClaudeRequest(params: AICallParams) {\r\n  const { apiKey, apiUrl, model, prompt, systemPrompt, temperature, maxTokens } = params\r\n  \r\n  const baseUrl = apiUrl?.trim() || AI_SERVICE_URLS.claude\r\n  const url = resolveEndpoint(baseUrl, '/messages')\r\n  \r\n  const headers: Record<string, string> = {\r\n    'Content-Type': 'application/json',\r\n    'x-api-key': apiKey,\r\n    'anthropic-version': '2023-06-01'\r\n  }\r\n  \r\n  const body = {\r\n    model: model || AI_DEFAULT_MODELS.claude,\r\n    system: systemPrompt || SYSTEM_PROMPT,\r\n    max_tokens: maxTokens ?? 2000,\r\n    temperature: temperature ?? 0.7,\r\n    messages: [\r\n      { role: 'user', content: prompt }\r\n    ]\r\n  }\r\n  \r\n  return { url, headers, body }\r\n}\r\n\r\n/**\r\n * 从 OpenAI 兼容格式的响应中提取内容\r\n */\r\nfunction extractOpenAIContent(data: unknown): string | undefined {\r\n  if (!data || typeof data !== 'object') return undefined\r\n  \r\n  const dataObj = data as Record<string, unknown>\r\n  const choices = dataObj.choices\r\n  \r\n  if (!Array.isArray(choices) || choices.length === 0) return undefined\r\n  \r\n  const firstChoice = choices[0]\r\n  if (!firstChoice || typeof firstChoice !== 'object') return undefined\r\n  \r\n  const message = (firstChoice as Record<string, unknown>).message\r\n  if (!message || typeof message !== 'object') return undefined\r\n  \r\n  const content = (message as Record<string, unknown>).content\r\n  if (typeof content === 'string') return content.trim()\r\n  \r\n  return undefined\r\n}\r\n\r\n/**\r\n * 从 Claude 格式的响应中提取内容\r\n */\r\nfunction extractClaudeContent(data: unknown): string | undefined {\r\n  if (!data || typeof data !== 'object') return undefined\r\n  \r\n  const dataObj = data as Record<string, unknown>\r\n  const content = dataObj.content\r\n  \r\n  if (Array.isArray(content) && content.length > 0) {\r\n    const first = content[0]\r\n    if (first && typeof first === 'object' && 'text' in first) {\r\n      return (first as { text: string }).text.trim()\r\n    }\r\n  }\r\n  \r\n  return undefined\r\n}\r\n\r\n/**\r\n * 调用 AI 服务\r\n */\r\nexport async function callAI(params: AICallParams): Promise<AICallResult> {\r\n  const { provider } = params\r\n  \r\n  let url: string\r\n  let headers: Record<string, string>\r\n  let body: Record<string, unknown>\r\n  let extractContent: (data: unknown) => string | undefined\r\n  \r\n  // 根据服务商构建请求\r\n  switch (provider) {\r\n    case 'claude':\r\n      ({ url, headers, body } = buildClaudeRequest(params))\r\n      extractContent = extractClaudeContent\r\n      break\r\n    \r\n    case 'openai':\r\n    case 'deepseek':\r\n      ({ url, headers, body } = buildOpenAIRequest(params, { includeJsonResponseFormat: true }))\r\n      extractContent = extractOpenAIContent\r\n      break\r\n    \r\n    case 'siliconflow':\r\n      ({ url, headers, body } = buildOpenAIRequest(params, { additionalBody: { stream: false } }))\r\n      extractContent = extractOpenAIContent\r\n      break\r\n    \r\n    case 'modelscope':\r\n      ({ url, headers, body } = buildOpenAIRequest(params, { additionalBody: { result_format: 'message' } }))\r\n      extractContent = extractOpenAIContent\r\n      break\r\n    \r\n    default:\r\n      // zhipu, iflow, custom 都使用 OpenAI 兼容格式\r\n      ({ url, headers, body } = buildOpenAIRequest(params))\r\n      extractContent = extractOpenAIContent\r\n  }\r\n  \r\n  // 发送请求\r\n  const controller = new AbortController()\r\n  const timeoutId = setTimeout(() => controller.abort(), AI_TIMEOUT)\r\n  \r\n  try {\r\n    const response = await fetch(url, {\r\n      method: 'POST',\r\n      headers,\r\n      body: JSON.stringify(body),\r\n      signal: controller.signal\r\n    })\r\n    \r\n    clearTimeout(timeoutId)\r\n    \r\n    if (!response.ok) {\r\n      let errorText: string\r\n      try {\r\n        errorText = await response.text()\r\n      } catch {\r\n        errorText = 'Unknown error'\r\n      }\r\n      throw new Error(`AI API 请求失败 (${response.status}): ${errorText.substring(0, 200)}`)\r\n    }\r\n    \r\n    const data = await response.json()\r\n    const content = extractContent(data)\r\n    \r\n    if (!content) {\r\n      throw new Error(`AI 响应格式错误: ${JSON.stringify(data).substring(0, 200)}`)\r\n    }\r\n    \r\n    return { content, raw: data }\r\n  } catch (error) {\r\n    clearTimeout(timeoutId)\r\n    \r\n    if (error instanceof Error && error.name === 'AbortError') {\r\n      throw new Error('AI 请求超时，请稍后重试')\r\n    }\r\n    \r\n    throw error\r\n  }\r\n}\r\n\r\n/**\r\n * 测试 AI 连接\r\n */\r\nexport async function testAIConnection(params: {\r\n  provider: AIProvider\r\n  apiKey: string\r\n  apiUrl?: string\r\n  model?: string\r\n}): Promise<{ success: boolean; latency: number; error?: string }> {\r\n  const startTime = Date.now()\r\n  \r\n  try {\r\n    await callAI({\r\n      ...params,\r\n      prompt: 'Hi',\r\n      maxTokens: 5\r\n    })\r\n    \r\n    return {\r\n      success: true,\r\n      latency: Date.now() - startTime\r\n    }\r\n  } catch (error) {\r\n    return {\r\n      success: false,\r\n      latency: Date.now() - startTime,\r\n      error: error instanceof Error ? error.message : 'Unknown error'\r\n    }\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/lib/ai/constants.ts",
    "content": "/**\r\n * AI 服务常量定义\r\n * 包含服务商类型、API 地址和默认模型配置\r\n */\r\n\r\nexport type AIProvider = 'openai' | 'deepseek' | 'claude' | 'siliconflow' | 'modelscope' | 'custom'\r\n\r\nexport const AI_SERVICE_URLS: Record<AIProvider, string> = {\r\n  openai: 'https://api.openai.com/v1',\r\n  deepseek: 'https://api.deepseek.com/v1',\r\n  claude: 'https://api.anthropic.com/v1',\r\n  siliconflow: 'https://api.siliconflow.cn/v1',\r\n  modelscope: 'https://api-inference.modelscope.cn/v1',\r\n  custom: '',\r\n}\r\n\r\nexport const AI_DEFAULT_MODELS: Record<AIProvider, string> = {\r\n  openai: 'gpt-4o-mini',\r\n  deepseek: 'deepseek-chat',\r\n  claude: 'claude-3-5-sonnet-20241022',\r\n  siliconflow: 'deepseek-ai/DeepSeek-V2.5',\r\n  modelscope: 'Qwen/Qwen2.5-72B-Instruct',\r\n  custom: '',\r\n}\r\n\r\nexport const AI_TIMEOUT = 60_000\r\n"
  },
  {
    "path": "tmarks/src/lib/ai/models.ts",
    "content": "/**\r\n * AI 模型列表获取服务\r\n * 支持从 OpenAI 兼容 API 自动获取可用模型\r\n */\r\n\r\nimport i18n from '@/i18n'\r\nimport type { AIProvider } from './constants'\r\nimport { AI_SERVICE_URLS } from './constants'\r\n\r\n// 支持自动获取模型的服务商\r\nconst OPENAI_COMPATIBLE_PROVIDERS = new Set<AIProvider>([\r\n  'openai',\r\n  'deepseek',\r\n  'siliconflow',\r\n  'custom'\r\n])\r\n\r\n/**\r\n * 清理 API URL\r\n */\r\nconst sanitizeBaseUrl = (baseUrl: string): string => {\r\n  const trimmed = baseUrl.trim()\r\n  if (!trimmed) return trimmed\r\n\r\n  return trimmed\r\n    .replace(/\\s+/g, '')\r\n    .replace(/\\/chat\\/completions$/, '')\r\n    .replace(/\\/$/, '')\r\n}\r\n\r\n/**\r\n * 解析 API URL\r\n */\r\nconst resolveBaseUrl = (provider: AIProvider, apiUrl?: string): string | undefined => {\r\n  if (apiUrl && apiUrl.trim()) {\r\n    return sanitizeBaseUrl(apiUrl)\r\n  }\r\n\r\n  const fallback = AI_SERVICE_URLS[provider]\r\n  return fallback ? sanitizeBaseUrl(fallback) : undefined\r\n}\r\n\r\n/**\r\n * 从 OpenAI 兼容 API 获取模型列表\r\n */\r\nconst fetchOpenAIStyleModels = async (baseUrl: string, apiKey: string): Promise<string[]> => {\r\n  const url = `${baseUrl}/models`\r\n  const response = await fetch(url, {\r\n    method: 'GET',\r\n    headers: {\r\n      Authorization: `Bearer ${apiKey}`,\r\n      'Content-Type': 'application/json'\r\n    }\r\n  })\r\n\r\n  if (!response.ok) {\r\n    const errorText = await response.text()\r\n    throw new Error(`${i18n.t('errors:ai.modelListFailed')} (${response.status}): ${errorText || response.statusText}`)\r\n  }\r\n\r\n  const json = await response.json()\r\n  if (!Array.isArray(json?.data)) {\r\n    throw new Error(i18n.t('errors:ai.modelListInvalid'))\r\n  }\r\n\r\n  const models = json.data\r\n    .map((item: unknown) => {\r\n      if (item && typeof item === 'object' && 'id' in item) {\r\n        return (item as { id: unknown }).id\r\n      }\r\n      return undefined\r\n    })\r\n    .filter((id: unknown): id is string => typeof id === 'string' && id.length > 0)\r\n\r\n  if (models.length === 0) {\r\n    throw new Error(i18n.t('errors:ai.modelListEmpty'))\r\n  }\r\n\r\n  return models\r\n}\r\n\r\n/**\r\n * 检查是否支持自动获取模型\r\n */\r\nexport const canFetchModels = (provider: AIProvider, apiUrl?: string): boolean => {\r\n  if (!OPENAI_COMPATIBLE_PROVIDERS.has(provider)) {\r\n    return false\r\n  }\r\n\r\n  if (provider === 'custom') {\r\n    const trimmed = apiUrl?.trim()\r\n    return Boolean(trimmed && /^https?:\\/\\//.test(trimmed))\r\n  }\r\n\r\n  return true\r\n}\r\n\r\n/**\r\n * 获取可用模型列表\r\n */\r\nexport async function fetchAvailableModels(\r\n  provider: AIProvider,\r\n  apiKey: string,\r\n  apiUrl?: string\r\n): Promise<string[]> {\r\n  if (!OPENAI_COMPATIBLE_PROVIDERS.has(provider)) {\r\n    throw new Error(i18n.t('errors:ai.providerNotSupported'))\r\n  }\r\n\r\n  if (!apiKey.trim()) {\r\n    throw new Error(i18n.t('errors:ai.missingApiKey'))\r\n  }\r\n\r\n  const baseUrl = resolveBaseUrl(provider, apiUrl)\r\n  if (!baseUrl) {\r\n    throw new Error(i18n.t('errors:ai.missingApiUrl'))\r\n  }\r\n\r\n  return fetchOpenAIStyleModels(baseUrl, apiKey)\r\n}\r\n"
  },
  {
    "path": "tmarks/src/lib/api-client.ts",
    "content": "import type { ApiResponse } from './types'\r\nimport { useAuthStore } from '@/stores/authStore'\r\nimport { logger } from '@/lib/logger'\r\n\r\nconst API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'\r\n\r\nexport class ApiError extends Error {\r\n  constructor(\r\n    public code: string,\r\n    message: string,\r\n    public status: number\r\n  ) {\r\n    super(message)\r\n    this.name = 'ApiError'\r\n  }\r\n}\r\n\r\nlet isRefreshing = false\r\nlet refreshSubscribers: Array<{\r\n  resolve: (token: string) => void\r\n  reject: (error: Error) => void\r\n}> = []\r\n\r\nfunction subscribeToRefresh(): { promise: Promise<string>; unsubscribe: () => void } {\r\n  let entry: { resolve: (token: string) => void; reject: (error: Error) => void }\r\n  const promise = new Promise<string>((resolve, reject) => {\r\n    entry = { resolve, reject }\r\n    refreshSubscribers.push(entry)\r\n  })\r\n  const unsubscribe = () => {\r\n    refreshSubscribers = refreshSubscribers.filter(e => e !== entry)\r\n  }\r\n  return { promise, unsubscribe }\r\n}\r\n\r\nfunction onRefreshed(token: string) {\r\n  refreshSubscribers.forEach(({ resolve }) => resolve(token))\r\n  refreshSubscribers = []\r\n}\r\n\r\nfunction rejectSubscribers(error: Error) {\r\n  refreshSubscribers.forEach(({ reject }) => reject(error))\r\n  refreshSubscribers = []\r\n}\r\n\r\n/**\r\n * HTTP 客户端\r\n */\r\nclass HttpClient {\r\n  private baseURL: string\r\n\r\n  constructor(baseURL: string) {\r\n    this.baseURL = baseURL\r\n  }\r\n\r\n  private getAuthToken(): string | null {\r\n    return useAuthStore.getState().accessToken\r\n  }\r\n\r\n  private async request<T>(\r\n    endpoint: string,\r\n    options: RequestInit = {}\r\n  ): Promise<ApiResponse<T>> {\r\n    const url = `${this.baseURL}${endpoint}`\r\n    let token = this.getAuthToken()\r\n\r\n    const makeRequest = async (authToken: string) => {\r\n      const headers = new Headers({\r\n        'Content-Type': 'application/json',\r\n        ...(options.headers && typeof options.headers === 'object' ? options.headers : {}),\r\n      })\r\n\r\n      if (authToken && !headers.get('Authorization')) {\r\n        headers.set('Authorization', `Bearer ${authToken}`)\r\n      }\r\n\r\n      return fetch(url, {\r\n        ...options,\r\n        headers,\r\n      })\r\n    }\r\n\r\n    const handle401Error = async () => {\r\n      if (!isRefreshing) {\r\n        isRefreshing = true\r\n        try {\r\n          const authStore = useAuthStore.getState()\r\n          await authStore.refreshAccessToken()\r\n          const newToken = authStore.accessToken\r\n          if (newToken) {\r\n            onRefreshed(newToken)\r\n            return newToken\r\n          } else {\r\n            throw new Error('Failed to get new token after refresh')\r\n          }\r\n        } catch (error) {\r\n          const err = error instanceof Error ? error : new Error('Token refresh failed')\r\n          rejectSubscribers(err)\r\n          this.clearAuthAndRedirect()\r\n          logger.error('Token refresh failed:', err)\r\n          throw error\r\n        } finally {\r\n          isRefreshing = false\r\n        }\r\n      } else {\r\n        const { promise, unsubscribe } = subscribeToRefresh()\r\n        let timeoutId: ReturnType<typeof setTimeout>\r\n        const timeout = new Promise<never>((_, reject) => {\r\n          timeoutId = setTimeout(() => {\r\n            unsubscribe()\r\n            reject(new Error('Token refresh timeout'))\r\n          }, 10000)\r\n        })\r\n        return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId))\r\n      }\r\n    }\r\n\r\n    try {\r\n      let response = await makeRequest(token || '')\r\n\r\n      if (response.status === 401 && !endpoint.includes('/auth/refresh')) {\r\n        try {\r\n          const newToken = await handle401Error()\r\n          if (newToken) {\r\n            token = newToken\r\n            response = await makeRequest(newToken)\r\n          }\r\n        } catch {\r\n          let data: { error?: { code: string; message: string } } = { error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }\r\n          try {\r\n            const text = await response.text()\r\n            if (text) {\r\n              const parsed = JSON.parse(text) as { error?: { code: string; message: string } }\r\n              data = parsed\r\n            }\r\n          } catch {\r\n            // use default error\r\n          }\r\n          const apiError = data.error || { code: 'UNAUTHORIZED', message: 'Unauthorized' }\r\n          throw new ApiError(apiError.code, apiError.message, response.status)\r\n        }\r\n      }\r\n\r\n      if (response.status === 204) {\r\n        return {} as ApiResponse<T>\r\n      }\r\n\r\n      let data: unknown\r\n      try {\r\n        const text = await response.text()\r\n        if (!text || text.trim() === '') {\r\n          if (!response.ok) {\r\n            throw new ApiError('EMPTY_RESPONSE', 'Server returned empty response', response.status)\r\n          }\r\n          return {} as ApiResponse<T>\r\n        }\r\n        data = JSON.parse(text) as unknown\r\n      } catch (parseError) {\r\n        if (parseError instanceof ApiError) {\r\n          throw parseError\r\n        }\r\n        throw new ApiError(\r\n          'INVALID_RESPONSE',\r\n          `Failed to parse server response: ${parseError instanceof Error ? parseError.message : 'Invalid JSON'}`,\r\n          response.status\r\n        )\r\n      }\r\n\r\n      if (!response.ok) {\r\n        const errorData = data as { error?: { code: string; message: string } }\r\n        const error = errorData.error || { code: 'UNKNOWN_ERROR', message: 'An error occurred' }\r\n        throw new ApiError(error.code, error.message, response.status)\r\n      }\r\n\r\n      return data as ApiResponse<T>\r\n    } catch (error) {\r\n      if (error instanceof ApiError) {\r\n        throw error\r\n      }\r\n\r\n      throw new ApiError(\r\n        'NETWORK_ERROR',\r\n        error instanceof Error ? error.message : 'Network request failed',\r\n        0\r\n      )\r\n    }\r\n  }\r\n\r\n  private clearAuthAndRedirect() {\r\n    const { clearAuth } = useAuthStore.getState()\r\n    clearAuth()\r\n\r\n    if (!window.location.pathname.includes('/login')) {\r\n      window.location.href = '/login'\r\n    }\r\n  }\r\n\r\n  async get<T>(endpoint: string, options?: RequestInit): Promise<ApiResponse<T>> {\r\n    return this.request<T>(endpoint, { ...options, method: 'GET' })\r\n  }\r\n\r\n  async post<T>(endpoint: string, body?: unknown, options?: RequestInit): Promise<ApiResponse<T>> {\r\n    return this.request<T>(endpoint, {\r\n      ...options,\r\n      method: 'POST',\r\n      body: body ? JSON.stringify(body) : undefined,\r\n    })\r\n  }\r\n\r\n  async put<T>(endpoint: string, body?: unknown, options?: RequestInit): Promise<ApiResponse<T>> {\r\n    return this.request<T>(endpoint, {\r\n      ...options,\r\n      method: 'PUT',\r\n      body: body ? JSON.stringify(body) : undefined,\r\n    })\r\n  }\r\n\r\n  async patch<T>(endpoint: string, body?: unknown, options?: RequestInit): Promise<ApiResponse<T>> {\r\n    return this.request<T>(endpoint, {\r\n      ...options,\r\n      method: 'PATCH',\r\n      body: body ? JSON.stringify(body) : undefined,\r\n    })\r\n  }\r\n\r\n  async delete<T>(endpoint: string, options?: RequestInit): Promise<ApiResponse<T>> {\r\n    return this.request<T>(endpoint, { ...options, method: 'DELETE' })\r\n  }\r\n}\r\n\r\nexport const apiClient = new HttpClient(API_BASE_URL)\r\n"
  },
  {
    "path": "tmarks/src/lib/constants/bookmarks.ts",
    "content": "import type { SortOption } from '@/components/common/SortSelector'\n\nexport const VIEW_MODES = ['list', 'card', 'minimal', 'title'] as const\nexport type ViewMode = typeof VIEW_MODES[number]\n\nexport const SORT_OPTIONS: SortOption[] = ['created', 'updated', 'pinned', 'popular']\n\nexport type VisibilityFilter = 'all' | 'public' | 'private'\nexport const VISIBILITY_FILTERS: VisibilityFilter[] = ['all', 'public', 'private']\n\nexport const VIEW_MODE_STORAGE_KEY = 'tmarks:view_mode'\nexport const VIEW_MODE_UPDATED_AT_STORAGE_KEY = 'tmarks:view_mode_updated_at'\n"
  },
  {
    "path": "tmarks/src/lib/constants/z-index.ts",
    "content": "/**\n * Z-Index 层级常量\n * 统一管理所有组件的 z-index 值，避免层级冲突\n * \n * 层级规范：\n * - 0-9: 正常内容层\n * - 10-49: 固定元素（导航栏、底部栏等）\n * - 50-99: 弹出层（下拉菜单、工具提示等）\n * - 100-199: 模态框背景层\n * - 200-299: 模态框内容层\n * - 300-399: 全局提示/通知\n * - 400-499: 最高优先级（确认对话框等）\n */\n\nexport const Z_INDEX = {\n  // 正常内容层 (0-9)\n  NORMAL: 0,\n  \n  // 固定元素层 (10-49)\n  HEADER: 10,\n  MOBILE_BOTTOM_NAV: 20,\n  BATCH_ACTION_BAR: 30,\n  \n  // 弹出层 (50-99)\n  DROPDOWN: 50,\n  TOOLTIP: 60,\n  POPOVER: 70,\n  TAGS_INPUT: 50,\n  DRAWER_BACKDROP: 40,\n  DRAWER_CONTENT: 50,\n  \n  // 模态框层 (100-299)\n  MODAL_BACKDROP: 100,\n  MODAL_CONTENT: 200,\n  BOOKMARK_FORM: 200,\n  SNAPSHOT_VIEWER: 200,\n  API_KEY_MODAL: 200,\n  TAG_MANAGE_MODAL: 200,\n  MOVE_ITEM_DIALOG: 200,\n  SHARE_DIALOG: 200,\n  MOVE_TO_FOLDER_DIALOG: 200,\n  TAG_FORM_MODAL: 210,\n  \n  // 全局提示层 (300-399)\n  TOAST: 300,\n  SUCCESS_MESSAGE: 300,\n  ERROR_MESSAGE: 300,\n  \n  // 最高优先级层 (400-499)\n  CONFIRM_DIALOG: 400,\n  ALERT_DIALOG: 400,\n} as const\n\nexport type ZIndexKey = keyof typeof Z_INDEX\n"
  },
  {
    "path": "tmarks/src/lib/image-utils.ts",
    "content": "/**\n * 图片工具模块\n * 用于检测图片比例、类型判断和自适应处理\n */\n\nexport type ImageType = 'favicon' | 'cover' | 'unknown'\n\nexport interface ImageInfo {\n  type: ImageType\n  width: number\n  height: number\n  aspectRatio: number\n}\n\n/**\n * 加载图片并获取其尺寸信息\n */\nexport function loadImage(url: string): Promise<HTMLImageElement> {\n  return new Promise((resolve, reject) => {\n    const img = new Image()\n    img.crossOrigin = 'anonymous'\n\n    img.onload = () => resolve(img)\n    img.onerror = () => reject(new Error('Failed to load image'))\n\n    img.src = url\n  })\n}\n\n/**\n * 判断图片类型\n * - favicon: 正方形或接近正方形的图标 (宽高比在 0.8-1.25 之间)\n * - cover: 横向长方形封面图 (宽度明显大于高度)\n * - unknown: 其他类型\n */\nexport function detectImageType(width: number, height: number): ImageType {\n  if (width === 0 || height === 0) {\n    return 'unknown'\n  }\n\n  const aspectRatio = width / height\n\n  // 正方形或接近正方形 (0.8 - 1.25)\n  if (aspectRatio >= 0.8 && aspectRatio <= 1.25) {\n    return 'favicon'\n  }\n\n  // 横向长方形 (宽度大于高度)\n  if (aspectRatio > 1.25) {\n    return 'cover'\n  }\n\n  // 纵向或其他\n  return 'unknown'\n}\n\n/**\n * 分析图片并返回完整信息\n */\nexport async function analyzeImage(url: string): Promise<ImageInfo> {\n  try {\n    const img = await loadImage(url)\n    const type = detectImageType(img.naturalWidth, img.naturalHeight)\n\n    return {\n      type,\n      width: img.naturalWidth,\n      height: img.naturalHeight,\n      aspectRatio: img.naturalWidth / img.naturalHeight,\n    }\n  } catch {\n    return {\n      type: 'unknown',\n      width: 0,\n      height: 0,\n      aspectRatio: 0,\n    }\n  }\n}\n\n/**\n * 获取图片的 CSS 类名，用于不同类型的样式\n */\nexport function getImageClassName(type: ImageType): string {\n  switch (type) {\n    case 'favicon':\n      return 'image-favicon'\n    case 'cover':\n      return 'image-cover'\n    default:\n      return 'image-unknown'\n  }\n}\n"
  },
  {
    "path": "tmarks/src/lib/logger.ts",
    "content": "/**\r\n * 开发环境专用日志工具\r\n * 生产环境自动禁用所有 console 输出\r\n */\r\n\r\nconst isDev = import.meta.env.DEV\r\n\r\ntype LogValue = string | number | boolean | null | undefined | Error | Record<string, unknown> | unknown[] | unknown\r\n\r\nexport const logger = {\r\n  /**\r\n   * 普通日志\r\n   */\r\n  log: (...args: LogValue[]) => {\r\n    if (isDev) {\r\n      console.log(...args)\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 错误日志\r\n   */\r\n  error: (...args: LogValue[]) => {\r\n    if (isDev) {\r\n      console.error(...args)\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 警告日志\r\n   */\r\n  warn: (...args: LogValue[]) => {\r\n    if (isDev) {\r\n      console.warn(...args)\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 调试日志\r\n   */\r\n  debug: (...args: LogValue[]) => {\r\n    if (isDev) {\r\n      console.debug(...args)\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 信息日志\r\n   */\r\n  info: (...args: LogValue[]) => {\r\n    if (isDev) {\r\n      console.info(...args)\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 分组日志开始\r\n   */\r\n  group: (label: string) => {\r\n    if (isDev) {\r\n      console.group(label)\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 分组日志结束\r\n   */\r\n  groupEnd: () => {\r\n    if (isDev) {\r\n      console.groupEnd()\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 表格日志\r\n   */\r\n  table: (data: Record<string, unknown>[] | Record<string, unknown>) => {\r\n    if (isDev) {\r\n      console.table(data)\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 计时开始\r\n   */\r\n  time: (label: string) => {\r\n    if (isDev) {\r\n      console.time(label)\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 计时结束\r\n   */\r\n  timeEnd: (label: string) => {\r\n    if (isDev) {\r\n      console.timeEnd(label)\r\n    }\r\n  },\r\n}\r\n\r\n/**\r\n * 性能监控工具\r\n */\r\nexport const perf = {\r\n  /**\r\n   * 测量函数执行时间\r\n   */\r\n  measure: async <T>(label: string, fn: () => T | Promise<T>): Promise<T> => {\r\n    if (!isDev) {\r\n      return fn()\r\n    }\r\n\r\n    const start = performance.now()\r\n    try {\r\n      const result = await fn()\r\n      const end = performance.now()\r\n      logger.log(`⏱️ ${label}: ${(end - start).toFixed(2)}ms`)\r\n      return result\r\n    } catch (error) {\r\n      const end = performance.now()\r\n      logger.error(`❌ ${label} failed after ${(end - start).toFixed(2)}ms:`, error)\r\n      throw error\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 标记性能点\r\n   */\r\n  mark: (name: string) => {\r\n    if (isDev && performance.mark) {\r\n      performance.mark(name)\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 测量两个标记之间的时间\r\n   */\r\n  measureBetween: (name: string, startMark: string, endMark: string) => {\r\n    if (isDev && performance.measure) {\r\n      try {\r\n        performance.measure(name, startMark, endMark)\r\n        const measure = performance.getEntriesByName(name)[0]\r\n        if (measure) {\r\n          logger.log(`⏱️ ${name}: ${measure.duration.toFixed(2)}ms`)\r\n        }\r\n      } catch (error) {\r\n        logger.warn(`Failed to measure ${name}:`, error)\r\n      }\r\n    }\r\n  },\r\n}\r\n\r\n/**\r\n * 错误追踪工具（生产环境可以发送到错误追踪服务）\r\n */\r\nexport const errorTracker = {\r\n  /**\r\n   * 捕获错误\r\n   */\r\n  capture: (error: Error, context?: Record<string, unknown>) => {\r\n    if (isDev) {\r\n      logger.error('Error captured:', error, context)\r\n    } else {\r\n      // 生产环境：发送到错误追踪服务（如 Sentry）\r\n      // sendToErrorTrackingService(error, context)\r\n    }\r\n  },\r\n\r\n  /**\r\n   * 捕获异常\r\n   */\r\n  captureException: (exception: unknown, context?: Record<string, unknown>) => {\r\n    if (isDev) {\r\n      logger.error('Exception captured:', exception, context)\r\n    } else {\r\n      // 生产环境：发送到错误追踪服务\r\n      // sendToErrorTrackingService(exception, context)\r\n    }\r\n  },\r\n}\r\n\r\nexport default logger\r\n"
  },
  {
    "path": "tmarks/src/lib/query-client.ts",
    "content": "/**\r\n * React Query 客户端配置\r\n * \r\n * 提供持久化缓存支持，减少 API 请求，提升用户体验\r\n */\r\n\r\nimport { QueryClient } from '@tanstack/react-query'\r\nimport { persistQueryClient } from '@tanstack/react-query-persist-client'\r\nimport { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'\r\n\r\n/**\r\n * 创建持久化存储\r\n * 使用 localStorage 存储缓存数据\r\n */\r\nconst persister = createSyncStoragePersister({\r\n  storage: window.localStorage,\r\n  key: 'tmarks-cache',\r\n  serialize: JSON.stringify,\r\n  deserialize: JSON.parse,\r\n})\r\n\r\n/**\r\n * 创建 QueryClient 实例\r\n * \r\n * 配置说明:\r\n * - staleTime: 30分钟 (书签变化不频繁)\r\n * - gcTime: 24小时 (保留缓存数据)\r\n * - refetchOnWindowFocus: true (窗口聚焦时刷新)\r\n */\r\nexport const queryClient = new QueryClient({\r\n  defaultOptions: {\r\n    queries: {\r\n      staleTime: 30 * 60 * 1000,       // 30分钟\r\n      gcTime: 24 * 60 * 60 * 1000,     // 24小时\r\n      retry: 2,\r\n      refetchOnWindowFocus: true,       // 窗口聚焦时刷新\r\n      refetchOnReconnect: true,         // 重新连接时刷新\r\n    },\r\n    mutations: {\r\n      retry: 1,\r\n    },\r\n  },\r\n})\r\n\r\n/**\r\n * 启用持久化\r\n * \r\n * 配置说明:\r\n * - maxAge: 24小时 (缓存最大保留时间)\r\n * - buster: 版本号 (更新版本号会清除旧缓存)\r\n */\r\npersistQueryClient({\r\n  queryClient,\r\n  persister,\r\n  maxAge: 24 * 60 * 60 * 1000,  // 24小时\r\n  buster: 'v1',                  // 版本控制\r\n})\r\n"
  },
  {
    "path": "tmarks/src/lib/search-utils.ts",
    "content": "/**\r\n * 搜索工具函数 - 优化搜索性能\r\n */\r\n\r\n/**\r\n * 快速字符串匹配（不区分大小写）\r\n */\r\nexport function fastIncludes(text: string, query: string): boolean {\r\n  if (!query) return true\r\n  if (!text) return false\r\n  \r\n  const textLen = text.length\r\n  const queryLen = query.length\r\n  \r\n  if (queryLen > textLen) return false\r\n  if (queryLen === 0) return true\r\n  \r\n  const lowerText = text.toLowerCase()\r\n  const lowerQuery = query.toLowerCase()\r\n  \r\n  return lowerText.includes(lowerQuery)\r\n}\r\n\r\n/**\r\n * 批量搜索多个字段\r\n */\r\nexport function searchInFields(fields: string[], query: string): boolean {\r\n  if (!query) return true\r\n  \r\n  const lowerQuery = query.toLowerCase()\r\n  \r\n  for (const field of fields) {\r\n    if (field && field.toLowerCase().includes(lowerQuery)) {\r\n      return true\r\n    }\r\n  }\r\n  \r\n  return false\r\n}\r\n"
  },
  {
    "path": "tmarks/src/lib/types/api.types.ts",
    "content": "export interface ApiResponse<T = unknown> {\n  data?: T\n  error?: ApiError\n  meta?: {\n    page?: number\n    page_size?: number\n    total?: number\n    next_cursor?: string\n    count?: number\n  }\n}\n\nexport interface ApiError {\n  code: string\n  message: string\n  details?: unknown\n}\n"
  },
  {
    "path": "tmarks/src/lib/types/auth.types.ts",
    "content": "export interface User {\n  id: string\n  username: string\n  email: string | null\n  role?: 'user' | 'admin'\n  created_at?: string\n}\n\nexport interface LoginRequest {\n  username: string\n  password: string\n  remember_me?: boolean\n}\n\nexport interface LoginResponse {\n  access_token: string\n  refresh_token: string\n  token_type: string\n  expires_in: number\n  user: User\n}\n\nexport interface RegisterRequest {\n  username: string\n  password: string\n  email?: string\n}\n\nexport interface RegisterResponse {\n  user: User\n}\n\nexport interface RefreshTokenRequest {\n  refresh_token: string\n}\n\nexport interface RefreshTokenResponse {\n  access_token: string\n  token_type: string\n  expires_in: number\n  user: User\n}\n"
  },
  {
    "path": "tmarks/src/lib/types/bookmark.types.ts",
    "content": "export interface Tag {\n  id: string\n  user_id?: string\n  name: string\n  color: string | null\n  bookmark_count?: number\n  click_count?: number\n  last_clicked_at?: string | null\n  created_at?: string\n  updated_at?: string\n}\n\nexport interface Bookmark {\n  id: string\n  user_id: string\n  title: string\n  url: string\n  description: string | null\n  cover_image: string | null\n  favicon: string | null\n  is_pinned: boolean\n  is_archived: boolean\n  is_public: boolean\n  click_count: number\n  last_clicked_at: string | null\n  has_snapshot: boolean\n  latest_snapshot_at: string | null\n  snapshot_count?: number\n  ai_summary?: string | null\n  created_at: string\n  updated_at: string\n  deleted_at?: string | null\n  tags: Tag[]\n}\n\nexport interface CreateBookmarkRequest {\n  title: string\n  url: string\n  description?: string\n  cover_image?: string\n  favicon?: string\n  tag_ids?: string[]\n  is_pinned?: boolean\n  is_archived?: boolean\n  is_public?: boolean\n}\n\nexport interface UpdateBookmarkRequest {\n  title?: string\n  url?: string\n  description?: string | null\n  cover_image?: string | null\n  favicon?: string | null\n  tag_ids?: string[]\n  is_pinned?: boolean\n  is_archived?: boolean\n  is_public?: boolean\n}\n\nexport interface BookmarksResponse {\n  bookmarks: Bookmark[]\n  meta: {\n    page_size: number\n    count: number\n    next_cursor?: string\n    has_more: boolean\n    related_tag_ids?: string[]\n  }\n}\n\nexport interface CreateTagRequest {\n  name: string\n  color?: string\n}\n\nexport interface UpdateTagRequest {\n  name?: string\n  color?: string | null\n}\n\nexport interface TagsResponse {\n  tags: Tag[]\n}\n\nexport interface BookmarkQueryParams {\n  keyword?: string\n  tags?: string\n  page_size?: number\n  page_cursor?: string\n  sort?: 'created' | 'updated' | 'pinned' | 'popular'\n  archived?: boolean\n  pinned?: boolean\n}\n\nexport interface TagQueryParams {\n  sort?: 'usage' | 'name' | 'clicks'\n}\n\nexport type BatchActionType = 'delete' | 'update_tags' | 'pin' | 'unpin' | 'archive' | 'unarchive'\n\nexport interface BatchActionRequest {\n  action: BatchActionType\n  bookmark_ids: string[]\n  add_tag_ids?: string[]\n  remove_tag_ids?: string[]\n}\n\nexport interface BatchActionResponse {\n  success: boolean\n  affected_count: number\n  errors?: Array<{ bookmark_id: string; message: string }>\n}\n"
  },
  {
    "path": "tmarks/src/lib/types/index.ts",
    "content": "export * from './api.types'\nexport * from './auth.types'\nexport * from './bookmark.types'\nexport * from './preferences.types'\nexport * from './tab-group.types'\n"
  },
  {
    "path": "tmarks/src/lib/types/preferences.types.ts",
    "content": "import { Bookmark, Tag } from './bookmark.types'\n\nexport type TagLayoutPreference = 'grid' | 'masonry'\nexport type SortByPreference = 'created' | 'updated' | 'pinned' | 'popular'\n\nexport type DefaultBookmarkIcon = 'favicon' | 'letter' | 'hash' | 'none' | 'orbital-spinner'\n\nexport interface UserPreferences {\n  user_id?: string\n  theme: 'light' | 'dark' | 'system'\n  page_size: number\n  view_mode: 'list' | 'card' | 'minimal' | 'title'\n  density: 'compact' | 'normal' | 'comfortable'\n  tag_layout: TagLayoutPreference\n  sort_by: SortByPreference\n  search_auto_clear_seconds: number\n  tag_selection_auto_clear_seconds: number\n  enable_search_auto_clear: boolean\n  enable_tag_selection_auto_clear: boolean\n  default_bookmark_icon: DefaultBookmarkIcon\n  snapshot_retention_count: number\n  updated_at: string\n}\n\nexport interface UpdatePreferencesRequest {\n  theme?: 'light' | 'dark' | 'system'\n  page_size?: number\n  view_mode?: 'list' | 'card' | 'minimal' | 'title'\n  density?: 'compact' | 'normal' | 'comfortable'\n  tag_layout?: TagLayoutPreference\n  sort_by?: SortByPreference\n  search_auto_clear_seconds?: number\n  tag_selection_auto_clear_seconds?: number\n  enable_search_auto_clear?: boolean\n  enable_tag_selection_auto_clear?: boolean\n  search_debounce_ms?: number\n  mobile_edit_auto_cancel_seconds?: number\n  double_click_delay_ms?: number\n  enable_edit_confirmation?: boolean\n  default_bookmark_icon?: DefaultBookmarkIcon\n  snapshot_retention_count?: number\n  enable_animations?: boolean\n  animation_speed?: 'fast' | 'normal' | 'slow'\n  enable_virtual_scroll?: boolean\n  toast_duration_seconds?: number\n  enable_success_sound?: boolean\n  auto_copy_share_link?: boolean\n  auto_save_delay_seconds?: number\n  warn_unsaved_changes?: boolean\n  show_bookmark_thumbnails?: boolean\n  show_bookmark_descriptions?: boolean\n  show_tag_colors?: boolean\n  sidebar_default_expanded?: boolean\n}\n\nexport interface PreferencesResponse {\n  preferences: UserPreferences\n}\n\nexport interface ShareSettings {\n  enabled: boolean\n  slug: string | null\n  title: string | null\n  description: string | null\n}\n\nexport interface ShareSettingsResponse {\n  share: ShareSettings\n}\n\nexport interface R2StorageQuota {\n  used_bytes: number\n  limit_bytes: number | null\n  unlimited: boolean\n}\n\nexport interface R2StorageQuotaResponse {\n  quota: R2StorageQuota\n}\n\nexport interface UpdateShareSettingsRequest {\n  enabled?: boolean\n  slug?: string | null\n  title?: string | null\n  description?: string | null\n  regenerate_slug?: boolean\n}\n\nexport interface PublicSharePayload {\n  profile: {\n    username: string\n    title: string | null\n    description: string | null\n    slug: string\n  }\n  bookmarks: Bookmark[]\n  tags: Array<Tag & { bookmark_count: number }>\n  generated_at: string\n}\n\nexport interface PublicSharePaginatedPayload {\n  profile: {\n    username: string\n    title: string | null\n    description: string | null\n    slug: string\n  }\n  bookmarks: Bookmark[]\n  tags: Array<Tag & { bookmark_count: number }>\n  meta: {\n    page_size: number\n    count: number\n    next_cursor: string | null\n    has_more: boolean\n  }\n}\n"
  },
  {
    "path": "tmarks/src/lib/types/tab-group.types.ts",
    "content": "export interface TabGroupItem {\n  id: string\n  group_id: string\n  title: string\n  url: string\n  favicon: string | null\n  position: number\n  created_at: string\n  is_pinned?: boolean\n  is_todo?: boolean\n  is_archived?: boolean\n}\n\nexport interface TabGroup {\n  id: string\n  user_id: string\n  title: string\n  color: string | null\n  tags: string[] | null\n  parent_id: string | null\n  is_folder: number\n  is_deleted: number\n  deleted_at: string | null\n  position: number\n  created_at: string\n  updated_at: string\n  items?: TabGroupItem[]\n  item_count?: number\n  children?: TabGroup[]\n}\n\nexport interface CreateTabGroupRequest {\n  title?: string\n  parent_id?: string | null\n  is_folder?: number // Changed from boolean to number to match TabGroup.is_folder\n  items?: Array<{\n    title: string\n    url: string\n    favicon?: string\n  }>\n}\n\nexport interface UpdateTabGroupRequest {\n  title?: string\n  color?: string | null\n  tags?: string[] | null\n  parent_id?: string | null\n  position?: number\n}\n\nexport interface TabGroupsResponse {\n  tab_groups: TabGroup[]\n  meta?: {\n    page_size?: number\n    count: number\n    next_cursor?: string\n    has_more?: boolean\n  }\n}\n\nexport interface TabGroupResponse {\n  tab_group: TabGroup\n}\n\nexport interface Share {\n  id: string\n  group_id: string\n  user_id: string\n  share_token: string\n  is_public: number\n  view_count: number\n  created_at: string\n  expires_at: string | null\n}\n\nexport interface ShareResponse {\n  share: Share\n  share_url: string\n}\n\nexport interface StatisticsSummary {\n  total_groups: number\n  total_deleted_groups: number\n  total_items: number\n  total_shares: number\n}\n\nexport interface TrendData {\n  date: string\n  count: number\n}\n\nexport interface DomainCount {\n  domain: string\n  count: number\n}\n\nexport interface GroupSizeDistribution {\n  range: string\n  count: number\n}\n\nexport interface StatisticsResponse {\n  summary: StatisticsSummary\n  trends: {\n    groups: TrendData[]\n    items: TrendData[]\n  }\n  top_domains: DomainCount[]\n  group_size_distribution: GroupSizeDistribution[]\n}\n"
  },
  {
    "path": "tmarks/src/lib/types.ts",
    "content": "export * from './types/index'\r\n"
  },
  {
    "path": "tmarks/src/main.tsx",
    "content": "import React from 'react'\r\nimport ReactDOM from 'react-dom/client'\r\nimport App from './App'\r\nimport './styles/index.css'\r\n\r\n// 初始化 i18n\r\nimport './i18n'\r\n\r\nReactDOM.createRoot(document.getElementById('root')!).render(\r\n  <React.StrictMode>\r\n    <App />\r\n  </React.StrictMode>\r\n)\r\n"
  },
  {
    "path": "tmarks/src/pages/about/AboutPage.tsx",
    "content": ""
  },
  {
    "path": "tmarks/src/pages/auth/LoginPage.tsx",
    "content": "import { useState } from 'react'\r\nimport { useNavigate, Link } from 'react-router-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { useAuthStore } from '@/stores/authStore'\r\nimport { ApiError } from '@/lib/api-client'\r\n\r\nexport function LoginPage() {\r\n  const { t } = useTranslation('auth')\r\n  const navigate = useNavigate()\r\n  const login = useAuthStore((state) => state.login)\r\n  const isLoading = useAuthStore((state) => state.isLoading)\r\n\r\n  const [username, setUsername] = useState('')\r\n  const [password, setPassword] = useState('')\r\n  const [rememberMe, setRememberMe] = useState(false)\r\n  const [error, setError] = useState('')\r\n  const [showPassword, setShowPassword] = useState(false)\r\n\r\n  const handleSubmit = async (e: React.FormEvent) => {\r\n    e.preventDefault()\r\n    setError('')\r\n\r\n    if (!username || !password) {\r\n      setError(t('validation.usernameRequired'))\r\n      return\r\n    }\r\n\r\n    try {\r\n      await login(username, password, rememberMe)\r\n      navigate('/')\r\n    } catch (err) {\r\n      if (err instanceof ApiError) {\r\n        setError(err.message)\r\n      } else {\r\n        setError(t('error.loginFailed'))\r\n      }\r\n    }\r\n  }\r\n\r\n  return (\r\n    <div className=\"min-h-screen flex items-center justify-center p-4 relative overflow-hidden bg-gradient-to-br from-background via-background to-primary/5\">\r\n      {/* 背景装饰 */}\r\n      <div className=\"absolute inset-0 overflow-hidden pointer-events-none\">\r\n        <div className=\"absolute -top-40 -right-40 w-80 h-80 bg-primary/20 rounded-full blur-3xl\" />\r\n        <div className=\"absolute -bottom-40 -left-40 w-80 h-80 bg-secondary/20 rounded-full blur-3xl\" />\r\n      </div>\r\n\r\n      <div className=\"card w-full max-w-md shadow-float animate-fade-in relative z-10\">\r\n        {/* Logo/图标区域 */}\r\n        <div className=\"text-center mb-8\">\r\n          <div className=\"inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-secondary mb-4 shadow-float\">\r\n            <svg className=\"w-10 h-10 text-primary-content\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253\" />\r\n            </svg>\r\n          </div>\r\n          <h2 className=\"text-3xl font-bold mb-2 text-primary\">\r\n            {t('login.title')}\r\n          </h2>\r\n          <p className=\"text-base-content/60 text-sm\">{t('login.subtitle')}</p>\r\n        </div>\r\n\r\n        {error && (\r\n          <div className=\"mb-6 p-4 bg-error/10 border-2 border-error/30 text-error rounded-xl text-sm shadow-float animate-fade-in\">\r\n            <div className=\"flex items-center gap-2\">\r\n              <svg className=\"w-5 h-5 flex-shrink-0\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\r\n                <path fillRule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z\" clipRule=\"evenodd\" />\r\n              </svg>\r\n              <span>{error}</span>\r\n            </div>\r\n          </div>\r\n        )}\r\n\r\n        <form onSubmit={handleSubmit} className=\"space-y-5\">\r\n          <div>\r\n            <label htmlFor=\"username\" className=\"block text-sm font-semibold mb-2 text-base-content\">\r\n              {t('login.username')}\r\n            </label>\r\n            <input\r\n              id=\"username\"\r\n              type=\"text\"\r\n              className=\"input\"\r\n              placeholder={t('login.usernamePlaceholder')}\r\n              value={username}\r\n              onChange={(e) => setUsername(e.target.value)}\r\n              disabled={isLoading}\r\n              autoComplete=\"username\"\r\n            />\r\n          </div>\r\n\r\n          <div>\r\n            <label htmlFor=\"password\" className=\"block text-sm font-semibold mb-2 text-base-content\">\r\n              {t('login.password')}\r\n            </label>\r\n            <div className=\"relative\">\r\n              <input\r\n                id=\"password\"\r\n                type={showPassword ? \"text\" : \"password\"}\r\n                className=\"input pr-12\"\r\n                placeholder={t('login.passwordPlaceholder')}\r\n                value={password}\r\n                onChange={(e) => setPassword(e.target.value)}\r\n                disabled={isLoading}\r\n                autoComplete=\"current-password\"\r\n              />\r\n              <button\r\n                type=\"button\"\r\n                onClick={() => setShowPassword(!showPassword)}\r\n                className=\"absolute right-3 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content/70 transition-colors p-1\"\r\n                tabIndex={-1}\r\n              >\r\n                {showPassword ? (\r\n                  <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n                    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21\" />\r\n                  </svg>\r\n                ) : (\r\n                  <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n                    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\r\n                    <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\" />\r\n                  </svg>\r\n                )}\r\n              </button>\r\n            </div>\r\n          </div>\r\n\r\n          <div className=\"flex items-center\">\r\n            <input\r\n              id=\"remember\"\r\n              type=\"checkbox\"\r\n              className=\"checkbox mr-3\"\r\n              checked={rememberMe}\r\n              onChange={(e) => setRememberMe(e.target.checked)}\r\n              disabled={isLoading}\r\n            />\r\n            <label htmlFor=\"remember\" className=\"text-sm font-medium cursor-pointer\">\r\n              {t('login.rememberMe')}\r\n            </label>\r\n          </div>\r\n\r\n          <button type=\"submit\" className=\"btn w-full mt-6\" disabled={isLoading}>\r\n            {isLoading ? (\r\n              <span className=\"flex items-center justify-center gap-2\">\r\n                <svg className=\"animate-spin h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\">\r\n                  <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\"></circle>\r\n                  <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\r\n                </svg>\r\n                {t('login.submitting')}\r\n              </span>\r\n            ) : (\r\n              t('login.submit')\r\n            )}\r\n          </button>\r\n        </form>\r\n\r\n        <div className=\"mt-8 pt-6 border-t border-base-300/50\">\r\n          <p className=\"text-center text-sm text-base-content/60\">\r\n            {t('login.noAccount')}{' '}\r\n            <Link\r\n              to=\"/register\"\r\n              className=\"text-primary hover:text-primary/80 font-semibold transition-colors\"\r\n            >\r\n              {t('login.register')}\r\n            </Link>\r\n          </p>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/auth/RegisterPage.tsx",
    "content": "import { useState } from 'react'\r\nimport { useNavigate, Link } from 'react-router-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { useAuthStore } from '@/stores/authStore'\r\nimport { ApiError } from '@/lib/api-client'\r\n\r\nexport function RegisterPage() {\r\n  const { t } = useTranslation('auth')\r\n  const navigate = useNavigate()\r\n  const register = useAuthStore((state) => state.register)\r\n  const isLoading = useAuthStore((state) => state.isLoading)\r\n\r\n  const [username, setUsername] = useState('')\r\n  const [email, setEmail] = useState('')\r\n  const [password, setPassword] = useState('')\r\n  const [confirmPassword, setConfirmPassword] = useState('')\r\n  const [error, setError] = useState('')\r\n  const [success, setSuccess] = useState(false)\r\n  const [showPassword, setShowPassword] = useState(false)\r\n  const [showConfirmPassword, setShowConfirmPassword] = useState(false)\r\n\r\n  const handleSubmit = async (e: React.FormEvent) => {\r\n    e.preventDefault()\r\n    setError('')\r\n\r\n    if (!username || !password) {\r\n      setError(t('validation.usernameRequired'))\r\n      return\r\n    }\r\n\r\n    if (username.length < 3 || username.length > 20) {\r\n      setError(t('validation.usernameLength'))\r\n      return\r\n    }\r\n\r\n    if (!/^[a-zA-Z0-9_]+$/.test(username)) {\r\n      setError(t('validation.usernameFormat'))\r\n      return\r\n    }\r\n\r\n    if (password.length < 8) {\r\n      setError(t('validation.passwordLength'))\r\n      return\r\n    }\r\n\r\n    if (password !== confirmPassword) {\r\n      setError(t('validation.passwordMismatch'))\r\n      return\r\n    }\r\n\r\n    if (email && !/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email)) {\r\n      setError(t('validation.emailFormat'))\r\n      return\r\n    }\r\n\r\n    try {\r\n      await register(username, password, email || undefined)\r\n      setSuccess(true)\r\n      setTimeout(() => {\r\n        navigate('/login')\r\n      }, 2000)\r\n    } catch (err) {\r\n      if (err instanceof ApiError) {\r\n        if (err.status === 409) {\r\n          setError(t('error.userExists'))\r\n        } else if (err.status === 500) {\r\n          setError(t('error.serverErrorMaySuccess'))\r\n        } else {\r\n          setError(err.message)\r\n        }\r\n      } else {\r\n        setError(t('error.registerFailed'))\r\n      }\r\n      console.error('Register error:', err)\r\n    }\r\n  }\r\n\r\n  if (success) {\r\n    return (\r\n      <div className=\"min-h-screen flex items-center justify-center p-4 relative overflow-hidden bg-gradient-to-br from-background via-background to-primary/5\">\r\n        <div className=\"absolute inset-0 overflow-hidden pointer-events-none\">\r\n          <div className=\"absolute -top-40 -right-40 w-80 h-80 bg-primary/20 rounded-full blur-3xl\" />\r\n          <div className=\"absolute -bottom-40 -left-40 w-80 h-80 bg-secondary/20 rounded-full blur-3xl\" />\r\n        </div>\r\n\r\n        <div className=\"card w-full max-w-md text-center shadow-float animate-fade-in relative z-10\">\r\n          <div className=\"inline-flex items-center justify-center w-20 h-20 rounded-3xl bg-success mb-6 shadow-float mx-auto\">\r\n            <svg className=\"w-12 h-12 text-success-content\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M5 13l4 4L19 7\" />\r\n            </svg>\r\n          </div>\r\n          <h2 className=\"text-3xl font-bold mb-3 text-success\">\r\n            {t('register.successTitle')}\r\n          </h2>\r\n          <p className=\"text-base-content/60 text-lg\">{t('register.successMessage')}</p>\r\n          <div className=\"mt-6 flex justify-center\">\r\n            <div className=\"animate-spin w-6 h-6 border-3 border-primary border-t-transparent rounded-full\" />\r\n          </div>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <div className=\"min-h-screen flex items-center justify-center p-4 py-8 relative overflow-hidden bg-gradient-to-br from-background via-background to-primary/5\">\r\n      <div className=\"absolute inset-0 overflow-hidden pointer-events-none\">\r\n        <div className=\"absolute -top-40 -right-40 w-80 h-80 bg-primary/20 rounded-full blur-3xl\" />\r\n        <div className=\"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-accent/10 rounded-full blur-3xl\" />\r\n        <div className=\"absolute -bottom-40 -left-40 w-80 h-80 bg-secondary/20 rounded-full blur-3xl\" />\r\n      </div>\r\n\r\n      <div className=\"card w-full max-w-lg shadow-float animate-fade-in relative z-10\">\r\n        <div className=\"text-center mb-8\">\r\n          <div className=\"inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-primary to-secondary mb-4 shadow-float\">\r\n            <svg className=\"w-10 h-10 text-primary-content\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253\" />\r\n            </svg>\r\n          </div>\r\n          <h2 className=\"text-3xl font-bold mb-2 text-primary\">\r\n            {t('register.title')}\r\n          </h2>\r\n          <p className=\"text-base-content/60 text-sm\">{t('register.subtitle')}</p>\r\n        </div>\r\n\r\n        {error && (\r\n          <div className=\"mb-6 p-4 bg-error/10 border-2 border-error/30 text-error rounded-xl text-sm shadow-float animate-fade-in\">\r\n            <div className=\"flex items-center gap-2\">\r\n              <svg className=\"w-5 h-5 flex-shrink-0\" fill=\"currentColor\" viewBox=\"0 0 20 20\">\r\n                <path fillRule=\"evenodd\" d=\"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z\" clipRule=\"evenodd\" />\r\n              </svg>\r\n              <span>{error}</span>\r\n            </div>\r\n          </div>\r\n        )}\r\n\r\n        <form onSubmit={handleSubmit} className=\"space-y-5\">\r\n          <div>\r\n            <label htmlFor=\"username\" className=\"block text-sm font-semibold mb-2 text-base-content\">\r\n              {t('register.username')} <span className=\"text-error text-base\">*</span>\r\n            </label>\r\n            <input\r\n              id=\"username\"\r\n              type=\"text\"\r\n              className=\"input\"\r\n              placeholder={t('register.usernamePlaceholder')}\r\n              value={username}\r\n              onChange={(e) => setUsername(e.target.value)}\r\n              disabled={isLoading}\r\n              autoComplete=\"username\"\r\n            />\r\n          </div>\r\n\r\n          <div>\r\n            <label htmlFor=\"email\" className=\"block text-sm font-semibold mb-2 text-base-content\">\r\n              {t('register.email')}{' '}\r\n              <span className=\"text-xs font-normal text-base-content/50\">{t('register.emailOptional')}</span>\r\n            </label>\r\n            <input\r\n              id=\"email\"\r\n              type=\"email\"\r\n              className=\"input\"\r\n              placeholder={t('register.emailPlaceholder')}\r\n              value={email}\r\n              onChange={(e) => setEmail(e.target.value)}\r\n              disabled={isLoading}\r\n              autoComplete=\"email\"\r\n            />\r\n          </div>\r\n\r\n          <div className=\"grid grid-cols-2 gap-4\">\r\n            <div>\r\n              <label htmlFor=\"password\" className=\"block text-sm font-semibold mb-2 text-base-content\">\r\n                {t('register.password')} <span className=\"text-error text-base\">*</span>\r\n              </label>\r\n              <div className=\"relative\">\r\n                <input\r\n                  id=\"password\"\r\n                  type={showPassword ? \"text\" : \"password\"}\r\n                  className=\"input pr-12\"\r\n                  placeholder={t('register.passwordPlaceholder')}\r\n                  value={password}\r\n                  onChange={(e) => setPassword(e.target.value)}\r\n                  disabled={isLoading}\r\n                  autoComplete=\"new-password\"\r\n                />\r\n                <button\r\n                  type=\"button\"\r\n                  onClick={() => setShowPassword(!showPassword)}\r\n                  className=\"absolute right-3 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content/70 transition-colors p-1\"\r\n                  tabIndex={-1}\r\n                >\r\n                  {showPassword ? (\r\n                    <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n                      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21\" />\r\n                    </svg>\r\n                  ) : (\r\n                    <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n                      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\r\n                      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\" />\r\n                    </svg>\r\n                  )}\r\n                </button>\r\n              </div>\r\n            </div>\r\n\r\n            <div>\r\n              <label htmlFor=\"confirmPassword\" className=\"block text-sm font-semibold mb-2 text-base-content\">\r\n                {t('register.confirmPassword')} <span className=\"text-error text-base\">*</span>\r\n              </label>\r\n              <div className=\"relative\">\r\n                <input\r\n                  id=\"confirmPassword\"\r\n                  type={showConfirmPassword ? \"text\" : \"password\"}\r\n                  className=\"input pr-12\"\r\n                  placeholder={t('register.confirmPasswordPlaceholder')}\r\n                  value={confirmPassword}\r\n                  onChange={(e) => setConfirmPassword(e.target.value)}\r\n                  disabled={isLoading}\r\n                  autoComplete=\"new-password\"\r\n                />\r\n                <button\r\n                  type=\"button\"\r\n                  onClick={() => setShowConfirmPassword(!showConfirmPassword)}\r\n                  className=\"absolute right-3 top-1/2 -translate-y-1/2 text-base-content/40 hover:text-base-content/70 transition-colors p-1\"\r\n                  tabIndex={-1}\r\n                >\r\n                  {showConfirmPassword ? (\r\n                    <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n                      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21\" />\r\n                    </svg>\r\n                  ) : (\r\n                    <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\r\n                      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\" />\r\n                      <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z\" />\r\n                    </svg>\r\n                  )}\r\n                </button>\r\n              </div>\r\n            </div>\r\n          </div>\r\n\r\n          <button type=\"submit\" className=\"btn w-full mt-6\" disabled={isLoading}>\r\n            {isLoading ? (\r\n              <span className=\"flex items-center justify-center gap-2\">\r\n                <svg className=\"animate-spin h-5 w-5\" viewBox=\"0 0 24 24\" fill=\"none\">\r\n                  <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\"></circle>\r\n                  <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\r\n                </svg>\r\n                {t('register.submitting')}\r\n              </span>\r\n            ) : (\r\n              t('register.submit')\r\n            )}\r\n          </button>\r\n        </form>\r\n\r\n        <div className=\"mt-8 pt-6 border-t border-base-300/50\">\r\n          <p className=\"text-center text-sm text-base-content/60\">\r\n            {t('register.hasAccount')}{' '}\r\n            <Link\r\n              to=\"/login\"\r\n              className=\"text-primary hover:text-primary/80 font-semibold transition-colors\"\r\n            >\r\n              {t('register.login')}\r\n            </Link>\r\n          </p>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/bookmarks/BookmarkStatisticsPage.tsx",
    "content": "import { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { BarChart3, ArrowLeft, ChevronLeft, ChevronRight } from 'lucide-react'\r\nimport { Link } from 'react-router-dom'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\nimport { MobileHeader } from '@/components/common/MobileHeader'\r\nimport { StatisticsCards } from './components/StatisticsCards'\r\nimport { useStatisticsData, type Granularity } from './hooks/useStatisticsData'\r\n\r\ninterface BookmarkStatisticsPageProps {\r\n  embedded?: boolean\r\n}\r\n\r\nexport function BookmarkStatisticsPage({ embedded = false }: BookmarkStatisticsPageProps) {\r\n  const { t, i18n } = useTranslation('bookmarks')\r\n  const isMobile = useIsMobile()\r\n  const [granularity, setGranularity] = useState<Granularity>('day')\r\n  const [currentDate, setCurrentDate] = useState(new Date())\r\n\r\n  const { statistics, isLoading, error, loadStatistics, getDateRange } = useStatisticsData(granularity, currentDate)\r\n\r\n  const navigateTime = (direction: 'prev' | 'next') => {\r\n    const newDate = new Date(currentDate)\r\n    switch (granularity) {\r\n      case 'day':\r\n        newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1))\r\n        break\r\n      case 'week':\r\n        newDate.setDate(newDate.getDate() + (direction === 'next' ? 7 : -7))\r\n        break\r\n      case 'month':\r\n        newDate.setMonth(newDate.getMonth() + (direction === 'next' ? 1 : -1))\r\n        break\r\n      case 'year':\r\n        newDate.setFullYear(newDate.getFullYear() + (direction === 'next' ? 1 : -1))\r\n        break\r\n    }\r\n    setCurrentDate(newDate)\r\n  }\r\n\r\n  const goToToday = () => setCurrentDate(new Date())\r\n\r\n  const formatCurrentRange = () => {\r\n    const range = getDateRange()\r\n    const start = new Date(range.startDate)\r\n    const end = new Date(range.endDate)\r\n    const locale = i18n.language\r\n\r\n    if (granularity === 'day') {\r\n      return start.toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' })\r\n    }\r\n    if (granularity === 'week') {\r\n      return `${start.toLocaleDateString(locale, { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString(locale, { month: 'short', day: 'numeric' })}`\r\n    }\r\n    if (granularity === 'month') {\r\n      return start.toLocaleDateString(locale, { year: 'numeric', month: 'long' })\r\n    }\r\n    return start.getFullYear().toString()\r\n  }\r\n\r\n  const formatDate = (dateString: string) => {\r\n    if (!dateString) return ''\r\n    const locale = i18n.language\r\n    if (granularity === 'year') return dateString\r\n    if (granularity === 'month') {\r\n      const [year, month] = dateString.split('-')\r\n      if (!year || !month) return dateString\r\n      const monthNum = Number.parseInt(month, 10)\r\n      if (!Number.isFinite(monthNum)) return dateString\r\n      const date = new Date(Number(year), monthNum - 1)\r\n      return date.toLocaleDateString(locale, { year: 'numeric', month: 'short' })\r\n    }\r\n    if (granularity === 'week') {\r\n      const [year, weekPart] = dateString.split('-W')\r\n      if (!year || !weekPart) return dateString\r\n      const weekNum = Number.parseInt(weekPart, 10)\r\n      if (!Number.isFinite(weekNum)) return dateString\r\n      return `${year} W${weekNum}`\r\n    }\r\n    const date = new Date(dateString)\r\n    if (Number.isNaN(date.getTime())) return dateString\r\n    return date.toLocaleDateString(locale, { month: 'short', day: 'numeric' })\r\n  }\r\n\r\n  const formatDateTime = (dateString: string) => {\r\n    const date = new Date(dateString)\r\n    return date.toLocaleString(i18n.language, {\r\n      month: 'short',\r\n      day: 'numeric',\r\n      hour: '2-digit',\r\n      minute: '2-digit'\r\n    })\r\n  }\r\n\r\n  const getNavigationLabel = (direction: 'prev' | 'next') => {\r\n    const key = direction === 'prev' \r\n      ? `statistics.navigation.prev${granularity.charAt(0).toUpperCase() + granularity.slice(1)}`\r\n      : `statistics.navigation.next${granularity.charAt(0).toUpperCase() + granularity.slice(1)}`\r\n    return t(key)\r\n  }\r\n\r\n  if (isLoading) {\r\n    return (\r\n      <div className=\"flex items-center justify-center min-h-[400px]\">\r\n        <div className=\"text-center\">\r\n          <div className=\"animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4\"></div>\r\n          <p className=\"text-muted-foreground\">{t('statistics.loading')}</p>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  if (error || !statistics) {\r\n    return (\r\n      <div className=\"flex items-center justify-center min-h-[400px]\">\r\n        <div className=\"text-center\">\r\n          <p className=\"text-destructive mb-4\">{error || t('statistics.loadFailed')}</p>\r\n          <button\r\n            onClick={loadStatistics}\r\n            className=\"px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90\"\r\n          >\r\n            {t('statistics.retry')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <div className={`${embedded ? '' : 'min-h-screen'} bg-background ${isMobile && !embedded ? 'pb-20' : ''}`}>\r\n      {isMobile && !embedded && (\r\n        <MobileHeader\r\n          title={t('statistics.title')}\r\n          showMenu={false}\r\n          showSearch={false}\r\n          showMore={false}\r\n        />\r\n      )}\r\n\r\n      <div className={`${embedded ? '' : 'max-w-7xl mx-auto px-3 sm:px-6 lg:px-8 py-4 sm:py-8'}`}>\r\n        <div className=\"mb-6 sm:mb-8\">\r\n          {!isMobile && !embedded && (\r\n            <Link\r\n              to=\"/bookmarks\"\r\n              className=\"inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-4 transition-colors\"\r\n            >\r\n              <ArrowLeft className=\"w-5 h-5\" />\r\n              <span>{t('statistics.backToBookmarks')}</span>\r\n            </Link>\r\n          )}\r\n\r\n          <div className=\"flex items-center justify-between mb-4\">\r\n            {!isMobile && !embedded && (\r\n              <div className=\"flex items-center gap-3\">\r\n                <BarChart3 className=\"w-8 h-8 text-primary\" />\r\n                <h1 className=\"text-3xl font-bold text-foreground\">{t('statistics.title')}</h1>\r\n              </div>\r\n            )}\r\n\r\n            <select\r\n              value={granularity}\r\n              onChange={(e) => setGranularity(e.target.value as Granularity)}\r\n              className={`px-4 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-card text-foreground ${embedded ? 'ml-auto' : ''}`}\r\n            >\r\n              <option value=\"day\">{t('statistics.granularity.day')}</option>\r\n              <option value=\"week\">{t('statistics.granularity.week')}</option>\r\n              <option value=\"month\">{t('statistics.granularity.month')}</option>\r\n              <option value=\"year\">{t('statistics.granularity.year')}</option>\r\n            </select>\r\n          </div>\r\n\r\n          <div className=\"flex items-center justify-center gap-2 sm:gap-4\">\r\n            <button\r\n              onClick={() => navigateTime('prev')}\r\n              className=\"btn btn-ghost btn-sm flex items-center gap-1 hover:bg-muted/30\"\r\n              title={getNavigationLabel('prev')}\r\n            >\r\n              <ChevronLeft className=\"w-4 h-4\" />\r\n              <span className=\"hidden sm:inline\">\r\n                {getNavigationLabel('prev')}\r\n              </span>\r\n            </button>\r\n\r\n            <div className=\"flex items-center gap-2\">\r\n              <div className=\"text-base sm:text-lg font-semibold text-foreground px-3 sm:px-4 py-2 bg-card border border-border rounded-lg min-w-[200px] sm:min-w-[280px] text-center\">\r\n                {formatCurrentRange()}\r\n              </div>\r\n              <button\r\n                onClick={goToToday}\r\n                className=\"btn btn-ghost btn-sm\"\r\n                title={t('statistics.navigation.today')}\r\n              >\r\n                {t('statistics.navigation.today')}\r\n              </button>\r\n            </div>\r\n\r\n            <button\r\n              onClick={() => navigateTime('next')}\r\n              className=\"btn btn-ghost btn-sm flex items-center gap-1 hover:bg-muted/30\"\r\n              title={getNavigationLabel('next')}\r\n            >\r\n              <span className=\"hidden sm:inline\">\r\n                {getNavigationLabel('next')}\r\n              </span>\r\n              <ChevronRight className=\"w-4 h-4\" />\r\n            </button>\r\n          </div>\r\n        </div>\r\n\r\n        <StatisticsCards\r\n          statistics={statistics}\r\n          formatDate={formatDate}\r\n          formatDateTime={formatDateTime}\r\n        />\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/bookmarks/BookmarkTrashPage.tsx",
    "content": "import { useState, useEffect, useCallback } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Archive, Trash2, ArrowLeft, AlertTriangle } from 'lucide-react'\r\nimport { Link } from 'react-router-dom'\r\nimport { bookmarksService } from '@/services/bookmarks'\r\nimport type { Bookmark } from '@/lib/types'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { logger } from '@/lib/logger'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\nimport { MobileHeader } from '@/components/common/MobileHeader'\r\nimport { TrashBookmarkItem } from './TrashBookmarkItem'\r\n\r\nexport function BookmarkTrashPage() {\r\n  const { t } = useTranslation('bookmarks')\r\n  const isMobile = useIsMobile()\r\n  const { success, error: showError } = useToastStore()\r\n  const [bookmarks, setBookmarks] = useState<Bookmark[]>([])\r\n  const [isLoading, setIsLoading] = useState(true)\r\n  const [error, setError] = useState<string | null>(null)\r\n\r\n  // Confirm dialog state\r\n  const [confirmDialog, setConfirmDialog] = useState<{\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n    isDanger?: boolean\r\n  }>({\r\n    isOpen: false,\r\n    title: '',\r\n    message: '',\r\n    onConfirm: () => {},\r\n    isDanger: false,\r\n  })\r\n\r\n  const loadTrash = useCallback(async () => {\r\n    try {\r\n      setIsLoading(true)\r\n      setError(null)\r\n      const response = await bookmarksService.getTrash({ page_size: 100 })\r\n      setBookmarks(response.bookmarks)\r\n    } catch (err) {\r\n      logger.error('Failed to load bookmark trash:', err)\r\n      setError(t('trash.loadFailed'))\r\n    } finally {\r\n      setIsLoading(false)\r\n    }\r\n  }, [t])\r\n\r\n  useEffect(() => {\r\n    loadTrash()\r\n  }, [loadTrash])\r\n\r\n  const handleRestore = (id: string, title: string) => {\r\n    setConfirmDialog({\r\n      isOpen: true,\r\n      title: t('trash.restoreTitle'),\r\n      message: t('trash.restoreMessage', { title }),\r\n      isDanger: false,\r\n      onConfirm: async () => {\r\n        setConfirmDialog(prev => ({ ...prev, isOpen: false }))\r\n        try {\r\n          await bookmarksService.restoreFromTrash(id)\r\n          setBookmarks(prev => prev.filter(b => b.id !== id))\r\n          success(t('trash.restoreSuccess'))\r\n        } catch (err) {\r\n          logger.error('Failed to restore bookmark:', err)\r\n          showError(t('trash.restoreFailed'))\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handlePermanentDelete = (id: string, title: string) => {\r\n    setConfirmDialog({\r\n      isOpen: true,\r\n      title: t('trash.permanentDeleteTitle'),\r\n      message: t('trash.permanentDeleteMessage', { title }),\r\n      isDanger: true,\r\n      onConfirm: async () => {\r\n        setConfirmDialog(prev => ({ ...prev, isOpen: false }))\r\n        try {\r\n          await bookmarksService.permanentDelete(id)\r\n          setBookmarks(prev => prev.filter(b => b.id !== id))\r\n          success(t('trash.permanentDeleteSuccess'))\r\n        } catch (err) {\r\n          logger.error('Failed to permanently delete bookmark:', err)\r\n          showError(t('trash.permanentDeleteFailed'))\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handleEmptyTrash = () => {\r\n    if (bookmarks.length === 0) return\r\n\r\n    setConfirmDialog({\r\n      isOpen: true,\r\n      title: t('trash.emptyTrashTitle'),\r\n      message: t('trash.emptyTrashMessage', { count: bookmarks.length }),\r\n      isDanger: true,\r\n      onConfirm: async () => {\r\n        setConfirmDialog(prev => ({ ...prev, isOpen: false }))\r\n        try {\r\n          const result = await bookmarksService.emptyTrash()\r\n          setBookmarks([])\r\n          success(t('trash.emptyTrashSuccess', { count: result.count }))\r\n        } catch (err) {\r\n          logger.error('Failed to empty trash:', err)\r\n          showError(t('trash.emptyTrashFailed'))\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  if (isLoading) {\r\n    return (\r\n      <div className=\"flex items-center justify-center min-h-[400px]\">\r\n        <div className=\"text-center\">\r\n          <div className=\"animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4\"></div>\r\n          <p className=\"text-muted-foreground\">{t('trash.loading')}</p>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  if (error) {\r\n    return (\r\n      <div className=\"flex items-center justify-center min-h-[400px]\">\r\n        <div className=\"text-center\">\r\n          <p className=\"text-destructive mb-4\">{error}</p>\r\n          <button\r\n            onClick={loadTrash}\r\n            className=\"px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90\"\r\n          >\r\n            {t('trash.retry')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <div className={`h-screen flex flex-col bg-background ${isMobile ? 'overflow-hidden' : ''}`}>\r\n      {/* 移动端顶部工具栏 */}\r\n      {isMobile && (\r\n        <MobileHeader\r\n          title={t('trash.title')}\r\n          showMenu={false}\r\n          showSearch={false}\r\n          showMore={false}\r\n        />\r\n      )}\r\n\r\n      <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20 min-h-0' : ''}`}>\r\n        <div className=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\">\r\n          {/* Header - 桌面端显示 */}\r\n          {!isMobile && (\r\n            <div className=\"mb-8\">\r\n              <Link\r\n                to=\"/bookmarks\"\r\n                className=\"inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-4 transition-colors\"\r\n              >\r\n                <ArrowLeft className=\"w-5 h-5\" />\r\n                <span>{t('trash.backToBookmarks')}</span>\r\n              </Link>\r\n              <div className=\"flex items-center justify-between\">\r\n                <div>\r\n                  <div className=\"flex items-center gap-3 mb-2\">\r\n                    <Archive className=\"w-8 h-8 text-muted-foreground\" />\r\n                    <h1 className=\"text-3xl font-bold text-foreground\">{t('trash.title')}</h1>\r\n                  </div>\r\n                  <p className=\"text-muted-foreground\">{t('trash.warning')}</p>\r\n                </div>\r\n                {bookmarks.length > 0 && (\r\n                  <button\r\n                    onClick={handleEmptyTrash}\r\n                    className=\"flex items-center gap-2 px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors\"\r\n                  >\r\n                    <Trash2 className=\"w-4 h-4\" />\r\n                    {t('trash.emptyTrash')}\r\n                  </button>\r\n                )}\r\n              </div>\r\n            </div>\r\n          )}\r\n\r\n          {/* 移动端清空按钮 */}\r\n          {isMobile && bookmarks.length > 0 && (\r\n            <div className=\"mb-4 flex justify-end\">\r\n              <button\r\n                onClick={handleEmptyTrash}\r\n                className=\"flex items-center gap-2 px-3 py-1.5 text-sm bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors\"\r\n              >\r\n                <Trash2 className=\"w-4 h-4\" />\r\n                {t('trash.empty')}\r\n              </button>\r\n            </div>\r\n          )}\r\n\r\n          {/* Empty State */}\r\n          {bookmarks.length === 0 ? (\r\n            <div className=\"text-center py-16\">\r\n              <Archive className=\"w-16 h-16 text-muted-foreground/30 mx-auto mb-4\" />\r\n              <h3 className=\"text-lg font-medium text-foreground mb-2\">{t('trash.emptyState.title')}</h3>\r\n              <p className=\"text-muted-foreground\">{t('trash.emptyState.description')}</p>\r\n            </div>\r\n          ) : (\r\n            <div className=\"space-y-4\">\r\n              {/* 提示信息 */}\r\n              <div className=\"flex items-start gap-3 p-4 bg-warning/10 border border-warning/20 rounded-lg\">\r\n                <AlertTriangle className=\"w-5 h-5 text-warning flex-shrink-0 mt-0.5\" />\r\n                <div className=\"text-sm text-muted-foreground\">\r\n                  <p>{t('trash.warning')}</p>\r\n                </div>\r\n              </div>\r\n\r\n              {/* 书签列表 */}\r\n              {bookmarks.map((bookmark) => (\r\n                <TrashBookmarkItem\r\n                  key={bookmark.id}\r\n                  bookmark={bookmark}\r\n                  onRestore={handleRestore}\r\n                  onDelete={handlePermanentDelete}\r\n                />\r\n              ))}\r\n            </div>\r\n          )}\r\n\r\n          {/* Confirm Dialog */}\r\n          <ConfirmDialog\r\n            isOpen={confirmDialog.isOpen}\r\n            title={confirmDialog.title}\r\n            message={confirmDialog.message}\r\n            onConfirm={confirmDialog.onConfirm}\r\n            onCancel={() => setConfirmDialog(prev => ({ ...prev, isOpen: false }))}\r\n            confirmText={confirmDialog.isDanger ? t('trash.confirmDelete') : t('trash.confirm')}\r\n            cancelText={t('batch.cancel')}\r\n          />\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/bookmarks/BookmarksPage.tsx",
    "content": "import { useMemo, useEffect, useCallback } from 'react'\r\nimport { Link } from 'react-router-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { CheckCircle, CheckSquare, Plus, Trash2 } from 'lucide-react'\r\nimport { BookmarkForm } from '@/components/bookmarks/BookmarkForm'\r\nimport { BatchActionBar } from '@/components/bookmarks/BatchActionBar'\r\nimport { BookmarkListLayout } from '@/components/bookmarks/BookmarkListLayout'\r\nimport { BatchSelectionPrompt } from './components/BatchSelectionPrompt'\r\nimport { useBookmarksState } from './hooks/useBookmarksState'\r\nimport { useBookmarksEffects } from './hooks/useBookmarksEffects'\r\nimport { useInfiniteBookmarks } from '@/hooks/useBookmarks'\r\nimport { useTags } from '@/hooks/useTags'\r\nimport type { Bookmark, BookmarkQueryParams } from '@/lib/types'\r\n\r\nexport function BookmarksPage() {\r\n  const { t } = useTranslation('bookmarks')\r\n  const state = useBookmarksState()\r\n  const {\r\n    selectedTags, setSelectedTags, debouncedSelectedTags,\r\n    searchKeyword, setSearchKeyword, debouncedSearchKeyword,\r\n    searchMode, setSearchMode,\r\n    sortBy, setSortBy, handleSortChange,\r\n    viewMode, setViewMode, handleViewModeChange,\r\n    visibilityFilter, setVisibilityFilter,\r\n    tagLayout, setTagLayout, handleTagLayoutChange,\r\n    showForm, setShowForm, editingBookmark, setEditingBookmark,\r\n    batchMode, setBatchMode, selectedIds, setSelectedIds,\r\n    sortByInitialized, setSortByInitialized,\r\n    previousCountRef, autoCleanupTimerRef,\r\n  } = state\r\n\r\n  const { handleViewModeSync, handleSortSync, handleTagLayoutSync } = useBookmarksEffects({\r\n    selectedTags, setSelectedTags, searchKeyword, setSearchKeyword,\r\n    setViewMode, setTagLayout, setSortBy,\r\n    sortByInitialized, setSortByInitialized, autoCleanupTimerRef,\r\n  })\r\n\r\n  const queryParams = useMemo<BookmarkQueryParams>(() => {\r\n    const params: BookmarkQueryParams = { sort: sortBy }\r\n    if (searchMode === 'bookmark' && debouncedSearchKeyword.trim()) {\r\n      params.keyword = debouncedSearchKeyword.trim()\r\n    }\r\n    if (debouncedSelectedTags.length > 0) {\r\n      params.tags = debouncedSelectedTags.join(',')\r\n    }\r\n    return params\r\n  }, [searchMode, debouncedSearchKeyword, debouncedSelectedTags, sortBy])\r\n\r\n  const bookmarksQuery = useInfiniteBookmarks(queryParams)\r\n  const { refetch: refetchTags } = useTags()\r\n\r\n  const bookmarks = useMemo(() => {\r\n    if (!bookmarksQuery.data?.pages?.length) return [] as Bookmark[]\r\n    const uniqueMap = new Map<string, Bookmark>()\r\n    bookmarksQuery.data.pages.flatMap(p => p.bookmarks).forEach(b => {\r\n      const existing = uniqueMap.get(b.id)\r\n      if (!existing || (b.tags?.length ?? 0) > (existing.tags?.length ?? 0)) {\r\n        uniqueMap.set(b.id, b)\r\n      } else if ((b.tags?.length ?? 0) === (existing.tags?.length ?? 0)) {\r\n        uniqueMap.set(b.id, { ...existing, ...b })\r\n      }\r\n    })\r\n    return Array.from(uniqueMap.values())\r\n  }, [bookmarksQuery.data])\r\n\r\n  const serverRelatedTagIds = useMemo(() =>\r\n    bookmarksQuery.data?.pages?.[0]?.meta?.related_tag_ids\r\n  , [bookmarksQuery.data?.pages])\r\n\r\n  const filteredBookmarks = useMemo(() => {\r\n    if (visibilityFilter === 'all') return bookmarks\r\n    return bookmarks.filter(b => visibilityFilter === 'public' ? b.is_public : !b.is_public)\r\n  }, [bookmarks, visibilityFilter])\r\n\r\n  const handleOpenForm = useCallback((bookmark?: Bookmark) => {\r\n    setEditingBookmark(bookmark || null)\r\n    setShowForm(true)\r\n  }, [setEditingBookmark, setShowForm])\r\n\r\n  const isInitialLoading = bookmarksQuery.isLoading && bookmarks.length === 0\r\n  useEffect(() => {\r\n    if (filteredBookmarks.length > 0) previousCountRef.current = filteredBookmarks.length\r\n  }, [filteredBookmarks.length, previousCountRef])\r\n\r\n  const handleVisibilityChange = useCallback(() => {\r\n    setVisibilityFilter(\r\n      visibilityFilter === 'all' ? 'public' : visibilityFilter === 'public' ? 'private' : 'all'\r\n    )\r\n  }, [visibilityFilter, setVisibilityFilter])\r\n\r\n  const extraActions = (\r\n    <>\r\n      <button\r\n        onClick={() => { setBatchMode(!batchMode); if (batchMode) setSelectedIds([]) }}\r\n        className={`btn btn-sm p-2 flex-shrink-0 ${batchMode ? 'btn-primary' : 'btn-ghost'}`}\r\n        title={batchMode ? t('toolbar.exitBatchMode') : t('toolbar.batchMode')}\r\n      >\r\n        <CheckCircle className=\"w-4 h-4 lg:hidden\" />\r\n        <CheckSquare className=\"w-5 h-5 hidden lg:block\" />\r\n      </button>\r\n      <Link to=\"/bookmarks/trash\" className=\"btn btn-sm btn-ghost p-2 flex-shrink-0\" title={t('toolbar.trash')}>\r\n        <Trash2 className=\"w-4 h-4 lg:w-5 lg:h-5\" />\r\n      </Link>\r\n      <button onClick={() => handleOpenForm()} className=\"btn btn-sm btn-primary p-2 flex-shrink-0\" title={t('toolbar.addBookmark')}>\r\n        <Plus className=\"w-4 h-4 lg:w-5 lg:h-5\" strokeWidth={2.5} />\r\n      </button>\r\n    </>\r\n  )\r\n\r\n  return (\r\n    <>\r\n      <BookmarkListLayout\r\n        fullScreen\r\n        bookmarks={filteredBookmarks}\r\n        isLoading={isInitialLoading || bookmarksQuery.isFetching}\r\n        isError={bookmarksQuery.isError}\r\n        onRetry={() => bookmarksQuery.refetch()}\r\n        hasMore={bookmarksQuery.hasNextPage ?? false}\r\n        isFetchingMore={bookmarksQuery.isFetchingNextPage}\r\n        onLoadMore={() => bookmarksQuery.fetchNextPage()}\r\n        totalLoaded={bookmarks.length}\r\n        searchMode={searchMode}\r\n        onSearchModeToggle={() => setSearchMode(searchMode === 'bookmark' ? 'tag' : 'bookmark')}\r\n        searchKeyword={searchKeyword}\r\n        onSearchKeywordChange={setSearchKeyword}\r\n        sortBy={sortBy}\r\n        onSortByChange={() => handleSortSync(handleSortChange())}\r\n        visibilityFilter={visibilityFilter}\r\n        onVisibilityChange={handleVisibilityChange}\r\n        viewMode={viewMode}\r\n        onViewModeChange={() => handleViewModeSync(handleViewModeChange())}\r\n        selectedTags={selectedTags}\r\n        onTagsChange={setSelectedTags}\r\n        tagLayout={tagLayout}\r\n        onTagLayoutChange={(l) => { handleTagLayoutChange(l); handleTagLayoutSync(l) }}\r\n        debouncedSearchKeyword={debouncedSearchKeyword}\r\n        relatedTagIds={serverRelatedTagIds}\r\n        batchMode={batchMode}\r\n        selectedIds={selectedIds}\r\n        onToggleSelect={(id) => setSelectedIds(p => p.includes(id) ? p.filter(i => i !== id) : [...p, id])}\r\n        onEdit={handleOpenForm}\r\n        extraActions={extraActions}\r\n        selectionPromptSlot={\r\n          <BatchSelectionPrompt\r\n            batchMode={batchMode} selectedCount={selectedIds.length}\r\n            totalCount={filteredBookmarks.length}\r\n            onSelectAll={() => setSelectedIds(filteredBookmarks.map(b => b.id))}\r\n            onClearSelection={() => { setSelectedIds([]); setBatchMode(false) }}\r\n          />\r\n        }\r\n        footerSlot={\r\n          <>\r\n            {showForm && (\r\n              <BookmarkForm\r\n                bookmark={editingBookmark} onClose={() => setShowForm(false)}\r\n                onSuccess={() => { bookmarksQuery.refetch(); refetchTags() }}\r\n              />\r\n            )}\r\n            {batchMode && selectedIds.length > 0 && (\r\n              <BatchActionBar\r\n                selectedIds={selectedIds} onClearSelection={() => { setSelectedIds([]); setBatchMode(false) }}\r\n                onSuccess={() => { setSelectedIds([]); setBatchMode(false); bookmarksQuery.refetch(); refetchTags() }}\r\n              />\r\n            )}\r\n          </>\r\n        }\r\n      />\r\n    </>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/bookmarks/TrashBookmarkItem.tsx",
    "content": "import { useTranslation } from 'react-i18next'\nimport { RotateCcw, Trash2, Calendar, Link2 } from 'lucide-react'\nimport type { Bookmark } from '@/lib/types'\nimport { formatDistanceToNow } from 'date-fns'\nimport { zhCN, enUS } from 'date-fns/locale'\n\ninterface TrashBookmarkItemProps {\n  bookmark: Bookmark\n  onRestore: (id: string, title: string) => void\n  onDelete: (id: string, title: string) => void\n}\n\nexport function TrashBookmarkItem({ bookmark, onRestore, onDelete }: TrashBookmarkItemProps) {\n  const { t, i18n } = useTranslation('bookmarks')\n  const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS\n\n  return (\n    <div className=\"card p-4 sm:p-6 hover:shadow-md transition-shadow\">\n      <div className=\"flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4\">\n        <div className=\"flex-1 min-w-0\">\n          <div className=\"flex items-start gap-3\">\n            {bookmark.favicon ? (\n              <img\n                src={bookmark.favicon}\n                alt=\"\"\n                className=\"w-6 h-6 rounded flex-shrink-0\"\n                onError={(e) => {\n                  (e.target as HTMLImageElement).style.display = 'none'\n                }}\n              />\n            ) : (\n              <Link2 className=\"w-6 h-6 text-muted-foreground flex-shrink-0\" />\n            )}\n            <div className=\"min-w-0 flex-1\">\n              <h3 className=\"text-lg font-semibold text-foreground mb-1 truncate\">\n                {bookmark.title}\n              </h3>\n              <p className=\"text-sm text-muted-foreground truncate mb-2\">\n                {bookmark.url}\n              </p>\n              <div className=\"flex items-center gap-4 text-sm text-muted-foreground\">\n                <div className=\"flex items-center gap-1\">\n                  <Calendar className=\"w-4 h-4\" />\n                  <span>\n                    {t('trash.deletedAt', {\n                      time: bookmark.deleted_at\n                        ? formatDistanceToNow(new Date(bookmark.deleted_at), {\n                            addSuffix: true,\n                            locale: dateLocale,\n                          })\n                        : ''\n                    })}\n                  </span>\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center gap-2 flex-shrink-0\">\n          <button\n            onClick={() => onRestore(bookmark.id, bookmark.title)}\n            className=\"flex items-center gap-2 px-3 py-1.5 sm:px-4 sm:py-2 bg-success text-success-foreground rounded-lg hover:bg-success/90 transition-colors text-sm\"\n          >\n            <RotateCcw className=\"w-4 h-4\" />\n            {t('trash.restore')}\n          </button>\n          <button\n            onClick={() => onDelete(bookmark.id, bookmark.title)}\n            className=\"flex items-center gap-2 px-3 py-1.5 sm:px-4 sm:py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors text-sm\"\n          >\n            <Trash2 className=\"w-4 h-4\" />\n            {t('trash.delete')}\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/pages/bookmarks/components/BatchSelectionPrompt.tsx",
    "content": "import { useTranslation } from 'react-i18next'\n\ninterface BatchSelectionPromptProps {\n  batchMode: boolean\n  selectedCount: number\n  totalCount: number\n  onSelectAll: () => void\n  onClearSelection: () => void\n}\n\nexport function BatchSelectionPrompt({\n  batchMode,\n  selectedCount,\n  totalCount,\n  onSelectAll,\n  onClearSelection,\n}: BatchSelectionPromptProps) {\n  const { t } = useTranslation('bookmarks')\n\n  if (!batchMode) return null\n\n  return (\n    <div className=\"flex-shrink-0 px-3 sm:px-4 md:px-6 pb-3 sm:pb-4 w-full\">\n      <div className=\"card bg-primary/10 border border-primary/20 w-full\">\n        <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0\">\n          <div className=\"flex flex-wrap items-center gap-2 sm:gap-4 text-xs sm:text-sm\">\n            <span className=\"font-medium text-foreground whitespace-nowrap\">\n              {selectedCount > 0\n                ? t('batch.selectedCount', { count: selectedCount })\n                : t('batch.pleaseSelect')}\n            </span>\n            {selectedCount < totalCount && (\n              <>\n                <span className=\"text-border hidden sm:inline\">|</span>\n                <button\n                  onClick={onSelectAll}\n                  className=\"text-primary hover:text-primary/80 transition-colors whitespace-nowrap\"\n                >\n                  {t('batch.selectAll', { count: totalCount })}\n                </button>\n              </>\n            )}\n            {selectedCount > 0 && (\n              <>\n                <span className=\"text-border hidden sm:inline\">|</span>\n                <button\n                  onClick={onClearSelection}\n                  className=\"text-primary hover:text-primary/80 transition-colors whitespace-nowrap\"\n                >\n                  {t('batch.cancel')}\n                </button>\n              </>\n            )}\n          </div>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/pages/bookmarks/components/MobileTagDrawer.tsx",
    "content": "import { useTranslation } from 'react-i18next'\n\nimport { TagSidebar } from '@/components/tags/TagSidebar'\nimport type { Bookmark } from '@/lib/types'\n\ninterface MobileTagDrawerProps {\n  isOpen: boolean\n  onClose: () => void\n  selectedTags: string[]\n  onTagsChange: (tags: string[]) => void\n  tagLayout: 'grid' | 'masonry'\n  onTagLayoutChange: (layout: 'grid' | 'masonry') => void\n  bookmarks: Bookmark[]\n  isLoading: boolean\n  searchMode: 'bookmark' | 'tag'\n  searchKeyword: string\n  relatedTagIds?: string[]\n}\n\nexport function MobileTagDrawer({\n  isOpen,\n  onClose,\n  selectedTags,\n  onTagsChange,\n  tagLayout,\n  onTagLayoutChange,\n  bookmarks,\n  isLoading,\n  searchMode,\n  searchKeyword,\n  relatedTagIds,\n}: MobileTagDrawerProps) {\n  const { t } = useTranslation(['bookmarks', 'tags'])\n\n  if (!isOpen) return null\n\n  return (\n    <div className=\"fixed inset-0 z-50 lg:hidden\">\n      <div\n        className=\"absolute inset-0 bg-background/80 backdrop-blur-sm\"\n        onClick={onClose}\n      />\n\n      <div className=\"absolute left-0 top-0 bottom-0 w-80 max-w-[85vw] bg-background border-r border-border shadow-xl animate-in slide-in-from-left duration-300 flex flex-col\">\n        <div className=\"flex items-center justify-between p-4 border-b border-border bg-background flex-shrink-0\">\n          <h3 className=\"text-lg font-semibold text-foreground\">{t('tags:filter.title')}</h3>\n          <button\n            onClick={onClose}\n            className=\"w-8 h-8 rounded-lg flex items-center justify-center hover:bg-muted transition-colors\"\n            aria-label={t('tags:filter.close')}\n          >\n            <svg className=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" strokeWidth={2}>\n              <path strokeLinecap=\"round\" strokeLinejoin=\"round\" d=\"M6 18L18 6M6 6l12 12\" />\n            </svg>\n          </button>\n        </div>\n\n        <div className=\"flex-1 overflow-y-auto p-4 bg-background min-h-0 overscroll-contain touch-auto\">\n          <TagSidebar\n            selectedTags={selectedTags}\n            onTagsChange={(tags) => {\n              onTagsChange(tags)\n              if (tags.length >= 2 && tags.length > selectedTags.length) {\n                setTimeout(() => onClose(), 500)\n              }\n            }}\n            tagLayout={tagLayout}\n            onTagLayoutChange={onTagLayoutChange}\n            relatedTagIds={relatedTagIds}\n            bookmarks={bookmarks}\n            isLoadingBookmarks={isLoading}\n            searchQuery={searchMode === 'tag' ? searchKeyword : ''}\n          />\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/pages/bookmarks/components/StatisticsCards.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { Bookmark, Tag, TrendingUp, Globe, Clock, ExternalLink } from 'lucide-react'\r\nimport type { BookmarkStatistics } from '../hooks/useStatisticsData'\r\n\r\ninterface StatisticsCardsProps {\r\n  statistics: BookmarkStatistics\r\n  formatDate: (date: string) => string\r\n  formatDateTime: (date: string) => string\r\n}\r\n\r\nexport function StatisticsCards({ statistics, formatDate, formatDateTime }: StatisticsCardsProps) {\r\n  const { t } = useTranslation('bookmarks')\r\n\r\n  return (\r\n    <>\r\n      {/* Summary Cards */}\r\n      <div className=\"grid grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-6 mb-6 sm:mb-8\">\r\n        <div className=\"card p-4 sm:p-6\">\r\n          <div className=\"flex items-center justify-between mb-2\">\r\n            <Bookmark className=\"w-6 h-6 sm:w-8 sm:h-8 text-primary\" />\r\n            <span className=\"text-2xl sm:text-3xl font-bold text-foreground\">{statistics.summary.total_bookmarks}</span>\r\n          </div>\r\n          <p className=\"text-xs sm:text-sm text-muted-foreground\">{t('statistics.summary.totalBookmarks')}</p>\r\n        </div>\r\n\r\n        <div className=\"card p-4 sm:p-6\">\r\n          <div className=\"flex items-center justify-between mb-2\">\r\n            <Tag className=\"w-6 h-6 sm:w-8 sm:h-8 text-success\" />\r\n            <span className=\"text-2xl sm:text-3xl font-bold text-foreground\">{statistics.summary.total_tags}</span>\r\n          </div>\r\n          <p className=\"text-xs sm:text-sm text-muted-foreground\">{t('statistics.summary.totalTags')}</p>\r\n        </div>\r\n\r\n        <div className=\"card p-4 sm:p-6\">\r\n          <div className=\"flex items-center justify-between mb-2\">\r\n            <TrendingUp className=\"w-6 h-6 sm:w-8 sm:h-8 text-accent\" />\r\n            <span className=\"text-2xl sm:text-3xl font-bold text-foreground\">{statistics.summary.total_clicks}</span>\r\n          </div>\r\n          <p className=\"text-xs sm:text-sm text-muted-foreground\">{t('statistics.summary.totalClicks')}</p>\r\n        </div>\r\n\r\n        <div className=\"card p-4 sm:p-6\">\r\n          <div className=\"flex items-center justify-between mb-2\">\r\n            <Globe className=\"w-6 h-6 sm:w-8 sm:h-8 text-primary\" />\r\n            <span className=\"text-2xl sm:text-3xl font-bold text-foreground\">{statistics.summary.public_bookmarks}</span>\r\n          </div>\r\n          <p className=\"text-xs sm:text-sm text-muted-foreground\">{t('statistics.summary.publicBookmarks')}</p>\r\n        </div>\r\n\r\n        <div className=\"card p-4 sm:p-6\">\r\n          <div className=\"flex items-center justify-between mb-2\">\r\n            <Clock className=\"w-6 h-6 sm:w-8 sm:h-8 text-muted-foreground\" />\r\n            <span className=\"text-2xl sm:text-3xl font-bold text-foreground\">{statistics.summary.archived_bookmarks}</span>\r\n          </div>\r\n          <p className=\"text-xs sm:text-sm text-muted-foreground\">{t('statistics.summary.archived')}</p>\r\n        </div>\r\n      </div>\r\n\r\n      {/* Top Bookmarks */}\r\n      <div className=\"card p-4 sm:p-6 mb-6 sm:mb-8\">\r\n        <h2 className=\"text-lg sm:text-xl font-semibold text-foreground mb-4 flex items-center gap-2\">\r\n          <TrendingUp className=\"w-5 h-5 sm:w-6 sm:h-6 text-primary\" />\r\n          {t('statistics.charts.topBookmarks')}\r\n        </h2>\r\n        {statistics.top_bookmarks.length === 0 ? (\r\n          <p className=\"text-muted-foreground text-center py-8\">{t('statistics.noData')}</p>\r\n        ) : (\r\n          <div className=\"space-y-3\">\r\n            {statistics.top_bookmarks.map((bookmark, index) => (\r\n              <div key={bookmark.id} className=\"flex items-center gap-3 sm:gap-4\">\r\n                <span className=\"text-base sm:text-lg font-semibold text-muted-foreground/50 w-6 sm:w-8\">{index + 1}</span>\r\n                <div className=\"flex-1 min-w-0\">\r\n                  <div className=\"flex items-center justify-between mb-1\">\r\n                    <a\r\n                      href={bookmark.url}\r\n                      target=\"_blank\"\r\n                      rel=\"noopener noreferrer\"\r\n                      className=\"text-sm sm:text-base text-foreground font-medium hover:text-primary truncate flex items-center gap-1\"\r\n                    >\r\n                      {bookmark.title}\r\n                      <ExternalLink className=\"w-3 h-3 flex-shrink-0\" />\r\n                    </a>\r\n                    <span className=\"text-xs sm:text-sm text-muted-foreground ml-2 flex-shrink-0\">\r\n                      {t('statistics.times', { count: bookmark.click_count })}\r\n                    </span>\r\n                  </div>\r\n                  <div className=\"w-full bg-muted rounded-full h-1.5 sm:h-2\">\r\n                    <div\r\n                      className=\"bg-primary h-1.5 sm:h-2 rounded-full transition-all\"\r\n                      style={{\r\n                        width: `${statistics.top_bookmarks[0] ? (bookmark.click_count / statistics.top_bookmarks[0].click_count) * 100 : 0}%`,\r\n                      }}\r\n                    ></div>\r\n                  </div>\r\n                </div>\r\n              </div>\r\n            ))}\r\n          </div>\r\n        )}\r\n      </div>\r\n\r\n      {/* Current Range Clicks */}\r\n      <div className=\"card p-4 sm:p-6 mb-6 sm:mb-8\">\r\n        <h2 className=\"text-lg sm:text-xl font-semibold text-foreground mb-4 flex items-center gap-2\">\r\n          <TrendingUp className=\"w-5 h-5 sm:w-6 sm:h-6 text-primary\" />\r\n          {t('statistics.charts.currentRangeClicks')}\r\n        </h2>\r\n        {statistics.bookmark_clicks.length === 0 ? (\r\n          <p className=\"text-muted-foreground text-center py-8\">{t('statistics.noClicksInRange')}</p>\r\n        ) : (\r\n          <div className=\"space-y-3\">\r\n            {statistics.bookmark_clicks.slice(0, 10).map((bookmark, index) => (\r\n              <div key={bookmark.id} className=\"flex items-center gap-3 sm:gap-4\">\r\n                <span className=\"text-base sm:text-lg font-semibold text-muted-foreground/50 w-6 sm:w-8\">\r\n                  {index + 1}\r\n                </span>\r\n                <div className=\"flex-1 min-w-0\">\r\n                  <div className=\"flex items-center justify-between mb-1\">\r\n                    <a\r\n                      href={bookmark.url}\r\n                      target=\"_blank\"\r\n                      rel=\"noopener noreferrer\"\r\n                      className=\"text-sm sm:text-base text-foreground font-medium hover:text-primary truncate flex items-center gap-1\"\r\n                    >\r\n                      {bookmark.title}\r\n                      <ExternalLink className=\"w-3 h-3 flex-shrink-0\" />\r\n                    </a>\r\n                    <span className=\"text-xs sm:text-sm text-muted-foreground ml-2 flex-shrink-0\">\r\n                      {t('statistics.times', { count: bookmark.click_count })}\r\n                    </span>\r\n                  </div>\r\n                </div>\r\n              </div>\r\n            ))}\r\n          </div>\r\n        )}\r\n      </div>\r\n\r\n      {/* Top Tags and Domains */}\r\n      <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-6 sm:gap-8 mb-6 sm:mb-8\">\r\n        <div className=\"card p-4 sm:p-6\">\r\n          <h2 className=\"text-lg sm:text-xl font-semibold text-foreground mb-4 flex items-center gap-2\">\r\n            <Tag className=\"w-5 h-5 sm:w-6 sm:h-6 text-primary\" />\r\n            {t('statistics.charts.topTags')}\r\n          </h2>\r\n          {statistics.top_tags.length === 0 ? (\r\n            <p className=\"text-muted-foreground text-center py-8\">{t('statistics.noData')}</p>\r\n          ) : (\r\n            <div className=\"space-y-3\">\r\n              {statistics.top_tags.map((tag, index) => (\r\n                <div key={tag.id} className=\"flex items-center gap-3\">\r\n                  <span className=\"text-sm sm:text-base font-semibold text-muted-foreground/50 w-6\">{index + 1}</span>\r\n                  <div className=\"flex-1\">\r\n                    <div className=\"flex items-center justify-between mb-1\">\r\n                      <span className=\"text-sm sm:text-base text-foreground font-medium\">{tag.name}</span>\r\n                      <div className=\"flex items-center gap-2 text-xs sm:text-sm text-muted-foreground\">\r\n                        <span>{t('statistics.times', { count: tag.click_count })}</span>\r\n                        <span>·</span>\r\n                        <span>{t('statistics.items', { count: tag.bookmark_count })}</span>\r\n                      </div>\r\n                    </div>\r\n                    <div className=\"w-full bg-muted rounded-full h-1.5\">\r\n                      <div\r\n                        className=\"bg-success h-1.5 rounded-full transition-all\"\r\n                        style={{\r\n                          width: `${statistics.top_tags[0] ? (tag.click_count / statistics.top_tags[0].click_count) * 100 : 0}%`,\r\n                        }}\r\n                      ></div>\r\n                    </div>\r\n                  </div>\r\n                </div>\r\n              ))}\r\n            </div>\r\n          )}\r\n        </div>\r\n\r\n        <div className=\"card p-4 sm:p-6\">\r\n          <h2 className=\"text-lg sm:text-xl font-semibold text-foreground mb-4 flex items-center gap-2\">\r\n            <Globe className=\"w-5 h-5 sm:w-6 sm:h-6 text-primary\" />\r\n            {t('statistics.charts.topDomains')}\r\n          </h2>\r\n          {statistics.top_domains.length === 0 ? (\r\n            <p className=\"text-muted-foreground text-center py-8\">{t('statistics.noData')}</p>\r\n          ) : (\r\n            <div className=\"space-y-3\">\r\n              {statistics.top_domains.map((domain, index) => (\r\n                <div key={domain.domain} className=\"flex items-center gap-3\">\r\n                  <span className=\"text-sm sm:text-base font-semibold text-muted-foreground/50 w-6\">{index + 1}</span>\r\n                  <div className=\"flex-1\">\r\n                    <div className=\"flex items-center justify-between mb-1\">\r\n                      <span className=\"text-sm sm:text-base text-foreground font-medium truncate\">{domain.domain}</span>\r\n                      <span className=\"text-xs sm:text-sm text-muted-foreground ml-2 flex-shrink-0\">\r\n                        {t('statistics.items', { count: domain.count })}\r\n                      </span>\r\n                    </div>\r\n                    <div className=\"w-full bg-muted rounded-full h-1.5\">\r\n                      <div\r\n                        className=\"bg-accent h-1.5 rounded-full transition-all\"\r\n                        style={{\r\n                          width: `${statistics.top_domains[0] ? (domain.count / statistics.top_domains[0].count) * 100 : 0}%`,\r\n                        }}\r\n                      ></div>\r\n                    </div>\r\n                  </div>\r\n                </div>\r\n              ))}\r\n            </div>\r\n          )}\r\n        </div>\r\n      </div>\r\n\r\n      {/* Recent Clicks */}\r\n      <div className=\"card p-4 sm:p-6 mb-6 sm:mb-8\">\r\n        <h2 className=\"text-lg sm:text-xl font-semibold text-foreground mb-4 flex items-center gap-2\">\r\n          <Clock className=\"w-5 h-5 sm:w-6 sm:h-6 text-primary\" />\r\n          {t('statistics.charts.recentVisits')}\r\n        </h2>\r\n        {statistics.recent_clicks.length === 0 ? (\r\n          <p className=\"text-muted-foreground text-center py-8\">{t('statistics.noData')}</p>\r\n        ) : (\r\n          <div className=\"space-y-2\">\r\n            {statistics.recent_clicks.map((bookmark) => (\r\n              <div key={bookmark.id} className=\"flex items-center justify-between p-2 sm:p-3 rounded-lg hover:bg-muted/50 transition-colors\">\r\n                <a\r\n                  href={bookmark.url}\r\n                  target=\"_blank\"\r\n                  rel=\"noopener noreferrer\"\r\n                  className=\"flex-1 text-sm sm:text-base text-foreground hover:text-primary truncate flex items-center gap-1\"\r\n                >\r\n                  {bookmark.title}\r\n                  <ExternalLink className=\"w-3 h-3 flex-shrink-0\" />\r\n                </a>\r\n                <span className=\"text-xs sm:text-sm text-muted-foreground ml-2 flex-shrink-0\">\r\n                  {formatDateTime(bookmark.last_clicked_at)}\r\n                </span>\r\n              </div>\r\n            ))}\r\n          </div>\r\n        )}\r\n      </div>\r\n\r\n      {/* Trends */}\r\n      <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-6 sm:gap-8\">\r\n        <div className=\"card p-4 sm:p-6\">\r\n          <h2 className=\"text-lg sm:text-xl font-semibold text-foreground mb-4\">{t('statistics.charts.creationTrend')}</h2>\r\n          {statistics.trends.bookmarks.length === 0 ? (\r\n            <p className=\"text-muted-foreground text-center py-8\">{t('statistics.noData')}</p>\r\n          ) : (\r\n            <div className=\"space-y-2\">\r\n              {statistics.trends.bookmarks.slice(-10).map((trend) => (\r\n                <div key={trend.date} className=\"flex items-center justify-between text-xs sm:text-sm\">\r\n                  <span className=\"text-muted-foreground\">{formatDate(trend.date)}</span>\r\n                  <span className=\"font-semibold text-foreground\">{t('statistics.items', { count: trend.count })}</span>\r\n                </div>\r\n              ))}\r\n            </div>\r\n          )}\r\n        </div>\r\n\r\n        <div className=\"card p-4 sm:p-6\">\r\n          <h2 className=\"text-lg sm:text-xl font-semibold text-foreground mb-4\">{t('statistics.charts.visitTrend')}</h2>\r\n          {statistics.trends.clicks.length === 0 ? (\r\n            <p className=\"text-muted-foreground text-center py-8\">{t('statistics.noData')}</p>\r\n          ) : (\r\n            <div className=\"space-y-2\">\r\n              {statistics.trends.clicks.slice(-10).map((trend) => (\r\n                <div key={trend.date} className=\"flex items-center justify-between text-xs sm:text-sm\">\r\n                  <span className=\"text-muted-foreground\">{formatDate(trend.date)}</span>\r\n                  <span className=\"font-semibold text-foreground\">{t('statistics.times', { count: trend.count })}</span>\r\n                </div>\r\n              ))}\r\n            </div>\r\n          )}\r\n        </div>\r\n      </div>\r\n    </>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/bookmarks/hooks/useBookmarksEffects.ts",
    "content": "import { useEffect } from 'react'\r\nimport { usePreferences, useUpdatePreferences } from '@/hooks/usePreferences'\r\nimport { getStoredViewModeUpdatedAt, setStoredViewMode } from '@/hooks/useBookmarkFilters'\r\nimport { ViewMode } from '@/lib/constants/bookmarks'\r\nimport type { SortOption } from '@/components/common/SortSelector'\r\n\r\ninterface UseBookmarksEffectsProps {\r\n  selectedTags: string[]\r\n  setSelectedTags: (tags: string[]) => void\r\n  searchKeyword: string\r\n  setSearchKeyword: (keyword: string) => void\r\n  setViewMode: (mode: ViewMode) => void\r\n  setTagLayout: (layout: 'grid' | 'masonry') => void\r\n  setSortBy: (sort: SortOption) => void\r\n  sortByInitialized: boolean\r\n  setSortByInitialized: (initialized: boolean) => void\r\n  autoCleanupTimerRef: React.MutableRefObject<NodeJS.Timeout | null>\r\n}\r\n\r\n/**\r\n * 书签页面的副作用管理 Hook\r\n */\r\nexport function useBookmarksEffects({\r\n  selectedTags,\r\n  setSelectedTags,\r\n  searchKeyword,\r\n  setSearchKeyword,\r\n  setViewMode,\r\n  setTagLayout,\r\n  setSortBy,\r\n  sortByInitialized,\r\n  setSortByInitialized,\r\n  autoCleanupTimerRef,\r\n}: UseBookmarksEffectsProps) {\r\n  const { data: preferences } = usePreferences()\r\n  const updatePreferences = useUpdatePreferences()\r\n\r\n  // 初始化视图模式和排序方式\r\n  useEffect(() => {\r\n    if (preferences?.view_mode) {\r\n      const storedUpdatedAt = getStoredViewModeUpdatedAt()\r\n      const serverUpdatedAt = preferences.updated_at ? new Date(preferences.updated_at).getTime() : 0\r\n\r\n      if (serverUpdatedAt > storedUpdatedAt) {\r\n        setViewMode(preferences.view_mode)\r\n        setStoredViewMode(preferences.view_mode, serverUpdatedAt)\r\n      }\r\n    }\r\n\r\n    if (preferences?.tag_layout) {\r\n      setTagLayout(preferences.tag_layout)\r\n    }\r\n\r\n    if (preferences?.sort_by && !sortByInitialized) {\r\n      setSortBy(preferences.sort_by)\r\n      setSortByInitialized(true)\r\n    }\r\n  }, [preferences, sortByInitialized, setViewMode, setTagLayout, setSortBy, setSortByInitialized])\r\n\r\n  // 标签自动清空\r\n  useEffect(() => {\r\n    const enableAutoClear = preferences?.enable_tag_selection_auto_clear ?? false\r\n    const clearSeconds = preferences?.tag_selection_auto_clear_seconds ?? 30\r\n\r\n    if (enableAutoClear && selectedTags.length > 0) {\r\n      if (autoCleanupTimerRef.current) clearTimeout(autoCleanupTimerRef.current)\r\n      autoCleanupTimerRef.current = setTimeout(() => {\r\n        setSelectedTags([])\r\n      }, clearSeconds * 1000)\r\n    }\r\n\r\n    return () => {\r\n      if (autoCleanupTimerRef.current) clearTimeout(autoCleanupTimerRef.current)\r\n    }\r\n  }, [selectedTags, preferences, autoCleanupTimerRef, setSelectedTags])\r\n\r\n  // 搜索自动清空\r\n  useEffect(() => {\r\n    const enableAutoClear = preferences?.enable_search_auto_clear ?? true\r\n    const clearSeconds = preferences?.search_auto_clear_seconds ?? 15\r\n\r\n    if (enableAutoClear && searchKeyword.trim()) {\r\n      const timer = setTimeout(() => {\r\n        setSearchKeyword('')\r\n      }, clearSeconds * 1000)\r\n      return () => clearTimeout(timer)\r\n    }\r\n  }, [searchKeyword, preferences, setSearchKeyword])\r\n\r\n  return {\r\n    updatePreferences,\r\n    handleViewModeSync: (mode: ViewMode) => {\r\n      setStoredViewMode(mode)\r\n      updatePreferences.mutate({ view_mode: mode })\r\n    },\r\n    handleSortSync: (sort: SortOption) => {\r\n      updatePreferences.mutate({ sort_by: sort })\r\n    },\r\n    handleTagLayoutSync: (layout: 'grid' | 'masonry') => {\r\n      updatePreferences.mutate({ tag_layout: layout })\r\n    }\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/bookmarks/hooks/useBookmarksState.ts",
    "content": "import { useState, useRef } from 'react'\r\nimport type { Bookmark } from '@/lib/types'\r\nimport { useBookmarkFilters } from '@/hooks/useBookmarkFilters'\r\n\r\n/**\r\n * 书签页面的状态管理 Hook\r\n */\r\nexport function useBookmarksState() {\r\n  const filters = useBookmarkFilters()\r\n  \r\n  // 表单和编辑状态\r\n  const [showForm, setShowForm] = useState(false)\r\n  const [editingBookmark, setEditingBookmark] = useState<Bookmark | null>(null)\r\n\r\n  // 批量操作状态\r\n  const [batchMode, setBatchMode] = useState(false)\r\n  const [selectedIds, setSelectedIds] = useState<string[]>([])\r\n\r\n  // UI 状态\r\n  const [isTagSidebarOpen, setIsTagSidebarOpen] = useState(false)\r\n  const [sortByInitialized, setSortByInitialized] = useState(false)\r\n\r\n  // Refs\r\n  const previousCountRef = useRef(0)\r\n  const autoCleanupTimerRef = useRef<NodeJS.Timeout | null>(null)\r\n\r\n  return {\r\n    ...filters,\r\n\r\n    // 表单和编辑\r\n    showForm,\r\n    setShowForm,\r\n    editingBookmark,\r\n    setEditingBookmark,\r\n\r\n    // 批量操作\r\n    batchMode,\r\n    setBatchMode,\r\n    selectedIds,\r\n    setSelectedIds,\r\n\r\n    // UI\r\n    isTagSidebarOpen,\r\n    setIsTagSidebarOpen,\r\n    sortByInitialized,\r\n    setSortByInitialized,\r\n\r\n    // Refs\r\n    previousCountRef,\r\n    autoCleanupTimerRef,\r\n    searchCleanupTimerRef: { current: null }, // Mock for compatibility if needed, but useBookmarkFilters handles it now\r\n    tagDebounceTimerRef: { current: null }, // Same here\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/bookmarks/hooks/useStatisticsData.ts",
    "content": "import { useState, useEffect, useCallback } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { bookmarksService } from '@/services/bookmarks'\r\nimport { logger } from '@/lib/logger'\r\n\r\nexport interface BookmarkStatistics {\r\n  summary: {\r\n    total_bookmarks: number\r\n    total_tags: number\r\n    total_clicks: number\r\n    archived_bookmarks: number\r\n    public_bookmarks: number\r\n  }\r\n  top_bookmarks: Array<{\r\n    id: string\r\n    title: string\r\n    url: string\r\n    click_count: number\r\n    last_clicked_at: string | null\r\n  }>\r\n  top_tags: Array<{\r\n    id: string\r\n    name: string\r\n    color: string | null\r\n    click_count: number\r\n    bookmark_count: number\r\n  }>\r\n  top_domains: Array<{\r\n    domain: string\r\n    count: number\r\n  }>\r\n  bookmark_clicks: Array<{\r\n    id: string\r\n    title: string\r\n    url: string\r\n    click_count: number\r\n  }>\r\n  recent_clicks: Array<{\r\n    id: string\r\n    title: string\r\n    url: string\r\n    last_clicked_at: string\r\n  }>\r\n  trends: {\r\n    bookmarks: Array<{ date: string; count: number }>\r\n    clicks: Array<{ date: string; count: number }>\r\n  }\r\n}\r\n\r\nexport type Granularity = 'day' | 'week' | 'month' | 'year'\r\n\r\nexport function useStatisticsData(granularity: Granularity, currentDate: Date) {\r\n  const { t } = useTranslation('bookmarks')\r\n  const [statistics, setStatistics] = useState<BookmarkStatistics | null>(null)\r\n  const [isLoading, setIsLoading] = useState(true)\r\n  const [error, setError] = useState<string | null>(null)\r\n\r\n  const getDateRange = useCallback((): { startDate: string; endDate: string } => {\r\n    const start = new Date(currentDate)\r\n    const end = new Date(currentDate)\r\n\r\n    switch (granularity) {\r\n      case 'day':\r\n        start.setHours(0, 0, 0, 0)\r\n        end.setHours(23, 59, 59, 999)\r\n        break\r\n      case 'week': {\r\n        const day = start.getDay()\r\n        const diff = start.getDate() - day + (day === 0 ? -6 : 1)\r\n        start.setDate(diff)\r\n        start.setHours(0, 0, 0, 0)\r\n        end.setDate(start.getDate() + 6)\r\n        end.setHours(23, 59, 59, 999)\r\n        break\r\n      }\r\n      case 'month':\r\n        start.setDate(1)\r\n        start.setHours(0, 0, 0, 0)\r\n        end.setMonth(start.getMonth() + 1)\r\n        end.setDate(0)\r\n        end.setHours(23, 59, 59, 999)\r\n        break\r\n      case 'year':\r\n        start.setMonth(0, 1)\r\n        start.setHours(0, 0, 0, 0)\r\n        end.setMonth(11, 31)\r\n        end.setHours(23, 59, 59, 999)\r\n        break\r\n    }\r\n\r\n    return {\r\n      startDate: start.toISOString().split('T')[0] as string,\r\n      endDate: end.toISOString().split('T')[0] as string\r\n    }\r\n  }, [currentDate, granularity])\r\n\r\n  const loadStatistics = useCallback(async () => {\n    try {\n      setIsLoading(true)\n      setError(null)\n      const range = getDateRange()\n      const data = await bookmarksService.getStatistics({\n        granularity,\n        startDate: range.startDate,\n        endDate: range.endDate\n      }) as BookmarkStatistics\n      setStatistics(data)\n    } catch (err) {\n      logger.error('Failed to load bookmark statistics:', err)\n      setError(t('statistics.loadFailed'))\n    } finally {\n      setIsLoading(false)\n    }\n  }, [granularity, getDateRange, t])\n\r\n  useEffect(() => {\r\n    loadStatistics()\r\n  }, [loadStatistics])\r\n\r\n  return {\r\n    statistics,\r\n    isLoading,\r\n    error,\r\n    loadStatistics,\r\n    getDateRange,\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/extension/ExtensionPage.tsx",
    "content": "import { useTranslation } from 'react-i18next'\nimport { Download, Chrome, CheckCircle, AlertCircle } from 'lucide-react'\n\nconst EXTENSION_ZIP = 'tmarks-extension-chrome.zip'\nconst CHROMIUM_BROWSERS = ['Chrome', 'Edge', 'Brave', 'Opera', '360', 'QQ', 'Sogou']\n\nexport function ExtensionPage() {\n  const { t } = useTranslation('info')\n\n  const handleDownload = () => {\n    const link = document.createElement('a')\n    link.href = `/extensions/${EXTENSION_ZIP}`\n    link.download = EXTENSION_ZIP\n    document.body.appendChild(link)\n    link.click()\n    document.body.removeChild(link)\n  }\n\n  const featureKeys = ['saveTabGroups', 'restoreTabs', 'autoSync'] as const\n  const installSteps = [1, 2, 3, 4, 5, 6] as const\n\n  return (\n    <div className=\"container mx-auto px-4 py-6 max-w-7xl\">\n      <div className=\"text-center mb-8\">\n        <div className=\"w-20 h-20 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-primary to-secondary flex items-center justify-center shadow-float\">\n          <Chrome className=\"w-12 h-12\" style={{ color: 'var(--foreground)' }} />\n        </div>\n        <h1 className=\"text-3xl font-bold mb-2\" style={{ color: 'var(--foreground)' }}>\n          {t('extension.title')}\n        </h1>\n        <p className=\"text-lg\" style={{ color: 'var(--muted-foreground)' }}>\n          {t('extension.subtitle')}\n        </p>\n      </div>\n\n      <div className=\"card shadow-float mb-8 bg-gradient-to-br from-primary/5 to-secondary/5\">\n        <h2 className=\"text-xl font-bold mb-4 text-center\" style={{ color: 'var(--foreground)' }}>\n          {t('extension.download.title')}\n        </h2>\n\n        <div className=\"max-w-md mx-auto text-center p-4 rounded-xl border-2\" style={{ background: 'var(--card)', borderColor: 'var(--border)' }}>\n          <div className=\"w-12 h-12 mx-auto mb-2 flex items-center justify-center\">\n            <Chrome className=\"w-12 h-12\" style={{ color: 'var(--foreground)' }} />\n          </div>\n          <h3 className=\"text-base font-bold mb-1\" style={{ color: 'var(--foreground)' }}>\n            Chrome / Chromium\n          </h3>\n          <p className=\"text-xs mb-3\" style={{ color: 'var(--muted-foreground)' }}>\n            {CHROMIUM_BROWSERS.join(' / ')}\n          </p>\n          <button\n            onClick={handleDownload}\n            className=\"w-full inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-semibold shadow-sm hover:shadow-md transition-all hover:scale-[1.01] active:scale-[0.99]\"\n          >\n            <Download className=\"w-4 h-4\" />\n            {t('extension.download.button')}\n          </button>\n        </div>\n      </div>\n\n      <div className=\"card shadow-float mb-8\">\n        <h2 className=\"text-xl font-bold mb-4 text-center\" style={{ color: 'var(--foreground)' }}>\n          {t('extension.browsers.title')}\n        </h2>\n\n        <div className=\"flex flex-wrap justify-center gap-2\">\n          {CHROMIUM_BROWSERS.map((name) => (\n            <span\n              key={name}\n              className=\"inline-flex items-center gap-2 px-3 py-1.5 rounded-full border bg-muted/30\"\n              style={{ borderColor: 'var(--border)', color: 'var(--foreground)' }}\n            >\n              <Chrome className=\"w-4 h-4 text-primary\" />\n              <span className=\"text-sm font-medium\">{name}</span>\n            </span>\n          ))}\n        </div>\n\n        <div className=\"mt-4 p-4 rounded-xl bg-primary/5 border border-primary/10 text-center\">\n          <p className=\"text-sm\" style={{ color: 'var(--muted-foreground)' }}>\n            {t('extension.version', { version: '1.0.4', size: '428 KB', date: '2026-02-06' })}\n          </p>\n          <p className=\"text-sm mt-2\" style={{ color: 'var(--muted-foreground)' }}>\n            {t('extension.tip')}\n          </p>\n        </div>\n      </div>\n\n      <div className=\"card shadow-float mb-8\">\n        <h2 className=\"text-xl font-bold mb-6 text-center\" style={{ color: 'var(--foreground)' }}>\n          {t('extension.features.title')}\n        </h2>\n\n        <div className=\"grid md:grid-cols-3 gap-4\">\n          {featureKeys.map((key) => (\n            <div key={key} className=\"p-5 rounded-xl border bg-muted/20\" style={{ borderColor: 'var(--border)' }}>\n              <div className=\"flex items-start gap-3\">\n                <div className=\"w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0\">\n                  <CheckCircle className=\"w-5 h-5 text-primary\" />\n                </div>\n                <div>\n                  <h3 className=\"font-semibold mb-1\" style={{ color: 'var(--foreground)' }}>\n                    {t(`extension.features.${key}.title`)}\n                  </h3>\n                  <p className=\"text-sm\" style={{ color: 'var(--muted-foreground)' }}>\n                    {t(`extension.features.${key}.description`)}\n                  </p>\n                </div>\n              </div>\n            </div>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"card shadow-float mb-8\">\n        <h2 className=\"text-xl font-bold mb-6 text-center\" style={{ color: 'var(--foreground)' }}>\n          {t('extension.install.title')}\n        </h2>\n\n        <div className=\"space-y-4 max-w-3xl mx-auto\">\n          {installSteps.map((step) => (\n            <div key={step} className=\"flex gap-4\">\n              <div className=\"flex-shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center\">\n                <span className=\"text-sm font-bold text-primary\">{step}</span>\n              </div>\n              <div className=\"flex-1\">\n                <h3 className=\"font-medium mb-1\" style={{ color: 'var(--foreground)' }}>\n                  {t(`extension.install.step${step}.title`)}\n                </h3>\n                <p className=\"text-sm\" style={{ color: 'var(--muted-foreground)' }}>\n                  {t(`extension.install.step${step}.description`)}\n                </p>\n                {step === 3 && (\n                  <div className=\"mt-2 p-3 bg-muted/30 rounded-lg border border-border text-sm\">\n                    <p className=\"mb-1\">Chrome: chrome://extensions/</p>\n                    <p>Edge: edge://extensions/</p>\n                  </div>\n                )}\n              </div>\n            </div>\n          ))}\n        </div>\n      </div>\n\n      <div className=\"card shadow-float bg-gradient-to-br from-primary/10 to-primary/5 border-primary/20 mb-8\">\n        <div className=\"flex items-start gap-3\">\n          <AlertCircle className=\"w-5 h-5 mt-0.5 flex-shrink-0 text-primary\" />\n          <div>\n            <h3 className=\"font-medium mb-2\" style={{ color: 'var(--foreground)' }}>\n              {t('extension.tips.title')}\n            </h3>\n            <ul className=\"text-sm space-y-1\" style={{ color: 'var(--muted-foreground)' }}>\n              <li>• {t('extension.tips.tip1')}</li>\n              <li>• {t('extension.tips.tip2')}</li>\n              <li>• {t('extension.tips.tip3')}</li>\n              <li>• {t('extension.tips.tip4')}</li>\n            </ul>\n          </div>\n        </div>\n      </div>\n\n      <div className=\"card shadow-float\">\n        <h2 className=\"text-xl font-bold mb-4\" style={{ color: 'var(--foreground)' }}>\n          {t('extension.faq.title')}\n        </h2>\n        <div className=\"space-y-4\">\n          {[1, 2, 3, 4, 5].map((n) => (\n            <div key={n}>\n              <h3 className=\"font-medium mb-1\" style={{ color: 'var(--foreground)' }}>\n                Q: {t(`extension.faq.q${n}`)}\n              </h3>\n              <p className=\"text-sm\" style={{ color: 'var(--muted-foreground)' }}>\n                A: {t(`extension.faq.a${n}`)}\n              </p>\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  )\n}\n\n"
  },
  {
    "path": "tmarks/src/pages/info/AboutPage.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { Heart, Zap, Shield, Globe, Github, Star } from 'lucide-react'\r\n\r\nexport function AboutPage() {\r\n  const { t } = useTranslation('info')\r\n\r\n  return (\r\n    <div className=\"max-w-4xl mx-auto p-4 sm:p-6 space-y-6 sm:space-y-8\">\r\n      {/* 标题 */}\r\n      <div className=\"text-center space-y-3\">\r\n        <h1 className=\"text-3xl sm:text-4xl font-bold text-foreground\">{t('about.title')}</h1>\r\n        <p className=\"text-base sm:text-lg text-muted-foreground max-w-2xl mx-auto\">\r\n          {t('about.subtitle')}\r\n        </p>\r\n      </div>\r\n\r\n      {/* 版本信息 */}\r\n      <div className=\"card p-6 text-center\">\r\n        <div className=\"inline-flex items-center gap-2 px-4 py-2 rounded-full bg-primary/10 text-primary mb-4\">\r\n          <Star className=\"w-4 h-4\" />\r\n          <span className=\"text-sm font-medium\">{t('about.version')} 2.0.0</span>\r\n        </div>\r\n        <p className=\"text-sm text-muted-foreground\">{t('about.releaseNote')}</p>\r\n      </div>\r\n\r\n      {/* 核心特性 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl sm:text-2xl font-bold text-foreground\">{t('about.features.title')}</h2>\r\n        <div className=\"grid sm:grid-cols-2 gap-4\">\r\n          <div className=\"card p-5 space-y-2\">\r\n            <div className=\"flex items-center gap-3\">\r\n              <div className=\"w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center\">\r\n                <Zap className=\"w-5 h-5 text-primary\" />\r\n              </div>\r\n              <h3 className=\"font-semibold text-foreground\">{t('about.features.fast.title')}</h3>\r\n            </div>\r\n            <p className=\"text-sm text-muted-foreground\">{t('about.features.fast.description')}</p>\r\n          </div>\r\n\r\n          <div className=\"card p-5 space-y-2\">\r\n            <div className=\"flex items-center gap-3\">\r\n              <div className=\"w-10 h-10 rounded-lg bg-success/10 flex items-center justify-center\">\r\n                <Shield className=\"w-5 h-5 text-success\" />\r\n              </div>\r\n              <h3 className=\"font-semibold text-foreground\">{t('about.features.secure.title')}</h3>\r\n            </div>\r\n            <p className=\"text-sm text-muted-foreground\">{t('about.features.secure.description')}</p>\r\n          </div>\r\n\r\n          <div className=\"card p-5 space-y-2\">\r\n            <div className=\"flex items-center gap-3\">\r\n              <div className=\"w-10 h-10 rounded-lg bg-warning/10 flex items-center justify-center\">\r\n                <Globe className=\"w-5 h-5 text-warning\" />\r\n              </div>\r\n              <h3 className=\"font-semibold text-foreground\">{t('about.features.sync.title')}</h3>\r\n            </div>\r\n            <p className=\"text-sm text-muted-foreground\">{t('about.features.sync.description')}</p>\r\n          </div>\r\n\r\n          <div className=\"card p-5 space-y-2\">\r\n            <div className=\"flex items-center gap-3\">\r\n              <div className=\"w-10 h-10 rounded-lg bg-error/10 flex items-center justify-center\">\r\n                <Heart className=\"w-5 h-5 text-error\" />\r\n              </div>\r\n              <h3 className=\"font-semibold text-foreground\">{t('about.features.opensource.title')}</h3>\r\n            </div>\r\n            <p className=\"text-sm text-muted-foreground\">{t('about.features.opensource.description')}</p>\r\n          </div>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 技术栈 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl sm:text-2xl font-bold text-foreground\">{t('about.techStack.title')}</h2>\r\n        <div className=\"card p-6 space-y-4\">\r\n          <div>\r\n            <h3 className=\"text-sm font-semibold text-foreground mb-2\">{t('about.techStack.frontend')}</h3>\r\n            <div className=\"flex flex-wrap gap-2\">\r\n              {['React 18', 'TypeScript', 'Vite', 'TailwindCSS', 'React Router', 'Zustand', 'React Query'].map((tech) => (\r\n                <span key={tech} className=\"px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium\">\r\n                  {tech}\r\n                </span>\r\n              ))}\r\n            </div>\r\n          </div>\r\n\r\n          <div>\r\n            <h3 className=\"text-sm font-semibold text-foreground mb-2\">{t('about.techStack.backend')}</h3>\r\n            <div className=\"flex flex-wrap gap-2\">\r\n              {['Cloudflare Pages', 'Cloudflare D1', 'Cloudflare KV', 'JWT'].map((tech) => (\r\n                <span key={tech} className=\"px-3 py-1 rounded-full bg-success/10 text-success text-xs font-medium\">\r\n                  {tech}\r\n                </span>\r\n              ))}\r\n            </div>\r\n          </div>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 开源信息 */}\r\n      <div className=\"card p-6 space-y-4\">\r\n        <div className=\"flex items-center gap-3\">\r\n          <Github className=\"w-6 h-6 text-foreground\" />\r\n          <h2 className=\"text-xl font-bold text-foreground\">{t('about.opensource.title')}</h2>\r\n        </div>\r\n        <p className=\"text-sm text-muted-foreground\">{t('about.opensource.description')}</p>\r\n        <ul className=\"space-y-2 text-sm text-muted-foreground\">\r\n          <li className=\"flex items-start gap-2\">\r\n            <span className=\"text-primary mt-0.5\">•</span>\r\n            <span>{t('about.opensource.contribute1')}</span>\r\n          </li>\r\n          <li className=\"flex items-start gap-2\">\r\n            <span className=\"text-primary mt-0.5\">•</span>\r\n            <span>{t('about.opensource.contribute2')}</span>\r\n          </li>\r\n          <li className=\"flex items-start gap-2\">\r\n            <span className=\"text-primary mt-0.5\">•</span>\r\n            <span>{t('about.opensource.contribute3')}</span>\r\n          </li>\r\n          <li className=\"flex items-start gap-2\">\r\n            <span className=\"text-primary mt-0.5\">•</span>\r\n            <span>{t('about.opensource.contribute4')}</span>\r\n          </li>\r\n        </ul>\r\n        <a\r\n          href=\"https://github.com/ai-tmarks/tmarks\"\r\n          target=\"_blank\"\r\n          rel=\"noopener noreferrer\"\r\n          className=\"btn btn-primary inline-flex items-center gap-2\"\r\n        >\r\n          <Github className=\"w-4 h-4\" />\r\n          {t('about.opensource.visitGithub')}\r\n        </a>\r\n      </div>\r\n\r\n      {/* 致谢 */}\r\n      <div className=\"card p-6 space-y-3\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('about.thanks.title')}</h2>\r\n        <p className=\"text-sm text-muted-foreground\">{t('about.thanks.description')}</p>\r\n        <div className=\"grid sm:grid-cols-2 gap-2 text-sm text-muted-foreground\">\r\n          <div>• Cloudflare Pages & D1</div>\r\n          <div>• React & TypeScript</div>\r\n          <div>• Vite & TailwindCSS</div>\r\n          <div>• Lucide Icons</div>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/info/HelpPage.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { HelpCircle, MessageCircle, FileText } from 'lucide-react'\r\nimport { Link } from 'react-router-dom'\r\n\r\nexport function HelpPage() {\r\n  const { t } = useTranslation('info')\r\n\r\n  const faqs = [\r\n    { question: t('help.faq.q1'), answer: t('help.faq.a1') },\r\n    { question: t('help.faq.q2'), answer: t('help.faq.a2') },\r\n    { question: t('help.faq.q3'), answer: t('help.faq.a3') },\r\n    { question: t('help.faq.q4'), answer: t('help.faq.a4') },\r\n    { question: t('help.faq.q5'), answer: t('help.faq.a5') },\r\n    { question: t('help.faq.q6'), answer: t('help.faq.a6') },\r\n    { question: t('help.faq.q7'), answer: t('help.faq.a7') },\r\n    { question: t('help.faq.q8'), answer: t('help.faq.a8') }\r\n  ]\r\n\r\n  const guides = [\r\n    {\r\n      title: t('help.guides.quickStart.title'),\r\n      description: t('help.guides.quickStart.description'),\r\n      icon: FileText,\r\n      link: '/extension'\r\n    },\r\n    {\r\n      title: t('help.guides.extension.title'),\r\n      description: t('help.guides.extension.description'),\r\n      icon: FileText,\r\n      link: '/extension'\r\n    },\r\n    {\r\n      title: t('help.guides.importExport.title'),\r\n      description: t('help.guides.importExport.description'),\r\n      icon: FileText,\r\n      link: '/settings/general?section=data'\r\n    },\r\n    {\r\n      title: t('help.guides.share.title'),\r\n      description: t('help.guides.share.description'),\r\n      icon: FileText,\r\n      link: '/settings/general?section=share'\r\n    }\r\n  ]\r\n\r\n  return (\r\n    <div className=\"max-w-4xl mx-auto p-4 sm:p-6 space-y-6 sm:space-y-8\">\r\n      {/* 标题 */}\r\n      <div className=\"text-center space-y-3\">\r\n        <h1 className=\"text-3xl sm:text-4xl font-bold text-foreground\">{t('help.title')}</h1>\r\n        <p className=\"text-base sm:text-lg text-muted-foreground max-w-2xl mx-auto\">\r\n          {t('help.subtitle')}\r\n        </p>\r\n      </div>\r\n\r\n      {/* 快速指南 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl sm:text-2xl font-bold text-foreground\">{t('help.guides.title')}</h2>\r\n        <div className=\"grid sm:grid-cols-2 gap-4\">\r\n          {guides.map((guide) => {\r\n            const Icon = guide.icon\r\n            return (\r\n              <Link\r\n                key={guide.title}\r\n                to={guide.link}\r\n                className=\"card p-5 hover:border-primary/50 transition-all group\"\r\n              >\r\n                <div className=\"flex items-start gap-3\">\r\n                  <div className=\"w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center group-hover:bg-primary/20 transition-colors\">\r\n                    <Icon className=\"w-5 h-5 text-primary\" />\r\n                  </div>\r\n                  <div className=\"flex-1\">\r\n                    <h3 className=\"font-semibold text-foreground mb-1\">{guide.title}</h3>\r\n                    <p className=\"text-sm text-muted-foreground\">{guide.description}</p>\r\n                  </div>\r\n                </div>\r\n              </Link>\r\n            )\r\n          })}\r\n        </div>\r\n      </div>\r\n\r\n      {/* 常见问题 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl sm:text-2xl font-bold text-foreground\">{t('help.faq.title')}</h2>\r\n        <div className=\"space-y-3\">\r\n          {faqs.map((faq, index) => (\r\n            <details key={index} className=\"card p-5 group\">\r\n              <summary className=\"flex items-start gap-3 cursor-pointer list-none\">\r\n                <HelpCircle className=\"w-5 h-5 text-primary flex-shrink-0 mt-0.5\" />\r\n                <div className=\"flex-1\">\r\n                  <h3 className=\"font-semibold text-foreground group-open:text-primary transition-colors\">\r\n                    {faq.question}\r\n                  </h3>\r\n                </div>\r\n                <svg\r\n                  className=\"w-5 h-5 text-muted-foreground transition-transform group-open:rotate-180\"\r\n                  fill=\"none\"\r\n                  stroke=\"currentColor\"\r\n                  viewBox=\"0 0 24 24\"\r\n                >\r\n                  <path strokeLinecap=\"round\" strokeLinejoin=\"round\" strokeWidth={2} d=\"M19 9l-7 7-7-7\" />\r\n                </svg>\r\n              </summary>\r\n              <div className=\"mt-3 pl-8 text-sm text-muted-foreground\">\r\n                {faq.answer}\r\n              </div>\r\n            </details>\r\n          ))}\r\n        </div>\r\n      </div>\r\n\r\n      {/* 联系支持 */}\r\n      <div className=\"card p-6 space-y-4\">\r\n        <div className=\"flex items-center gap-3\">\r\n          <MessageCircle className=\"w-6 h-6 text-primary\" />\r\n          <h2 className=\"text-xl font-bold text-foreground\">{t('help.contact.title')}</h2>\r\n        </div>\r\n        <p className=\"text-sm text-muted-foreground\">{t('help.contact.description')}</p>\r\n        <div className=\"flex flex-col sm:flex-row gap-3\">\r\n          <a\r\n            href=\"https://github.com/ai-tmarks/tmarks/issues\"\r\n            target=\"_blank\"\r\n            rel=\"noopener noreferrer\"\r\n            className=\"btn btn-secondary flex items-center gap-2 justify-center\"\r\n          >\r\n            <FileText className=\"w-4 h-4\" />\r\n            {t('help.contact.submitIssue')}\r\n          </a>\r\n          <a\r\n            href=\"mailto:support@tmarks.com\"\r\n            className=\"btn btn-secondary flex items-center gap-2 justify-center\"\r\n          >\r\n            <MessageCircle className=\"w-4 h-4\" />\r\n            {t('help.contact.contactSupport')}\r\n          </a>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/info/PrivacyPage.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { Shield, Lock, Eye, Database, UserCheck } from 'lucide-react'\r\n\r\nexport function PrivacyPage() {\r\n  const { t } = useTranslation('info')\r\n\r\n  return (\r\n    <div className=\"max-w-4xl mx-auto p-4 sm:p-6 space-y-6 sm:space-y-8\">\r\n      {/* 标题 */}\r\n      <div className=\"space-y-3\">\r\n        <h1 className=\"text-3xl sm:text-4xl font-bold text-foreground\">{t('privacy.title')}</h1>\r\n        <p className=\"text-sm text-muted-foreground\">\r\n          {t('privacy.lastUpdated', { date: '2024-11-19' })}\r\n        </p>\r\n      </div>\r\n\r\n      {/* 简介 */}\r\n      <div className=\"card p-6 space-y-3\">\r\n        <div className=\"flex items-center gap-3\">\r\n          <Shield className=\"w-6 h-6 text-primary\" />\r\n          <h2 className=\"text-xl font-bold text-foreground\">{t('privacy.commitment.title')}</h2>\r\n        </div>\r\n        <p className=\"text-sm text-muted-foreground leading-relaxed\">\r\n          {t('privacy.commitment.description')}\r\n        </p>\r\n      </div>\r\n\r\n      {/* 信息收集 */}\r\n      <div className=\"space-y-4\">\r\n        <div className=\"flex items-center gap-3\">\r\n          <Database className=\"w-5 h-5 text-primary\" />\r\n          <h2 className=\"text-xl font-bold text-foreground\">{t('privacy.collection.title')}</h2>\r\n        </div>\r\n        <div className=\"card p-6 space-y-4\">\r\n          <div>\r\n            <h3 className=\"text-sm font-semibold text-foreground mb-2\">{t('privacy.collection.account.title')}</h3>\r\n            <p className=\"text-sm text-muted-foreground\">\r\n              {t('privacy.collection.account.description')}\r\n            </p>\r\n          </div>\r\n          <div>\r\n            <h3 className=\"text-sm font-semibold text-foreground mb-2\">{t('privacy.collection.bookmarks.title')}</h3>\r\n            <p className=\"text-sm text-muted-foreground\">\r\n              {t('privacy.collection.bookmarks.description')}\r\n            </p>\r\n          </div>\r\n          <div>\r\n            <h3 className=\"text-sm font-semibold text-foreground mb-2\">{t('privacy.collection.usage.title')}</h3>\r\n            <p className=\"text-sm text-muted-foreground\">\r\n              {t('privacy.collection.usage.description')}\r\n            </p>\r\n          </div>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 信息使用 */}\r\n      <div className=\"space-y-4\">\r\n        <div className=\"flex items-center gap-3\">\r\n          <Eye className=\"w-5 h-5 text-primary\" />\r\n          <h2 className=\"text-xl font-bold text-foreground\">{t('privacy.usage.title')}</h2>\r\n        </div>\r\n        <div className=\"card p-6\">\r\n          <ul className=\"space-y-3 text-sm text-muted-foreground\">\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.usage.item1')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.usage.item2')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.usage.item3')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.usage.item4')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.usage.item5')}</span>\r\n            </li>\r\n          </ul>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 数据安全 */}\r\n      <div className=\"space-y-4\">\r\n        <div className=\"flex items-center gap-3\">\r\n          <Lock className=\"w-5 h-5 text-primary\" />\r\n          <h2 className=\"text-xl font-bold text-foreground\">{t('privacy.security.title')}</h2>\r\n        </div>\r\n        <div className=\"card p-6 space-y-4\">\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('privacy.security.description')}\r\n          </p>\r\n          <ul className=\"space-y-3 text-sm text-muted-foreground\">\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-success mt-0.5\">✓</span>\r\n              <span>{t('privacy.security.item1')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-success mt-0.5\">✓</span>\r\n              <span>{t('privacy.security.item2')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-success mt-0.5\">✓</span>\r\n              <span>{t('privacy.security.item3')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-success mt-0.5\">✓</span>\r\n              <span>{t('privacy.security.item4')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-success mt-0.5\">✓</span>\r\n              <span>{t('privacy.security.item5')}</span>\r\n            </li>\r\n          </ul>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 用户权利 */}\r\n      <div className=\"space-y-4\">\r\n        <div className=\"flex items-center gap-3\">\r\n          <UserCheck className=\"w-5 h-5 text-primary\" />\r\n          <h2 className=\"text-xl font-bold text-foreground\">{t('privacy.rights.title')}</h2>\r\n        </div>\r\n        <div className=\"card p-6\">\r\n          <ul className=\"space-y-3 text-sm text-muted-foreground\">\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.rights.access')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.rights.modify')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.rights.delete')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.rights.export')}</span>\r\n            </li>\r\n          </ul>\r\n        </div>\r\n      </div>\r\n\r\n      {/* Cookie 使用 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('privacy.cookies.title')}</h2>\r\n        <div className=\"card p-6 space-y-3\">\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('privacy.cookies.description')}\r\n          </p>\r\n          <ul className=\"space-y-2 text-sm text-muted-foreground\">\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.cookies.item1')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.cookies.item2')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.cookies.item3')}</span>\r\n            </li>\r\n          </ul>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 第三方服务 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('privacy.thirdParty.title')}</h2>\r\n        <div className=\"card p-6 space-y-3\">\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('privacy.thirdParty.description')}\r\n          </p>\r\n          <ul className=\"space-y-2 text-sm text-muted-foreground\">\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('privacy.thirdParty.cloudflare')}</span>\r\n            </li>\r\n          </ul>\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('privacy.thirdParty.note')}\r\n          </p>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 政策更新 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('privacy.updates.title')}</h2>\r\n        <div className=\"card p-6\">\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('privacy.updates.description')}\r\n          </p>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 联系我们 */}\r\n      <div className=\"card p-6 space-y-3\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('privacy.contact.title')}</h2>\r\n        <p className=\"text-sm text-muted-foreground\">\r\n          {t('privacy.contact.description')}\r\n        </p>\r\n        <div className=\"text-sm text-muted-foreground\">\r\n          <p>{t('privacy.contact.email')}<a href=\"mailto:privacy@tmarks.com\" className=\"text-primary hover:underline\">privacy@tmarks.com</a></p>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/info/TermsPage.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { FileText, AlertCircle, Scale, Ban } from 'lucide-react'\r\n\r\nexport function TermsPage() {\r\n  const { t } = useTranslation('info')\r\n\r\n  return (\r\n    <div className=\"max-w-4xl mx-auto p-4 sm:p-6 space-y-6 sm:space-y-8\">\r\n      {/* 标题 */}\r\n      <div className=\"space-y-3\">\r\n        <h1 className=\"text-3xl sm:text-4xl font-bold text-foreground\">{t('terms.title')}</h1>\r\n        <p className=\"text-sm text-muted-foreground\">\r\n          {t('terms.lastUpdated', { date: '2024-11-19' })}\r\n        </p>\r\n      </div>\r\n\r\n      {/* 简介 */}\r\n      <div className=\"card p-6 space-y-3\">\r\n        <div className=\"flex items-center gap-3\">\r\n          <FileText className=\"w-6 h-6 text-primary\" />\r\n          <h2 className=\"text-xl font-bold text-foreground\">{t('terms.welcome.title')}</h2>\r\n        </div>\r\n        <p className=\"text-sm text-muted-foreground leading-relaxed\">\r\n          {t('terms.welcome.description')}\r\n        </p>\r\n      </div>\r\n\r\n      {/* 服务说明 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('terms.service.title')}</h2>\r\n        <div className=\"card p-6 space-y-3\">\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('terms.service.description')}\r\n          </p>\r\n          <ul className=\"space-y-2 text-sm text-muted-foreground\">\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('terms.service.item1')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('terms.service.item2')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('terms.service.item3')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('terms.service.item4')}</span>\r\n            </li>\r\n          </ul>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 用户责任 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('terms.responsibility.title')}</h2>\r\n        <div className=\"card p-6 space-y-3\">\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('terms.responsibility.description')}\r\n          </p>\r\n          <ul className=\"space-y-2 text-sm text-muted-foreground\">\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('terms.responsibility.item1')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('terms.responsibility.item2')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('terms.responsibility.item3')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('terms.responsibility.item4')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-primary mt-0.5\">•</span>\r\n              <span>{t('terms.responsibility.item5')}</span>\r\n            </li>\r\n          </ul>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 禁止行为 */}\r\n      <div className=\"space-y-4\">\r\n        <div className=\"flex items-center gap-3\">\r\n          <Ban className=\"w-5 h-5 text-error\" />\r\n          <h2 className=\"text-xl font-bold text-foreground\">{t('terms.prohibited.title')}</h2>\r\n        </div>\r\n        <div className=\"card p-6 border-error/20\">\r\n          <p className=\"text-sm text-muted-foreground mb-3\">\r\n            {t('terms.prohibited.description')}\r\n          </p>\r\n          <ul className=\"space-y-2 text-sm text-muted-foreground\">\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-error mt-0.5\">✗</span>\r\n              <span>{t('terms.prohibited.item1')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-error mt-0.5\">✗</span>\r\n              <span>{t('terms.prohibited.item2')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-error mt-0.5\">✗</span>\r\n              <span>{t('terms.prohibited.item3')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-error mt-0.5\">✗</span>\r\n              <span>{t('terms.prohibited.item4')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-error mt-0.5\">✗</span>\r\n              <span>{t('terms.prohibited.item5')}</span>\r\n            </li>\r\n          </ul>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 知识产权 */}\r\n      <div className=\"space-y-4\">\r\n        <div className=\"flex items-center gap-3\">\r\n          <Scale className=\"w-5 h-5 text-primary\" />\r\n          <h2 className=\"text-xl font-bold text-foreground\">{t('terms.ip.title')}</h2>\r\n        </div>\r\n        <div className=\"card p-6 space-y-3\">\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('terms.ip.description1')}\r\n          </p>\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('terms.ip.description2')}\r\n          </p>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 服务变更和终止 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('terms.changes.title')}</h2>\r\n        <div className=\"card p-6 space-y-3\">\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('terms.changes.description1')}\r\n          </p>\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('terms.changes.description2')}\r\n          </p>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 免责声明 */}\r\n      <div className=\"space-y-4\">\r\n        <div className=\"flex items-center gap-3\">\r\n          <AlertCircle className=\"w-5 h-5 text-warning\" />\r\n          <h2 className=\"text-xl font-bold text-foreground\">{t('terms.disclaimer.title')}</h2>\r\n        </div>\r\n        <div className=\"card p-6 border-warning/20\">\r\n          <p className=\"text-sm text-muted-foreground mb-3\">\r\n            {t('terms.disclaimer.description')}\r\n          </p>\r\n          <ul className=\"space-y-2 text-sm text-muted-foreground\">\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-warning mt-0.5\">!</span>\r\n              <span>{t('terms.disclaimer.item1')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-warning mt-0.5\">!</span>\r\n              <span>{t('terms.disclaimer.item2')}</span>\r\n            </li>\r\n            <li className=\"flex items-start gap-2\">\r\n              <span className=\"text-warning mt-0.5\">!</span>\r\n              <span>{t('terms.disclaimer.item3')}</span>\r\n            </li>\r\n          </ul>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 责任限制 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('terms.liability.title')}</h2>\r\n        <div className=\"card p-6\">\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('terms.liability.description')}\r\n          </p>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 条款变更 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('terms.termChanges.title')}</h2>\r\n        <div className=\"card p-6\">\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('terms.termChanges.description')}\r\n          </p>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 适用法律 */}\r\n      <div className=\"space-y-4\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('terms.law.title')}</h2>\r\n        <div className=\"card p-6\">\r\n          <p className=\"text-sm text-muted-foreground\">\r\n            {t('terms.law.description')}\r\n          </p>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 联系我们 */}\r\n      <div className=\"card p-6 space-y-3\">\r\n        <h2 className=\"text-xl font-bold text-foreground\">{t('terms.contact.title')}</h2>\r\n        <p className=\"text-sm text-muted-foreground\">\r\n          {t('terms.contact.description')}\r\n        </p>\r\n        <div className=\"text-sm text-muted-foreground\">\r\n          <p>{t('terms.contact.email')}<a href=\"mailto:legal@tmarks.com\" className=\"text-primary hover:underline\">legal@tmarks.com</a></p>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/settings/ApiKeysPage.tsx",
    "content": "/**\r\n * API Keys Management Page\r\n */\r\n\r\nimport { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { useApiKeys, useRevokeApiKey, useDeleteApiKey } from '@/hooks/useApiKeys'\r\nimport { CreateApiKeyModal } from '@/components/api-keys/CreateApiKeyModal'\r\nimport { ApiKeyCard } from '@/components/api-keys/ApiKeyCard'\r\nimport { ApiKeyDetailModal } from '@/components/api-keys/ApiKeyDetailModal'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { AlertDialog } from '@/components/common/AlertDialog'\r\nimport type { ApiKey } from '@/services/api-keys'\r\n\r\nexport function ApiKeysPage() {\r\n  const { t } = useTranslation('settings')\r\n  const { t: tCommon } = useTranslation('common')\r\n  const { data, isLoading } = useApiKeys()\r\n  const revokeApiKey = useRevokeApiKey()\r\n  const deleteApiKey = useDeleteApiKey()\r\n\r\n  const [showCreateModal, setShowCreateModal] = useState(false)\r\n  const [selectedKey, setSelectedKey] = useState<ApiKey | null>(null)\r\n  const [confirmState, setConfirmState] = useState<{\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n  } | null>(null)\r\n  const [alertState, setAlertState] = useState<{\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    type: 'success' | 'error' | 'info' | 'warning'\r\n  } | null>(null)\r\n\r\n  const handleRevoke = async (id: string) => {\r\n    setConfirmState({\r\n      isOpen: true,\r\n      title: t('apiKey.page.revokeTitle'),\r\n      message: t('apiKey.page.revokeMessage'),\r\n      onConfirm: async () => {\r\n        setConfirmState(null)\r\n        try {\r\n          await revokeApiKey.mutateAsync(id)\r\n          setAlertState({\r\n            isOpen: true,\r\n            title: tCommon('dialog.successTitle'),\r\n            message: t('apiKey.page.revokeSuccess'),\r\n            type: 'success',\r\n          })\r\n        } catch {\r\n          setAlertState({\r\n            isOpen: true,\r\n            title: tCommon('dialog.errorTitle'),\r\n            message: t('apiKey.page.revokeFailed'),\r\n            type: 'error',\r\n          })\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handleDelete = async (id: string) => {\r\n    setConfirmState({\r\n      isOpen: true,\r\n      title: t('apiKey.page.deleteTitle'),\r\n      message: t('apiKey.page.deleteMessage'),\r\n      onConfirm: async () => {\r\n        setConfirmState(null)\r\n        try {\r\n          await deleteApiKey.mutateAsync(id)\r\n          setAlertState({\r\n            isOpen: true,\r\n            title: tCommon('dialog.successTitle'),\r\n            message: t('apiKey.page.deleteSuccess'),\r\n            type: 'success',\r\n          })\r\n        } catch {\r\n          setAlertState({\r\n            isOpen: true,\r\n            title: tCommon('dialog.errorTitle'),\r\n            message: t('apiKey.page.deleteFailed'),\r\n            type: 'error',\r\n          })\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  if (isLoading) {\r\n    return (\r\n      <div className=\"container mx-auto px-4 py-8 max-w-5xl\">\r\n        <div className=\"text-center text-muted-foreground\">{tCommon('status.loading')}</div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  const keys = data?.keys || []\r\n  const quota = data?.quota || { used: 0, limit: 3 }\r\n\r\n  return (\r\n    <div className=\"w-full space-y-4 sm:space-y-6\">\r\n      {confirmState && (\r\n        <ConfirmDialog\r\n          isOpen={confirmState.isOpen}\r\n          title={confirmState.title}\r\n          message={confirmState.message}\r\n          type=\"warning\"\r\n          onConfirm={confirmState.onConfirm}\r\n          onCancel={() => setConfirmState(null)}\r\n        />\r\n      )}\r\n\r\n      {alertState && (\r\n        <AlertDialog\r\n          isOpen={alertState.isOpen}\r\n          title={alertState.title}\r\n          message={alertState.message}\r\n          type={alertState.type}\r\n          onConfirm={() => setAlertState(null)}\r\n        />\r\n      )}\r\n\r\n      {/* Title card */}\r\n      <div className=\"card p-4 sm:p-6\">\r\n        <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4\">\r\n          <div>\r\n            <h1 className=\"text-xl sm:text-2xl font-bold text-foreground\">{t('apiKey.page.title')}</h1>\r\n            <p className=\"text-sm text-muted-foreground mt-1\">\r\n              {t('apiKey.page.description')}\r\n            </p>\r\n          </div>\r\n          <button\r\n            className=\"btn btn-primary w-full sm:w-auto touch-manipulation\"\r\n            onClick={() => setShowCreateModal(true)}\r\n            disabled={quota.used >= quota.limit}\r\n          >\r\n            + {t('apiKey.page.createNew')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n\r\n      {/* Content card */}\r\n      <div className=\"card p-4 sm:p-6\">\r\n        {/* Info text */}\r\n        <div className=\"mb-4 sm:mb-6 p-3 sm:p-4 bg-muted/30 border border-border rounded-lg\">\r\n          <p className=\"text-xs sm:text-sm text-muted-foreground mb-2 leading-relaxed\">\r\n            {t('apiKey.page.info')}\r\n          </p>\r\n          <p className=\"text-xs sm:text-sm text-muted-foreground\">\r\n            {t('apiKey.page.currentUsage')}: <strong>{quota.used} / {quota.limit >= 999 ? t('apiKey.page.unlimited') : quota.limit}</strong>\r\n          </p>\r\n        </div>\r\n\r\n        {/* API Keys list */}\r\n        {keys.length === 0 ? (\r\n          <div className=\"text-center py-8 sm:py-12\">\r\n            <p className=\"text-sm sm:text-base text-muted-foreground mb-4\">{t('apiKey.page.empty')}</p>\r\n            <button\r\n              className=\"btn btn-primary w-full sm:w-auto touch-manipulation\"\r\n              onClick={() => setShowCreateModal(true)}\r\n            >\r\n              {t('apiKey.page.createFirst')}\r\n            </button>\r\n          </div>\r\n        ) : (\r\n          <div className=\"space-y-3 sm:space-y-4\">\r\n            {keys.map((key) => (\r\n              <ApiKeyCard\r\n                key={key.id}\r\n                apiKey={key}\r\n                onViewDetails={() => setSelectedKey(key)}\r\n                onRevoke={() => handleRevoke(key.id)}\r\n                onDelete={() => handleDelete(key.id)}\r\n              />\r\n            ))}\r\n          </div>\r\n        )}\r\n\r\n        {/* Tips */}\r\n        <div className=\"mt-6 p-4 bg-info/10 border border-info/30 rounded-lg\">\r\n          <h4 className=\"font-medium text-info mb-2\">{t('apiKey.page.tipsTitle')}</h4>\r\n          <ul className=\"text-xs text-muted-foreground space-y-1 list-disc list-inside\">\r\n            <li>{t('apiKey.page.tip1', { limit: quota.limit >= 999 ? t('apiKey.page.unlimited') : quota.limit })}</li>\r\n            <li>{t('apiKey.page.tip2')}</li>\r\n            <li>{t('apiKey.page.tip3')}</li>\r\n          </ul>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 创建 API Key 模态框 */}\r\n      {showCreateModal && (\r\n        <CreateApiKeyModal onClose={() => setShowCreateModal(false)} />\r\n      )}\r\n\r\n      {/* API Key 详情模态框 */}\r\n      {selectedKey && (\r\n        <ApiKeyDetailModal\r\n          apiKey={selectedKey}\r\n          onClose={() => setSelectedKey(null)}\r\n        />\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/settings/GeneralSettingsPage.tsx",
    "content": "import { useState, useEffect, useMemo } from 'react'\r\nimport { useNavigate, useSearchParams } from 'react-router-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Settings, Zap, Camera, Key, Share2, Database, LogOut } from 'lucide-react'\r\nimport { usePreferences, useUpdatePreferences } from '@/hooks/usePreferences'\r\nimport { useAuthStore } from '@/stores/authStore'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport type { UserPreferences } from '@/lib/types'\r\nimport { ApiError } from '@/lib/api-client'\r\nimport { SettingsNav, type SettingsNavGroup } from '@/components/settings/SettingsNav'\r\nimport { SettingsSaveBar } from '@/components/settings/SettingsSaveBar'\r\nimport { BasicSettingsTab } from '@/components/settings/tabs/BasicSettingsTab'\r\nimport { AutomationSettingsTab } from '@/components/settings/tabs/AutomationSettingsTab'\r\nimport { SnapshotSettingsTab } from '@/components/settings/tabs/SnapshotSettingsTab'\r\nimport { ApiSettingsTab } from '@/components/settings/tabs/ApiSettingsTab'\r\nimport { ShareSettingsTab } from '@/components/settings/tabs/ShareSettingsTab'\r\nimport { DataSettingsTab } from '@/components/settings/tabs/DataSettingsTab'\r\n\r\nconst VALID_SECTIONS = ['basic', 'automation', 'snapshot', 'api', 'share', 'data'] as const\r\ntype SectionId = typeof VALID_SECTIONS[number]\r\n\r\nfunction isValidSection(s: string | null): s is SectionId {\r\n  return s !== null && (VALID_SECTIONS as readonly string[]).includes(s)\r\n}\r\n\r\nexport function GeneralSettingsPage() {\r\n  const { t } = useTranslation('settings')\r\n  const navigate = useNavigate()\r\n  const [searchParams, setSearchParams] = useSearchParams()\r\n  const { data: preferences, isLoading } = usePreferences()\r\n  const updatePreferences = useUpdatePreferences()\r\n  const { user, logout } = useAuthStore()\r\n  const { addToast } = useToastStore()\r\n\r\n  const sectionFromUrl = searchParams.get('section')\r\n  const initialSection: SectionId = isValidSection(sectionFromUrl) ? sectionFromUrl : 'basic'\r\n  const [activeSection, setActiveSection] = useState<SectionId>(initialSection)\r\n  const [localPreferences, setLocalPreferences] = useState<UserPreferences | null>(null)\r\n\r\n  useEffect(() => {\r\n    if (preferences) setLocalPreferences(preferences)\r\n  }, [preferences])\r\n\r\n  // 同步 URL query param\r\n  const handleSectionChange = (sectionId: string) => {\r\n    if (isValidSection(sectionId)) {\r\n      setActiveSection(sectionId)\r\n      setSearchParams(sectionId === 'basic' ? {} : { section: sectionId }, { replace: true })\r\n    }\r\n  }\r\n\r\n  const handleUpdate = (updates: Partial<UserPreferences>) => {\r\n    if (localPreferences) {\r\n      setLocalPreferences({ ...localPreferences, ...updates })\r\n    }\r\n  }\r\n\r\n  // 判断 automation+snapshot 设置是否有修改\r\n  const isDirty = useMemo(() => {\r\n    if (!preferences || !localPreferences) return false\r\n    const keys: (keyof UserPreferences)[] = [\r\n      'enable_search_auto_clear', 'search_auto_clear_seconds',\r\n      'enable_tag_selection_auto_clear', 'tag_selection_auto_clear_seconds',\r\n      'snapshot_retention_count',\r\n    ]\r\n    return keys.some((k) => preferences[k] !== localPreferences[k])\r\n  }, [preferences, localPreferences])\r\n\r\n  const handleSave = async () => {\r\n    if (!localPreferences) return\r\n    try {\r\n      await updatePreferences.mutateAsync({\r\n        theme: localPreferences.theme,\r\n        page_size: localPreferences.page_size,\r\n        view_mode: localPreferences.view_mode,\r\n        density: localPreferences.density,\r\n        tag_layout: localPreferences.tag_layout,\r\n        sort_by: localPreferences.sort_by,\r\n        search_auto_clear_seconds: localPreferences.search_auto_clear_seconds,\r\n        tag_selection_auto_clear_seconds: localPreferences.tag_selection_auto_clear_seconds,\r\n        enable_search_auto_clear: localPreferences.enable_search_auto_clear,\r\n        enable_tag_selection_auto_clear: localPreferences.enable_tag_selection_auto_clear,\r\n        snapshot_retention_count: localPreferences.snapshot_retention_count,\r\n      })\r\n      addToast('success', t('message.saveSuccess'))\r\n    } catch (error) {\r\n      let message = t('message.saveFailed')\r\n      if (error instanceof ApiError && error.message) {\r\n        message = t('message.saveFailedWithError', { error: error.message })\r\n      }\r\n      addToast('error', message)\r\n    }\r\n  }\r\n\r\n  const handleDiscard = () => {\r\n    if (preferences) {\r\n      setLocalPreferences(preferences)\r\n    }\r\n  }\r\n\r\n  const handleLogout = async () => {\r\n    try {\r\n      await logout()\r\n      navigate('/login')\r\n    } catch {\r\n      addToast('error', t('message.logoutFailed'))\r\n    }\r\n  }\r\n\r\n  const navGroups: SettingsNavGroup[] = useMemo(() => [\r\n    {\r\n      label: t('navGroup.account'),\r\n      items: [\r\n        { id: 'basic', label: t('tabs.basic'), icon: Settings },\r\n      ],\r\n    },\r\n    {\r\n      label: t('navGroup.features'),\r\n      items: [\r\n        { id: 'automation', label: t('tabs.automation'), icon: Zap },\r\n        { id: 'snapshot', label: t('tabs.snapshot'), icon: Camera },\r\n      ],\r\n    },\r\n    {\r\n      label: t('navGroup.integration'),\r\n      items: [\r\n        { id: 'api', label: t('tabs.api'), icon: Key },\r\n      ],\r\n    },\r\n    {\r\n      label: t('navGroup.dataAndShare'),\r\n      items: [\r\n        { id: 'share', label: t('tabs.share'), icon: Share2 },\r\n        { id: 'data', label: t('tabs.data'), icon: Database },\r\n      ],\r\n    },\r\n  ], [t])\r\n\r\n  if (isLoading || !localPreferences) {\r\n    return (\r\n      <div className=\"flex items-center justify-center h-64\">\r\n        <div className=\"animate-spin rounded-full h-8 w-8 border-b-2 border-primary\"></div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <div className=\"w-[80%] mx-auto px-4 sm:px-6 lg:px-8 space-y-4 sm:space-y-6\">\r\n      {/* 页面标题 */}\r\n      <div className=\"card p-4 sm:p-6\">\r\n        <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3\">\r\n          <div className=\"flex-1 min-w-0\">\r\n            <h1 className=\"text-xl sm:text-2xl font-bold text-foreground\">{t('title')}</h1>\r\n            <p className=\"text-xs sm:text-sm text-muted-foreground mt-1\">\r\n              {user?.username && <span className=\"font-medium text-foreground\">{user.username}</span>}\r\n              {user?.username && ' · '}\r\n              {t('description')}\r\n            </p>\r\n          </div>\r\n          <button\r\n            onClick={handleLogout}\r\n            className=\"btn btn-ghost btn-sm flex items-center gap-2 text-error hover:bg-error/10 flex-shrink-0\"\r\n            title={t('action.logout')}\r\n          >\r\n            <LogOut className=\"w-4 h-4\" />\r\n            <span className=\"hidden sm:inline\">{t('action.logout')}</span>\r\n          </button>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 侧栏导航 + 内容 */}\r\n      <div className=\"card p-3 sm:p-6\">\r\n        <SettingsNav groups={navGroups} activeSection={activeSection} onSectionChange={handleSectionChange}>\r\n          {activeSection === 'basic' && <BasicSettingsTab />}\r\n\r\n          {activeSection === 'automation' && (\r\n            <AutomationSettingsTab\r\n              searchEnabled={localPreferences.enable_search_auto_clear}\r\n              searchSeconds={localPreferences.search_auto_clear_seconds}\r\n              tagEnabled={localPreferences.enable_tag_selection_auto_clear}\r\n              tagSeconds={localPreferences.tag_selection_auto_clear_seconds}\r\n              onSearchEnabledChange={(enabled) => handleUpdate({ enable_search_auto_clear: enabled })}\r\n              onSearchSecondsChange={(seconds) => handleUpdate({ search_auto_clear_seconds: seconds })}\r\n              onTagEnabledChange={(enabled) => handleUpdate({ enable_tag_selection_auto_clear: enabled })}\r\n              onTagSecondsChange={(seconds) => handleUpdate({ tag_selection_auto_clear_seconds: seconds })}\r\n            />\r\n          )}\r\n\r\n          {activeSection === 'snapshot' && (\r\n            <SnapshotSettingsTab\r\n              retentionCount={localPreferences.snapshot_retention_count}\r\n              onRetentionCountChange={(count) => handleUpdate({ snapshot_retention_count: count })}\r\n            />\r\n          )}\r\n\r\n          {activeSection === 'api' && <ApiSettingsTab />}\r\n          {activeSection === 'share' && <ShareSettingsTab />}\r\n          {activeSection === 'data' && <DataSettingsTab />}\r\n\r\n          <SettingsSaveBar\r\n            isDirty={isDirty}\r\n            isSaving={updatePreferences.isPending}\r\n            onSave={handleSave}\r\n            onDiscard={handleDiscard}\r\n          />\r\n        </SettingsNav>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/settings/ImportExportPage.tsx",
    "content": "/**\r\n * Import/Export Settings Page\r\n */\r\n\r\nimport { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { ArrowLeft, Download, RefreshCw } from 'lucide-react'\r\nimport { useNavigate } from 'react-router-dom'\r\nimport { ExportSection } from '../../components/import-export/ExportSection'\r\nimport type { ExportFormat, ExportOptions } from '@shared/import-export-types'\r\n\r\nexport function ImportExportPage() {\r\n  const { t } = useTranslation('import')\r\n  const navigate = useNavigate()\r\n  const [lastOperation, setLastOperation] = useState<{\r\n    type: 'export'\r\n    timestamp: string\r\n    details: string\r\n  } | null>(null)\r\n\r\n  const handleExportComplete = (format: ExportFormat, options: ExportOptions) => {\r\n    const tags = options.include_tags ? t('page.withTags') : ''\r\n    const metadata = options.include_metadata ? t('page.withMetadata') : ''\r\n    setLastOperation({\r\n      type: 'export',\r\n      timestamp: new Date().toLocaleString(),\r\n      details: t('page.exportDetails', { format: format.toUpperCase(), tags, metadata })\r\n    })\r\n  }\r\n\r\n  return (\r\n    <div className=\"min-h-screen bg-background pb-16 sm:pb-0\">\r\n      {/* Header */}\r\n      <div className=\"bg-card border-b border-border\">\r\n        <div className=\"max-w-4xl mx-auto px-3 sm:px-6 lg:px-8\">\r\n          <div className=\"flex items-center justify-between h-14 sm:h-16\">\r\n            <div className=\"flex items-center space-x-3 sm:space-x-4\">\r\n              <button\r\n                onClick={() => navigate(-1)}\r\n                className=\"p-2 text-muted-foreground hover:text-foreground rounded-md hover:bg-muted touch-manipulation transition-colors\"\r\n              >\r\n                <ArrowLeft className=\"h-5 w-5 sm:h-5 sm:w-5\" />\r\n              </button>\r\n              <div>\r\n                <h1 className=\"text-lg sm:text-xl font-semibold text-foreground\">\r\n                  {t('page.title')}\r\n                </h1>\r\n                <p className=\"text-xs sm:text-sm text-muted-foreground hidden sm:block\">\r\n                  {t('page.description')}\r\n                </p>\r\n              </div>\r\n            </div>\r\n\r\n            {lastOperation && (\r\n              <div className=\"hidden md:flex items-center space-x-2 text-sm text-muted-foreground\">\r\n                <RefreshCw className=\"h-4 w-4\" />\r\n                <span>\r\n                  {t('page.recentOperation')}: {t('page.exportOperation')}\r\n                  ({lastOperation.timestamp})\r\n                </span>\r\n              </div>\r\n            )}\r\n          </div>\r\n        </div>\r\n      </div>\r\n\r\n      {/* 主要内容 */}\r\n      <div className=\"max-w-4xl mx-auto px-3 sm:px-6 lg:px-8 py-4 sm:py-8\">\r\n        {/* 页面说明 */}\r\n        <div className=\"mb-4 sm:mb-6 md:mb-8\">\r\n          <div className=\"px-1\">\r\n            <h2 className=\"text-base sm:text-lg font-semibold text-foreground mb-2\">\r\n              {t('page.exportTab')}\r\n            </h2>\r\n            <p className=\"text-xs sm:text-sm text-muted-foreground leading-relaxed\">\r\n              {t('page.exportDesc')}\r\n            </p>\r\n          </div>\r\n        </div>\r\n\r\n        {/* 内容区域 */}\r\n        <div className=\"card shadow-float\">\r\n          <div className=\"p-4 sm:p-6\">\r\n            <ExportSection onExport={handleExportComplete} />\r\n          </div>\r\n        </div>\r\n\r\n        {/* Recent operation history */}\r\n        {lastOperation && (\r\n          <div className=\"mt-6 sm:mt-8 card shadow-float\">\r\n            <div className=\"p-4 sm:p-6\">\r\n              <h3 className=\"text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4\">\r\n                {t('page.recentOperation')}\r\n              </h3>\r\n              <div className=\"flex items-start space-x-3\">\r\n                <div className=\"flex-shrink-0 w-8 h-8 sm:w-8 sm:h-8 rounded-full flex items-center justify-center bg-primary/10 text-primary\">\r\n                  <Download className=\"h-4 w-4\" />\r\n                </div>\r\n                <div className=\"flex-1 min-w-0\">\r\n                  <div className=\"flex flex-col sm:flex-row sm:items-center sm:space-x-2\">\r\n                    <span className=\"font-medium text-foreground text-sm sm:text-base\">\r\n                      {t('page.dataExport')}\r\n                    </span>\r\n                    <span className=\"text-xs sm:text-sm text-muted-foreground mt-1 sm:mt-0\">\r\n                      {lastOperation.timestamp}\r\n                    </span>\r\n                  </div>\r\n                  <p className=\"text-sm text-muted-foreground mt-1 break-words\">\r\n                    {lastOperation.details}\r\n                  </p>\r\n                </div>\r\n              </div>\r\n            </div>\r\n          </div>\r\n        )}\r\n\r\n        {/* Help info */}\r\n        <div className=\"mt-4 sm:mt-6 md:mt-8 bg-primary/5 rounded-lg border border-primary/20\">\r\n          <div className=\"p-3 sm:p-4 md:p-6\">\r\n            <h3 className=\"text-sm sm:text-base md:text-lg font-semibold text-foreground mb-3 sm:mb-4\">\r\n              {t('help.title')}\r\n            </h3>\r\n            <div className=\"space-y-3 sm:space-y-4\">\r\n              <div className=\"space-y-2\">\r\n                <h4 className=\"text-sm font-semibold text-foreground flex items-center\">\r\n                  <span className=\"w-2 h-2 bg-primary rounded-full mr-2 flex-shrink-0\"></span>\r\n                  {t('help.exportTitle')}\r\n                </h4>\r\n                <div className=\"ml-4 space-y-1.5\">\r\n                  <div className=\"flex items-start space-x-2\">\r\n                    <span className=\"w-1 h-1 bg-primary rounded-full mt-2 flex-shrink-0\"></span>\r\n                    <span className=\"text-xs sm:text-sm text-muted-foreground leading-relaxed\">{t('help.exportTip1')}</span>\r\n                  </div>\r\n                  <div className=\"flex items-start space-x-2\">\r\n                    <span className=\"w-1 h-1 bg-primary rounded-full mt-2 flex-shrink-0\"></span>\r\n                    <span className=\"text-xs sm:text-sm text-muted-foreground leading-relaxed\">{t('help.exportTip2')}</span>\r\n                  </div>\r\n                  <div className=\"flex items-start space-x-2\">\r\n                    <span className=\"w-1 h-1 bg-primary rounded-full mt-2 flex-shrink-0\"></span>\r\n                    <span className=\"text-xs sm:text-sm text-muted-foreground leading-relaxed\">{t('help.exportTip3')}</span>\r\n                  </div>\r\n                  <div className=\"flex items-start space-x-2\">\r\n                    <span className=\"w-1 h-1 bg-primary rounded-full mt-2 flex-shrink-0\"></span>\r\n                    <span className=\"text-xs sm:text-sm text-muted-foreground leading-relaxed\">{t('help.exportTip4')}</span>\r\n                  </div>\r\n                </div>\r\n              </div>\r\n\r\n              <div className=\"space-y-2\">\r\n                <h4 className=\"text-sm font-semibold text-foreground flex items-center\">\r\n                  <span className=\"w-2 h-2 bg-warning rounded-full mr-2 flex-shrink-0\"></span>\r\n                  {t('help.notesTitle')}\r\n                </h4>\r\n                <div className=\"ml-4 space-y-1.5\">\r\n                  <div className=\"flex items-start space-x-2\">\r\n                    <span className=\"w-1 h-1 bg-warning rounded-full mt-2 flex-shrink-0\"></span>\r\n                    <span className=\"text-xs sm:text-sm text-muted-foreground leading-relaxed\">{t('help.notesTip1')}</span>\r\n                  </div>\r\n                  <div className=\"flex items-start space-x-2\">\r\n                    <span className=\"w-1 h-1 bg-warning rounded-full mt-2 flex-shrink-0\"></span>\r\n                    <span className=\"text-xs sm:text-sm text-muted-foreground leading-relaxed\">{t('help.notesTip2')}</span>\r\n                  </div>\r\n                  <div className=\"flex items-start space-x-2\">\r\n                    <span className=\"w-1 h-1 bg-warning rounded-full mt-2 flex-shrink-0\"></span>\r\n                    <span className=\"text-xs sm:text-sm text-muted-foreground leading-relaxed\">{t('help.notesTip3')}</span>\r\n                  </div>\r\n                  <div className=\"flex items-start space-x-2\">\r\n                    <span className=\"w-1 h-1 bg-warning rounded-full mt-2 flex-shrink-0\"></span>\r\n                    <span className=\"text-xs sm:text-sm text-muted-foreground leading-relaxed\">{t('help.notesTip4')}</span>\r\n                  </div>\r\n                </div>\r\n              </div>\r\n            </div>\r\n          </div>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/settings/PermissionsPage.tsx",
    "content": "import { ExternalLink, AlertCircle, Info } from 'lucide-react'\r\nimport { useTranslation } from 'react-i18next'\r\n\r\nexport function PermissionsPage() {\r\n  const { t } = useTranslation('settings')\r\n\r\n  return (\r\n    <div className=\"container mx-auto px-4 py-6 max-w-4xl\">\r\n      <div className=\"mb-6\">\r\n        <h1 className=\"text-3xl font-bold text-foreground mb-2\">{t('browserPermissions.title')}</h1>\r\n        <p className=\"text-muted-foreground\">\r\n          {t('browserPermissions.description')}\r\n        </p>\r\n      </div>\r\n\r\n      {/* Popup permission card */}\r\n      <div className=\"card p-6 mb-6\">\r\n        <div className=\"flex items-start gap-4\">\r\n          <div className=\"w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0\">\r\n            <ExternalLink className=\"w-6 h-6 text-primary\" />\r\n          </div>\r\n          \r\n          <div className=\"flex-1\">\r\n            <h2 className=\"text-xl font-semibold text-foreground mb-2\">\r\n              {t('browserPermissions.popup.title')}\r\n            </h2>\r\n            <p className=\"text-muted-foreground\">\r\n              {t('browserPermissions.popup.description')}\r\n            </p>\r\n          </div>\r\n        </div>\r\n      </div>\r\n\r\n      {/* How to instructions */}\r\n      <div className=\"card p-6 mb-6\">\r\n        <div className=\"flex items-start gap-3 mb-4\">\r\n          <Info className=\"w-5 h-5 text-primary flex-shrink-0 mt-0.5\" />\r\n          <div>\r\n            <h3 className=\"font-semibold text-foreground mb-2\">{t('browserPermissions.howTo.title')}</h3>\r\n            <ol className=\"space-y-3 text-sm text-muted-foreground\">\r\n              <li className=\"flex gap-2\">\r\n                <span className=\"font-semibold text-foreground\">1.</span>\r\n                <span>{t('browserPermissions.howTo.step1')}</span>\r\n              </li>\r\n              <li className=\"flex gap-2\">\r\n                <span className=\"font-semibold text-foreground\">2.</span>\r\n                <span>{t('browserPermissions.howTo.step2')}</span>\r\n              </li>\r\n              <li className=\"flex gap-2\">\r\n                <span className=\"font-semibold text-foreground\">3.</span>\r\n                <span>{t('browserPermissions.howTo.step3')}</span>\r\n              </li>\r\n              <li className=\"flex gap-2\">\r\n                <span className=\"font-semibold text-foreground\">4.</span>\r\n                <span>{t('browserPermissions.howTo.step4')}</span>\r\n              </li>\r\n            </ol>\r\n          </div>\r\n        </div>\r\n      </div>\r\n\r\n      {/* Browser-specific instructions */}\r\n      <div className=\"card p-6\">\r\n        <div className=\"flex items-start gap-3\">\r\n          <AlertCircle className=\"w-5 h-5 text-primary flex-shrink-0 mt-0.5\" />\r\n          <div>\r\n            <h3 className=\"font-semibold text-foreground mb-3\">{t('browserPermissions.browsers.title')}</h3>\r\n            \r\n            <div className=\"space-y-4 text-sm\">\r\n              {/* Chrome */}\r\n              <div>\r\n                <h4 className=\"font-semibold text-foreground mb-1\">{t('browserPermissions.browsers.chrome.title')}</h4>\r\n                <p className=\"text-muted-foreground\">\r\n                  {t('browserPermissions.browsers.chrome.description')}\r\n                </p>\r\n              </div>\r\n\r\n              {/* Firefox */}\r\n              <div>\r\n                <h4 className=\"font-semibold text-foreground mb-1\">{t('browserPermissions.browsers.firefox.title')}</h4>\r\n                <p className=\"text-muted-foreground\">\r\n                  {t('browserPermissions.browsers.firefox.description')}\r\n                </p>\r\n              </div>\r\n\r\n              {/* Safari */}\r\n              <div>\r\n                <h4 className=\"font-semibold text-foreground mb-1\">{t('browserPermissions.browsers.safari.title')}</h4>\r\n                <p className=\"text-muted-foreground\">\r\n                  {t('browserPermissions.browsers.safari.description')}\r\n                </p>\r\n              </div>\r\n            </div>\r\n          </div>\r\n        </div>\r\n      </div>\r\n\r\n      {/* Why popup permission is needed */}\r\n      <div className=\"mt-6 p-4 bg-muted/50 rounded-lg\">\r\n        <h4 className=\"font-semibold text-foreground mb-2\">{t('browserPermissions.why.title')}</h4>\r\n        <p className=\"text-sm text-muted-foreground\">\r\n          {t('browserPermissions.why.description')}\r\n        </p>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/settings/ShareSettingsPage.tsx",
    "content": "import { useEffect, useMemo, useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { useShareSettings, useUpdateShareSettings } from '@/hooks/useShare'\r\n\r\nexport function ShareSettingsPage() {\r\n  const { t } = useTranslation('share')\r\n  const { data, isLoading } = useShareSettings()\r\n  const updateShare = useUpdateShareSettings()\r\n\r\n  const [enabled, setEnabled] = useState(false)\r\n  const [slug, setSlug] = useState('')\r\n  const [title, setTitle] = useState('')\r\n  const [description, setDescription] = useState('')\r\n  const [copied, setCopied] = useState(false)\r\n\r\n  useEffect(() => {\r\n    if (!data) return\r\n    setEnabled(data.enabled)\r\n    setSlug(data.slug || '')\r\n    setTitle(data.title || '')\r\n    setDescription(data.description || '')\r\n  }, [data])\r\n\r\n  const shareUrl = useMemo(() => {\r\n    if (!slug) return ''\r\n    if (typeof window === 'undefined') return `/share/${slug}`\r\n    return `${window.location.origin}/share/${slug}`\r\n  }, [slug])\r\n\r\n  const handleSave = () => {\r\n    updateShare.mutate({\r\n      enabled,\r\n      slug: slug.trim() || null,\r\n      title: title.trim() || null,\r\n      description: description.trim() || null,\r\n    })\r\n  }\r\n\r\n  const handleRegenerate = () => {\r\n    updateShare.mutate({\r\n      regenerate_slug: true,\r\n      enabled: true,\r\n      title: title.trim() || null,\r\n      description: description.trim() || null,\r\n    })\r\n  }\r\n\r\n  const handleCopy = async () => {\r\n    if (!shareUrl) return\r\n    try {\r\n      await navigator.clipboard.writeText(shareUrl)\r\n      setCopied(true)\r\n      setTimeout(() => setCopied(false), 1500)\r\n    } catch (error) {\r\n      console.error('Copy failed', error)\r\n    }\r\n  }\r\n\r\n  return (\r\n    <div className=\"max-w-3xl mx-auto py-8\">\r\n      <h1 className=\"text-2xl font-bold mb-6\">{t('settings.title')}</h1>\r\n\r\n      <div className=\"card p-6 space-y-6\">\r\n        <div className=\"flex items-center justify-between\">\r\n          <div>\r\n            <h2 className=\"text-lg font-semibold\">{t('settings.enableTitle')}</h2>\r\n            <p className=\"text-sm text-base-content/70\">{t('settings.enableDescription')}</p>\r\n          </div>\r\n          <label className=\"inline-flex items-center gap-2 text-sm\">\r\n            <span>{t('settings.enableLabel')}</span>\r\n            <input\r\n              type=\"checkbox\"\r\n              checked={enabled}\r\n              onChange={(e) => setEnabled(e.target.checked)}\r\n              disabled={isLoading || updateShare.isPending}\r\n            />\r\n          </label>\r\n        </div>\r\n\r\n        <div className=\"grid md:grid-cols-2 gap-4\">\r\n          <div className=\"space-y-2\">\r\n            <label className=\"text-xs font-medium text-base-content/70\">{t('settings.slugLabel')}</label>\r\n            <div className=\"flex gap-2\">\r\n              <input\r\n                type=\"text\"\r\n                className=\"input flex-1\"\r\n                placeholder={t('settings.slugPlaceholder')}\r\n                value={slug}\r\n                onChange={(e) => setSlug(e.target.value.replace(/[^a-zA-Z0-9-]/g, '').replace(/^-+|-+$/g, '').toLowerCase())}\r\n                disabled={isLoading || updateShare.isPending}\r\n              />\r\n              <button\r\n                type=\"button\"\r\n                className=\"btn btn-sm\"\r\n                onClick={handleRegenerate}\r\n                disabled={updateShare.isPending}\r\n              >\r\n                {t('settings.regenerate')}\r\n              </button>\r\n            </div>\r\n            <p className=\"text-xs text-base-content/60\">{t('settings.slugHint')}</p>\r\n          </div>\r\n\r\n          <div className=\"space-y-2\">\r\n            <label className=\"text-xs font-medium text-base-content/70\">{t('settings.pageTitleLabel')}</label>\r\n            <input\r\n              type=\"text\"\r\n              className=\"input\"\r\n              placeholder={t('settings.pageTitlePlaceholder')}\r\n              value={title}\r\n              onChange={(e) => setTitle(e.target.value)}\r\n              disabled={isLoading || updateShare.isPending}\r\n            />\r\n          </div>\r\n        </div>\r\n\r\n        <div className=\"space-y-2\">\r\n          <label className=\"text-xs font-medium text-base-content/70\">{t('settings.descriptionLabel')}</label>\r\n          <textarea\r\n            className=\"input min-h-[80px]\"\r\n            placeholder={t('settings.descriptionPlaceholder')}\r\n            value={description}\r\n            onChange={(e) => setDescription(e.target.value)}\r\n            disabled={isLoading || updateShare.isPending}\r\n          />\r\n        </div>\r\n\r\n        <div className=\"space-y-2\">\r\n          <label className=\"text-xs font-medium text-base-content/70\">{t('settings.linkLabel')}</label>\r\n          <div className=\"flex gap-2\">\r\n            <input\r\n              type=\"text\"\r\n              className=\"input flex-1\"\r\n              readOnly\r\n              value={shareUrl || t('settings.linkPlaceholder')}\r\n            />\r\n            <button\r\n              type=\"button\"\r\n              className=\"btn btn-sm\"\r\n              onClick={handleCopy}\r\n              disabled={!shareUrl}\r\n            >\r\n              {copied ? t('settings.copied') : t('settings.copy')}\r\n            </button>\r\n          </div>\r\n        </div>\r\n\r\n        <div className=\"flex justify-end gap-3\">\r\n          <button\r\n            type=\"button\"\r\n            className=\"btn btn-sm btn-outline\"\r\n            onClick={() => {\r\n              if (!data) return\r\n              setEnabled(data.enabled)\r\n              setSlug(data.slug || '')\r\n              setTitle(data.title || '')\r\n              setDescription(data.description || '')\r\n            }}\r\n            disabled={isLoading || updateShare.isPending}\r\n          >\r\n            {t('settings.reset')}\r\n          </button>\r\n          <button\r\n            type=\"button\"\r\n            className=\"btn btn-sm\"\r\n            onClick={handleSave}\r\n            disabled={updateShare.isPending}\r\n          >\r\n            {updateShare.isPending ? t('settings.saving') : t('settings.save')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/share/PublicSharePage.tsx",
    "content": "import { useCallback } from 'react'\r\nimport { useParams } from 'react-router-dom'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { BookmarkListLayout } from '@/components/bookmarks/BookmarkListLayout'\r\nimport { usePublicShare } from '@/hooks/useShare'\r\nimport { useBookmarkFilters } from '@/hooks/useBookmarkFilters'\r\nimport { useClientSideFilter } from '@/hooks/useClientSideFilter'\r\n\r\nexport function PublicSharePage() {\r\n  const { t } = useTranslation('share')\r\n  const { slug = '' } = useParams()\r\n\r\n  const {\r\n    selectedTags, setSelectedTags,\r\n    debouncedSelectedTags,\r\n    searchKeyword, setSearchKeyword,\r\n    debouncedSearchKeyword,\r\n    searchMode, setSearchMode,\r\n    sortBy, handleSortChange,\r\n    viewMode, handleViewModeChange,\r\n    visibilityFilter, handleVisibilityChange,\r\n    tagLayout, setTagLayout, handleTagLayoutChange,\r\n  } = useBookmarkFilters()\r\n\r\n  const shareQuery = usePublicShare(slug, true)\r\n  const shareInfo = shareQuery.data?.profile\r\n  const allBookmarks = shareQuery.data?.bookmarks || []\r\n  const tags = shareQuery.data?.tags || []\r\n\r\n  const {\r\n    displayedBookmarks,\r\n    tagFilteredBookmarks,\r\n    sortedBookmarks,\r\n    hasMore,\r\n    loadMore,\r\n  } = useClientSideFilter({\r\n    bookmarks: allBookmarks,\r\n    selectedTags: debouncedSelectedTags,\r\n    searchKeyword: debouncedSearchKeyword,\r\n    sortBy,\r\n    visibilityFilter,\r\n  })\r\n\r\n  const handleSearchModeToggle = useCallback(\r\n    () => setSearchMode(searchMode === 'bookmark' ? 'tag' : 'bookmark'),\r\n    [searchMode, setSearchMode]\r\n  )\r\n\r\n  if (shareQuery.isLoading) {\r\n    return <div className=\"text-center text-muted-foreground py-24\">{t('loading')}</div>\r\n  }\r\n  if (shareQuery.isError) {\r\n    return <div className=\"text-center text-muted-foreground py-24\">{t('error')}</div>\r\n  }\r\n\r\n  return (\r\n    <BookmarkListLayout\r\n      readOnly\r\n      bookmarks={displayedBookmarks}\r\n      tagBookmarks={tagFilteredBookmarks}\r\n      isLoading={shareQuery.isLoading}\r\n      hasMore={hasMore}\r\n      onLoadMore={loadMore}\r\n      searchMode={searchMode}\r\n      onSearchModeToggle={handleSearchModeToggle}\r\n      searchKeyword={searchKeyword}\r\n      onSearchKeywordChange={setSearchKeyword}\r\n      sortBy={sortBy}\r\n      onSortByChange={handleSortChange}\r\n      visibilityFilter={visibilityFilter}\r\n      onVisibilityChange={handleVisibilityChange}\r\n      viewMode={viewMode}\r\n      onViewModeChange={handleViewModeChange}\r\n      selectedTags={selectedTags}\r\n      onTagsChange={setSelectedTags}\r\n      tagLayout={tagLayout}\r\n      onTagLayoutChange={(l) => { handleTagLayoutChange(l); setTagLayout(l) }}\r\n      debouncedSearchKeyword={debouncedSearchKeyword}\r\n      availableTags={tags}\r\n      i18nNs=\"share\"\r\n      headerSlot={\r\n        <ShareHeader\r\n          shareInfo={shareInfo}\r\n          totalCount={allBookmarks.length}\r\n          filteredCount={sortedBookmarks.length}\r\n          t={t}\r\n        />\r\n      }\r\n    />\r\n  )\r\n}\r\n\r\n/** 分享信息头部卡片 */\r\nfunction ShareHeader({ shareInfo, totalCount, filteredCount, t }: {\r\n  shareInfo?: { title?: string | null; description?: string | null; username?: string } | null\r\n  totalCount: number\r\n  filteredCount: number\r\n  t: (key: string, opts?: Record<string, unknown>) => string\r\n}) {\r\n  if (!shareInfo && totalCount === 0) return null\r\n\r\n  return (\r\n    <div className=\"card shadow-float p-4\">\r\n      <div className=\"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3\">\r\n        <div className=\"flex-1\">\r\n          <h1 className=\"text-xl sm:text-2xl font-bold text-primary\">\r\n            {shareInfo?.title || (shareInfo?.username ? t('defaultTitle', { username: shareInfo.username }) : t('guestTitle'))}\r\n          </h1>\r\n          {shareInfo?.description && <p className=\"text-sm text-muted-foreground mt-1\">{shareInfo.description}</p>}\r\n        </div>\r\n        {totalCount > 0 && (\r\n          <div className=\"text-sm text-muted-foreground\">\r\n            {filteredCount === totalCount\r\n              ? t('stats.total', { count: totalCount })\r\n              : t('stats.filtered', { filtered: filteredCount, total: totalCount })}\r\n          </div>\r\n        )}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/share/components/ShareTopBar.tsx",
    "content": "import { \r\n  LayoutGrid, \r\n  List, \r\n  AlignLeft, \r\n  Type, \r\n  Eye, \r\n  Lock, \r\n  Layers, \r\n  Calendar, \r\n  RefreshCw, \r\n  Bookmark as BookmarkIcon, \r\n  TrendingUp,\r\n  Tag as TagIcon,\r\n  Search\r\n} from 'lucide-react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport type { ViewMode, VisibilityFilter } from '../hooks/usePublicShareState'\r\nimport type { SortOption } from '@/components/common/SortSelector'\r\n\r\nfunction ViewModeIcon({ mode }: { mode: ViewMode }) {\r\n  switch (mode) {\r\n    case 'card':\r\n      return <LayoutGrid className=\"w-4 h-4\" />\r\n    case 'list':\r\n      return <List className=\"w-4 h-4\" />\r\n    case 'minimal':\r\n      return <AlignLeft className=\"w-4 h-4\" />\r\n    case 'title':\r\n      return <Type className=\"w-4 h-4\" />\r\n    default:\r\n      return <LayoutGrid className=\"w-4 h-4\" />\r\n  }\r\n}\r\n\r\nfunction VisibilityIcon({ filter }: { filter: VisibilityFilter }) {\r\n  switch (filter) {\r\n    case 'public':\r\n      return <Eye className=\"w-4 h-4\" />\r\n    case 'private':\r\n      return <Lock className=\"w-4 h-4\" />\r\n    case 'all':\r\n      return <Layers className=\"w-4 h-4\" />\r\n    default:\r\n      return <Layers className=\"w-4 h-4\" />\r\n  }\r\n}\r\n\r\nfunction SortIcon({ sort }: { sort: SortOption }) {\r\n  switch (sort) {\r\n    case 'created':\r\n      return <Calendar className=\"w-4 h-4\" />\r\n    case 'updated':\r\n      return <RefreshCw className=\"w-4 h-4\" />\r\n    case 'pinned':\r\n      return <BookmarkIcon className=\"w-4 h-4\" />\r\n    case 'popular':\r\n      return <TrendingUp className=\"w-4 h-4\" />\r\n    default:\r\n      return <Calendar className=\"w-4 h-4\" />\r\n  }\r\n}\r\n\r\ninterface ShareTopBarProps {\r\n  searchMode: 'bookmark' | 'tag'\r\n  setSearchMode: (mode: 'bookmark' | 'tag') => void\r\n  searchKeyword: string\r\n  setSearchKeyword: (keyword: string) => void\r\n  sortBy: SortOption\r\n  setSortBy: (sort: SortOption) => void\r\n  visibilityFilter: VisibilityFilter\r\n  setVisibilityFilter: (filter: VisibilityFilter) => void\r\n  viewMode: ViewMode\r\n  setViewMode: (mode: ViewMode) => void\r\n  setIsTagSidebarOpen: (open: boolean) => void\r\n}\r\n\r\nconst SORT_OPTIONS: SortOption[] = ['created', 'updated', 'pinned', 'popular']\r\nconst VIEW_MODES = ['list', 'card', 'minimal', 'title'] as const\r\n\r\nexport function ShareTopBar({\r\n  searchMode,\r\n  setSearchMode,\r\n  searchKeyword,\r\n  setSearchKeyword,\r\n  sortBy,\r\n  setSortBy,\r\n  visibilityFilter,\r\n  setVisibilityFilter,\r\n  viewMode,\r\n  setViewMode,\r\n  setIsTagSidebarOpen,\r\n}: ShareTopBarProps) {\r\n  const { t } = useTranslation('share')\r\n  \r\n  const handleSortChange = () => {\r\n    const currentIndex = SORT_OPTIONS.indexOf(sortBy)\r\n    const nextIndex = (currentIndex + 1) % SORT_OPTIONS.length\r\n    setSortBy(SORT_OPTIONS[nextIndex]!)\r\n  }\r\n\r\n  const handleViewModeChange = () => {\r\n    const currentIndex = VIEW_MODES.indexOf(viewMode)\r\n    const nextIndex = (currentIndex + 1) % VIEW_MODES.length\r\n    setViewMode(VIEW_MODES[nextIndex]!)\r\n  }\r\n\r\n  const handleVisibilityChange = () => {\r\n    const filters: VisibilityFilter[] = ['all', 'public', 'private']\r\n    const currentIndex = filters.indexOf(visibilityFilter)\r\n    const nextIndex = (currentIndex + 1) % filters.length\r\n    setVisibilityFilter(filters[nextIndex]!)\r\n  }\r\n\r\n  const getViewModeLabel = (mode: ViewMode) => t(`view.${mode}`)\r\n  const getSortLabel = (sort: SortOption) => t(`sort.${sort}`)\r\n  const getVisibilityLabel = (filter: VisibilityFilter) => t(`filter.${filter}`)\r\n\r\n  return (\r\n    <div className=\"flex-shrink-0 px-3 sm:px-4 md:px-6 pt-3 sm:pt-4 md:pt-6 pb-3 sm:pb-4 w-full\">\r\n      <div className=\"p-4 sm:p-5 w-full\">\r\n        <div className=\"flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4 w-full\">\r\n          <div className=\"flex items-center gap-3 flex-1 min-w-0 w-full sm:min-w-[280px]\">\r\n            <button\r\n              onClick={() => setIsTagSidebarOpen(true)}\r\n              className=\"group lg:hidden w-11 h-11 sm:w-12 sm:h-12 rounded-2xl flex items-center justify-center transition-all duration-300 bg-card border border-border hover:border-primary/30 hover:bg-primary/5 active:scale-95 text-foreground shadow-sm hover:shadow-md\"\r\n              title={t('filter.openTags')}\r\n            >\r\n              <TagIcon className=\"w-5 h-5 transition-transform duration-300 group-hover:scale-110\" />\r\n            </button>\r\n\r\n            <div className=\"flex-1 min-w-0\">\r\n              <div className=\"relative w-full\">\r\n                <button\r\n                  onClick={() => setSearchMode(searchMode === 'bookmark' ? 'tag' : 'bookmark')}\r\n                  className=\"absolute left-3 sm:left-4 top-1/2 -translate-y-1/2 z-10 flex items-center justify-center transition-all hover:text-primary hover:scale-110\"\r\n                  title={searchMode === 'bookmark' ? t('search.switchToTag') : t('search.switchToBookmark')}\r\n                >\r\n                  {searchMode === 'bookmark' ? (\r\n                    <BookmarkIcon className=\"w-5 h-5\" />\r\n                  ) : (\r\n                    <TagIcon className=\"w-5 h-5\" />\r\n                  )}\r\n                </button>\r\n\r\n                <Search className=\"absolute left-10 sm:left-12 top-1/2 -translate-y-1/2 w-4 h-4 sm:w-5 sm:h-5 text-muted-foreground pointer-events-none\" />\r\n\r\n                <input\r\n                  type=\"text\"\r\n                  className=\"input w-full !pl-16 sm:!pl-[4.5rem] h-11 sm:h-auto text-sm sm:text-base\"\r\n                  placeholder={searchMode === 'bookmark' ? t('search.bookmarkPlaceholder') : t('search.tagPlaceholder')}\r\n                  value={searchKeyword}\r\n                  onChange={(e) => setSearchKeyword(e.target.value)}\r\n                />\r\n              </div>\r\n            </div>\r\n          </div>\r\n\r\n          <div className=\"flex items-center gap-2 w-full sm:w-auto overflow-x-auto scrollbar-hide pb-1 sm:pb-0\">\r\n            <button\r\n              onClick={handleSortChange}\r\n              className=\"btn btn-sm btn-ghost p-2 flex-shrink-0 !border-0 !shadow-none\"\r\n              title={getSortLabel(sortBy)}\r\n            >\r\n              <SortIcon sort={sortBy} />\r\n            </button>\r\n\r\n            <button\r\n              onClick={handleVisibilityChange}\r\n              className=\"btn btn-sm btn-ghost p-2 flex-shrink-0 !border-0 !shadow-none\"\r\n              title={getVisibilityLabel(visibilityFilter)}\r\n            >\r\n              <VisibilityIcon filter={visibilityFilter} />\r\n            </button>\r\n\r\n            <button\r\n              onClick={handleViewModeChange}\r\n              className=\"btn btn-sm btn-ghost p-2 flex-shrink-0 !border-0 !shadow-none\"\r\n              title={getViewModeLabel(viewMode)}\r\n            >\r\n              <ViewModeIcon mode={viewMode} />\r\n            </button>\r\n          </div>\r\n        </div>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/share/hooks/usePublicShareState.ts",
    "content": "import { useState, useRef } from 'react'\r\nimport type { SortOption } from '@/components/common/SortSelector'\r\n\r\nexport type ViewMode = 'list' | 'card' | 'minimal' | 'title'\r\nexport type VisibilityFilter = 'all' | 'public' | 'private'\r\n\r\nexport function usePublicShareState() {\r\n  const [selectedTags, setSelectedTags] = useState<string[]>([])\r\n  const [debouncedSelectedTags, setDebouncedSelectedTags] = useState<string[]>([])\r\n  const [searchKeyword, setSearchKeyword] = useState('')\r\n  const [debouncedSearchKeyword, setDebouncedSearchKeyword] = useState('')\r\n  const [searchMode, setSearchMode] = useState<'bookmark' | 'tag'>('bookmark')\r\n  const [viewMode, setViewMode] = useState<ViewMode>('card')\r\n  const [sortBy, setSortBy] = useState<SortOption>('created')\r\n  const [visibilityFilter, setVisibilityFilter] = useState<VisibilityFilter>('all')\r\n  const [tagLayout, setTagLayout] = useState<'grid' | 'masonry'>('grid')\r\n  const [isTagSidebarOpen, setIsTagSidebarOpen] = useState(false)\r\n  const [currentPage, setCurrentPage] = useState(1)\r\n  const [tagSortBy, setTagSortBy] = useState<'usage' | 'name' | 'clicks'>('usage')\r\n  \r\n  const tagDebounceTimerRef = useRef<NodeJS.Timeout | null>(null)\r\n  const autoCleanupTimerRef = useRef<NodeJS.Timeout | null>(null)\r\n  const searchCleanupTimerRef = useRef<NodeJS.Timeout | null>(null)\r\n\r\n  return {\r\n    selectedTags,\r\n    setSelectedTags,\r\n    debouncedSelectedTags,\r\n    setDebouncedSelectedTags,\r\n    searchKeyword,\r\n    setSearchKeyword,\r\n    debouncedSearchKeyword,\r\n    setDebouncedSearchKeyword,\r\n    searchMode,\r\n    setSearchMode,\r\n    viewMode,\r\n    setViewMode,\r\n    sortBy,\r\n    setSortBy,\r\n    visibilityFilter,\r\n    setVisibilityFilter,\r\n    tagLayout,\r\n    setTagLayout,\r\n    isTagSidebarOpen,\r\n    setIsTagSidebarOpen,\r\n    currentPage,\r\n    setCurrentPage,\r\n    tagSortBy,\r\n    setTagSortBy,\r\n    tagDebounceTimerRef,\r\n    autoCleanupTimerRef,\r\n    searchCleanupTimerRef,\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/StatisticsPage.tsx",
    "content": "import { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { BarChart3, TrendingUp, Layers, Share2, Archive, Globe, ArrowLeft } from 'lucide-react'\r\nimport { Link } from 'react-router-dom'\r\nimport { BottomNav } from '@/components/common/BottomNav'\r\nimport { MobileHeader } from '@/components/common/MobileHeader'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\nimport { useTabGroupsStatisticsQuery } from '@/hooks/useTabGroupsQuery'\r\n\r\nexport function StatisticsPage() {\r\n  const { t } = useTranslation('tabGroups')\r\n  const { t: tc } = useTranslation('common')\r\n  const isMobile = useIsMobile()\r\n  const [days, setDays] = useState(30)\r\n  const statisticsQuery = useTabGroupsStatisticsQuery(days)\r\n  const statistics = statisticsQuery.data || null\r\n\r\n  if (statisticsQuery.isLoading) {\r\n    return (\r\n      <div className=\"flex items-center justify-center min-h-[400px]\">\r\n        <div className=\"text-center\">\r\n          <div className=\"animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4\"></div>\r\n          <p className=\"text-muted-foreground\">{tc('status.loading')}</p>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  if (statisticsQuery.isError || !statistics) {\r\n    return (\r\n      <div className=\"flex items-center justify-center min-h-[400px]\">\r\n        <div className=\"text-center\">\r\n          <p className=\"text-destructive mb-4\">{t('page.loadFailed')}</p>\r\n          <button\r\n            onClick={() => {\r\n              void statisticsQuery.refetch()\r\n            }}\r\n            className=\"px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90\"\r\n          >\r\n            {tc('button.retry')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <div className={`h-screen flex flex-col bg-background ${isMobile ? 'overflow-hidden' : ''}`}>\r\n      {isMobile && (\r\n        <MobileHeader\r\n          title={t('statistics.title')}\r\n          showMenu={false}\r\n          showSearch={false}\r\n          showMore={false}\r\n        />\r\n      )}\r\n\r\n      <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20 min-h-0' : ''}`}>\r\n        <div className=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\">\r\n          <div className=\"mb-8\">\r\n            {!isMobile && (\r\n              <Link\r\n                to=\"/tab\"\r\n                className=\"inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-4 transition-colors\"\r\n              >\r\n                <ArrowLeft className=\"w-5 h-5\" />\r\n                <span>{t('statistics.backToTabGroups')}</span>\r\n              </Link>\r\n            )}\r\n            <div className={`flex items-center ${isMobile ? 'flex-col gap-4' : 'justify-between'}`}>\r\n              {!isMobile && (\r\n                <div className=\"flex items-center gap-3\">\r\n                  <BarChart3 className=\"w-8 h-8 text-primary\" />\r\n                  <h1 className=\"text-3xl font-bold text-foreground\">{t('statistics.title')}</h1>\r\n                </div>\r\n              )}\r\n              <select\r\n                value={days}\r\n                onChange={(e) => setDays(Number(e.target.value))}\r\n                className={`px-4 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary bg-card text-foreground ${isMobile ? 'w-full' : ''}`}\r\n              >\r\n                <option value={7}>{t('statistics.last7Days')}</option>\r\n                <option value={30}>{t('statistics.last30Days')}</option>\r\n                <option value={90}>{t('statistics.last90Days')}</option>\r\n              </select>\r\n            </div>\r\n          </div>\r\n\r\n          <div className=\"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8\">\r\n            <div className=\"card p-6\">\r\n              <div className=\"flex items-center justify-between mb-2\">\r\n                <Layers className=\"w-8 h-8 text-primary\" />\r\n                <span className=\"text-3xl font-bold text-foreground\">{statistics.summary.total_groups}</span>\r\n              </div>\r\n              <p className=\"text-muted-foreground\">{t('statistics.tabGroups')}</p>\r\n            </div>\r\n\r\n            <div className=\"card p-6\">\r\n              <div className=\"flex items-center justify-between mb-2\">\r\n                <TrendingUp className=\"w-8 h-8 text-success\" />\r\n                <span className=\"text-3xl font-bold text-foreground\">{statistics.summary.total_items}</span>\r\n              </div>\r\n              <p className=\"text-muted-foreground\">{t('statistics.tabs')}</p>\r\n            </div>\r\n\r\n            <div className=\"card p-6\">\r\n              <div className=\"flex items-center justify-between mb-2\">\r\n                <Share2 className=\"w-8 h-8 text-accent\" />\r\n                <span className=\"text-3xl font-bold text-foreground\">{statistics.summary.total_shares}</span>\r\n              </div>\r\n              <p className=\"text-muted-foreground\">{t('statistics.shares')}</p>\r\n            </div>\r\n\r\n            <div className=\"card p-6\">\r\n              <div className=\"flex items-center justify-between mb-2\">\r\n                <Archive className=\"w-8 h-8 text-muted-foreground\" />\r\n                <span className=\"text-3xl font-bold text-foreground\">{statistics.summary.total_deleted_groups}</span>\r\n              </div>\r\n              <p className=\"text-muted-foreground\">{t('statistics.trash')}</p>\r\n            </div>\r\n          </div>\r\n\r\n          <div className=\"card p-6 mb-8\">\r\n            <h2 className=\"text-xl font-semibold text-foreground mb-4 flex items-center gap-2\">\r\n              <Globe className=\"w-6 h-6 text-primary\" />\r\n              {t('statistics.topDomains')}\r\n            </h2>\r\n            {statistics.top_domains.length === 0 ? (\r\n              <p className=\"text-muted-foreground text-center py-8\">{t('statistics.noData')}</p>\r\n            ) : (\r\n              <div className=\"space-y-3\">\r\n                {statistics.top_domains.map((domain, index) => (\r\n                  <div key={domain.domain} className=\"flex items-center gap-4\">\r\n                    <span className=\"text-lg font-semibold text-muted-foreground/50 w-8\">{index + 1}</span>\r\n                    <div className=\"flex-1\">\r\n                      <div className=\"flex items-center justify-between mb-1\">\r\n                        <span className=\"text-foreground font-medium\">{domain.domain}</span>\r\n                        <span className=\"text-muted-foreground\">{t('statistics.count', { count: domain.count })}</span>\r\n                      </div>\r\n                      <div className=\"w-full bg-muted rounded-full h-2\">\r\n                        <div\r\n                          className=\"bg-primary h-2 rounded-full transition-all\"\r\n                          style={{\r\n                            width: `${statistics.top_domains[0] ? (domain.count / statistics.top_domains[0].count) * 100 : 0}%`,\r\n                          }}\r\n                        ></div>\r\n                      </div>\r\n                    </div>\r\n                  </div>\r\n                ))}\r\n              </div>\r\n            )}\r\n          </div>\r\n\r\n          <div className=\"card p-6\">\r\n            <h2 className=\"text-xl font-semibold text-foreground mb-4 flex items-center gap-2\">\r\n              <Layers className=\"w-6 h-6 text-primary\" />\r\n              {t('statistics.groupSizeDistribution')}\r\n            </h2>\r\n            {statistics.group_size_distribution.length === 0 ? (\r\n              <p className=\"text-muted-foreground text-center py-8\">{t('statistics.noData')}</p>\r\n            ) : (\r\n              <div className=\"space-y-3\">\r\n                {statistics.group_size_distribution.map((item) => (\r\n                  <div key={item.range} className=\"flex items-center gap-4\">\r\n                    <span className=\"text-foreground font-medium w-20\">{item.range}</span>\r\n                    <div className=\"flex-1\">\r\n                      <div className=\"flex items-center justify-between mb-1\">\r\n                        <div className=\"w-full bg-muted rounded-full h-8\">\r\n                          <div\r\n                            className=\"bg-success h-8 rounded-full transition-all flex items-center justify-end pr-3\"\r\n                            style={{\r\n                              width: `${(item.count / Math.max(...statistics.group_size_distribution.map((entry) => entry.count))) * 100}%`,\r\n                              minWidth: '60px',\r\n                            }}\r\n                          >\r\n                            <span className=\"text-primary-foreground font-semibold\">{item.count}</span>\r\n                          </div>\r\n                        </div>\r\n                      </div>\r\n                    </div>\r\n                  </div>\r\n                ))}\r\n              </div>\r\n            )}\r\n          </div>\r\n\r\n          <div className=\"grid grid-cols-1 lg:grid-cols-2 gap-6 mt-8\">\r\n            <div className=\"card p-6\">\r\n              <h2 className=\"text-xl font-semibold text-foreground mb-4\">{t('statistics.groupCreationTrend')}</h2>\r\n              {statistics.trends.groups.length === 0 ? (\r\n                <p className=\"text-muted-foreground text-center py-8\">{t('statistics.noData')}</p>\r\n              ) : (\r\n                <div className=\"space-y-2\">\r\n                  {statistics.trends.groups.slice(-10).map((trend) => (\r\n                    <div key={trend.date} className=\"flex items-center justify-between text-sm\">\r\n                      <span className=\"text-muted-foreground\">{trend.date}</span>\r\n                      <span className=\"font-semibold text-foreground\">{t('statistics.count', { count: trend.count })}</span>\r\n                    </div>\r\n                  ))}\r\n                </div>\r\n              )}\r\n            </div>\r\n\r\n            <div className=\"card p-6\">\r\n              <h2 className=\"text-xl font-semibold text-foreground mb-4\">{t('statistics.tabAdditionTrend')}</h2>\r\n              {statistics.trends.items.length === 0 ? (\r\n                <p className=\"text-muted-foreground text-center py-8\">{t('statistics.noData')}</p>\r\n              ) : (\r\n                <div className=\"space-y-2\">\r\n                  {statistics.trends.items.slice(-10).map((trend) => (\r\n                    <div key={trend.date} className=\"flex items-center justify-between text-sm\">\r\n                      <span className=\"text-muted-foreground\">{trend.date}</span>\r\n                      <span className=\"font-semibold text-foreground\">{t('statistics.count', { count: trend.count })}</span>\r\n                    </div>\r\n                  ))}\r\n                </div>\r\n              )}\r\n            </div>\r\n          </div>\r\n        </div>\r\n      </div>\r\n\r\n      {isMobile && <BottomNav />}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/TabGroupDetailHeader.tsx",
    "content": "import { useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { useNavigate } from 'react-router-dom'\nimport {\n  ArrowLeft,\n  Calendar,\n  ExternalLink,\n  Trash2,\n  Edit2,\n  Check,\n  X,\n} from 'lucide-react'\nimport { formatDistanceToNow } from 'date-fns'\nimport { zhCN, enUS } from 'date-fns/locale'\nimport { tabGroupsService } from '@/services/tab-groups'\nimport { useToastStore } from '@/stores/toastStore'\nimport { logger } from '@/lib/logger'\nimport type { TabGroup } from '@/lib/types'\n\ninterface TabGroupDetailHeaderProps {\n  tabGroup: TabGroup\n  onRefresh: () => Promise<void>\n  onDelete: () => void\n}\n\nexport function TabGroupDetailHeader({ tabGroup, onRefresh, onDelete }: TabGroupDetailHeaderProps) {\n  const { t, i18n } = useTranslation('tabGroups')\n  const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS\n  const navigate = useNavigate()\n  const { success, error: showError } = useToastStore()\n\n  const [isEditingTitle, setIsEditingTitle] = useState(false)\n  const [editedTitle, setEditedTitle] = useState('')\n  const [isSavingTitle, setIsSavingTitle] = useState(false)\n\n  const handleSaveTitle = async () => {\n    if (!editedTitle.trim()) return\n    try {\n      setIsSavingTitle(true)\n      await tabGroupsService.updateTabGroup(tabGroup.id, { title: editedTitle.trim() })\n      await onRefresh()\n      setIsEditingTitle(false)\n      success(t('detail.titleUpdateSuccess'))\n    } catch (err) {\n      logger.error('Failed to update title:', err)\n      showError(t('detail.titleUpdateFailed'))\n    } finally {\n      setIsSavingTitle(false)\n    }\n  }\n\n  const formatDate = (dateStr: string) => {\n    try {\n      return formatDistanceToNow(new Date(dateStr), {\n        addSuffix: true,\n        locale: dateLocale,\n      })\n    } catch {\n      return dateStr\n    }\n  }\n\n  return (\n    <div className=\"mb-6\">\n      <button\n        onClick={() => navigate('/tab')}\n        className=\"flex items-center gap-2 mb-4 text-sm hover:opacity-70 transition-opacity\"\n        style={{ color: 'var(--muted-foreground)' }}\n      >\n        <ArrowLeft className=\"w-4 h-4\" />\n        {t('detail.backToList')}\n      </button>\n\n      <div className=\"flex items-start justify-between gap-4\">\n        <div className=\"flex-1\">\n          {isEditingTitle ? (\n            <div className=\"flex items-center gap-2 mb-2\">\n              <input\n                type=\"text\"\n                value={editedTitle}\n                onChange={(e) => setEditedTitle(e.target.value)}\n                className=\"flex-1 px-3 py-2 rounded-lg border border-border bg-card text-lg font-semibold\"\n                style={{ color: 'var(--foreground)' }}\n                autoFocus\n                onKeyDown={(e) => {\n                  if (e.key === 'Enter') void handleSaveTitle()\n                  if (e.key === 'Escape') {\n                    setEditedTitle(tabGroup.title)\n                    setIsEditingTitle(false)\n                  }\n                }}\n              />\n              <button\n                onClick={() => void handleSaveTitle()}\n                disabled={isSavingTitle || !editedTitle.trim()}\n                className=\"p-2 rounded-lg bg-success text-success-foreground hover:bg-success/90 transition-colors disabled:opacity-50\"\n              >\n                <Check className=\"w-5 h-5\" />\n              </button>\n              <button\n                onClick={() => {\n                  setEditedTitle(tabGroup.title)\n                  setIsEditingTitle(false)\n                }}\n                disabled={isSavingTitle}\n                className=\"p-2 rounded-lg border border-border hover:bg-muted/50 transition-colors\"\n              >\n                <X className=\"w-5 h-5\" />\n              </button>\n            </div>\n          ) : (\n            <div className=\"flex items-center gap-2 mb-2\">\n              <h1 className=\"text-2xl font-bold\" style={{ color: 'var(--foreground)' }}>\n                {tabGroup.title}\n              </h1>\n              <button\n                onClick={() => {\n                  setEditedTitle(tabGroup.title)\n                  setIsEditingTitle(true)\n                }}\n                className=\"p-1.5 rounded-lg hover:bg-muted/50 transition-colors\"\n                title={t('detail.editTitle')}\n              >\n                <Edit2 className=\"w-4 h-4\" style={{ color: 'var(--muted-foreground)' }} />\n              </button>\n            </div>\n          )}\n\n          <div className=\"flex items-center gap-4 text-sm\" style={{ color: 'var(--muted-foreground)' }}>\n            <div className=\"flex items-center gap-1.5\">\n              <Calendar className=\"w-4 h-4\" />\n              <span>{formatDate(tabGroup.created_at)}</span>\n            </div>\n            <div className=\"flex items-center gap-1.5\">\n              <ExternalLink className=\"w-4 h-4\" />\n              <span>{t('header.tabCount', { count: tabGroup.items?.length || 0 })}</span>\n            </div>\n          </div>\n        </div>\n\n        <div className=\"flex items-center gap-2\">\n          <button\n            onClick={onDelete}\n            className=\"px-4 py-2 rounded-lg border border-border hover:bg-destructive/10 hover:border-destructive/50 transition-colors flex items-center gap-2\"\n            title={t('confirm.deleteGroup')}\n          >\n            <Trash2 className=\"w-4 h-4 text-destructive\" />\n            <span className=\"text-sm font-medium text-destructive\">{t('action.delete')}</span>\n          </button>\n        </div>\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/TabGroupDetailPage.tsx",
    "content": "import { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { useParams, useNavigate } from 'react-router-dom'\r\nimport { RotateCcw, Layers } from 'lucide-react'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { useTabGroupDetailQuery, useInvalidateTabGroups } from '@/hooks/useTabGroupsQuery'\r\nimport { tabGroupsService } from '@/services/tab-groups'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { logger } from '@/lib/logger'\r\nimport { buildTabOpenerHtml, getThemeColors } from '@/hooks/buildTabOpenerHtml'\r\nimport { TabGroupItem } from './TabGroupItem'\r\nimport { TabGroupEmptyState } from './TabGroupEmptyState'\r\nimport { TabGroupDetailHeader } from './TabGroupDetailHeader'\r\n\r\nexport function TabGroupDetailPage() {\r\n  const { t } = useTranslation('tabGroups')\r\n  const { id } = useParams<{ id: string }>()\r\n  const navigate = useNavigate()\r\n  const { success, error: showError } = useToastStore()\r\n  const detailQuery = useTabGroupDetailQuery(id)\r\n  const invalidateTabGroups = useInvalidateTabGroups()\r\n  const tabGroup = detailQuery.data || null\r\n\r\n  const [confirmDialog, setConfirmDialog] = useState<{\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n  }>({\r\n    isOpen: false,\r\n    title: '',\r\n    message: '',\r\n    onConfirm: () => {},\r\n  })\r\n\r\n  const refresh = async () => {\r\n    await Promise.all([detailQuery.refetch(), invalidateTabGroups()])\r\n  }\r\n\r\n  const handleDelete = () => {\r\n    if (!tabGroup) return\r\n    setConfirmDialog({\r\n      isOpen: true,\r\n      title: t('confirm.deleteGroup'),\r\n      message: t('confirm.deleteGroupMessage', { title: tabGroup.title }),\r\n      onConfirm: async () => {\r\n        setConfirmDialog((prev) => ({ ...prev, isOpen: false }))\r\n        try {\r\n          await tabGroupsService.deleteTabGroup(tabGroup.id)\r\n          await invalidateTabGroups()\r\n          success(t('message.deleteSuccess'))\r\n          navigate('/tab')\r\n        } catch (err) {\r\n          logger.error('Failed to delete tab group:', err)\r\n          showError(t('message.deleteFailed'))\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handleRestoreAll = () => {\r\n    if (!tabGroup?.items?.length) return\r\n    const items = tabGroup.items\r\n    const itemCount = items.length\r\n    const message = itemCount > 10\r\n      ? t('detail.openAllWarning', { count: itemCount })\r\n      : t('detail.openAllMessage', { count: itemCount })\r\n\r\n    setConfirmDialog({\r\n      isOpen: true,\r\n      title: t('detail.openAllTabs'),\r\n      message,\r\n      onConfirm: () => {\r\n        setConfirmDialog((prev) => ({ ...prev, isOpen: false }))\r\n\r\n        // Use tab opener popup to avoid browser popup blocker\r\n        const colors = getThemeColors()\r\n        const i18nSuccessPartial = t('tabOpener.successPartial', { opened: '__OPENED__', failed: '__FAILED__' } as Record<string, unknown>)\r\n          .replace('__OPENED__', \"' + opened + '\")\r\n          .replace('__FAILED__', \"' + failed + '\")\r\n        const i18nSuccessAll = t('tabOpener.successAll', { count: '__COUNT__' } as Record<string, unknown>)\r\n          .replace('__COUNT__', \"' + opened + '\")\r\n        const html = buildTabOpenerHtml(\r\n          items.map(item => ({ url: item.url, title: item.title })),\r\n          colors,\r\n          {\r\n            title: t('tabOpener.title', { defaultValue: 'Opening Tabs' }),\r\n            heading: t('tabOpener.heading', { defaultValue: 'Opening Tabs...' }),\r\n            preparing: t('tabOpener.preparing', { defaultValue: 'Preparing...' }),\r\n            opening: t('tabOpener.opening', { defaultValue: 'Opening: ' }),\r\n            successPartial: i18nSuccessPartial,\r\n            successAll: i18nSuccessAll,\r\n            closeWindow: t('tabOpener.closeWindow', { defaultValue: 'Close Window' }),\r\n          }\r\n        )\r\n        const blob = new Blob([html], { type: 'text/html' })\r\n        const url = URL.createObjectURL(blob)\r\n        const newWindow = window.open(url, '_blank', 'width=800,height=600')\r\n\r\n        if (newWindow) {\r\n          setTimeout(() => URL.revokeObjectURL(url), 5000)\r\n          success(t('detail.allOpened', { count: itemCount }))\r\n        } else {\r\n          URL.revokeObjectURL(url)\r\n          showError(t('message.cannotOpenWindow', { defaultValue: 'Popup was blocked. Please allow popups for this site.' }))\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  if (detailQuery.isLoading) {\r\n    return (\r\n      <div className=\"flex items-center justify-center min-h-[400px]\">\r\n        <div className=\"flex flex-col items-center gap-3\">\r\n          <div className=\"w-8 h-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin\" />\r\n          <p className=\"text-sm\" style={{ color: 'var(--muted-foreground)' }}>{t('page.loading')}</p>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  if (detailQuery.isError || !tabGroup) {\r\n    return (\r\n      <div className=\"flex items-center justify-center min-h-[400px]\">\r\n        <div className=\"text-center\">\r\n          <p className=\"text-destructive mb-4\">{t('detail.groupNotFound')}</p>\r\n          <button\r\n            onClick={() => navigate('/tab')}\r\n            className=\"px-4 py-2 rounded-lg border border-border hover:bg-muted/50 transition-colors\"\r\n            style={{ color: 'var(--foreground)' }}\r\n          >\r\n            {t('detail.backToList')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <div className=\"container mx-auto px-4 py-6 max-w-5xl\">\r\n      <TabGroupDetailHeader \r\n        tabGroup={tabGroup} \r\n        onRefresh={refresh} \r\n        onDelete={handleDelete} \r\n      />\r\n\r\n      <div className=\"space-y-3\">\r\n        {tabGroup.items && tabGroup.items.length > 0 ? (\r\n          <>\r\n            <div className=\"rounded-2xl border border-border bg-gradient-to-br from-emerald-500/10 to-teal-500/10 p-4 mb-4\">\r\n              <div className=\"flex items-center justify-between\">\r\n                <div className=\"flex items-center gap-2\">\r\n                  <Layers className=\"w-5 h-5\" style={{ color: 'var(--foreground)' }} />\r\n                  <span className=\"font-medium\" style={{ color: 'var(--foreground)' }}>\r\n                    {t('detail.totalTabs', { count: tabGroup.items.length })}\r\n                  </span>\r\n                </div>\r\n                <button\r\n                  onClick={handleRestoreAll}\r\n                  className=\"px-3 py-1.5 rounded-lg bg-success text-success-foreground text-sm font-medium hover:shadow-lg hover:bg-success/90 transition-all duration-200 flex items-center gap-1.5\"\r\n                >\r\n                  <RotateCcw className=\"w-3.5 h-3.5\" />\r\n                  {t('detail.restoreAll')}\r\n                </button>\r\n              </div>\r\n            </div>\r\n\r\n            {tabGroup.items.map((item, index) => (\r\n              <TabGroupItem\r\n                key={item.id}\r\n                item={item}\r\n                index={index}\r\n                onRefresh={refresh}\r\n              />\r\n            ))}\r\n          </>\r\n        ) : (\r\n          <TabGroupEmptyState />\r\n        )}\r\n      </div>\r\n\r\n      <ConfirmDialog\r\n        isOpen={confirmDialog.isOpen}\r\n        title={confirmDialog.title}\r\n        message={confirmDialog.message}\r\n        onConfirm={confirmDialog.onConfirm}\r\n        onCancel={() => setConfirmDialog((prev) => ({ ...prev, isOpen: false }))}\r\n      />\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/TabGroupEmptyState.tsx",
    "content": "import { ExternalLink } from 'lucide-react'\nimport { useTranslation } from 'react-i18next'\n\nexport function TabGroupEmptyState() {\n  const { t } = useTranslation('tabGroups')\n\n  return (\n    <div className=\"text-center py-12 rounded-2xl border border-border bg-card\">\n      <div className=\"w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-emerald-500/20 to-teal-500/20 flex items-center justify-center\">\n        <ExternalLink className=\"w-8 h-8\" style={{ color: 'var(--muted-foreground)' }} />\n      </div>\n      <p className=\"text-lg font-medium mb-1\" style={{ color: 'var(--foreground)' }}>\n        {t('detail.noTabs')}\n      </p>\n      <p className=\"text-sm\" style={{ color: 'var(--muted-foreground)' }}>\n        {t('detail.tabsCleared')}\n      </p>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/TabGroupItem.tsx",
    "content": "import { useState } from 'react'\nimport { useTranslation } from 'react-i18next'\nimport { Edit2, ExternalLink, Check, X } from 'lucide-react'\nimport { tabGroupsService } from '@/services/tab-groups'\nimport type { TabGroupItem as TabGroupItemType } from '@/lib/types'\nimport { useToastStore } from '@/stores/toastStore'\nimport { logger } from '@/lib/logger'\n\ninterface TabGroupItemProps {\n  item: TabGroupItemType\n  index: number\n  onRefresh: () => Promise<void>\n}\n\nexport function TabGroupItem({ item, index, onRefresh }: TabGroupItemProps) {\n  const { t } = useTranslation('tabGroups')\n  const { success, error: showError } = useToastStore()\n  const [isEditing, setIsEditing] = useState(false)\n  const [editingTitle, setEditingTitle] = useState('')\n\n  const handleEdit = () => {\n    setEditingTitle(item.title)\n    setIsEditing(true)\n  }\n\n  const handleCancel = () => {\n    setIsEditing(false)\n    setEditingTitle('')\n  }\n\n  const handleSave = async () => {\n    if (!editingTitle.trim()) {\n      showError(t('message.titleRequired'))\n      return\n    }\n\n    try {\n      await tabGroupsService.updateTabGroupItem(item.id, { title: editingTitle.trim() })\n      await onRefresh()\n      setIsEditing(false)\n      setEditingTitle('')\n      success(t('detail.updateSuccess'))\n    } catch (err) {\n      logger.error('Failed to update item:', err)\n      showError(t('detail.updateFailed'))\n    }\n  }\n\n  return (\n    <div className=\"group relative flex items-center gap-3 p-4 rounded-xl border border-border bg-card hover:border-success/50 hover:shadow-md transition-all duration-200\">\n      <div className=\"flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-success/20 to-success/10 flex-shrink-0\">\n        <span className=\"text-sm font-semibold\" style={{ color: 'var(--foreground)' }}>\n          {index + 1}\n        </span>\n      </div>\n\n      {item.favicon && (\n        <img src={item.favicon} alt=\"\" className=\"w-5 h-5 rounded flex-shrink-0\" />\n      )}\n\n      <div className=\"flex-1 min-w-0\">\n        {isEditing ? (\n          <input\n            type=\"text\"\n            value={editingTitle}\n            onChange={(e) => setEditingTitle(e.target.value)}\n            className=\"w-full px-2 py-1 rounded border border-border bg-card text-sm font-medium mb-1\"\n            style={{ color: 'var(--foreground)' }}\n            autoFocus\n            onKeyDown={(e) => {\n              if (e.key === 'Enter') void handleSave()\n              if (e.key === 'Escape') handleCancel()\n            }}\n            onClick={(e) => e.stopPropagation()}\n          />\n        ) : (\n          <h3\n            className=\"font-medium truncate mb-0.5 cursor-pointer hover:text-primary transition-colors\"\n            style={{ color: 'var(--foreground)' }}\n            onClick={() => window.open(item.url, '_blank', 'noopener,noreferrer')}\n          >\n            {item.title}\n          </h3>\n        )}\n        <p className=\"text-sm truncate\" style={{ color: 'var(--muted-foreground)' }}>\n          {item.url}\n        </p>\n      </div>\n\n      <div className=\"flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0\">\n        {isEditing ? (\n          <>\n            <button\n              onClick={() => void handleSave()}\n              className=\"p-1.5 rounded-lg bg-success text-success-foreground hover:bg-success/90 transition-colors\"\n            >\n              <Check className=\"w-4 h-4\" />\n            </button>\n            <button\n              onClick={handleCancel}\n              className=\"p-1.5 rounded-lg border border-border hover:bg-muted/50 transition-colors\"\n            >\n              <X className=\"w-4 h-4\" />\n            </button>\n          </>\n        ) : (\n          <>\n            <button\n              onClick={(e) => {\n                e.stopPropagation()\n                handleEdit()\n              }}\n              className=\"p-1.5 rounded-lg hover:bg-muted/50 transition-colors\"\n            >\n              <Edit2 className=\"w-4 h-4\" style={{ color: 'var(--muted-foreground)' }} />\n            </button>\n            <button\n              onClick={(e) => {\n                e.stopPropagation()\n                window.open(item.url, '_blank', 'noopener,noreferrer')\n              }}\n              className=\"p-1.5 rounded-lg hover:bg-muted/50 transition-colors\"\n            >\n              <ExternalLink className=\"w-4 h-4\" style={{ color: 'var(--muted-foreground)' }} />\n            </button>\n          </>\n        )}\n      </div>\n    </div>\n  )\n}\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/TabGroupsPage.tsx",
    "content": "import { useEffect } from 'react'\r\nimport { KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { BottomNav } from '@/components/common/BottomNav'\r\nimport { Drawer } from '@/components/common/Drawer'\r\nimport { MobileHeader } from '@/components/common/MobileHeader'\r\nimport { ResizablePanel } from '@/components/common/ResizablePanel'\r\nimport { BatchActionBar } from '@/components/tab-groups/BatchActionBar'\r\nimport { MoveItemDialog } from '@/components/tab-groups/MoveItemDialog'\r\nimport { SearchBar } from '@/components/tab-groups/SearchBar'\r\nimport { ShareDialog } from '@/components/tab-groups/ShareDialog'\r\nimport { TabGroupTree } from '@/components/tab-groups/TabGroupTree'\r\nimport { TodoSidebar } from '@/components/tab-groups/TodoSidebar'\r\nimport { useBatchActions } from '@/hooks/useBatchActions'\r\nimport { usePreferences } from '@/hooks/usePreferences'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { useIsDesktop, useIsMobile } from '@/hooks/useMediaQuery'\r\nimport { useTabGroupActions } from '@/hooks/useTabGroupActions'\r\nimport { useTabGroupsData } from './hooks/useTabGroupsData'\r\nimport { useTabGroupItemDnD } from './hooks/useTabGroupItemDnD'\r\nimport { useTabGroupsState } from './hooks/useTabGroupsState'\r\nimport { TabGroupsGrid } from './components/TabGroupsGrid'\r\nimport { useGroupManagement } from './hooks/useGroupManagement'\r\n\r\nexport function TabGroupsPage() {\r\n  const { t } = useTranslation('tabGroups')\r\n  const { t: tc } = useTranslation('common')\r\n  const state = useTabGroupsState()\r\n  const { error: showError } = useToastStore()\r\n  const isMobile = useIsMobile()\r\n  const isDesktop = useIsDesktop()\r\n\r\n  const actions = useTabGroupActions({\r\n    setTabGroups: state.setTabGroups,\r\n    setDeletingId: state.setDeletingId,\r\n    setConfirmDialog: state.setConfirmDialog,\r\n    confirmDialog: state.confirmDialog,\r\n  })\r\n\r\n  const batch = useBatchActions({\r\n    tabGroups: state.tabGroups,\r\n    setTabGroups: state.setTabGroups,\r\n    selectedItems: state.selectedItems,\r\n    setSelectedItems: state.setSelectedItems,\r\n    setConfirmDialog: state.setConfirmDialog,\r\n    confirmDialog: state.confirmDialog,\r\n  })\r\n\r\n  const data = useTabGroupsData({\r\n    tabGroups: state.tabGroups,\r\n    setTabGroups: state.setTabGroups,\r\n    selectedGroupId: state.selectedGroupId,\r\n    searchQuery: state.searchQuery,\r\n    sortBy: state.sortBy,\r\n  })\r\n\r\n  const dnd = useTabGroupItemDnD({\r\n    tabGroups: state.tabGroups,\r\n    setTabGroups: state.setTabGroups,\r\n    moveItemDialog: state.moveItemDialog,\r\n    setMoveItemDialog: state.setMoveItemDialog,\r\n    refreshTreeOnly: data.refreshTreeOnly,\r\n    showError,\r\n    moveFailedMessage: t('page.moveFailed'),\r\n  })\r\n\r\n  const groupMgmt = useGroupManagement({\r\n    tabGroups: state.tabGroups,\r\n    refreshTreeOnly: data.refreshTreeOnly,\r\n    showError,\r\n    batchMode: state.batchMode,\r\n    selectedItems: state.selectedItems,\r\n    setSelectedItems: state.setSelectedItems,\r\n    setHighlightedDomain: state.setHighlightedDomain,\r\n  })\r\n\r\n  const sensors = useSensors(\r\n    useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),\r\n    useSensor(KeyboardSensor)\r\n  )\r\n\r\n  const { data: preferences } = usePreferences()\r\n  const { searchQuery, searchCleanupTimerRef, setSearchQuery } = state\r\n  useEffect(() => {\r\n    if (searchCleanupTimerRef.current) {\r\n      clearTimeout(searchCleanupTimerRef.current)\r\n      searchCleanupTimerRef.current = null\r\n    }\r\n\r\n    const enableAutoClear = preferences?.enable_search_auto_clear ?? true\r\n    if (enableAutoClear && searchQuery.trim()) {\r\n      searchCleanupTimerRef.current = setTimeout(() => {\r\n        setSearchQuery('')\r\n      }, (preferences?.search_auto_clear_seconds ?? 15) * 1000)\r\n    }\r\n\r\n    return () => {\r\n      if (searchCleanupTimerRef.current) {\r\n        clearTimeout(searchCleanupTimerRef.current)\r\n        searchCleanupTimerRef.current = null\r\n      }\r\n    }\r\n  }, [searchQuery, searchCleanupTimerRef, setSearchQuery, preferences?.enable_search_auto_clear, preferences?.search_auto_clear_seconds])\r\n\r\n  if (data.isLoading) return (\r\n    <div className=\"flex flex-col items-center justify-center min-h-[400px] gap-3\">\r\n      <div className=\"w-8 h-8 border-4 border-primary/30 border-t-primary rounded-full animate-spin\" />\r\n      <p className=\"text-sm text-muted-foreground\">{t('page.loading')}</p>\r\n    </div>\r\n  )\r\n\r\n  if (data.error) return (\r\n    <div className=\"flex flex-col items-center justify-center min-h-[400px] text-center\">\r\n      <p className=\"text-destructive mb-4\">{data.error}</p>\r\n      <button onClick={() => void data.refetchTabGroups()} className=\"px-4 py-2 rounded-lg border border-border hover:bg-muted/50 transition-colors\">\r\n        {tc('button.retry')}\r\n      </button>\r\n    </div>\r\n  )\r\n\r\n  return (\r\n    <div className=\"w-full h-[calc(100vh-4rem)] sm:h-[calc(100vh-5rem)] flex flex-col overflow-hidden touch-none\">\r\n      <div className={`flex ${isMobile ? 'flex-col' : ''} w-full h-full overflow-hidden touch-none`}>\r\n        {isMobile && <MobileHeader title={t('title')} onMenuClick={() => state.setIsDrawerOpen(true)} showSearch={false} showMore={false} />}\r\n        {isDesktop ? (\r\n          <ResizablePanel side=\"left\" defaultWidth={240} minWidth={200} maxWidth={400} storageKey=\"tab-groups-left-sidebar-width\">\r\n            <TabGroupTree tabGroups={state.tabGroups} selectedGroupId={state.selectedGroupId} onSelectGroup={state.setSelectedGroupId} onCreateFolder={groupMgmt.handleCreateFolder} onRenameGroup={groupMgmt.handleRenameGroup} onMoveGroup={groupMgmt.handleMoveGroup} onRefresh={data.refreshTreeOnly} />\r\n          </ResizablePanel>\r\n        ) : (\r\n          <Drawer isOpen={state.isDrawerOpen} onClose={() => state.setIsDrawerOpen(false)} title={t('title')} side=\"left\">\r\n            <TabGroupTree tabGroups={state.tabGroups} selectedGroupId={state.selectedGroupId} onSelectGroup={(groupId) => { state.setSelectedGroupId(groupId); state.setIsDrawerOpen(false) }} onCreateFolder={groupMgmt.handleCreateFolder} onRenameGroup={groupMgmt.handleRenameGroup} onMoveGroup={groupMgmt.handleMoveGroup} onRefresh={data.refreshTreeOnly} />\r\n          </Drawer>\r\n        )}\r\n\r\n        <div className={`flex-1 overflow-y-auto bg-muted/30 ${isMobile ? 'min-h-0' : ''}`}>\r\n          <div className={`w-full px-4 ${isMobile ? 'py-4 pb-20' : 'py-6'}`}>\r\n            <div className=\"mb-6\">\r\n              {state.tabGroups.length > 0 && (\r\n                <div className=\"flex items-center gap-4 w-full\">\r\n                  {!isMobile && <h1 className=\"text-xl font-semibold text-foreground whitespace-nowrap flex-shrink-0\">{t('title')}</h1>}\r\n                  <SearchBar searchQuery={state.searchQuery} onSearchChange={state.setSearchQuery} sortBy={state.sortBy} onSortChange={state.setSortBy} onBatchModeToggle={() => state.setBatchMode(!state.batchMode)} batchMode={state.batchMode} />\r\n                </div>\r\n              )}\r\n              {state.batchMode && state.selectedItems.size > 0 && (\r\n                <div className=\"mt-4\">\r\n                  <BatchActionBar selectedCount={state.selectedItems.size} onSelectAll={() => {\r\n                    const allItemIds = new Set<string>()\r\n                    state.tabGroups.forEach((group) => group.items?.forEach((item) => allItemIds.add(item.id)))\r\n                    state.setSelectedItems(allItemIds)\r\n                  }} onDeselectAll={batch.handleDeselectAll} onBatchDelete={batch.handleBatchDelete} onBatchPin={batch.handleBatchPin} onBatchTodo={batch.handleBatchTodo} onBatchExport={batch.handleBatchExport} onCancel={() => { state.setBatchMode(false); state.setSelectedItems(new Set()) }} />\r\n                </div>\r\n              )}\r\n            </div>\r\n\r\n            <TabGroupsGrid tabGroups={state.tabGroups} filteredTabGroups={data.filteredTabGroups} sortedGroups={data.sortedGroups} selectedGroupId={state.selectedGroupId} searchQuery={state.searchQuery} activeId={dnd.activeId} sensors={sensors} highlightedDomain={state.highlightedDomain} selectedItems={state.selectedItems} batchMode={state.batchMode} deletingId={state.deletingId} editingGroupId={actions.editingGroupId} editingGroupTitle={actions.editingGroupTitle} editingItemId={actions.editingItemId} editingTitle={actions.editingTitle} onDragStart={dnd.handleDragStart} onDragEnd={dnd.handleDragEnd} onEditGroup={actions.handleEditGroup} onSaveGroupEdit={actions.handleSaveGroupEdit} onSetEditingGroupId={actions.setEditingGroupId} onSetEditingGroupTitle={actions.setEditingGroupTitle} onOpenAll={actions.handleOpenAll} onExportMarkdown={actions.handleExportMarkdown} onDelete={actions.handleDelete} onShareClick={state.setSharingGroupId} onItemClick={groupMgmt.handleItemClick} onEditItem={actions.handleEditItem} onSaveEdit={actions.handleSaveEdit} onTogglePin={actions.handleTogglePin} onToggleTodo={actions.handleToggleTodo} onDeleteItem={actions.handleDeleteItem} onMoveItem={dnd.handleMoveItem} onSetEditingItemId={actions.setEditingItemId} onSetEditingTitle={actions.setEditingTitle} extractDomain={groupMgmt.extractDomain} />\r\n\r\n            {state.sharingGroupId && <ShareDialog groupId={state.sharingGroupId} groupTitle={state.tabGroups.find((group) => group.id === state.sharingGroupId)?.title || ''} onClose={() => state.setSharingGroupId(null)} />}\r\n            <MoveItemDialog isOpen={state.moveItemDialog.isOpen} itemTitle={state.moveItemDialog.item?.title || ''} currentGroupId={state.moveItemDialog.currentGroupId} availableGroups={state.tabGroups} onMove={dnd.handleMoveItemToGroup} onClose={() => state.setMoveItemDialog({ isOpen: false, item: null, currentGroupId: '' })} />\r\n            <ConfirmDialog isOpen={state.confirmDialog.isOpen} title={state.confirmDialog.title} message={state.confirmDialog.message} onConfirm={state.confirmDialog.onConfirm} onCancel={() => state.setConfirmDialog({ ...state.confirmDialog, isOpen: false })} />\r\n          </div>\r\n        </div>\r\n\r\n        {isDesktop && <ResizablePanel side=\"right\" defaultWidth={320} minWidth={280} maxWidth={500} storageKey=\"tab-groups-right-sidebar-width\">\r\n          <TodoSidebar tabGroups={state.tabGroups} />\r\n        </ResizablePanel>}\r\n        {isMobile && <BottomNav />}\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/TodoPage.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { MobileHeader } from '@/components/common/MobileHeader'\r\nimport { TodoSidebar } from '@/components/tab-groups/TodoSidebar'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\nimport { useTabGroupsQuery } from '@/hooks/useTabGroupsQuery'\r\n\r\nexport function TodoPage() {\r\n  const { t } = useTranslation('tabGroups')\r\n  const { t: tc } = useTranslation('common')\r\n  const isMobile = useIsMobile()\r\n  const tabGroupsQuery = useTabGroupsQuery()\r\n\r\n  if (tabGroupsQuery.isLoading) {\r\n    return (\r\n      <div className=\"flex items-center justify-center h-screen\">\r\n        <div className=\"text-muted-foreground\">{tc('status.loading')}</div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <div className={`h-screen flex flex-col bg-background ${isMobile ? 'overflow-hidden' : ''}`}>\r\n      {isMobile && (\r\n        <MobileHeader\r\n          title={t('todo.title')}\r\n          showMenu={false}\r\n          showSearch={false}\r\n          showMore={false}\r\n        />\r\n      )}\r\n\r\n      <div className={`flex-1 overflow-hidden ${isMobile ? 'min-h-0' : ''}`}>\r\n        <TodoSidebar tabGroups={tabGroupsQuery.data || []} />\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/TrashPage.tsx",
    "content": "import { useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Archive, RotateCcw, Trash2, Calendar, Layers, ArrowLeft } from 'lucide-react'\r\nimport { Link } from 'react-router-dom'\r\nimport { formatDistanceToNow } from 'date-fns'\r\nimport { zhCN, enUS } from 'date-fns/locale'\r\nimport { ConfirmDialog } from '@/components/common/ConfirmDialog'\r\nimport { BottomNav } from '@/components/common/BottomNav'\r\nimport { MobileHeader } from '@/components/common/MobileHeader'\r\nimport { useIsMobile } from '@/hooks/useMediaQuery'\r\nimport { useTabGroupsTrashQuery, useInvalidateTabGroups } from '@/hooks/useTabGroupsQuery'\r\nimport { tabGroupsService } from '@/services/tab-groups'\r\nimport { useToastStore } from '@/stores/toastStore'\r\nimport { logger } from '@/lib/logger'\r\n\r\nexport function TrashPage() {\r\n  const { t, i18n } = useTranslation('tabGroups')\r\n  const { t: tc } = useTranslation('common')\r\n  const dateLocale = i18n.language === 'zh-CN' ? zhCN : enUS\r\n  const isMobile = useIsMobile()\r\n  const { success, error: showError } = useToastStore()\r\n  const trashQuery = useTabGroupsTrashQuery()\r\n  const invalidateTabGroups = useInvalidateTabGroups()\r\n  const tabGroups = trashQuery.data || []\r\n  const [confirmDialog, setConfirmDialog] = useState<{\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n  }>({\r\n    isOpen: false,\r\n    title: '',\r\n    message: '',\r\n    onConfirm: () => {},\r\n  })\r\n\r\n  const handleRestore = (id: string, title: string) => {\r\n    setConfirmDialog({\r\n      isOpen: true,\r\n      title: t('confirm.restoreGroup'),\r\n      message: t('confirm.restoreGroupMessage', { title }),\r\n      onConfirm: async () => {\r\n        setConfirmDialog((prev) => ({ ...prev, isOpen: false }))\r\n        try {\r\n          await tabGroupsService.restoreTabGroup(id)\r\n          await invalidateTabGroups()\r\n          success(t('trashPage.restoreSuccess'))\r\n        } catch (err) {\r\n          logger.error('Failed to restore:', err)\r\n          showError(t('trashPage.restoreFailed'))\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  const handlePermanentDelete = (id: string, title: string) => {\r\n    setConfirmDialog({\r\n      isOpen: true,\r\n      title: t('confirm.permanentDelete'),\r\n      message: t('confirm.permanentDeleteMessage', { title }),\r\n      onConfirm: async () => {\r\n        setConfirmDialog((prev) => ({ ...prev, isOpen: false }))\r\n        try {\r\n          await tabGroupsService.permanentDeleteTabGroup(id)\r\n          await invalidateTabGroups()\r\n          success(t('trashPage.deleteSuccess'))\r\n        } catch (err) {\r\n          logger.error('Failed to delete:', err)\r\n          showError(t('trashPage.deleteFailed'))\r\n        }\r\n      },\r\n    })\r\n  }\r\n\r\n  if (trashQuery.isLoading) {\r\n    return (\r\n      <div className=\"flex items-center justify-center min-h-[400px]\">\r\n        <div className=\"text-center\">\r\n          <div className=\"animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4\"></div>\r\n          <p className=\"text-muted-foreground\">{tc('status.loading')}</p>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  if (trashQuery.isError) {\r\n    return (\r\n      <div className=\"flex items-center justify-center min-h-[400px]\">\r\n        <div className=\"text-center\">\r\n          <p className=\"text-destructive mb-4\">{t('trashPage.loadFailed')}</p>\r\n          <button\r\n            onClick={() => {\r\n              void trashQuery.refetch()\r\n            }}\r\n            className=\"px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90\"\r\n          >\r\n            {tc('button.retry')}\r\n          </button>\r\n        </div>\r\n      </div>\r\n    )\r\n  }\r\n\r\n  return (\r\n    <div className={`h-screen flex flex-col bg-background ${isMobile ? 'overflow-hidden' : ''}`}>\r\n      {isMobile && (\r\n        <MobileHeader\r\n          title={t('trashPage.title')}\r\n          showMenu={false}\r\n          showSearch={false}\r\n          showMore={false}\r\n        />\r\n      )}\r\n\r\n      <div className={`flex-1 overflow-y-auto ${isMobile ? 'pb-20 min-h-0' : ''}`}>\r\n        <div className=\"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8\">\r\n          {!isMobile && (\r\n            <div className=\"mb-8\">\r\n              <Link\r\n                to=\"/tab\"\r\n                className=\"inline-flex items-center gap-2 text-muted-foreground hover:text-foreground mb-4 transition-colors\"\r\n              >\r\n                <ArrowLeft className=\"w-5 h-5\" />\r\n                <span>{t('statistics.backToTabGroups')}</span>\r\n              </Link>\r\n              <div className=\"flex items-center gap-3 mb-2\">\r\n                <Archive className=\"w-8 h-8 text-muted-foreground\" />\r\n                <h1 className=\"text-3xl font-bold text-foreground\">{t('trashPage.title')}</h1>\r\n              </div>\r\n              <p className=\"text-muted-foreground\">{t('trashPage.description')}</p>\r\n            </div>\r\n          )}\r\n\r\n          {tabGroups.length === 0 ? (\r\n            <div className=\"text-center py-16\">\r\n              <Archive className=\"w-16 h-16 text-muted-foreground/30 mx-auto mb-4\" />\r\n              <h3 className=\"text-lg font-medium text-foreground mb-2\">{t('trashPage.empty')}</h3>\r\n              <p className=\"text-muted-foreground\">{t('trashPage.emptyDescription')}</p>\r\n            </div>\r\n          ) : (\r\n            <div className=\"space-y-4\">\r\n              {tabGroups.map((group) => (\r\n                <div key={group.id} className=\"card p-6 hover:shadow-md transition-shadow\">\r\n                  <div className=\"flex items-start justify-between\">\r\n                    <div className=\"flex-1\">\r\n                      <h3 className=\"text-lg font-semibold text-foreground mb-2\">{group.title}</h3>\r\n                      <div className=\"flex items-center gap-4 text-sm text-muted-foreground\">\r\n                        <div className=\"flex items-center gap-1\">\r\n                          <Layers className=\"w-4 h-4\" />\r\n                          <span>{t('header.tabCount', { count: group.item_count || 0 })}</span>\r\n                        </div>\r\n                        <div className=\"flex items-center gap-1\">\r\n                          <Calendar className=\"w-4 h-4\" />\r\n                          <span>\r\n                            {t('trashPage.deletedAt')}{' '}\r\n                            {group.deleted_at\r\n                              ? formatDistanceToNow(new Date(group.deleted_at), {\r\n                                  addSuffix: true,\r\n                                  locale: dateLocale,\r\n                                })\r\n                              : '-'}\r\n                          </span>\r\n                        </div>\r\n                      </div>\r\n                    </div>\r\n\r\n                    <div className=\"flex items-center gap-2\">\r\n                      <button\r\n                        onClick={() => handleRestore(group.id, group.title)}\r\n                        className=\"flex items-center gap-2 px-4 py-2 bg-success text-success-foreground rounded-lg hover:bg-success/90 transition-colors\"\r\n                      >\r\n                        <RotateCcw className=\"w-4 h-4\" />\r\n                        {t('trashPage.restore')}\r\n                      </button>\r\n                      <button\r\n                        onClick={() => handlePermanentDelete(group.id, group.title)}\r\n                        className=\"flex items-center gap-2 px-4 py-2 bg-destructive text-destructive-foreground rounded-lg hover:bg-destructive/90 transition-colors\"\r\n                      >\r\n                        <Trash2 className=\"w-4 h-4\" />\r\n                        {t('trashPage.permanentDelete')}\r\n                      </button>\r\n                    </div>\r\n                  </div>\r\n                </div>\r\n              ))}\r\n            </div>\r\n          )}\r\n\r\n          <ConfirmDialog\r\n            isOpen={confirmDialog.isOpen}\r\n            title={confirmDialog.title}\r\n            message={confirmDialog.message}\r\n            onConfirm={confirmDialog.onConfirm}\r\n            onCancel={() => setConfirmDialog((prev) => ({ ...prev, isOpen: false }))}\r\n          />\r\n        </div>\r\n      </div>\r\n\r\n      {isMobile && <BottomNav />}\r\n    </div>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/components/TabGroupsGrid.tsx",
    "content": "import { DndContext, DragOverlay, closestCenter } from '@dnd-kit/core'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { EmptyState } from '@/components/tab-groups/EmptyState'\r\nimport { PinnedItemsSection } from '@/components/tab-groups/PinnedItemsSection'\r\nimport { TabGroupHeader } from '@/components/tab-groups/TabGroupHeader'\r\nimport { TabItemList } from '@/components/tab-groups/TabItemList'\r\nimport type { TabGroup, TabGroupItem } from '@/lib/types'\r\n\r\ninterface TabGroupsGridProps {\r\n  tabGroups: TabGroup[]\r\n  filteredTabGroups: TabGroup[]\r\n  sortedGroups: TabGroup[]\r\n  selectedGroupId: string | null\r\n  searchQuery: string\r\n  activeId: string | null\r\n  sensors: Parameters<typeof DndContext>[0]['sensors']\r\n  highlightedDomain: string | null\r\n  selectedItems: Set<string>\r\n  batchMode: boolean\r\n  deletingId: string | null\r\n  editingGroupId: string | null\r\n  editingGroupTitle: string\r\n  editingItemId: string | null\r\n  editingTitle: string\r\n  onDragStart: Parameters<typeof DndContext>[0]['onDragStart']\r\n  onDragEnd: Parameters<typeof DndContext>[0]['onDragEnd']\r\n  onEditGroup: (group: TabGroup) => void\r\n  onSaveGroupEdit: (groupId: string) => void\r\n  onSetEditingGroupId: (id: string | null) => void\r\n  onSetEditingGroupTitle: (title: string) => void\r\n  onOpenAll: (items: TabGroupItem[]) => void\r\n  onExportMarkdown: (group: TabGroup) => void\r\n  onDelete: (id: string, title: string) => void\r\n  onShareClick: (id: string) => void\r\n  onItemClick: (item: TabGroupItem, e: React.MouseEvent | React.ChangeEvent<HTMLInputElement>) => void\r\n  onEditItem: (item: TabGroupItem) => void\r\n  onSaveEdit: (groupId: string, itemId: string) => void\r\n  onTogglePin: (groupId: string, itemId: string, currentPinned: boolean) => void\r\n  onToggleTodo: (groupId: string, itemId: string, currentTodo: boolean) => void\r\n  onDeleteItem: (groupId: string, itemId: string, title: string) => void\r\n  onMoveItem: (item: TabGroupItem) => void\r\n  onSetEditingItemId: (id: string | null) => void\r\n  onSetEditingTitle: (title: string) => void\r\n  extractDomain: (url: string) => string\r\n}\r\n\r\nfunction renderGroupCard(props: TabGroupsGridProps & { group: TabGroup }) {\r\n  const {\r\n    group,\r\n    highlightedDomain,\r\n    selectedItems,\r\n    batchMode,\r\n    deletingId,\r\n    editingGroupId,\r\n    editingGroupTitle,\r\n    editingItemId,\r\n    editingTitle,\r\n    onEditGroup,\r\n    onSaveGroupEdit,\r\n    onSetEditingGroupId,\r\n    onSetEditingGroupTitle,\r\n    onOpenAll,\r\n    onExportMarkdown,\r\n    onDelete,\r\n    onShareClick,\r\n    onItemClick,\r\n    onEditItem,\r\n    onSaveEdit,\r\n    onTogglePin,\r\n    onToggleTodo,\r\n    onDeleteItem,\r\n    onMoveItem,\r\n    onSetEditingItemId,\r\n    onSetEditingTitle,\r\n    extractDomain,\r\n  } = props\r\n\r\n  return (\r\n    <div key={group.id} className=\"card border-l-[3px] border-l-primary p-6 hover:shadow-xl transition-all duration-200\">\r\n      <TabGroupHeader\r\n        group={group}\r\n        isEditingTitle={editingGroupId === group.id}\r\n        editingTitle={editingGroupTitle}\r\n        onEditTitle={() => onEditGroup(group)}\r\n        onSaveTitle={() => onSaveGroupEdit(group.id)}\r\n        onCancelEdit={() => {\r\n          onSetEditingGroupId(null)\r\n          onSetEditingGroupTitle('')\r\n        }}\r\n        onTitleChange={onSetEditingGroupTitle}\r\n        onOpenAll={() => onOpenAll(group.items || [])}\r\n        onExport={() => onExportMarkdown(group)}\r\n        onDelete={() => onDelete(group.id, group.title)}\r\n        isDeleting={deletingId === group.id}\r\n        onShareClick={() => onShareClick(group.id)}\r\n      />\r\n\r\n      {group.items && group.items.length > 0 && (\r\n        <TabItemList\r\n          items={group.items}\r\n          groupId={group.id}\r\n          highlightedDomain={highlightedDomain}\r\n          selectedItems={selectedItems}\r\n          batchMode={batchMode}\r\n          editingItemId={editingItemId}\r\n          editingTitle={editingTitle}\r\n          onItemClick={onItemClick}\r\n          onEditItem={onEditItem}\r\n          onSaveEdit={onSaveEdit}\r\n          onTogglePin={onTogglePin}\r\n          onToggleTodo={onToggleTodo}\r\n          onDeleteItem={onDeleteItem}\r\n          onMoveItem={onMoveItem}\r\n          setEditingItemId={onSetEditingItemId}\r\n          setEditingTitle={onSetEditingTitle}\r\n          extractDomain={extractDomain}\r\n        />\r\n      )}\r\n    </div>\r\n  )\r\n}\r\n\r\nexport function TabGroupsGrid(props: TabGroupsGridProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const {\r\n    tabGroups,\r\n    filteredTabGroups,\r\n    sortedGroups,\r\n    selectedGroupId,\r\n    searchQuery,\r\n    activeId,\r\n    sensors,\r\n    onDragStart,\r\n    onDragEnd,\r\n  } = props\r\n\r\n  if (tabGroups.length === 0) {\r\n    return <EmptyState isSearching={false} searchQuery=\"\" />\r\n  }\r\n\r\n  if (filteredTabGroups.length === 0) {\r\n    return <EmptyState isSearching={true} searchQuery={searchQuery} />\r\n  }\r\n\r\n  return (\r\n    <>\r\n      {!searchQuery && sortedGroups.length > 0 && (\r\n        <PinnedItemsSection\r\n          tabGroups={sortedGroups}\r\n          onUnpin={(groupId, itemId) => props.onTogglePin(groupId, itemId, true)}\r\n        />\r\n      )}\r\n\r\n      {sortedGroups.length > 0 && (\r\n        <DndContext\r\n          sensors={sensors}\r\n          collisionDetection={closestCenter}\r\n          onDragStart={onDragStart}\r\n          onDragEnd={onDragEnd}\r\n        >\r\n          <div className=\"grid grid-cols-1 gap-6\">\r\n            {(() => {\r\n              const groupsByParent = new Map<string | null, TabGroup[]>()\r\n              sortedGroups.forEach((group) => {\r\n                const parentId = group.parent_id || null\r\n                const groups = groupsByParent.get(parentId) || []\r\n                groups.push(group)\r\n                groupsByParent.set(parentId, groups)\r\n              })\r\n\r\n              const cards: JSX.Element[] = []\r\n              if (selectedGroupId) {\r\n                sortedGroups.forEach((group) => {\r\n                  if (group.is_folder !== 1) {\r\n                    cards.push(renderGroupCard({ ...props, group }))\r\n                  }\r\n                })\r\n                return cards\r\n              }\r\n\r\n              const rootGroups = groupsByParent.get(null) || []\r\n              rootGroups.forEach((group) => {\r\n                if (group.is_folder === 1) {\r\n                  const children = groupsByParent.get(group.id) || []\r\n                  if (children.length > 0) {\r\n                    cards.push(\r\n                      <div key={`folder-${group.id}`} className=\"mt-6 first:mt-0\">\r\n                        <h2 className=\"text-lg font-semibold text-foreground mb-4 flex items-center gap-2\">\r\n                          <span>📁</span>\r\n                          <span>{group.title}</span>\r\n                          <span className=\"text-sm text-muted-foreground\">\r\n                            {t('header.tabCount', {\r\n                              count: children.reduce((sum, entry) => sum + (entry.item_count || 0), 0),\r\n                            })}\r\n                          </span>\r\n                        </h2>\r\n                        <div className=\"space-y-6\">\r\n                          {children.map((childGroup) => renderGroupCard({ ...props, group: childGroup }))}\r\n                        </div>\r\n                      </div>\r\n                    )\r\n                  }\r\n                } else {\r\n                  cards.push(renderGroupCard({ ...props, group }))\r\n                }\r\n              })\r\n\r\n              return cards\r\n            })()}\r\n          </div>\r\n\r\n          <DragOverlay>\r\n            {activeId ? (\r\n              <div\r\n                className=\"bg-card border-2 border-primary rounded shadow-xl cursor-grabbing p-3 opacity-95\"\r\n                style={{ transform: 'scale(1.05)' }}\r\n              >\r\n                {(() => {\r\n                  for (const group of tabGroups) {\r\n                    const item = group.items?.find((entry) => entry.id === activeId)\r\n                    if (item) {\r\n                      return (\r\n                        <div className=\"flex items-center gap-3\">\r\n                          <div className=\"w-4 h-4 rounded bg-primary/20 flex-shrink-0\" />\r\n                          <span className=\"text-sm font-medium text-foreground truncate max-w-[300px]\">\r\n                            {item.title}\r\n                          </span>\r\n                        </div>\r\n                      )\r\n                    }\r\n                  }\r\n                  return null\r\n                })()}\r\n              </div>\r\n            ) : null}\r\n          </DragOverlay>\r\n        </DndContext>\r\n      )}\r\n    </>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/components/TabGroupsList.tsx",
    "content": "import { useTranslation } from 'react-i18next'\r\nimport { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'\r\nimport { TabGroupHeader } from '@/components/tab-groups/TabGroupHeader'\r\nimport { TabItemList } from '@/components/tab-groups/TabItemList'\r\nimport type { TabGroup, TabGroupItem } from '@/lib/types'\r\n\r\ninterface TabGroupsListProps {\r\n  sortedGroups: TabGroup[]\r\n  selectedGroupId: string | null\r\n  editingGroupId: string | null\r\n  editingGroupTitle: string\r\n  setEditingGroupId: (id: string | null) => void\r\n  setEditingGroupTitle: (title: string) => void\r\n  deletingId: string | null\r\n  highlightedDomain: string | null\r\n  selectedItems: Set<string>\r\n  batchMode: boolean\r\n  editingItemId: string | null\r\n  editingTitle: string\r\n  setEditingItemId: (id: string | null) => void\r\n  setEditingTitle: (title: string) => void\r\n  onEditGroup: (group: TabGroup) => void\r\n  onSaveGroupEdit: (groupId: string) => Promise<void>\r\n  onOpenAll: (items: TabGroupItem[]) => void\r\n  onExportMarkdown: (group: TabGroup) => void\r\n  onDelete: (groupId: string, title: string) => void\r\n  onShareClick: (groupId: string) => void\r\n  onItemClick: (item: TabGroupItem) => void\r\n  onEditItem: (item: TabGroupItem) => void\r\n  onSaveEdit: (itemId: string, groupId: string) => Promise<void>\r\n  onTogglePin: (itemId: string, groupId: string) => Promise<void>\r\n  onToggleTodo: (itemId: string, groupId: string) => Promise<void>\r\n  onDeleteItem: (itemId: string, groupId: string) => Promise<void>\r\n  onMoveItem: (item: TabGroupItem) => void\r\n  onDragEnd: (event: DragEndEvent) => Promise<void>\r\n  extractDomain: (url: string) => string\r\n}\r\n\r\nexport function TabGroupsList({\r\n  sortedGroups,\r\n  selectedGroupId,\r\n  editingGroupId,\r\n  editingGroupTitle,\r\n  setEditingGroupId,\r\n  setEditingGroupTitle,\r\n  deletingId,\r\n  highlightedDomain,\r\n  selectedItems,\r\n  batchMode,\r\n  editingItemId,\r\n  editingTitle,\r\n  setEditingItemId,\r\n  setEditingTitle,\r\n  onEditGroup,\r\n  onSaveGroupEdit,\r\n  onOpenAll,\r\n  onExportMarkdown,\r\n  onDelete,\r\n  onShareClick,\r\n  onItemClick,\r\n  onEditItem,\r\n  onSaveEdit,\r\n  onTogglePin,\r\n  onToggleTodo,\r\n  onDeleteItem,\r\n  onMoveItem,\r\n  onDragEnd,\r\n  extractDomain,\r\n}: TabGroupsListProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const sensors = useSensors(\r\n    useSensor(PointerSensor, {\r\n      activationConstraint: {\r\n        distance: 8,\r\n      },\r\n    }),\r\n    useSensor(KeyboardSensor)\r\n  )\r\n\r\n  // 渲染单个分组卡片\r\n  const renderGroup = (group: TabGroup) => (\r\n    <div\r\n      key={group.id}\r\n      className=\"card border-l-[3px] border-l-primary p-6 hover:shadow-xl transition-all duration-200\"\r\n    >\r\n      <TabGroupHeader\r\n        group={group}\r\n        isEditingTitle={editingGroupId === group.id}\r\n        editingTitle={editingGroupTitle}\r\n        onEditTitle={() => onEditGroup(group)}\r\n        onSaveTitle={() => onSaveGroupEdit(group.id)}\r\n        onCancelEdit={() => {\r\n          setEditingGroupId(null)\r\n          setEditingGroupTitle('')\r\n        }}\r\n        onTitleChange={setEditingGroupTitle}\r\n        onOpenAll={() => onOpenAll(group.items || [])}\r\n        onExport={() => onExportMarkdown(group)}\r\n        onDelete={() => onDelete(group.id, group.title)}\r\n        isDeleting={deletingId === group.id}\r\n        onShareClick={() => onShareClick(group.id)}\r\n      />\r\n\r\n      {group.items && group.items.length > 0 && (\r\n        <TabItemList\r\n          items={group.items}\r\n          groupId={group.id}\r\n          highlightedDomain={highlightedDomain}\r\n          selectedItems={selectedItems}\r\n          batchMode={batchMode}\r\n          editingItemId={editingItemId}\r\n          editingTitle={editingTitle}\r\n          onItemClick={onItemClick}\r\n          onEditItem={onEditItem}\r\n          onSaveEdit={onSaveEdit}\r\n          onTogglePin={onTogglePin}\r\n          onToggleTodo={onToggleTodo}\r\n          onDeleteItem={onDeleteItem}\r\n          onMoveItem={onMoveItem}\r\n          setEditingItemId={setEditingItemId}\r\n          setEditingTitle={setEditingTitle}\r\n          extractDomain={extractDomain}\r\n        />\r\n      )}\r\n    </div>\r\n  )\r\n\r\n  // 按文件夹分组渲染\r\n  const renderGroupedList = () => {\r\n    const groupsByParent = new Map<string | null, TabGroup[]>()\r\n    sortedGroups.forEach(group => {\r\n      const parentId = group.parent_id || null\r\n      if (!groupsByParent.has(parentId)) {\r\n        groupsByParent.set(parentId, [])\r\n      }\r\n      groupsByParent.get(parentId)!.push(group)\r\n    })\r\n\r\n    const result: JSX.Element[] = []\r\n    \r\n    // 如果选中了特定分组，直接显示该分组（排除文件夹）\r\n    if (selectedGroupId) {\r\n      sortedGroups.forEach(group => {\r\n        if (group.is_folder !== 1) {\r\n          result.push(renderGroup(group))\r\n        }\r\n      })\r\n    } else {\r\n      // 显示全部时，按文件夹分组显示\r\n      const rootGroups = groupsByParent.get(null) || []\r\n      \r\n      rootGroups.forEach(group => {\r\n        if (group.is_folder === 1) {\r\n          const children = groupsByParent.get(group.id) || []\r\n          if (children.length > 0) {\r\n            result.push(\r\n              <div key={`folder-${group.id}`} className=\"mt-6 first:mt-0\">\r\n                <h2 className=\"text-lg font-semibold text-foreground mb-4 flex items-center gap-2\">\r\n                  <span>📁</span>\r\n                  <span>{group.title}</span>\r\n                  <span className=\"text-sm text-muted-foreground\">\r\n                    ({t('folder.tabsInFolder', { count: children.reduce((sum, g) => sum + (g.item_count || 0), 0) })})\r\n                  </span>\r\n                </h2>\r\n                <div className=\"space-y-6\">\r\n                  {children.map(childGroup => renderGroup(childGroup))}\r\n                </div>\r\n              </div>\r\n            )\r\n          }\r\n        } else {\r\n          result.push(renderGroup(group))\r\n        }\r\n      })\r\n    }\r\n\r\n    return result\r\n  }\r\n\r\n  if (sortedGroups.length === 0) {\r\n    return null\r\n  }\r\n\r\n  return (\r\n    <DndContext\r\n      sensors={sensors}\r\n      collisionDetection={closestCenter}\r\n      onDragEnd={onDragEnd}\r\n    >\r\n      <div className=\"grid grid-cols-1 gap-6\">\r\n        {renderGroupedList()}\r\n      </div>\r\n    </DndContext>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/hooks/useGroupManagement.ts",
    "content": "import { useTranslation } from 'react-i18next'\nimport { logger } from '@/lib/logger'\nimport { tabGroupsService } from '@/services/tab-groups'\nimport type { TabGroup, TabGroupItem } from '@/lib/types'\n\ninterface UseGroupManagementProps {\n  tabGroups: TabGroup[]\n  refreshTreeOnly: () => Promise<void>\n  showError: (message: string) => void\n  batchMode: boolean\n  selectedItems: Set<string>\n  setSelectedItems: (items: Set<string>) => void\n  setHighlightedDomain: React.Dispatch<React.SetStateAction<string | null>>\n}\n\nexport function useGroupManagement({\n  tabGroups,\n  refreshTreeOnly,\n  showError,\n  batchMode,\n  selectedItems,\n  setSelectedItems,\n  setHighlightedDomain,\n}: UseGroupManagementProps) {\n  const { t } = useTranslation('tabGroups')\n\n  const handleCreateFolder = async () => {\n    try {\n      await tabGroupsService.createFolder(t('folder.newFolder'))\n      await refreshTreeOnly()\n    } catch (err) {\n      logger.error('Failed to create folder:', err)\n      showError(t('page.createFolderFailed'))\n    }\n  }\n\n  const handleRenameGroup = async (groupId: string, newTitle: string) => {\n    try {\n      await tabGroupsService.updateTabGroup(groupId, { title: newTitle })\n      await refreshTreeOnly()\n    } catch (err) {\n      logger.error('Failed to rename group:', err)\n      showError(t('page.renameFailed'))\n    }\n  }\n\n  const handleMoveGroup = async (groupId: string, newParentId: string | null, newPosition: number) => {\n    try {\n      const draggedGroup = tabGroups.find((group) => group.id === groupId)\n      if (!draggedGroup) return\n\n      const siblings = tabGroups.filter((group) => (group.parent_id || null) === newParentId)\n      siblings.sort((a, b) => (a.position || 0) - (b.position || 0))\n\n      const draggedIndex = siblings.findIndex((group) => group.id === groupId)\n      if (draggedIndex !== -1) siblings.splice(draggedIndex, 1)\n\n      siblings.splice(newPosition, 0, draggedGroup)\n      const updates = siblings.map((group, index) => ({\n        id: group.id,\n        position: index,\n        parent_id: newParentId,\n      }))\n\n      await tabGroupsService.batchUpdatePositions(updates)\n      await refreshTreeOnly()\n    } catch (err) {\n      logger.error('Failed to move group:', err)\n      showError(t('page.moveFailed'))\n    }\n  }\n\n  const extractDomain = (url: string): string => {\n    try {\n      return new URL(url).hostname\n    } catch {\n      return ''\n    }\n  }\n\n  const handleItemClick = (item: TabGroupItem, e: React.MouseEvent | React.ChangeEvent<HTMLInputElement>) => {\n    if (batchMode) {\n      e.preventDefault()\n      const next = new Set(selectedItems)\n      if (next.has(item.id)) next.delete(item.id)\n      else next.add(item.id)\n      setSelectedItems(next)\n      return\n    }\n\n    const domain = extractDomain(item.url)\n    setHighlightedDomain((prev) => (prev === domain ? null : domain))\n  }\n\n  return {\n    handleCreateFolder,\n    handleRenameGroup,\n    handleMoveGroup,\n    handleItemClick,\n    extractDomain,\n  }\n}\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/hooks/useTabGroupDetailState.ts",
    "content": "import { useState } from 'react'\r\nimport type { TabGroup } from '@/lib/types'\r\n\r\nexport function useTabGroupDetailState() {\r\n  const [tabGroup, setTabGroup] = useState<TabGroup | null>(null)\r\n  const [isLoading, setIsLoading] = useState(true)\r\n  const [error, setError] = useState<string | null>(null)\r\n  const [isEditingTitle, setIsEditingTitle] = useState(false)\r\n  const [editedTitle, setEditedTitle] = useState('')\r\n  const [isSavingTitle, setIsSavingTitle] = useState(false)\r\n  const [editingItemId, setEditingItemId] = useState<string | null>(null)\r\n  const [editingItemTitle, setEditingItemTitle] = useState('')\r\n  const [confirmDialog, setConfirmDialog] = useState<{\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n  }>({\r\n    isOpen: false,\r\n    title: '',\r\n    message: '',\r\n    onConfirm: () => {},\r\n  })\r\n\r\n  return {\r\n    tabGroup,\r\n    setTabGroup,\r\n    isLoading,\r\n    setIsLoading,\r\n    error,\r\n    setError,\r\n    isEditingTitle,\r\n    setIsEditingTitle,\r\n    editedTitle,\r\n    setEditedTitle,\r\n    isSavingTitle,\r\n    setIsSavingTitle,\r\n    editingItemId,\r\n    setEditingItemId,\r\n    editingItemTitle,\r\n    setEditingItemTitle,\r\n    confirmDialog,\r\n    setConfirmDialog,\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/hooks/useTabGroupItemDnD.ts",
    "content": "import { useState } from 'react'\r\nimport { arrayMove } from '@dnd-kit/sortable'\r\nimport type { DragEndEvent, DragStartEvent } from '@dnd-kit/core'\r\nimport { tabGroupsService } from '@/services/tab-groups'\r\nimport type { TabGroup, TabGroupItem } from '@/lib/types'\r\nimport { logger } from '@/lib/logger'\r\n\r\ninterface UseTabGroupItemDnDProps {\r\n  tabGroups: TabGroup[]\r\n  setTabGroups: React.Dispatch<React.SetStateAction<TabGroup[]>>\r\n  moveItemDialog: {\r\n    isOpen: boolean\r\n    item: TabGroupItem | null\r\n    currentGroupId: string\r\n  }\r\n  setMoveItemDialog: React.Dispatch<\r\n    React.SetStateAction<{\r\n      isOpen: boolean\r\n      item: TabGroupItem | null\r\n      currentGroupId: string\r\n    }>\r\n  >\r\n  refreshTreeOnly: () => Promise<unknown>\r\n  showError: (message: string) => void\r\n  moveFailedMessage: string\r\n}\r\n\r\nexport function useTabGroupItemDnD({\r\n  tabGroups,\r\n  setTabGroups,\r\n  moveItemDialog,\r\n  setMoveItemDialog,\r\n  refreshTreeOnly,\r\n  showError,\r\n  moveFailedMessage,\r\n}: UseTabGroupItemDnDProps) {\r\n  const [activeId, setActiveId] = useState<string | null>(null)\r\n\r\n  const handleDragStart = (event: DragStartEvent) => {\r\n    setActiveId(event.active.id as string)\r\n  }\r\n\r\n  const handleDragEnd = async (event: DragEndEvent) => {\r\n    const { active, over } = event\r\n    setActiveId(null)\r\n\r\n    if (!over || active.id === over.id) return\r\n\r\n    let sourceGroup: TabGroup | undefined\r\n    let sourceItem: TabGroupItem | undefined\r\n    let targetGroup: TabGroup | undefined\r\n    let targetItem: TabGroupItem | undefined\r\n\r\n    for (const group of tabGroups) {\r\n      const item = group.items?.find((entry) => entry.id === active.id)\r\n      if (item) {\r\n        sourceGroup = group\r\n        sourceItem = item\r\n        break\r\n      }\r\n    }\r\n\r\n    for (const group of tabGroups) {\r\n      const item = group.items?.find((entry) => entry.id === over.id)\r\n      if (item) {\r\n        targetGroup = group\r\n        targetItem = item\r\n        break\r\n      }\r\n    }\r\n\r\n    if (!sourceGroup || !sourceItem || !targetGroup || !targetItem) return\r\n\r\n    if (sourceGroup.id === targetGroup.id) {\r\n      if (!sourceGroup.items) {\r\n        logger.error('Source group items is undefined')\r\n        showError(moveFailedMessage)\r\n        return\r\n      }\r\n\r\n      const oldIndex = sourceGroup.items.findIndex((item) => item.id === active.id)\r\n      const newIndex = sourceGroup.items.findIndex((item) => item.id === over.id)\r\n      const newItems: TabGroupItem[] = arrayMove(sourceGroup.items, oldIndex, newIndex)\r\n\r\n      setTabGroups((prev) =>\r\n        prev.map((group) => (group.id === sourceGroup!.id ? { ...group, items: newItems } : group))\r\n      )\r\n\r\n      try {\r\n        await Promise.all(\r\n          newItems.map((item, index) => tabGroupsService.updateTabGroupItem(item.id, { position: index }))\r\n        )\r\n        await refreshTreeOnly()\r\n      } catch (err) {\r\n        logger.error('Failed to update positions:', err)\r\n        setTabGroups((prev) =>\r\n          prev.map((group) => (group.id === sourceGroup!.id ? { ...group, items: sourceGroup!.items } : group))\r\n        )\r\n        showError(moveFailedMessage)\r\n      }\r\n      return\r\n    }\r\n\r\n    if (!sourceGroup.items || !targetGroup.items) {\r\n      logger.error('Source or target group items is undefined')\r\n      showError(moveFailedMessage)\r\n      return\r\n    }\r\n\r\n    const targetIndex = targetGroup.items.findIndex((item) => item.id === over.id)\r\n    const newSourceItems: TabGroupItem[] = sourceGroup.items.filter((item) => item.id !== active.id)\r\n    const newTargetItems: TabGroupItem[] = [...targetGroup.items]\r\n    newTargetItems.splice(targetIndex, 0, sourceItem)\r\n\r\n    setTabGroups((prev) =>\r\n      prev.map((group) => {\r\n        if (group.id === sourceGroup!.id) {\r\n          return { ...group, items: newSourceItems, item_count: newSourceItems.length }\r\n        }\r\n        if (group.id === targetGroup!.id) {\r\n          return { ...group, items: newTargetItems, item_count: newTargetItems.length }\r\n        }\r\n        return group\r\n      })\r\n    )\r\n\r\n    try {\r\n      await tabGroupsService.moveTabGroupItem(sourceItem.id, targetGroup.id, targetIndex)\r\n      await Promise.all(\r\n        newSourceItems.map((item, index) => tabGroupsService.updateTabGroupItem(item.id, { position: index }))\r\n      )\r\n      await refreshTreeOnly()\r\n    } catch (err) {\r\n      logger.error('Failed to move item across groups:', err)\r\n      setTabGroups((prev) =>\r\n        prev.map((group) => {\r\n          if (group.id === sourceGroup!.id) {\r\n            return { ...group, items: sourceGroup!.items, item_count: sourceGroup!.items?.length ?? 0 }\r\n          }\r\n          if (group.id === targetGroup!.id) {\r\n            return { ...group, items: targetGroup!.items, item_count: targetGroup!.items?.length ?? 0 }\r\n          }\r\n          return group\r\n        })\r\n      )\r\n      showError(moveFailedMessage)\r\n    }\r\n  }\r\n\r\n  const handleMoveItem = (item: TabGroupItem) => {\r\n    const currentGroup = tabGroups.find((group) => group.items?.some((entry) => entry.id === item.id))\r\n    if (!currentGroup) return\r\n\r\n    setMoveItemDialog({\r\n      isOpen: true,\r\n      item,\r\n      currentGroupId: currentGroup.id,\r\n    })\r\n  }\r\n\r\n  const handleMoveItemToGroup = async (targetGroupId: string) => {\r\n    const { item, currentGroupId } = moveItemDialog\r\n    if (!item) return\r\n\r\n    const sourceGroup = tabGroups.find((group) => group.id === currentGroupId)\r\n    const targetGroup = tabGroups.find((group) => group.id === targetGroupId)\r\n    if (!sourceGroup || !targetGroup || !sourceGroup.items) return\r\n\r\n    const newSourceItems: TabGroupItem[] = sourceGroup.items.filter((entry) => entry.id !== item.id)\r\n    const newTargetItems: TabGroupItem[] = [...(targetGroup.items || []), item]\r\n\r\n    setTabGroups((prev) =>\r\n      prev.map((group) => {\r\n        if (group.id === currentGroupId) {\r\n          return { ...group, items: newSourceItems, item_count: newSourceItems.length }\r\n        }\r\n        if (group.id === targetGroupId) {\r\n          return { ...group, items: newTargetItems, item_count: newTargetItems.length }\r\n        }\r\n        return group\r\n      })\r\n    )\r\n\r\n    try {\r\n      await tabGroupsService.moveTabGroupItem(item.id, targetGroupId, newTargetItems.length - 1)\r\n      await Promise.all(\r\n        newSourceItems.map((entry, index) => tabGroupsService.updateTabGroupItem(entry.id, { position: index }))\r\n      )\r\n      await refreshTreeOnly()\r\n    } catch (err) {\r\n      logger.error('Failed to move item to group:', err)\r\n      setTabGroups((prev) =>\r\n        prev.map((group) => {\r\n          if (group.id === currentGroupId) {\r\n            return { ...group, items: sourceGroup.items, item_count: sourceGroup.items?.length ?? 0 }\r\n          }\r\n          if (group.id === targetGroupId) {\r\n            return { ...group, items: targetGroup.items, item_count: targetGroup.items?.length ?? 0 }\r\n          }\r\n          return group\r\n        })\r\n      )\r\n      showError(moveFailedMessage)\r\n    }\r\n  }\r\n\r\n  return {\r\n    activeId,\r\n    handleDragStart,\r\n    handleDragEnd,\r\n    handleMoveItem,\r\n    handleMoveItemToGroup,\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/hooks/useTabGroupsData.ts",
    "content": "import { useEffect, useMemo, useState } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { sortTabGroupsForView, type SortOption } from '@/components/tab-groups/sortUtils'\r\nimport { useTabGroupsQuery } from '@/hooks/useTabGroupsQuery'\r\nimport { searchInFields } from '@/lib/search-utils'\r\nimport type { TabGroup } from '@/lib/types'\r\n\r\ninterface UseTabGroupsDataProps {\r\n  tabGroups: TabGroup[]\r\n  setTabGroups: React.Dispatch<React.SetStateAction<TabGroup[]>>\r\n  selectedGroupId: string | null\r\n  searchQuery: string\r\n  sortBy: SortOption\r\n}\r\n\r\nexport function useTabGroupsData({\r\n  tabGroups,\r\n  setTabGroups,\r\n  selectedGroupId,\r\n  searchQuery,\r\n  sortBy,\r\n}: UseTabGroupsDataProps) {\r\n  const { t } = useTranslation('tabGroups')\r\n  const tabGroupsQuery = useTabGroupsQuery()\r\n  const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')\r\n\r\n  useEffect(() => {\r\n    const timer = setTimeout(() => {\r\n      setDebouncedSearchQuery(searchQuery)\r\n    }, 300)\r\n\r\n    return () => clearTimeout(timer)\r\n  }, [searchQuery])\r\n\r\n  useEffect(() => {\r\n    if (tabGroupsQuery.data) {\r\n      setTabGroups(tabGroupsQuery.data)\r\n    }\r\n  }, [tabGroupsQuery.data, setTabGroups])\r\n\r\n  const error = tabGroupsQuery.isError ? t('page.loadFailed') : null\r\n  const isLoading = tabGroupsQuery.isLoading && tabGroups.length === 0\r\n\r\n  const refreshTreeOnly = async () => {\r\n    await tabGroupsQuery.refetch()\r\n  }\r\n\r\n  const groupFilteredTabGroups = useMemo(() => {\r\n    if (tabGroups.length === 0) return []\r\n    if (!selectedGroupId) return tabGroups\r\n\r\n    const selectedGroup = tabGroups.find((group) => group.id === selectedGroupId)\r\n    if (!selectedGroup) return []\r\n\r\n    if (selectedGroup.is_folder === 1) {\r\n      return tabGroups.filter((group) => group.parent_id === selectedGroupId)\r\n    }\r\n\r\n    return [selectedGroup]\r\n  }, [selectedGroupId, tabGroups])\r\n\r\n  const filteredTabGroups = useMemo(() => {\r\n    if (groupFilteredTabGroups.length === 0) return []\r\n    if (!debouncedSearchQuery.trim()) return groupFilteredTabGroups\r\n\r\n    const query = debouncedSearchQuery.trim().toLowerCase()\r\n    const results: TabGroup[] = []\r\n\r\n    for (const group of groupFilteredTabGroups) {\r\n      if (searchInFields([group.title], query)) {\r\n        results.push(group)\r\n        continue\r\n      }\r\n\r\n      if (!group.items?.length) {\r\n        continue\r\n      }\r\n\r\n      const matchingItems = group.items.filter((item) =>\r\n        searchInFields([item.title, item.url], query)\r\n      )\r\n\r\n      if (matchingItems.length > 0) {\r\n        results.push({\r\n          ...group,\r\n          items: matchingItems,\r\n        })\r\n      }\r\n    }\r\n\r\n    return results\r\n  }, [debouncedSearchQuery, groupFilteredTabGroups])\r\n\r\n  const sortedGroups = useMemo(() => {\r\n    if (filteredTabGroups.length === 0) return []\r\n    return sortTabGroupsForView(filteredTabGroups, sortBy)\r\n  }, [filteredTabGroups, sortBy])\r\n\r\n  return {\r\n    isLoading,\r\n    error,\r\n    debouncedSearchQuery,\r\n    refetchTabGroups: tabGroupsQuery.refetch,\r\n    refreshTreeOnly,\r\n    groupFilteredTabGroups,\r\n    filteredTabGroups,\r\n    sortedGroups,\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/pages/tab-groups/hooks/useTabGroupsState.ts",
    "content": "import { useRef, useState } from 'react'\r\nimport type { TabGroup, TabGroupItem } from '@/lib/types'\r\nimport type { SortOption } from '@/components/tab-groups/sortUtils'\r\n\r\nexport function useTabGroupsState() {\r\n  const [tabGroups, setTabGroups] = useState<TabGroup[]>([])\r\n  const [deletingId, setDeletingId] = useState<string | null>(null)\r\n  const [searchQuery, setSearchQuery] = useState('')\r\n  const [highlightedDomain, setHighlightedDomain] = useState<string | null>(null)\r\n  const [sortBy, setSortBy] = useState<SortOption>('created')\r\n  const searchCleanupTimerRef = useRef<NodeJS.Timeout | null>(null)\r\n\r\n  const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set())\r\n  const [batchMode, setBatchMode] = useState(false)\r\n\r\n  const [sharingGroupId, setSharingGroupId] = useState<string | null>(null)\r\n  const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null)\r\n  const [isDrawerOpen, setIsDrawerOpen] = useState(false)\r\n\r\n  const [moveItemDialog, setMoveItemDialog] = useState<{\r\n    isOpen: boolean\r\n    item: TabGroupItem | null\r\n    currentGroupId: string\r\n  }>({\r\n    isOpen: false,\r\n    item: null,\r\n    currentGroupId: '',\r\n  })\r\n\r\n  const [confirmDialog, setConfirmDialog] = useState<{\r\n    isOpen: boolean\r\n    title: string\r\n    message: string\r\n    onConfirm: () => void\r\n  }>({\r\n    isOpen: false,\r\n    title: '',\r\n    message: '',\r\n    onConfirm: () => {},\r\n  })\r\n\r\n  return {\r\n    tabGroups,\r\n    setTabGroups,\r\n    deletingId,\r\n    setDeletingId,\r\n    searchQuery,\r\n    setSearchQuery,\r\n    highlightedDomain,\r\n    setHighlightedDomain,\r\n    sortBy,\r\n    setSortBy,\r\n    searchCleanupTimerRef,\r\n    selectedItems,\r\n    setSelectedItems,\r\n    batchMode,\r\n    setBatchMode,\r\n    sharingGroupId,\r\n    setSharingGroupId,\r\n    selectedGroupId,\r\n    setSelectedGroupId,\r\n    isDrawerOpen,\r\n    setIsDrawerOpen,\r\n    moveItemDialog,\r\n    setMoveItemDialog,\r\n    confirmDialog,\r\n    setConfirmDialog,\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/routes/index.tsx",
    "content": "import { lazy, Suspense } from 'react'\r\nimport { useTranslation } from 'react-i18next'\r\nimport { Routes, Route, Navigate } from 'react-router-dom'\r\nimport { AppShell } from '@/components/layout/AppShell'\r\nimport { FullScreenAppShell } from '@/components/layout/FullScreenAppShell'\r\nimport { PublicAppShell } from '@/components/layout/PublicAppShell'\r\nimport { ProtectedRoute } from '@/components/auth/ProtectedRoute'\r\n\r\n// 懒加载页面组件\r\nconst LoginPage = lazy(() => import('@/pages/auth/LoginPage').then(m => ({ default: m.LoginPage })))\r\nconst RegisterPage = lazy(() => import('@/pages/auth/RegisterPage').then(m => ({ default: m.RegisterPage })))\r\nconst BookmarksPage = lazy(() => import('@/pages/bookmarks/BookmarksPage').then(m => ({ default: m.BookmarksPage })))\r\nconst BookmarkTrashPage = lazy(() => import('@/pages/bookmarks/BookmarkTrashPage').then(m => ({ default: m.BookmarkTrashPage })))\r\nconst TabGroupsPage = lazy(() => import('@/pages/tab-groups/TabGroupsPage').then(m => ({ default: m.TabGroupsPage })))\r\nconst TabGroupDetailPage = lazy(() => import('@/pages/tab-groups/TabGroupDetailPage').then(m => ({ default: m.TabGroupDetailPage })))\r\nconst TrashPage = lazy(() => import('@/pages/tab-groups/TrashPage').then(m => ({ default: m.TrashPage })))\r\nconst StatisticsPage = lazy(() => import('@/pages/tab-groups/StatisticsPage').then(m => ({ default: m.StatisticsPage })))\r\nconst TodoPage = lazy(() => import('@/pages/tab-groups/TodoPage').then(m => ({ default: m.TodoPage })))\r\nconst PermissionsPage = lazy(() => import('@/pages/settings/PermissionsPage').then(m => ({ default: m.PermissionsPage })))\r\nconst GeneralSettingsPage = lazy(() => import('@/pages/settings/GeneralSettingsPage').then(m => ({ default: m.GeneralSettingsPage })))\r\nconst PublicSharePage = lazy(() => import('@/pages/share/PublicSharePage').then(m => ({ default: m.PublicSharePage })))\r\nconst ExtensionPage = lazy(() => import('@/pages/extension/ExtensionPage').then(m => ({ default: m.ExtensionPage })))\r\nconst AboutPage = lazy(() => import('@/pages/info/AboutPage').then(m => ({ default: m.AboutPage })))\r\nconst HelpPage = lazy(() => import('@/pages/info/HelpPage').then(m => ({ default: m.HelpPage })))\r\nconst PrivacyPage = lazy(() => import('@/pages/info/PrivacyPage').then(m => ({ default: m.PrivacyPage })))\r\nconst TermsPage = lazy(() => import('@/pages/info/TermsPage').then(m => ({ default: m.TermsPage })))\r\n\r\n// 加载中组件\r\nfunction PageLoader() {\r\n  const { t } = useTranslation('common')\r\n  return (\r\n    <div className=\"flex items-center justify-center min-h-screen\">\r\n      <div className=\"text-center\">\r\n        <svg className=\"animate-spin h-12 w-12 mx-auto mb-4 text-primary\" viewBox=\"0 0 24 24\" fill=\"none\">\r\n          <circle className=\"opacity-25\" cx=\"12\" cy=\"12\" r=\"10\" stroke=\"currentColor\" strokeWidth=\"4\"></circle>\r\n          <path className=\"opacity-75\" fill=\"currentColor\" d=\"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z\"></path>\r\n        </svg>\r\n        <p className=\"text-muted-foreground\">{t('status.loading')}</p>\r\n      </div>\r\n    </div>\r\n  )\r\n}\r\n\r\nexport function AppRouter() {\r\n  return (\r\n    <Suspense fallback={<PageLoader />}>\r\n      <Routes>\r\n        <Route path=\"/login\" element={<LoginPage />} />\r\n        <Route path=\"/register\" element={<RegisterPage />} />\r\n\r\n        {/* 公开分享页面使用公开布局 */}\r\n        <Route element={<PublicAppShell />}>\r\n          <Route path=\"/share/:slug\" element={<PublicSharePage />} />\r\n        </Route>\r\n\r\n        {/* 受保护的路由 */}\r\n        <Route element={<ProtectedRoute />}>\r\n          {/* 全屏布局 - 用于书签和标签页组 */}\r\n          <Route element={<FullScreenAppShell />}>\r\n            <Route path=\"/\" element={<BookmarksPage />} />\r\n            <Route path=\"/tab\" element={<TabGroupsPage />} />\r\n          </Route>\r\n\r\n          {/* 常规布局 - 用于设置和其他页面 */}\r\n          <Route element={<AppShell />}>\r\n            <Route path=\"/bookmarks/trash\" element={<BookmarkTrashPage />} />\r\n            <Route path=\"/tab/todo\" element={<TodoPage />} />\r\n            <Route path=\"/tab/trash\" element={<TrashPage />} />\r\n            <Route path=\"/tab/statistics\" element={<StatisticsPage />} />\r\n            <Route path=\"/tab/:id\" element={<TabGroupDetailPage />} />\r\n            <Route path=\"/settings/general\" element={<GeneralSettingsPage />} />\r\n            {/* 旧独立页面重定向到设置页面对应 section */}\r\n            <Route path=\"/api-keys\" element={<Navigate to=\"/settings/general?section=api\" replace />} />\r\n            <Route path=\"/share-settings\" element={<Navigate to=\"/settings/general?section=share\" replace />} />\r\n            <Route path=\"/import-export\" element={<Navigate to=\"/settings/general?section=data\" replace />} />\r\n            <Route path=\"/permissions\" element={<PermissionsPage />} />\r\n            <Route path=\"/extension\" element={<ExtensionPage />} />\r\n            <Route path=\"/about\" element={<AboutPage />} />\r\n            <Route path=\"/help\" element={<HelpPage />} />\r\n            <Route path=\"/privacy\" element={<PrivacyPage />} />\r\n            <Route path=\"/terms\" element={<TermsPage />} />\r\n            <Route path=\"*\" element={<Navigate to=\"/\" replace />} />\r\n          </Route>\r\n        </Route>\r\n      </Routes>\r\n    </Suspense>\r\n  )\r\n}\r\n"
  },
  {
    "path": "tmarks/src/services/api-keys.ts",
    "content": "/**\r\n * API Keys 服务层\r\n * 前端调用后端 API 的封装\r\n */\r\n\r\nimport { apiClient } from '@/lib/api-client'\r\nimport { assertData } from './index'\r\n\r\nexport interface ApiKey {\r\n  id: string\r\n  key_prefix: string\r\n  name: string\r\n  description: string | null\r\n  permissions: string[]\r\n  status: 'active' | 'revoked' | 'expired'\r\n  expires_at: string | null\r\n  last_used_at: string | null\r\n  last_used_ip: string | null\r\n  created_at: string\r\n  updated_at: string\r\n}\r\n\r\nexport interface ApiKeyWithKey extends ApiKey {\r\n  key: string // 完整 Key，仅在创建时返回\r\n}\r\n\r\nexport interface ApiKeyStats {\r\n  total_requests: number\r\n  last_used_at: string | null\r\n  last_used_ip: string | null\r\n}\r\n\r\nexport interface ApiKeyWithStats extends ApiKey {\r\n  stats: ApiKeyStats\r\n}\r\n\r\nexport interface ApiKeyLog {\r\n  api_key_id: string\r\n  user_id: string\r\n  endpoint: string\r\n  method: string\r\n  status: number\r\n  ip: string | null\r\n  created_at: string\r\n}\r\n\r\nexport interface CreateApiKeyRequest {\r\n  name: string\r\n  description?: string\r\n  permissions?: string[]\r\n  template?: 'READ_ONLY' | 'BASIC' | 'FULL'\r\n  expires_at?: string | null\r\n}\r\n\r\nexport interface UpdateApiKeyRequest {\r\n  name?: string\r\n  description?: string\r\n  permissions?: string[]\r\n  template?: 'READ_ONLY' | 'BASIC' | 'FULL'\r\n  expires_at?: string | null\r\n}\r\n\r\n/**\r\n * 获取用户的所有 API Keys\r\n */\r\nexport async function getApiKeys(): Promise<{\r\n  keys: ApiKey[]\r\n  quota: { used: number; limit: number }\r\n}> {\r\n  const response = await apiClient.get<{ keys: ApiKey[]; quota: { used: number; limit: number } }>('/settings/api-keys')\r\n  return assertData(response.data, 'GET /settings/api-keys')\r\n}\r\n\r\n/**\r\n * 获取单个 API Key 详情\r\n */\r\nexport async function getApiKey(id: string): Promise<ApiKeyWithStats> {\r\n  const response = await apiClient.get<ApiKeyWithStats>(`/settings/api-keys/${id}`)\r\n  return assertData(response.data, `GET /settings/api-keys/${id}`)\r\n}\r\n\r\n/**\r\n * 创建新的 API Key\r\n */\r\nexport async function createApiKey(data: CreateApiKeyRequest): Promise<ApiKeyWithKey> {\r\n  const response = await apiClient.post<ApiKeyWithKey>('/settings/api-keys', data)\r\n  return assertData(response.data, 'POST /settings/api-keys')\r\n}\r\n\r\n/**\r\n * 更新 API Key\r\n */\r\nexport async function updateApiKey(id: string, data: UpdateApiKeyRequest): Promise<ApiKey> {\r\n  const response = await apiClient.patch<ApiKey>(`/settings/api-keys/${id}`, data)\r\n  return assertData(response.data, `PATCH /settings/api-keys/${id}`)\r\n}\r\n\r\n/**\r\n * 撤销 API Key\r\n */\r\nexport async function revokeApiKey(id: string): Promise<void> {\r\n  await apiClient.delete(`/settings/api-keys/${id}`)\r\n}\r\n\r\n/**\r\n * 永久删除 API Key\r\n */\r\nexport async function deleteApiKey(id: string): Promise<void> {\r\n  await apiClient.delete(`/settings/api-keys/${id}?hard=true`)\r\n}\r\n\r\n/**\r\n * 获取 API Key 使用日志\r\n */\r\nexport async function getApiKeyLogs(\r\n  id: string,\r\n  limit: number = 10\r\n): Promise<{ logs: ApiKeyLog[]; limit: number }> {\r\n  const response = await apiClient.get<{ logs: ApiKeyLog[]; limit: number }>(`/settings/api-keys/${id}/logs?limit=${limit}`)\r\n  return assertData(response.data, `GET /settings/api-keys/${id}/logs`)\r\n}\r\n"
  },
  {
    "path": "tmarks/src/services/auth.ts",
    "content": "import { apiClient } from '@/lib/api-client'\r\nimport type {\r\n  LoginRequest,\r\n  LoginResponse,\r\n  RegisterRequest,\r\n  RegisterResponse,\r\n  RefreshTokenRequest,\r\n  RefreshTokenResponse,\r\n} from '@/lib/types'\r\nimport { assertData } from './index'\r\n\r\nexport const authService = {\r\n  /**\r\n   * 用户注册\r\n   */\r\n  async register(data: RegisterRequest) {\r\n    const response = await apiClient.post<RegisterResponse>('/auth/register', data)\r\n    return assertData(response.data, 'POST /auth/register')\r\n  },\r\n\r\n  /**\r\n   * 用户登录\r\n   */\r\n  async login(data: LoginRequest) {\r\n    const response = await apiClient.post<LoginResponse>('/auth/login', data)\r\n    return assertData(response.data, 'POST /auth/login')\r\n  },\r\n\r\n  /**\r\n   * 刷新访问令牌\r\n   */\r\n  async refreshToken(data: RefreshTokenRequest) {\r\n    const response = await apiClient.post<RefreshTokenResponse>('/auth/refresh', data)\r\n    return assertData(response.data, 'POST /auth/refresh')\r\n  },\r\n\r\n  /**\r\n   * 登出\r\n   */\r\n  async logout(refreshToken: string, revokeAll = false) {\r\n    await apiClient.post('/auth/logout', {\r\n      refresh_token: refreshToken,\r\n      revoke_all: revokeAll,\r\n    })\r\n  },\r\n}\r\n"
  },
  {
    "path": "tmarks/src/services/bookmarks.ts",
    "content": "import { apiClient } from '@/lib/api-client'\r\nimport type {\r\n  Bookmark,\r\n  BookmarksResponse,\r\n  CreateBookmarkRequest,\r\n  UpdateBookmarkRequest,\r\n  BookmarkQueryParams,\r\n  BatchActionRequest,\r\n  BatchActionResponse,\r\n} from '@/lib/types'\r\nimport { assertData } from './index'\r\n\r\nexport const bookmarksService = {\r\n  /**\r\n   * 获取书签列表\r\n   */\r\n  async getBookmarks(params?: BookmarkQueryParams) {\r\n    const searchParams = new URLSearchParams()\r\n\r\n    if (params?.keyword) searchParams.set('keyword', params.keyword)\r\n    if (params?.tags) searchParams.set('tags', params.tags)\r\n    if (params?.page_size) searchParams.set('page_size', params.page_size.toString())\r\n    if (params?.page_cursor) searchParams.set('page_cursor', params.page_cursor)\r\n    if (params?.sort) searchParams.set('sort', params.sort)\r\n    if (params?.archived !== undefined) searchParams.set('archived', params.archived.toString())\r\n    if (params?.pinned !== undefined) searchParams.set('pinned', params.pinned.toString())\r\n\r\n    const query = searchParams.toString()\r\n    const endpoint = query ? `/bookmarks?${query}` : '/bookmarks'\r\n\r\n    const response = await apiClient.get<BookmarksResponse>(endpoint)\r\n    return assertData(response.data, 'GET /bookmarks')\r\n  },\r\n\r\n  /**\r\n   * 创建书签\r\n   */\r\n  async createBookmark(data: CreateBookmarkRequest) {\r\n    const response = await apiClient.post<{ bookmark: Bookmark }>('/bookmarks', data)\r\n    return assertData(response.data, 'POST /bookmarks').bookmark\r\n  },\r\n\r\n  /**\r\n   * 更新书签\r\n   */\r\n  async updateBookmark(id: string, data: UpdateBookmarkRequest) {\r\n    const response = await apiClient.patch<{ bookmark: Bookmark }>(`/bookmarks/${id}`, data)\r\n    return assertData(response.data, `PATCH /bookmarks/${id}`).bookmark\r\n  },\r\n\r\n  /**\r\n   * 删除书签\r\n   */\r\n  async deleteBookmark(id: string) {\r\n    await apiClient.delete(`/bookmarks/${id}`)\r\n  },\r\n\r\n  /**\r\n   * 恢复已删除的书签\r\n   */\r\n  async restoreBookmark(id: string) {\r\n    const response = await apiClient.put<{ bookmark: Bookmark }>(`/bookmarks/${id}`)\r\n    return assertData(response.data, `PUT /bookmarks/${id}`).bookmark\r\n  },\r\n\r\n  /**\r\n   * 记录书签点击\r\n   */\r\n  async recordClick(id: string) {\r\n    const response = await apiClient.post<{ message: string; clicked_at: string }>(`/bookmarks/${id}/click`)\r\n    return assertData(response.data, `POST /bookmarks/${id}/click`)\r\n  },\r\n\r\n  /**\r\n   * 批量操作书签\r\n   */\r\n  async batchAction(data: BatchActionRequest) {\r\n    const response = await apiClient.patch<BatchActionResponse>('/bookmarks/bulk', data)\r\n    return assertData(response.data, 'PATCH /bookmarks/bulk')\r\n  },\r\n\r\n  /**\r\n   * 获取书签统计数据\r\n   */\r\n  async getStatistics(params: {\r\n    granularity: 'day' | 'week' | 'month' | 'year'\r\n    startDate: string\r\n    endDate: string\r\n  }) {\r\n    const { granularity, startDate, endDate } = params\r\n    const response = await apiClient.get(\r\n      `/bookmarks/statistics?granularity=${granularity}&start_date=${startDate}&end_date=${endDate}`\r\n    )\r\n    return assertData(response.data, 'GET /bookmarks/statistics')\r\n  },\r\n\r\n  /**\r\n   * 检查 URL 是否已存在\r\n   */\r\n  async checkUrlExists(url: string) {\r\n    const response = await apiClient.get<{ exists: boolean; bookmark?: Bookmark }>(\r\n      `/bookmarks/check-url?url=${encodeURIComponent(url)}`\r\n    )\r\n    return assertData(response.data, 'GET /bookmarks/check-url')\r\n  },\r\n\r\n  /**\r\n   * 获取回收站书签列表\r\n   */\r\n  async getTrash(params?: { page_size?: number; page_cursor?: string }) {\r\n    const searchParams = new URLSearchParams()\r\n    if (params?.page_size) searchParams.set('page_size', params.page_size.toString())\r\n    if (params?.page_cursor) searchParams.set('page_cursor', params.page_cursor)\r\n    \r\n    const query = searchParams.toString()\r\n    const endpoint = query ? `/bookmarks/trash?${query}` : '/bookmarks/trash'\r\n    \r\n    const response = await apiClient.get<BookmarksResponse>(endpoint)\r\n    return assertData(response.data, 'GET /bookmarks/trash')\r\n  },\r\n\r\n  /**\r\n   * 从回收站恢复书签\r\n   */\r\n  async restoreFromTrash(id: string) {\r\n    const response = await apiClient.patch<{ bookmark: Bookmark }>(`/bookmarks/${id}/restore`, {})\r\n    return assertData(response.data, `PATCH /bookmarks/${id}/restore`).bookmark\r\n  },\r\n\r\n  /**\r\n   * 永久删除书签\r\n   */\r\n  async permanentDelete(id: string) {\r\n    await apiClient.delete(`/bookmarks/${id}/permanent`)\r\n  },\r\n\r\n  /**\r\n   * 清空回收站\r\n   */\r\n  async emptyTrash() {\r\n    const response = await apiClient.delete<{ message: string; count: number }>('/bookmarks/trash/empty')\r\n    return assertData(response.data, 'DELETE /bookmarks/trash/empty')\r\n  },\r\n}\r\n"
  },
  {
    "path": "tmarks/src/services/index.ts",
    "content": "// ============ Services Exports ============\r\n\r\nexport * from './api-keys';\r\nexport * from './auth';\r\nexport * from './bookmarks';\r\nexport * from './preferences';\r\nexport * from './share';\r\nexport * from './storage';\r\nexport * from './tab-groups';\r\nexport * from './tags';\r\n\r\n/**\r\n * Assert response data exists, throw descriptive error instead of runtime crash\r\n */\r\nexport function assertData<T>(data: T | undefined, context: string): T {\r\n  if (data === undefined || data === null) {\r\n    throw new Error(`Unexpected empty response from ${context}`)\r\n  }\r\n  return data\r\n}\r\n"
  },
  {
    "path": "tmarks/src/services/preferences.ts",
    "content": "import { apiClient } from '@/lib/api-client'\r\nimport type { PreferencesResponse, UpdatePreferencesRequest, UserPreferences } from '@/lib/types'\r\nimport { assertData } from './index'\r\n\r\nexport const preferencesService = {\r\n  /**\r\n   * 获取用户偏好设置\r\n   */\r\n  async getPreferences(): Promise<UserPreferences> {\r\n    const response = await apiClient.get<PreferencesResponse>('/preferences')\r\n    return assertData(response.data, 'GET /preferences').preferences\r\n  },\r\n\r\n  /**\r\n   * 更新用户偏好设置\r\n   */\r\n  async updatePreferences(data: UpdatePreferencesRequest): Promise<UserPreferences> {\r\n    const response = await apiClient.patch<PreferencesResponse>('/preferences', data)\r\n    return assertData(response.data, 'PATCH /preferences').preferences\r\n  },\r\n}\r\n"
  },
  {
    "path": "tmarks/src/services/share.ts",
    "content": "import { apiClient } from '@/lib/api-client'\r\nimport i18n from '@/i18n'\r\nimport type {\r\n  ShareSettingsResponse,\r\n  UpdateShareSettingsRequest,\r\n  ShareSettings,\r\n  PublicSharePayload,\r\n  PublicSharePaginatedPayload,\r\n} from '@/lib/types'\r\nimport { assertData } from './index'\r\n\r\nconst PUBLIC_SHARE_BASE = import.meta.env.VITE_PUBLIC_SHARE_URL || '/api/public'\r\n\r\nexport const shareService = {\r\n  async getSettings(): Promise<ShareSettings> {\r\n    const response = await apiClient.get<ShareSettingsResponse>('/settings/share')\r\n    return assertData(response.data, 'GET /settings/share').share\r\n  },\r\n\r\n  async updateSettings(payload: UpdateShareSettingsRequest): Promise<ShareSettings> {\r\n    const response = await apiClient.put<ShareSettingsResponse>('/settings/share', payload)\r\n    return assertData(response.data, 'PUT /settings/share').share\r\n  },\r\n\r\n  async getPublicShare(slug: string): Promise<PublicSharePayload> {\r\n    const url = `${PUBLIC_SHARE_BASE.replace(/\\/$/, '')}/${encodeURIComponent(slug)}`\r\n    const response = await fetch(url, {\r\n      headers: {\r\n        'Content-Type': 'application/json',\r\n      },\r\n    })\r\n\r\n    if (!response.ok) {\r\n      throw new Error(i18n.t('errors:share.loadFailed'))\r\n    }\r\n\r\n    const data = (await response.json()) as { data?: PublicSharePayload; error?: { message: string } }\r\n    if (!data.data) {\r\n      throw new Error(data.error?.message || i18n.t('errors:share.notFound'))\r\n    }\r\n    return data.data\r\n  },\r\n\r\n  async getPublicSharePaginated(\r\n    slug: string,\r\n    params?: { page_size?: number; page_cursor?: string }\r\n  ): Promise<PublicSharePaginatedPayload> {\r\n    const searchParams = new URLSearchParams()\r\n    if (params?.page_size) {\r\n      searchParams.set('page_size', String(params.page_size))\r\n    }\r\n    if (params?.page_cursor) {\r\n      searchParams.set('page_cursor', params.page_cursor)\r\n    }\r\n\r\n    const queryString = searchParams.toString()\r\n    const url = `${PUBLIC_SHARE_BASE.replace(/\\/$/, '')}/${encodeURIComponent(slug)}${queryString ? `?${queryString}` : ''}`\r\n\r\n    const response = await fetch(url, {\r\n      headers: {\r\n        'Content-Type': 'application/json',\r\n      },\r\n    })\r\n\r\n    if (!response.ok) {\r\n      throw new Error(i18n.t('errors:share.loadFailed'))\r\n    }\r\n\r\n    const data = (await response.json()) as { data?: PublicSharePaginatedPayload; error?: { message: string } }\r\n    if (!data.data) {\r\n      throw new Error(data.error?.message || i18n.t('errors:share.notFound'))\r\n    }\r\n    return data.data\r\n  },\r\n}\r\n"
  },
  {
    "path": "tmarks/src/services/storage.ts",
    "content": "import { apiClient } from '@/lib/api-client'\r\nimport type { R2StorageQuotaResponse, R2StorageQuota } from '@/lib/types'\r\nimport { assertData } from './index'\r\n\r\nexport const storageService = {\r\n  async getR2Quota(): Promise<R2StorageQuota> {\r\n    const response = await apiClient.get<R2StorageQuotaResponse>('/settings/storage')\r\n    // 后端使用 ApiResponse 包裹，此处直接返回内部的 quota 对象\r\n    return assertData(response.data, 'GET /settings/storage').quota\r\n  },\r\n}\r\n"
  },
  {
    "path": "tmarks/src/services/tab-groups.ts",
    "content": "import { apiClient } from '@/lib/api-client'\r\nimport type {\r\n  TabGroup,\r\n  TabGroupsResponse,\r\n  CreateTabGroupRequest,\r\n  UpdateTabGroupRequest,\r\n  ShareResponse,\r\n  StatisticsResponse,\r\n} from '@/lib/types'\r\nimport { assertData } from './index'\r\n\r\nexport const tabGroupsService = {\r\n  /**\r\n   * 获取标签页组列表\r\n   */\r\n  async getTabGroups(params?: { page_size?: number; page_cursor?: string }) {\r\n    const searchParams = new URLSearchParams()\r\n\r\n    if (params?.page_size) searchParams.set('page_size', params.page_size.toString())\r\n    if (params?.page_cursor) searchParams.set('page_cursor', params.page_cursor)\r\n\r\n    const query = searchParams.toString()\r\n    const endpoint = query ? `/tab-groups?${query}` : '/tab-groups'\r\n\r\n    const response = await apiClient.get<TabGroupsResponse>(endpoint)\r\n    return assertData(response.data, 'GET /tab-groups')\r\n  },\r\n\r\n  /**\r\n   * 获取所有标签页组（自动分页）\r\n   */\r\n  async listAllTabGroups() {\r\n    const allGroups: TabGroup[] = []\r\n    let cursor: string | undefined = undefined\r\n    const MAX_PAGES = 50\r\n\r\n    for (let page = 0; page < MAX_PAGES; page++) {\r\n      const response = await this.getTabGroups({\r\n        page_size: 100,\r\n        page_cursor: cursor,\r\n      })\r\n\r\n      allGroups.push(...response.tab_groups)\r\n      cursor = response.meta?.next_cursor\r\n      if (!cursor) break\r\n    }\r\n\r\n    return allGroups\r\n  },\r\n\r\n  /**\r\n   * @deprecated Use `listAllTabGroups()` in new code.\r\n   */\r\n  async getAllTabGroups() {\r\n    return this.listAllTabGroups()\r\n  },\r\n\r\n  /**\r\n   * 获取单个标签页组详情\r\n   */\r\n  async getTabGroup(id: string) {\r\n    const response = await apiClient.get<{ tab_group: TabGroup }>(`/tab-groups/${id}`)\r\n    return assertData(response.data, `GET /tab-groups/${id}`).tab_group\r\n  },\r\n\r\n  /**\r\n   * 创建标签页组\r\n   */\r\n  async createTabGroup(data: CreateTabGroupRequest) {\r\n    const response = await apiClient.post<{ tab_group: TabGroup }>('/tab-groups', data)\r\n    return assertData(response.data, 'POST /tab-groups').tab_group\r\n  },\r\n\r\n  /**\r\n   * 创建文件夹\r\n   */\r\n  async createFolder(title: string, parentId?: string | null) {\r\n    const response = await apiClient.post<{ tab_group: TabGroup }>('/tab-groups', {\r\n      title,\r\n      parent_id: parentId,\r\n      is_folder: true,\r\n    })\r\n    return assertData(response.data, 'POST /tab-groups (folder)').tab_group\r\n  },\r\n\r\n  /**\r\n   * 更新标签页组\r\n   */\r\n  async updateTabGroup(id: string, data: UpdateTabGroupRequest) {\r\n    const response = await apiClient.patch<{ tab_group: TabGroup }>(`/tab-groups/${id}`, data)\r\n    return assertData(response.data, `PATCH /tab-groups/${id}`).tab_group\r\n  },\r\n\r\n  /**\r\n   * 删除标签页组\r\n   */\r\n  async deleteTabGroup(id: string) {\r\n    await apiClient.delete(`/tab-groups/${id}`)\r\n  },\r\n\r\n  /**\r\n   * 更新标签页项\r\n   */\r\n  async updateTabGroupItem(\r\n    itemId: string,\r\n    data: { title?: string; is_pinned?: boolean; is_todo?: boolean; is_archived?: boolean; position?: number }\r\n  ) {\r\n    interface UpdateItemResponse {\r\n      item: {\r\n        id: string\r\n        title: string\r\n        url: string\r\n        favicon?: string\r\n        position: number\r\n        is_pinned?: boolean\r\n        is_todo?: boolean\r\n        is_archived?: boolean\r\n        created_at: string\r\n      }\r\n    }\r\n    const response = await apiClient.patch<UpdateItemResponse>(`/tab-groups/items/${itemId}`, data)\r\n    return assertData(response.data, `PATCH /tab-groups/items/${itemId}`).item\r\n  },\r\n\r\n  /**\r\n   * 删除标签页项\r\n   */\r\n  async deleteTabGroupItem(itemId: string) {\r\n    await apiClient.delete(`/tab-groups/items/${itemId}`)\r\n  },\r\n\r\n  /**\r\n   * 移动标签页项到其他分组\r\n   */\r\n  async moveTabGroupItem(itemId: string, targetGroupId: string, position?: number) {\r\n    interface MoveItemResponse {\r\n      item: {\r\n        id: string\r\n        title: string\r\n        url: string\r\n        favicon?: string\r\n        position: number\r\n        is_pinned?: boolean\r\n        is_todo?: boolean\r\n        is_archived?: boolean\r\n        created_at: string\r\n      }\r\n    }\r\n    const response = await apiClient.post<MoveItemResponse>(\r\n      `/tab-groups/items/${itemId}/move`,\r\n      {\r\n        target_group_id: targetGroupId,\r\n        position,\r\n      }\r\n    )\r\n    return assertData(response.data, `POST /tab-groups/items/${itemId}/move`).item\r\n  },\r\n\r\n  /**\r\n   * 批量添加标签页项到分组\r\n   */\r\n  async addItemsToGroup(groupId: string, items: Array<{ title: string; url: string; favicon?: string }>) {\r\n    interface BatchAddResponse {\r\n      message: string\r\n      added_count: number\r\n      total_items: number\r\n      items: Array<{\r\n        id: string\r\n        title: string\r\n        url: string\r\n        favicon?: string\r\n        position: number\r\n        created_at: string\r\n      }>\r\n    }\r\n    const response = await apiClient.post<BatchAddResponse>(`/tab-groups/${groupId}/items/batch`, { items })\r\n    return assertData(response.data, `POST /tab-groups/${groupId}/items/batch`)\r\n  },\r\n\r\n  /**\r\n   * 批量更新标签页组位置\r\n   */\r\n  async batchUpdatePositions(updates: Array<{ id: string; position: number; parent_id: string | null }>) {\r\n    const response = await apiClient.patch<{ message: string; updated_count: number }>(\r\n      '/tab-groups/batch-update',\r\n      { updates }\r\n    )\r\n    return assertData(response.data, 'PATCH /tab-groups/batch-update')\r\n  },\r\n\r\n  /**\r\n   * 获取回收站中的标签页组\r\n   */\r\n  async getTrash() {\r\n    const response = await apiClient.get<TabGroupsResponse>('/tab-groups/trash')\r\n    return assertData(response.data, 'GET /tab-groups/trash')\r\n  },\r\n\r\n  /**\r\n   * 恢复标签页组\r\n   */\r\n  async restoreTabGroup(id: string) {\r\n    await apiClient.post(`/tab-groups/${id}/restore`, {})\r\n  },\r\n\r\n  /**\r\n   * 永久删除标签页组\r\n   */\r\n  async permanentDeleteTabGroup(id: string) {\r\n    await apiClient.delete(`/tab-groups/${id}/permanent-delete`)\r\n  },\r\n\r\n  /**\r\n   * 创建分享链接\r\n   */\r\n  async createShare(groupId: string, options?: { is_public?: boolean; expires_in_days?: number }) {\r\n    const response = await apiClient.post<ShareResponse>(`/tab-groups/${groupId}/share`, options || {})\r\n    return assertData(response.data, `POST /tab-groups/${groupId}/share`)\r\n  },\r\n\r\n  /**\r\n   * 获取分享信息\r\n   */\r\n  async getShare(groupId: string) {\r\n    const response = await apiClient.get<ShareResponse>(`/tab-groups/${groupId}/share`)\r\n    return assertData(response.data, `GET /tab-groups/${groupId}/share`)\r\n  },\r\n\r\n  /**\r\n   * 删除分享\r\n   */\r\n  async deleteShare(groupId: string) {\r\n    await apiClient.delete(`/tab-groups/${groupId}/share`)\r\n  },\r\n\r\n  /**\r\n   * 获取统计数据\r\n   */\r\n  async getStatistics(days: number = 30) {\r\n    const response = await apiClient.get<StatisticsResponse>(`/statistics?days=${days}`)\r\n    return assertData(response.data, 'GET /statistics')\r\n  },\r\n}\r\n"
  },
  {
    "path": "tmarks/src/services/tags.ts",
    "content": "import { apiClient } from '@/lib/api-client'\r\nimport type {\r\n  Tag,\r\n  TagsResponse,\r\n  CreateTagRequest,\r\n  UpdateTagRequest,\r\n  TagQueryParams,\r\n} from '@/lib/types'\r\nimport { assertData } from './index'\r\n\r\nexport const tagsService = {\r\n  /**\r\n   * 获取标签列表\r\n   */\r\n  async getTags(params?: TagQueryParams) {\r\n    const searchParams = new URLSearchParams()\r\n\r\n    if (params?.sort) searchParams.set('sort', params.sort)\r\n\r\n    const query = searchParams.toString()\r\n    const endpoint = query ? `/tags?${query}` : '/tags'\r\n\r\n    const response = await apiClient.get<TagsResponse>(endpoint)\r\n    return assertData(response.data, 'GET /tags')\r\n  },\r\n\r\n  /**\r\n   * 创建标签\r\n   */\r\n  async createTag(data: CreateTagRequest) {\r\n    const response = await apiClient.post<{ tag: Tag }>('/tags', data)\r\n    return assertData(response.data, 'POST /tags').tag\r\n  },\r\n\r\n  /**\r\n   * 更新标签\r\n   */\r\n  async updateTag(id: string, data: UpdateTagRequest) {\r\n    const response = await apiClient.patch<{ tag: Tag }>(`/tags/${id}`, data)\r\n    return assertData(response.data, `PATCH /tags/${id}`).tag\r\n  },\r\n\r\n  /**\r\n   * 删除标签\r\n   */\r\n  async deleteTag(id: string) {\r\n    await apiClient.delete(`/tags/${id}`)\r\n  },\r\n\r\n  /**\r\n   * 增加标签点击计数\r\n   */\r\n  async incrementClick(id: string) {\r\n    await apiClient.patch(`/tags/${id}/click`, {})\r\n  },\r\n}\r\n"
  },
  {
    "path": "tmarks/src/stores/authStore.ts",
    "content": "import { create } from 'zustand'\r\nimport { createJSONStorage, persist } from 'zustand/middleware'\r\nimport type { User } from '@/lib/types'\r\nimport { authService } from '@/services/auth'\r\nimport { logger } from '@/lib/logger'\r\n\r\ninterface AuthState {\r\n  user: User | null\r\n  accessToken: string | null\r\n  refreshToken: string | null\r\n  isAuthenticated: boolean\r\n  isLoading: boolean\r\n\r\n  // Actions\r\n  login: (username: string, password: string, rememberMe?: boolean) => Promise<void>\r\n  register: (username: string, password: string, email?: string) => Promise<void>\r\n  logout: (revokeAll?: boolean) => Promise<void>\r\n  refreshAccessToken: () => Promise<void>\r\n  setUser: (user: User) => void\r\n  clearAuth: () => void\r\n}\r\n\r\nexport const useAuthStore = create<AuthState>()(\r\n  persist(\r\n    (set, get) => ({\r\n      user: null,\r\n      accessToken: null,\r\n      refreshToken: null,\r\n      isAuthenticated: false,\r\n      isLoading: false,\r\n\r\n      login: async (username: string, password: string, rememberMe = false) => {\r\n        set({ isLoading: true })\r\n        try {\r\n          const data = await authService.login({ username, password, remember_me: rememberMe })\r\n\r\n          set({\r\n            user: data.user,\r\n            accessToken: data.access_token,\r\n            refreshToken: data.refresh_token,\r\n            isAuthenticated: true,\r\n            isLoading: false,\r\n          })\r\n        } catch (error) {\r\n          set({ isLoading: false })\r\n          throw error\r\n        }\r\n      },\r\n\r\n      register: async (username: string, password: string, email?: string) => {\r\n        set({ isLoading: true })\r\n        try {\r\n          await authService.register({ username, password, email })\r\n          set({ isLoading: false })\r\n          // 注册成功后不自动登录，让用户手动登录\r\n        } catch (error) {\r\n          set({ isLoading: false })\r\n          throw error\r\n        }\r\n      },\r\n\r\n      logout: async (revokeAll = false) => {\r\n        const { refreshToken, accessToken } = get()\r\n\r\n        // 只有在有有效 token 时才调用 API\r\n        if (refreshToken && accessToken) {\r\n          try {\r\n            await authService.logout(refreshToken, revokeAll)\r\n          } catch (error) {\r\n            // 如果是 401 错误（token 已过期），静默处理\r\n            // 其他错误则记录日志\r\n            if (error instanceof Error && !error.message.includes('Token expired')) {\r\n              logger.error('Logout error:', error)\r\n            }\r\n          }\r\n        }\r\n\r\n        set({\r\n          user: null,\r\n          accessToken: null,\r\n          refreshToken: null,\r\n          isAuthenticated: false,\r\n        })\r\n      },\r\n\r\n      refreshAccessToken: async () => {\r\n        const { refreshToken } = get()\r\n        if (!refreshToken) {\r\n          throw new Error('No refresh token available')\r\n        }\r\n\r\n        try {\r\n          const data = await authService.refreshToken({ refresh_token: refreshToken })\r\n\r\n          set({\r\n            user: data.user,\r\n            accessToken: data.access_token,\r\n            isAuthenticated: true,\r\n          })\r\n        } catch (error) {\r\n          // 刷新失败，清除认证状态\r\n          get().clearAuth()\r\n          throw error\r\n        }\r\n      },\r\n\r\n      setUser: (user: User) => {\r\n        set({ user })\r\n      },\r\n\r\n      clearAuth: () => {\r\n        set({\r\n          user: null,\r\n          accessToken: null,\r\n          refreshToken: null,\r\n          isAuthenticated: false,\r\n          isLoading: false,\r\n        })\r\n      },\r\n    }),\r\n    {\r\n      name: 'auth-storage',\r\n      storage: createJSONStorage(() => localStorage),\r\n      partialize: (state) => ({\r\n        user: state.user,\r\n        accessToken: state.accessToken,\r\n        refreshToken: state.refreshToken,\r\n        isAuthenticated: state.isAuthenticated,\r\n      }),\r\n    }\r\n  )\r\n)\r\n"
  },
  {
    "path": "tmarks/src/stores/dialogStore.ts",
    "content": "import { create } from 'zustand'\r\n\r\nexport type DialogType = 'info' | 'warning' | 'error' | 'success'\r\n\r\ninterface ConfirmDialogState {\r\n  isOpen: boolean\r\n  title: string\r\n  message: string\r\n  type: DialogType\r\n  confirmText?: string\r\n  cancelText?: string\r\n  resolve?: (result: boolean) => void\r\n}\r\n\r\ninterface AlertDialogState {\r\n  isOpen: boolean\r\n  title: string\r\n  message: string\r\n  type: DialogType\r\n  confirmText?: string\r\n  resolve?: () => void\r\n}\r\n\r\ninterface DialogState {\r\n  confirmDialog: ConfirmDialogState | null\r\n  alertDialog: AlertDialogState | null\r\n\r\n  confirm: (params: {\r\n    title?: string\r\n    message: string\r\n    type?: DialogType\r\n    confirmText?: string\r\n    cancelText?: string\r\n  }) => Promise<boolean>\r\n\r\n  alert: (params: {\r\n    title?: string\r\n    message: string\r\n    type?: DialogType\r\n    confirmText?: string\r\n  }) => Promise<void>\r\n\r\n  closeConfirm: (result: boolean) => void\r\n  closeAlert: () => void\r\n\r\n  info: (message: string, title?: string) => Promise<void>\r\n  warning: (message: string, title?: string) => Promise<void>\r\n  error: (message: string, title?: string) => Promise<void>\r\n  success: (message: string, title?: string) => Promise<void>\r\n}\r\n\r\nexport const useDialogStore = create<DialogState>((set, get) => ({\r\n  confirmDialog: null,\r\n  alertDialog: null,\r\n\r\n  confirm: async ({\r\n    title,\r\n    message,\r\n    type = 'warning',\r\n    confirmText,\r\n    cancelText,\r\n  }) => {\r\n    const existing = get().confirmDialog\r\n    if (existing?.isOpen) {\r\n      existing.resolve?.(false)\r\n    }\r\n\r\n    return await new Promise<boolean>((resolve) => {\r\n      set({\r\n        confirmDialog: {\r\n          isOpen: true,\r\n          title: title || '',\r\n          message,\r\n          type,\r\n          confirmText,\r\n          cancelText,\r\n          resolve,\r\n        },\r\n      })\r\n    })\r\n  },\r\n\r\n  alert: async ({ title, message, type = 'info', confirmText }) => {\r\n    const existing = get().alertDialog\r\n    if (existing?.isOpen) {\r\n      existing.resolve?.()\r\n    }\r\n\r\n    return await new Promise<void>((resolve) => {\r\n      set({\r\n        alertDialog: {\r\n          isOpen: true,\r\n          title: title || '',\r\n          message,\r\n          type,\r\n          confirmText,\r\n          resolve,\r\n        },\r\n      })\r\n    })\r\n  },\r\n\r\n  closeConfirm: (result) => {\r\n    const current = get().confirmDialog\r\n    current?.resolve?.(result)\r\n    set({ confirmDialog: null })\r\n  },\r\n\r\n  closeAlert: () => {\r\n    const current = get().alertDialog\r\n    current?.resolve?.()\r\n    set({ alertDialog: null })\r\n  },\r\n\r\n  info: async (message, title) => {\r\n    return await get().alert({ title, message, type: 'info' })\r\n  },\r\n\r\n  warning: async (message, title) => {\r\n    return await get().alert({ title, message, type: 'warning' })\r\n  },\r\n\r\n  error: async (message, title) => {\r\n    return await get().alert({ title, message, type: 'error' })\r\n  },\r\n\r\n  success: async (message, title) => {\r\n    return await get().alert({ title, message, type: 'success' })\r\n  },\r\n}))\r\n"
  },
  {
    "path": "tmarks/src/stores/index.ts",
    "content": "// ============ Stores Exports ============\r\n\r\nexport * from './authStore';\r\nexport * from './dialogStore';\r\nexport * from './themeStore';\r\nexport * from './toastStore';\r\n"
  },
  {
    "path": "tmarks/src/stores/themeStore.ts",
    "content": "import { create } from 'zustand'\r\nimport { persist } from 'zustand/middleware'\r\n\r\ntype Theme = 'light' | 'dark'\r\ntype ColorTheme = 'default' | 'violet' | 'green' | 'orange'\r\n\r\ninterface ThemeStore {\r\n  theme: Theme\r\n  colorTheme: ColorTheme\r\n  isAutoTheme: boolean\r\n  setTheme: (theme: Theme) => void\r\n  setColorTheme: (colorTheme: ColorTheme) => void\r\n  toggleTheme: () => void\r\n}\r\n\r\nconst getSystemTheme = (): Theme => {\r\n  if (typeof window === 'undefined') return 'light'\r\n  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'\r\n}\r\n\r\nexport const useThemeStore = create<ThemeStore>()(\r\n  persist(\r\n    (set) => ({\r\n      theme: getSystemTheme(),\r\n      colorTheme: 'default',\r\n      isAutoTheme: true,\r\n      setTheme: (theme) => set({ theme, isAutoTheme: false }),\r\n      setColorTheme: (colorTheme) => set({ colorTheme }),\r\n      toggleTheme: () =>\r\n        set((state) => ({\r\n          theme: state.theme === 'light' ? 'dark' : 'light',\r\n          isAutoTheme: false,\r\n        })),\r\n    }),\r\n    {\r\n      name: 'theme-storage',\r\n    }\r\n  )\r\n)\r\n\r\nif (typeof window !== 'undefined') {\r\n  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')\r\n  const handleThemeChange = (e: MediaQueryListEvent) => {\r\n    if (useThemeStore.getState().isAutoTheme) {\r\n      useThemeStore.setState({ theme: e.matches ? 'dark' : 'light' })\r\n    }\r\n  }\r\n\r\n  if (mediaQuery.addEventListener) {\r\n    mediaQuery.addEventListener('change', handleThemeChange)\r\n  } else {\r\n    mediaQuery.addListener(handleThemeChange)\r\n  }\r\n}\r\n"
  },
  {
    "path": "tmarks/src/stores/toastStore.ts",
    "content": "import { create } from 'zustand'\nimport type { ToastType, ToastProps } from '@/components/common/Toast'\n\ninterface ToastState {\n  toasts: ToastProps[]\n  addToast: (type: ToastType, message: string, duration?: number) => void\n  removeToast: (id: string) => void\n  success: (message: string, duration?: number) => void\n  error: (message: string, duration?: number) => void\n  info: (message: string, duration?: number) => void\n  warning: (message: string, duration?: number) => void\n}\n\nexport const useToastStore = create<ToastState>((set) => ({\n  toasts: [],\n\n  addToast: (type, message, duration = 3000) => {\n    const id = `toast-${Date.now()}-${Math.random()}`\n    set((state) => ({\n      toasts: [\n        ...state.toasts,\n        {\n          id,\n          type,\n          message,\n          duration,\n          onClose: (id: string) => {\n            set((state) => ({\n              toasts: state.toasts.filter((t) => t.id !== id),\n            }))\n          },\n        },\n      ],\n    }))\n  },\n\n  removeToast: (id) => {\n    set((state) => ({\n      toasts: state.toasts.filter((t) => t.id !== id),\n    }))\n  },\n\n  success: (message, duration) => {\n    useToastStore.getState().addToast('success', message, duration)\n  },\n\n  error: (message, duration) => {\n    useToastStore.getState().addToast('error', message, duration)\n  },\n\n  info: (message, duration) => {\n    useToastStore.getState().addToast('info', message, duration)\n  },\n\n  warning: (message, duration) => {\n    useToastStore.getState().addToast('warning', message, duration)\n  },\n}))\n\n"
  },
  {
    "path": "tmarks/src/styles/components.css",
    "content": "/* ===== 组件样式 ===== */\r\n\r\n/* 全局样式 */\r\n* {\r\n  border-color: var(--border);\r\n}\r\n\r\nbody {\r\n  background: var(--background);\r\n  color: var(--foreground);\r\n  min-height: 100vh;\r\n  overflow-x: hidden;\r\n  max-width: 100vw;\r\n}\r\n\r\nhtml {\r\n  overflow-x: hidden;\r\n  max-width: 100vw;\r\n}\r\n\r\n/* 玻璃磨砂效果 */\r\n.glass {\r\n  background: oklch(from var(--card) l c h / 0.7);\r\n  backdrop-filter: blur(20px) saturate(180%);\r\n  -webkit-backdrop-filter: blur(20px) saturate(180%);\r\n  border: 1px solid oklch(from var(--border) l c h / 0.5);\r\n}\r\n\r\n.toast-surface {\r\n  background: oklch(from var(--card) l c h / 0.98);\r\n}\r\n\r\n[data-theme='dark'] .toast-surface {\r\n  background: oklch(from var(--card) l c h / 0.92);\r\n}\r\n\r\n/* 悬浮阴影效果 */\r\n.shadow-float {\r\n  box-shadow:\r\n    0 10px 30px -5px oklch(0 0 0 / 0.1),\r\n    0 5px 15px -3px oklch(0 0 0 / 0.05);\r\n  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\r\n}\r\n\r\n.shadow-float:hover {\r\n  transform: translateY(-2px);\r\n  box-shadow:\r\n    0 20px 40px -5px oklch(0 0 0 / 0.15),\r\n    0 10px 25px -3px oklch(0 0 0 / 0.08);\r\n}\r\n\r\n[data-theme='dark'] .shadow-float {\r\n  box-shadow:\r\n    0 10px 30px -5px oklch(0 0 0 / 0.3),\r\n    0 5px 15px -3px oklch(0 0 0 / 0.2);\r\n}\r\n\r\n[data-theme='dark'] .shadow-float:hover {\r\n  box-shadow:\r\n    0 20px 40px -5px oklch(0 0 0 / 0.4),\r\n    0 10px 25px -3px oklch(0 0 0 / 0.25);\r\n}\r\n\r\n/* 按钮样式 */\r\n.btn {\r\n  padding: 0.75rem 1.5rem;\r\n  border-radius: var(--radius);\r\n  font-weight: 600;\r\n  font-size: 0.95rem;\r\n  letter-spacing: 0.02em;\r\n  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\r\n  background: linear-gradient(135deg, var(--primary) 0%, oklch(from var(--primary) calc(l - 0.05) c h) 100%);\r\n  color: var(--primary-foreground);\r\n  box-shadow:\r\n    0 4px 12px -2px rgba(from var(--primary) r g b / 0.3),\r\n    0 2px 6px -1px rgba(from var(--primary) r g b / 0.2);\r\n  position: relative;\r\n  overflow: hidden;\r\n}\r\n\r\n.btn::before {\r\n  content: '';\r\n  position: absolute;\r\n  top: 0;\r\n  left: 0;\r\n  width: 100%;\r\n  height: 100%;\r\n  background: linear-gradient(135deg, oklch(from var(--primary-foreground) l c h / 0.2) 0%, transparent 100%);\r\n  opacity: 0;\r\n  transition: opacity 0.3s ease;\r\n}\r\n\r\n.btn:hover {\r\n  transform: translateY(-2px);\r\n  box-shadow:\r\n    0 8px 20px -2px rgba(from var(--primary) r g b / 0.4),\r\n    0 4px 10px -1px rgba(from var(--primary) r g b / 0.3);\r\n}\r\n\r\n.btn:hover::before {\r\n  opacity: 1;\r\n}\r\n\r\n.btn:active {\r\n  transform: translateY(0);\r\n  box-shadow:\r\n    0 2px 8px -2px rgba(from var(--primary) r g b / 0.3),\r\n    0 1px 4px -1px rgba(from var(--primary) r g b / 0.2);\r\n}\r\n\r\n.btn:disabled {\r\n  opacity: 0.6;\r\n  cursor: not-allowed;\r\n  transform: none;\r\n  box-shadow: none;\r\n}\r\n\r\n.btn-sm {\r\n  padding: 0.5rem 1rem;\r\n  font-size: 0.875rem;\r\n  border-radius: 0.75rem;\r\n}\r\n\r\n.btn-lg {\r\n  padding: 0.875rem 1.75rem;\r\n  font-size: 1rem;\r\n}\r\n\r\n.btn-ghost {\r\n  background: transparent;\r\n  box-shadow: none;\r\n  color: var(--foreground);\r\n}\r\n\r\n.btn-ghost:hover {\r\n  background: oklch(from var(--primary) l c h / 0.1);\r\n}\r\n\r\n.btn-primary {\r\n  background: linear-gradient(135deg, var(--primary) 0%, oklch(from var(--primary) calc(l - 0.05) c h) 100%);\r\n}\r\n\r\n.btn-secondary {\r\n  background: linear-gradient(135deg, var(--secondary) 0%, oklch(from var(--secondary) calc(l - 0.05) c h) 100%);\r\n  box-shadow:\r\n    0 4px 12px -2px rgba(from var(--secondary) r g b / 0.3),\r\n    0 2px 6px -1px rgba(from var(--secondary) r g b / 0.2);\r\n}\r\n\r\n.btn-outline {\r\n  background: transparent;\r\n  border: 2px solid var(--primary);\r\n  color: var(--primary);\r\n  box-shadow: none;\r\n}\r\n\r\n.btn-outline:hover {\r\n  background: var(--primary);\r\n  color: var(--primary-foreground);\r\n  border-color: var(--primary);\r\n  box-shadow:\r\n    0 8px 20px -2px rgba(from var(--primary) r g b / 0.3),\r\n    0 4px 10px -1px rgba(from var(--primary) r g b / 0.2);\r\n}\r\n\r\n/* 输入框样式 */\r\n.input {\r\n  width: 100%;\r\n  padding: 0.875rem 1.25rem;\r\n  border-radius: var(--radius);\r\n  background: var(--card);\r\n  backdrop-filter: blur(10px);\r\n  border: 2px solid var(--border);\r\n  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);\r\n  font-size: 0.95rem;\r\n  color: var(--foreground);\r\n}\r\n\r\n.input:hover {\r\n  background: var(--card);\r\n  border-color: oklch(from var(--primary) l c h / 0.4);\r\n}\r\n\r\n.input:focus {\r\n  outline: none;\r\n  background: var(--card);\r\n  border-color: var(--primary);\r\n  box-shadow:\r\n    0 0 0 4px oklch(from var(--primary) l c h / 0.1),\r\n    0 4px 12px -2px rgba(from var(--primary) r g b / 0.15);\r\n  transform: translateY(-1px);\r\n}\r\n\r\n.input::placeholder {\r\n  color: var(--muted-foreground);\r\n}\r\n\r\n/* Select 下拉框样式 */\r\nselect.input {\r\n  cursor: pointer;\r\n  appearance: auto;\r\n  background-image: none;\r\n  padding-right: 1.25rem;\r\n}\r\n\r\nselect.input:hover {\r\n  transform: none;\r\n}\r\n\r\nselect.input:focus {\r\n  transform: none;\r\n  box-shadow: 0 0 0 4px oklch(from var(--primary) l c h / 0.1);\r\n}\r\n\r\nselect.input option {\r\n  background-color: var(--background);\r\n  color: var(--foreground);\r\n  padding: 0.5rem;\r\n}\r\n\r\n/* 卡片样式 */\r\n.card {\r\n  background: var(--card);\r\n  backdrop-filter: blur(20px) saturate(180%);\r\n  -webkit-backdrop-filter: blur(20px) saturate(180%);\r\n  border-radius: calc(var(--radius) * 1.5);\r\n  border: 1px solid var(--border);\r\n  box-shadow:\r\n    0 10px 30px -5px oklch(0 0 0 / 0.1),\r\n    0 5px 15px -3px oklch(0 0 0 / 0.05);\r\n  padding: 1.5rem;\r\n  color: var(--card-foreground);\r\n}\r\n\r\n/* 弹窗/模态框中的卡片 - 不使用 backdrop-filter 以避免透明问题 */\r\n.card-solid {\r\n  background-color: var(--card);\r\n  border-radius: calc(var(--radius) * 1.5);\r\n  border: 1px solid var(--border);\r\n  box-shadow:\r\n    0 10px 30px -5px oklch(0 0 0 / 0.1),\r\n    0 5px 15px -3px oklch(0 0 0 / 0.05);\r\n  padding: 1.5rem;\r\n  color: var(--card-foreground);\r\n}\r\n\r\n[data-theme='dark'] .card {\r\n  box-shadow:\r\n    0 10px 30px -5px oklch(0 0 0 / 0.3),\r\n    0 5px 15px -3px oklch(0 0 0 / 0.2);\r\n}\r\n\r\n[data-theme='dark'] .card-solid {\r\n  box-shadow:\r\n    0 10px 30px -5px oklch(0 0 0 / 0.3),\r\n    0 5px 15px -3px oklch(0 0 0 / 0.2);\r\n}\r\n\r\n/* 复选框样式 */\r\n.checkbox {\r\n  width: 1.25rem;\r\n  height: 1.25rem;\r\n  border-radius: 0.375rem;\r\n  border: 2px solid var(--border);\r\n  transition: all 0.2s ease;\r\n  cursor: pointer;\r\n}\r\n\r\n.checkbox:checked {\r\n  background-color: var(--primary);\r\n  border-color: var(--primary);\r\n}\r\n\r\n/* 标签样式 */\r\n.tag {\r\n  display: inline-flex;\r\n  align-items: center;\r\n  padding: 0.375rem 0.75rem;\r\n  border-radius: 0.75rem;\r\n  font-size: 0.875rem;\r\n  font-weight: 500;\r\n  background: oklch(from var(--primary) l c h / 0.1);\r\n  color: var(--primary);\r\n  border: 1px solid oklch(from var(--primary) l c h / 0.2);\r\n  transition: all 0.2s ease;\r\n}\r\n\r\n.tag:hover {\r\n  background: oklch(from var(--primary) l c h / 0.15);\r\n  border-color: oklch(from var(--primary) l c h / 0.3);\r\n  transform: translateY(-1px);\r\n}\r\n\r\n/* 跑马灯动画 - 四个方向 */\r\n@keyframes tag-marquee-move-right {\r\n  0% {\r\n    background-position: 0px 0px;\r\n  }\r\n  100% {\r\n    background-position: 12px 0px;\r\n  }\r\n}\r\n\r\n@keyframes tag-marquee-move-down {\r\n  0% {\r\n    background-position: 0px 0px;\r\n  }\r\n  100% {\r\n    background-position: 0px 12px;\r\n  }\r\n}\r\n\r\n@keyframes tag-marquee-move-left {\r\n  0% {\r\n    background-position: 0px 0px;\r\n  }\r\n  100% {\r\n    background-position: -12px 0px;\r\n  }\r\n}\r\n\r\n@keyframes tag-marquee-move-up {\r\n  0% {\r\n    background-position: 0px 0px;\r\n  }\r\n  100% {\r\n    background-position: 0px -12px;\r\n  }\r\n}\r\n\r\n/* 动画 */\r\n@keyframes fadeIn {\r\n  from {\r\n    opacity: 0;\r\n    transform: translateY(10px);\r\n  }\r\n  to {\r\n    opacity: 1;\r\n    transform: translateY(0);\r\n  }\r\n}\r\n\r\n@keyframes bookmark-dot-bounce {\r\n  0%, 80%, 100% {\r\n    transform: translateY(0) scale(1);\r\n    opacity: 0.5;\r\n  }\r\n  40% {\r\n    transform: translateY(-8px) scale(1.2);\r\n    opacity: 1;\r\n  }\r\n}\r\n\r\n.animate-fade-in {\r\n  animation: fadeIn 0.4s ease-out;\r\n}\r\n\r\n@keyframes scaleIn {\r\n  from {\r\n    opacity: 0;\r\n    transform: scale(0.95);\r\n  }\r\n  to {\r\n    opacity: 1;\r\n    transform: scale(1);\r\n  }\r\n}\r\n\r\n.animate-scale-in {\r\n  animation: scaleIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);\r\n}\r\n\r\n@keyframes slideIn {\r\n  from {\r\n    opacity: 0;\r\n    transform: translateX(100%);\r\n  }\r\n  to {\r\n    opacity: 1;\r\n    transform: translateX(0);\r\n  }\r\n}\r\n\r\n.animate-slide-in {\r\n  animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);\r\n}\r\n\r\n/* 模态框样式 */\r\n.modal {\r\n  position: fixed;\r\n  top: 50%;\r\n  left: 50%;\r\n  transform: translate(-50%, -50%);\r\n  z-index: 200; /* 使用统一的模态框层级 */\r\n  padding: 1rem;\r\n  background: transparent;\r\n  border: none;\r\n  max-width: 100vw;\r\n  max-height: 100vh;\r\n  margin: 0;\r\n}\r\n\r\n.modal::backdrop {\r\n  background: oklch(0 0 0 / 0.5);\r\n  backdrop-filter: blur(4px);\r\n}\r\n\r\n[data-theme='dark'] .modal::backdrop {\r\n  background: oklch(0 0 0 / 0.7);\r\n}\r\n\r\n.modal-box {\r\n  position: relative;\r\n  background: var(--card);\r\n  border-radius: calc(var(--radius) * 2);\r\n  padding: 2.5rem 2rem 2rem;\r\n  box-shadow:\r\n    0 25px 50px -12px oklch(0 0 0 / 0.25),\r\n    0 10px 20px -5px oklch(0 0 0 / 0.1);\r\n  border: 1px solid var(--border);\r\n  max-width: 28rem;\r\n  width: 100%;\r\n  animation: modalFadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);\r\n}\r\n\r\n[data-theme='dark'] .modal-box {\r\n  box-shadow:\r\n    0 25px 50px -12px oklch(0 0 0 / 0.5),\r\n    0 10px 20px -5px oklch(0 0 0 / 0.3);\r\n}\r\n\r\n@keyframes modalFadeIn {\r\n  from {\r\n    opacity: 0;\r\n    transform: scale(0.95);\r\n  }\r\n  to {\r\n    opacity: 1;\r\n    transform: scale(1);\r\n  }\r\n}\r\n\r\n.modal-backdrop {\r\n  position: absolute;\r\n  inset: 0;\r\n  cursor: pointer;\r\n}\r\n\r\n.modal-action {\r\n  display: flex;\r\n  gap: 0.75rem;\r\n  justify-content: flex-end;\r\n  margin-top: 1.5rem;\r\n}\r\n\r\n/* 隐藏滚动条但保持滚动功能 */\r\n::-webkit-scrollbar {\r\n  width: 0px;\r\n  height: 0px;\r\n  background: transparent;\r\n}\r\n\r\n::-webkit-scrollbar-track {\r\n  background: transparent;\r\n}\r\n\r\n::-webkit-scrollbar-thumb {\r\n  background: transparent;\r\n}\r\n\r\n::-webkit-scrollbar-thumb:hover {\r\n  background: transparent;\r\n}\r\n\r\n/* Firefox 隐藏滚动条 */\r\n* {\r\n  scrollbar-width: none;\r\n}\r\n\r\n/* 通用隐藏滚动条类 */\r\n.scrollbar-hide {\r\n  -ms-overflow-style: none;\r\n  scrollbar-width: none;\r\n}\r\n\r\n.scrollbar-hide::-webkit-scrollbar {\r\n  display: none;\r\n}\r\n\r\n/* 主题滚动条（用于局部需要显示滚动条的容器） */\r\n.scrollbar-theme {\r\n  scrollbar-width: thin;\r\n  scrollbar-color: var(--border) transparent;\r\n}\r\n\r\n.scrollbar-theme::-webkit-scrollbar {\r\n  width: 8px;\r\n  height: 8px;\r\n}\r\n\r\n.scrollbar-theme::-webkit-scrollbar-track {\r\n  background: transparent;\r\n}\r\n\r\n.scrollbar-theme::-webkit-scrollbar-thumb {\r\n  background-color: var(--border);\r\n  border-radius: 9999px;\r\n  border: 2px solid transparent;\r\n  background-clip: padding-box;\r\n}\r\n\r\n.scrollbar-theme::-webkit-scrollbar-thumb:hover {\r\n  background-color: var(--muted-foreground);\r\n}\r\n"
  },
  {
    "path": "tmarks/src/styles/index.css",
    "content": "@import 'tailwindcss';\r\n\r\n/* 导入所有颜色主题 */\r\n@import './themes/default.css';\r\n@import './themes/orange.css';\r\n\r\n/* 导入组件样式 */\r\n@import './components.css';\r\n\r\n/* 移动端安全区域支持 */\r\n@supports (padding: max(0px)) {\r\n  .safe-area-bottom {\r\n    padding-bottom: max(env(safe-area-inset-bottom), 0px);\r\n  }\r\n\r\n  .safe-area-top {\r\n    padding-top: max(env(safe-area-inset-top), 0px);\r\n  }\r\n\r\n  .safe-area-left {\r\n    padding-left: max(env(safe-area-inset-left), 0px);\r\n  }\r\n\r\n  .safe-area-right {\r\n    padding-right: max(env(safe-area-inset-right), 0px);\r\n  }\r\n}\r\n\r\n\r\n/* ========================================\r\n   性能优化：CSS Containment\r\n   ======================================== */\r\n\r\n/* 书签卡片性能优化 */\r\n.bookmark-card,\r\n[class*=\"bookmark-card\"] {\r\n  contain: layout style paint;\r\n  will-change: auto;\r\n}\r\n\r\n/* 标签项性能优化 */\r\n.tag-item,\r\n[class*=\"tag-item\"] {\r\n  contain: layout style paint;\r\n}\r\n\r\n/* 列表项性能优化 */\r\n.bookmark-list-item {\r\n  contain: layout style paint;\r\n}\r\n\r\n/* 减少重绘 */\r\n.card {\r\n  transform: translateZ(0);\r\n  backface-visibility: hidden;\r\n}\r\n"
  },
  {
    "path": "tmarks/src/styles/themes/default.css",
    "content": "/* 默认主题 - Default Theme */\r\n:root[data-color-theme='default'],\r\n:root:not([data-color-theme]) {\r\n  --radius: 0.65rem;\r\n  --background: oklch(1 0 0);\r\n  --foreground: oklch(0.145 0 0);\r\n  --card: oklch(1 0 0);\r\n  --card-foreground: oklch(0.145 0 0);\r\n  --popover: oklch(1 0 0);\r\n  --popover-foreground: oklch(0.145 0 0);\r\n  --primary: oklch(0.205 0 0);\r\n  --primary-foreground: oklch(0.985 0 0);\r\n  --secondary: oklch(0.97 0 0);\r\n  --secondary-foreground: oklch(0.205 0 0);\r\n  --muted: oklch(0.97 0 0);\r\n  --muted-foreground: oklch(0.556 0 0);\r\n  --accent: oklch(0.97 0 0);\r\n  --accent-foreground: oklch(0.205 0 0);\r\n  --destructive: oklch(0.577 0.245 27.325);\r\n  --destructive-foreground: oklch(0.985 0 0);\r\n  --success: oklch(0.6 0.15 145);\r\n  --success-foreground: oklch(0.985 0 0);\r\n  --warning: oklch(0.75 0.15 85);\r\n  --warning-foreground: oklch(0.145 0 0);\r\n  --border: oklch(0.922 0 0);\r\n  --input: oklch(0.922 0 0);\r\n  --ring: oklch(0.708 0 0);\r\n  --chart-1: oklch(0.646 0.222 41.116);\r\n  --chart-2: oklch(0.6 0.118 184.704);\r\n  --chart-3: oklch(0.398 0.07 227.392);\r\n  --chart-4: oklch(0.828 0.189 84.429);\r\n  --chart-5: oklch(0.769 0.188 70.08);\r\n  --sidebar: oklch(0.985 0 0);\r\n  --sidebar-foreground: oklch(0.145 0 0);\r\n  --sidebar-primary: oklch(0.205 0 0);\r\n  --sidebar-primary-foreground: oklch(0.985 0 0);\r\n  --sidebar-accent: oklch(0.97 0 0);\r\n  --sidebar-accent-foreground: oklch(0.205 0 0);\r\n  --sidebar-border: oklch(0.922 0 0);\r\n  --sidebar-ring: oklch(0.708 0 0);\r\n}\r\n\r\n:root[data-color-theme='default'][data-theme='dark'],\r\n:root:not([data-color-theme])[data-theme='dark'] {\r\n  --background: oklch(0.145 0 0);\r\n  --foreground: oklch(0.985 0 0);\r\n  --card: oklch(0.22 0 0);\r\n  --card-foreground: oklch(0.985 0 0);\r\n  --popover: oklch(0.22 0 0);\r\n  --popover-foreground: oklch(0.985 0 0);\r\n  --primary: oklch(0.922 0 0);\r\n  --primary-foreground: oklch(0.205 0 0);\r\n  --secondary: oklch(0.28 0 0);\r\n  --secondary-foreground: oklch(0.985 0 0);\r\n  --muted: oklch(0.28 0 0);\r\n  --muted-foreground: oklch(0.75 0 0);\r\n  --accent: oklch(0.28 0 0);\r\n  --accent-foreground: oklch(0.985 0 0);\r\n  --destructive: oklch(0.704 0.191 22.216);\r\n  --destructive-foreground: oklch(0.985 0 0);\r\n  --success: oklch(0.65 0.18 145);\r\n  --success-foreground: oklch(0.985 0 0);\r\n  --warning: oklch(0.75 0.18 85);\r\n  --warning-foreground: oklch(0.985 0 0);\r\n  --border: oklch(1 0 0 / 10%);\r\n  --input: oklch(1 0 0 / 15%);\r\n  --ring: oklch(0.556 0 0);\r\n  --chart-1: oklch(0.488 0.243 264.376);\r\n  --chart-2: oklch(0.696 0.17 162.48);\r\n  --chart-3: oklch(0.769 0.188 70.08);\r\n  --chart-4: oklch(0.627 0.265 303.9);\r\n  --chart-5: oklch(0.645 0.246 16.439);\r\n  --sidebar: oklch(0.205 0 0);\r\n  --sidebar-foreground: oklch(0.985 0 0);\r\n  --sidebar-primary: oklch(0.488 0.243 264.376);\r\n  --sidebar-primary-foreground: oklch(0.985 0 0);\r\n  --sidebar-accent: oklch(0.269 0 0);\r\n  --sidebar-accent-foreground: oklch(0.985 0 0);\r\n  --sidebar-border: oklch(1 0 0 / 10%);\r\n  --sidebar-ring: oklch(0.556 0 0);\r\n}\r\n"
  },
  {
    "path": "tmarks/src/styles/themes/orange.css",
    "content": "/* 橙色主题 - Orange Theme */\n[data-color-theme='orange'] {\n  --background: oklch(0.9818 0.0054 95.0986);\n  --foreground: oklch(0.3438 0.0269 95.7226);\n  --card: oklch(0.9818 0.0054 95.0986);\n  --card-foreground: oklch(0.1908 0.0020 106.5859);\n  --popover: oklch(1.0000 0 0);\n  --popover-foreground: oklch(0.2671 0.0196 98.9390);\n  --primary: oklch(0.6171 0.1375 39.0427);\n  --primary-foreground: oklch(1.0000 0 0);\n  --secondary: oklch(0.9245 0.0138 92.9892);\n  --secondary-foreground: oklch(0.4334 0.0177 98.6048);\n  --muted: oklch(0.9341 0.0153 90.2390);\n  --muted-foreground: oklch(0.6059 0.0075 97.4233);\n  --accent: oklch(0.9245 0.0138 92.9892);\n  --accent-foreground: oklch(0.2671 0.0196 98.9390);\n  --destructive: oklch(0.1908 0.0020 106.5859);\n  --destructive-foreground: oklch(1.0000 0 0);\n  --border: oklch(0.8847 0.0069 97.3627);\n  --input: oklch(0.7621 0.0156 98.3528);\n  --ring: oklch(0.6171 0.1375 39.0427);\n  --chart-1: oklch(0.5583 0.1276 42.9956);\n  --chart-2: oklch(0.6898 0.1581 290.4107);\n  --chart-3: oklch(0.8816 0.0276 93.1280);\n  --chart-4: oklch(0.8822 0.0403 298.1792);\n  --chart-5: oklch(0.5608 0.1348 42.0584);\n  --sidebar: oklch(0.9663 0.0080 98.8792);\n  --sidebar-foreground: oklch(0.3590 0.0051 106.6524);\n  --sidebar-primary: oklch(0.6171 0.1375 39.0427);\n  --sidebar-primary-foreground: oklch(0.9881 0 0);\n  --sidebar-accent: oklch(0.9245 0.0138 92.9892);\n  --sidebar-accent-foreground: oklch(0.3250 0 0);\n  --sidebar-border: oklch(0.9401 0 0);\n  --sidebar-ring: oklch(0.7731 0 0);\n  --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';\n  --font-serif: ui-serif, Georgia, Cambria, \"Times New Roman\", Times, serif;\n  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n  --radius: 0.5rem;\n  --shadow-x: 0;\n  --shadow-y: 1px;\n  --shadow-blur: 3px;\n  --shadow-spread: 0px;\n  --shadow-opacity: 0.1;\n  --shadow-color: oklch(0 0 0);\n  --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);\n  --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);\n  --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);\n  --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);\n  --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);\n  --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);\n  --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);\n  --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);\n  --tracking-normal: 0em;\n  --spacing: 0.25rem;\n}\n\n[data-color-theme='orange'][data-theme='dark'] {\n  --background: oklch(0.2679 0.0036 106.6427);\n  --foreground: oklch(0.8074 0.0142 93.0137);\n  --card: oklch(0.2679 0.0036 106.6427);\n  --card-foreground: oklch(0.9818 0.0054 95.0986);\n  --popover: oklch(0.3085 0.0035 106.6039);\n  --popover-foreground: oklch(0.9211 0.0040 106.4781);\n  --primary: oklch(0.6724 0.1308 38.7559);\n  --primary-foreground: oklch(1.0000 0 0);\n  --secondary: oklch(0.9818 0.0054 95.0986);\n  --secondary-foreground: oklch(0.3085 0.0035 106.6039);\n  --muted: oklch(0.2213 0.0038 106.7070);\n  --muted-foreground: oklch(0.7713 0.0169 99.0657);\n  --accent: oklch(0.2130 0.0078 95.4245);\n  --accent-foreground: oklch(0.9663 0.0080 98.8792);\n  --destructive: oklch(0.6368 0.2078 25.3313);\n  --destructive-foreground: oklch(1.0000 0 0);\n  --border: oklch(0.3618 0.0101 106.8928);\n  --input: oklch(0.4336 0.0113 100.2195);\n  --ring: oklch(0.6724 0.1308 38.7559);\n  --chart-1: oklch(0.5583 0.1276 42.9956);\n  --chart-2: oklch(0.6898 0.1581 290.4107);\n  --chart-3: oklch(0.2130 0.0078 95.4245);\n  --chart-4: oklch(0.3074 0.0516 289.3230);\n  --chart-5: oklch(0.5608 0.1348 42.0584);\n  --sidebar: oklch(0.2357 0.0024 67.7077);\n  --sidebar-foreground: oklch(0.8074 0.0142 93.0137);\n  --sidebar-primary: oklch(0.3250 0 0);\n  --sidebar-primary-foreground: oklch(0.9881 0 0);\n  --sidebar-accent: oklch(0.1680 0.0020 106.6177);\n  --sidebar-accent-foreground: oklch(0.8074 0.0142 93.0137);\n  --sidebar-border: oklch(0.9401 0 0);\n  --sidebar-ring: oklch(0.7731 0 0);\n  --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';\n  --font-serif: ui-serif, Georgia, Cambria, \"Times New Roman\", Times, serif;\n  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n  --radius: 0.5rem;\n  --shadow-x: 0;\n  --shadow-y: 1px;\n  --shadow-blur: 3px;\n  --shadow-spread: 0px;\n  --shadow-opacity: 0.1;\n  --shadow-color: oklch(0 0 0);\n  --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);\n  --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);\n  --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);\n  --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);\n  --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);\n  --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);\n  --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);\n  --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);\n}\n"
  },
  {
    "path": "tmarks/src/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n"
  },
  {
    "path": "tmarks/tailwind.config.js",
    "content": "/** @type {import('tailwindcss').Config} */\nexport default {\n  content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],\n  theme: {\n    extend: {\n      colors: {\n        // 基础颜色\n        background: 'var(--background)',\n        foreground: 'var(--foreground)',\n\n        // 卡片\n        card: 'var(--card)',\n        'card-foreground': 'var(--card-foreground)',\n\n        // 弹出层\n        popover: 'var(--popover)',\n        'popover-foreground': 'var(--popover-foreground)',\n\n        // 主色调\n        primary: 'var(--primary)',\n        'primary-foreground': 'var(--primary-foreground)',\n\n        // 次要色\n        secondary: 'var(--secondary)',\n        'secondary-foreground': 'var(--secondary-foreground)',\n\n        // 静音色\n        muted: 'var(--muted)',\n        'muted-foreground': 'var(--muted-foreground)',\n\n        // 强调色\n        accent: 'var(--accent)',\n        'accent-foreground': 'var(--accent-foreground)',\n\n        // 成功色\n        success: 'var(--success)',\n        'success-foreground': 'var(--success-foreground)',\n\n        // 错误/危险色\n        destructive: 'var(--destructive)',\n        'destructive-foreground': 'var(--destructive-foreground)',\n        error: 'var(--destructive)',\n        'error-content': 'var(--destructive-foreground)',\n\n        // 警告色\n        warning: 'var(--warning)',\n        'warning-foreground': 'var(--warning-foreground)',\n        'warning-content': 'var(--warning-foreground)',\n\n        // 边框和输入框\n        border: 'var(--border)',\n        input: 'var(--input)',\n        ring: 'var(--ring)',\n      },\n      borderRadius: {\n        DEFAULT: 'var(--radius)',\n        lg: `calc(var(--radius) + 4px)`,\n        md: `calc(var(--radius) + 2px)`,\n        sm: 'calc(var(--radius) - 2px)',\n      },\n    },\n  },\n  plugins: [],\n}\n"
  },
  {
    "path": "tmarks/test-register.js",
    "content": "// 测试注册API的简单脚本\r\nconst testRegister = async () => {\r\n  try {\r\n    const response = await fetch('https://b75ecc2c.tmarks-45l.pages.dev/api/v1/auth/register', {\r\n      method: 'POST',\r\n      headers: {\r\n        'Content-Type': 'application/json',\r\n      },\r\n      body: JSON.stringify({\r\n        username: 'testuser123',\r\n        password: 'testpassword123',\r\n        email: 'test@example.com'\r\n      })\r\n    });\r\n\r\n    console.log('Status:', response.status);\r\n    console.log('Headers:', Object.fromEntries(response.headers.entries()));\r\n    \r\n    const text = await response.text();\r\n    console.log('Response:', text);\r\n    \r\n    if (response.ok) {\r\n      console.log('✅ 注册成功');\r\n    } else {\r\n      console.log('❌ 注册失败');\r\n    }\r\n  } catch (error) {\r\n    console.error('请求错误:', error);\r\n  }\r\n};\r\n\r\ntestRegister();\r\n"
  },
  {
    "path": "tmarks/tsconfig.json",
    "content": "{\r\n  \"compilerOptions\": {\r\n    \"target\": \"ES2020\",\r\n    \"useDefineForClassFields\": true,\r\n    \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\r\n    \"module\": \"ESNext\",\r\n    \"skipLibCheck\": true,\r\n\r\n    /* Bundler mode */\r\n    \"moduleResolution\": \"bundler\",\r\n    \"allowImportingTsExtensions\": true,\r\n    \"isolatedModules\": true,\r\n    \"moduleDetection\": \"force\",\r\n    \"noEmit\": true,\r\n    \"jsx\": \"react-jsx\",\r\n\r\n    /* Linting */\r\n    \"strict\": true,\r\n    \"noUnusedLocals\": true,\r\n    \"noUnusedParameters\": true,\r\n    \"noFallthroughCasesInSwitch\": true,\r\n    \"noUncheckedIndexedAccess\": true,\r\n\r\n    /* Path mapping */\r\n    \"baseUrl\": \".\",\r\n    \"paths\": {\r\n      \"@/*\": [\"./src/*\"],\r\n      \"@shared/*\": [\"./shared/*\"]\r\n    }\r\n  },\r\n  \"include\": [\"src\", \"shared\"]\r\n}\r\n"
  },
  {
    "path": "tmarks/vite.config.ts",
    "content": "import { defineConfig, loadEnv } from 'vite'\nimport react from '@vitejs/plugin-react-swc'\nimport path from 'path'\nimport { fileURLToPath } from 'node:url'\nimport viteCompression from 'vite-plugin-compression'\n// import { visualizer } from 'rollup-plugin-visualizer'\n// import JavaScriptObfuscator from 'javascript-obfuscator'\n\nconst __dirname = path.dirname(fileURLToPath(import.meta.url))\n\r\n// 自定义代码混淆插件\r\n// function obfuscatorPlugin(): Plugin {\r\n//   return {\r\n//     name: 'vite-plugin-obfuscator-custom',\r\n//     enforce: 'post',\r\n//     apply: 'build',\r\n//     generateBundle(options, bundle) {\r\n//       for (const key in bundle) {\r\n//         const chunk = bundle[key]\r\n//         if (chunk.type === 'chunk' && chunk.fileName.endsWith('.js')) {\r\n//           // 混淆代码\r\n//           const obfuscationResult = JavaScriptObfuscator.obfuscate(chunk.code, {\r\n//             // 压缩代码\r\n//             compact: true,\r\n//             // 控制流平坦化（破坏程序结构）\r\n//             controlFlowFlattening: true,\r\n//             controlFlowFlatteningThreshold: 0.75,\r\n//             // 死代码注入\r\n//             deadCodeInjection: true,\r\n//             deadCodeInjectionThreshold: 0.4,\r\n//             // 调试保护（防止使用开发者工具）\r\n//             debugProtection: false,\r\n//             debugProtectionInterval: 0,\r\n//             // 禁用控制台输出\r\n//             disableConsoleOutput: true,\r\n//             // 标识符名称生成器\r\n//             identifierNamesGenerator: 'hexadecimal',\r\n//             log: false,\r\n//             // 标识符前缀\r\n//             identifiersPrefix: '',\r\n//             // 重命名全局变量\r\n//             renameGlobals: false,\r\n//             // 自我防护\r\n//             selfDefending: true,\r\n//             // 字符串数组编码\r\n//             stringArray: true,\r\n//             stringArrayCallsTransform: true,\r\n//             stringArrayCallsTransformThreshold: 0.75,\r\n//             stringArrayEncoding: ['base64'],\r\n//             stringArrayIndexShift: true,\r\n//             stringArrayRotate: true,\r\n//             stringArrayShuffle: true,\r\n//             stringArrayWrappersCount: 2,\r\n//             stringArrayWrappersChainedCalls: true,\r\n//             stringArrayWrappersParametersMaxCount: 4,\r\n//             stringArrayWrappersType: 'function',\r\n//             stringArrayThreshold: 0.75,\r\n//             // 转换对象键\r\n//             transformObjectKeys: true,\r\n//             // Unicode转义序列\r\n//             unicodeEscapeSequence: false,\r\n//           })\r\n\r\n//           chunk.code = obfuscationResult.getObfuscatedCode()\r\n//         }\r\n//       }\r\n//     },\r\n//   }\r\n// }\r\n\r\n// https://vitejs.dev/config/\r\nexport default defineConfig(({ mode }) => {\r\n  const isProduction = mode === 'production'\r\n  // const enableObfuscation = isProduction && env.VITE_ENABLE_OBFUSCATION === 'true'\r\n\r\n  return {\r\n    plugins: [\r\n      react(),\r\n      // 生产环境启用压缩\r\n      isProduction && viteCompression({\r\n        verbose: true,\r\n        disable: false,\r\n        threshold: 10240,\r\n        algorithm: 'gzip',\r\n        ext: '.gz',\r\n      }),\r\n      isProduction && viteCompression({\r\n        verbose: true,\r\n        disable: false,\r\n        threshold: 10240,\r\n        algorithm: 'brotliCompress',\r\n        ext: '.br',\r\n      }),\r\n      // 可选插件配置（需要时取消注释）:\r\n      // - 代码混淆: enableObfuscation && obfuscatorPlugin()\r\n      // - 构建分析: visualizer({ filename: './dist/stats.html' })\r\n    ].filter(Boolean),\r\n    resolve: {\r\n      alias: {\r\n        '@': path.resolve(__dirname, './src'),\r\n        '@shared': path.resolve(__dirname, './shared'),\r\n      },\r\n    },\r\n    server: {\r\n      port: 5173,\r\n      proxy: {\r\n        '/api': {\r\n          target: 'http://localhost:8787',\r\n          changeOrigin: true,\r\n        },\r\n      },\r\n    },\r\n    build: {\r\n      // 生产环境优化\r\n      minify: 'terser',\r\n      terserOptions: {\r\n        compress: {\r\n          // 删除 console\r\n          drop_console: true,\r\n          // 删除 debugger\r\n          drop_debugger: true,\r\n          // 移除未使用的代码\r\n          pure_funcs: ['console.log', 'console.info', 'console.debug', 'console.warn'],\r\n        },\r\n        format: {\r\n          // 删除注释\r\n          comments: false,\r\n        },\r\n        mangle: {\r\n          // 混淆变量名\r\n          toplevel: true,\r\n        },\r\n      },\r\n      // 分块策略\r\n      rollupOptions: {\r\n        output: {\r\n          // 手动分块\r\n          manualChunks: {\r\n            'react-vendor': ['react', 'react-dom', 'react-router-dom'],\r\n            'query-vendor': ['@tanstack/react-query', '@tanstack/react-virtual'],\r\n            'utils': ['date-fns', 'zustand'],\r\n          },\r\n          // 文件名混淆\r\n          chunkFileNames: 'assets/[name]-[hash].js',\r\n          entryFileNames: 'assets/[name]-[hash].js',\r\n          assetFileNames: 'assets/[name]-[hash].[ext]',\r\n        },\r\n      },\r\n      // 增加分块大小警告阈值\r\n      chunkSizeWarningLimit: 1000,\r\n      // 启用源码映射（仅用于错误追踪，不暴露源码）\r\n      sourcemap: false,\r\n    },\r\n  }\r\n})\r\n"
  },
  {
    "path": "tmarks/wrangler.toml.example",
    "content": "name = \"tmarks\"\r\ncompatibility_date = \"2024-03-18\"\r\npages_build_output_dir = \".deploy\"\r\n\r\n# ⚠️ 重要说明：\r\n# D1、KV 和 R2 资源绑定请在 Cloudflare Pages Dashboard 中配置\r\n# 路径：项目设置 → 函数 → 绑定\r\n#\r\n# 需要绑定的资源：\r\n# - D1 数据库：变量名 DB，选择数据库 tmarks-prod-db\r\n# - KV 命名空间：变量名 TMARKS_KV（用于公开分享缓存等）\r\n# - R2 存储桶：变量名 SNAPSHOTS_BUCKET，选择存储桶 tmarks-snapshots（用于网页快照）\r\n#\r\n# 这样配置的好处：\r\n# 1. 不需要在代码中暴露资源 ID\r\n# 2. 拉取更新时不会覆盖你的配置\r\n# 3. 更安全，更灵活\r\n\r\n[vars]\r\nALLOW_REGISTRATION = \"true\"\r\nENVIRONMENT = \"production\"\r\nJWT_ACCESS_TOKEN_EXPIRES_IN = \"365d\"\r\nJWT_REFRESH_TOKEN_EXPIRES_IN = \"365d\"\r\n\r\n# R2 相关配置（用于网页快照 & 图片存储）\r\n# 在本地开发时可以直接在这里填写；生产环境建议在 Dashboard → 环境变量 中配置真实值\r\n# R2_PUBLIC_URL：可选，封面图使用 R2 存储时的对外访问域名（用于生成封面图 URL）\r\n# 例如：https://tmarks-snapshots.example.com 或 https://<account-id>.r2.cloudflarestorage.com/tmarks-snapshots\r\n# R2_MAX_TOTAL_BYTES：R2 总存储上限（字节），可选；不配置或 <= 0 表示不限制（如需限制可手动设置，例如 7GiB）\r\n# R2_PUBLIC_URL = \"https://tmarks-snapshots.example.com\"\r\n# R2_MAX_TOTAL_BYTES = \"7516192768\"\r\n\r\n# ⚠️ 敏感环境变量请在 Dashboard 中配置：\r\n# 路径：项目设置 → 环境变量 → 生产环境\r\n# - JWT_SECRET\r\n# - ENCRYPTION_KEY\r\n#\r\n# 示例（仅供参考，请不要在仓库中提交真实密钥）：\r\nJWT_SECRET = \"your-long-random-jwt-secret-at-least-48-characters\"\r\nENCRYPTION_KEY = \"your-long-random-encryption-key-at-least-48-characters\""
  }
]