From 7f30dabfa072706b72d5c9c52bbc400868d4e66f Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:34:40 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .github/ISSUE_TEMPLATE/01-bug-report.yaml | 90 + .../ISSUE_TEMPLATE/02-feature-request.yaml | 68 + .../ISSUE_TEMPLATE/03-general-feedback.yaml | 53 + .github/workflows/release.yaml | 29 + .gitignore | 4 + .node-version | 1 + .vscode/settings.json | 3 + CLAUDE.md | 137 ++ README.wehub.md | 7 + bump.config.ts | 26 + global.d.ts | 1 + license | 21 + netlify.toml | 3 + package.json | 67 + plan.md | 65 + pnpm-lock.yaml | 1735 ++++++++++++++ readme-en.md | 214 ++ readme.md | 219 ++ src/css/animations.css | 125 ++ src/css/base.css | 49 + src/css/components.css | 1985 +++++++++++++++++ src/css/layout.css | 102 + src/css/reset.css | 57 + src/css/tokens.css | 75 + src/css/utilities.css | 30 + src/favicon.ico | Bin 0 -> 9423 bytes src/index.html | 1532 +++++++++++++ src/js/app.js | 854 +++++++ src/js/config-manager.js | 92 + src/js/constants.js | 22 + src/js/file-explorer.js | 406 ++++ src/js/file-operations.js | 541 +++++ src/js/file-preview.js | 202 ++ src/js/i18n.js | 1063 +++++++++ src/js/r2-client.js | 220 ++ src/js/ui-manager.js | 689 ++++++ src/js/upload-manager.js | 537 +++++ src/js/utils.js | 237 ++ src/main.js | 4 + src/manifest.json | 37 + src/r2-web.png | Bin 0 -> 8061 bytes src/style.css | 40 + tsconfig.json | 13 + vercel.json | 4 + wrangler.jsonc | 7 + 45 files changed, 11666 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/01-bug-report.yaml create mode 100644 .github/ISSUE_TEMPLATE/02-feature-request.yaml create mode 100644 .github/ISSUE_TEMPLATE/03-general-feedback.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 .node-version create mode 100644 .vscode/settings.json create mode 100644 CLAUDE.md create mode 100644 README.wehub.md create mode 100644 bump.config.ts create mode 100644 global.d.ts create mode 100644 license create mode 100644 netlify.toml create mode 100644 package.json create mode 100644 plan.md create mode 100644 pnpm-lock.yaml create mode 100644 readme-en.md create mode 100644 readme.md create mode 100644 src/css/animations.css create mode 100644 src/css/base.css create mode 100644 src/css/components.css create mode 100644 src/css/layout.css create mode 100644 src/css/reset.css create mode 100644 src/css/tokens.css create mode 100644 src/css/utilities.css create mode 100644 src/favicon.ico create mode 100644 src/index.html create mode 100644 src/js/app.js create mode 100644 src/js/config-manager.js create mode 100644 src/js/constants.js create mode 100644 src/js/file-explorer.js create mode 100644 src/js/file-operations.js create mode 100644 src/js/file-preview.js create mode 100644 src/js/i18n.js create mode 100644 src/js/r2-client.js create mode 100644 src/js/ui-manager.js create mode 100644 src/js/upload-manager.js create mode 100644 src/js/utils.js create mode 100644 src/main.js create mode 100644 src/manifest.json create mode 100644 src/r2-web.png create mode 100644 src/style.css create mode 100644 tsconfig.json create mode 100644 vercel.json create mode 100644 wrangler.jsonc diff --git a/.github/ISSUE_TEMPLATE/01-bug-report.yaml b/.github/ISSUE_TEMPLATE/01-bug-report.yaml new file mode 100644 index 0000000..b47dc2a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bug-report.yaml @@ -0,0 +1,90 @@ +name: '🐛 问题反馈' +description: '报告项目中遇到的问题或错误' +title: '【问题反馈】' +labels: ['bug'] +assignees: [] +body: + - type: markdown + attributes: + value: | + 感谢您的反馈!请详细描述您遇到的问题,这将帮助我们更快定位和解决问题。 + + - type: textarea + id: description + attributes: + label: '问题描述' + description: '请详细描述您遇到的问题' + placeholder: '请详细描述问题的现象和影响...' + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: '复现步骤(可选)' + description: '请提供复现问题的具体步骤' + placeholder: | + 1. 访问... + 2. 点击... + 3. 输入... + 4. 看到问题... + validations: + required: false + + - type: textarea + id: expected + attributes: + label: '期望行为(可选)' + description: '描述您期望的正常行为' + placeholder: '期望应该...' + validations: + required: false + + - type: input + id: version + attributes: + label: '版本信息(可选)' + description: '可以在设置页面的关于部分找到版本信息' + placeholder: '例如:v1.4.0' + validations: + required: false + + - type: textarea + id: logs + attributes: + label: '错误日志(可选)' + description: '如有错误日志,请粘贴在此' + render: shell + validations: + required: false + + - type: textarea + id: environment + attributes: + label: '环境信息(可选)' + description: '请提供相关的环境信息' + placeholder: | + - 操作系统: + - 浏览器/客户端: + - 其他相关信息: + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: '确认' + description: '请确认您已阅读相关内容' + options: + - label: '我已搜索过现有的 issue,确认没有重复' + required: true + - label: '我理解这是一个开源项目,维护者会根据情况处理反馈' + required: true + + - type: textarea + id: additional-info + attributes: + label: '其他信息(可选)' + description: '任何其他您认为有用的信息,如相关链接、截图说明等' + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/02-feature-request.yaml b/.github/ISSUE_TEMPLATE/02-feature-request.yaml new file mode 100644 index 0000000..f6a8a00 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature-request.yaml @@ -0,0 +1,68 @@ +name: '✨ 功能建议' +description: '提出新功能或改进现有功能的建议' +title: '【功能建议】' +labels: ['enhancement'] +assignees: [] +body: + - type: markdown + attributes: + value: | + 感谢您的功能建议!请尽可能详细地描述您的想法,帮助我们更好地理解需求。 + + - type: textarea + id: feature-description + attributes: + label: '功能描述' + description: '请清晰描述您希望添加的功能' + placeholder: '我希望能够...' + validations: + required: true + + - type: textarea + id: problem + attributes: + label: '解决的问题' + description: '这个功能解决了什么问题?或者满足了什么使用需求?' + placeholder: '目前我在使用时遇到了...(没有强制要求,描述背景即可)' + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: '建议的实现方式(可选)' + description: '如果您有具体的实现想法,请描述' + placeholder: '可以通过...方式实现,例如...' + validations: + required: false + + - type: dropdown + id: priority + attributes: + label: '优先级(可选)' + description: '您认为这个功能的优先级如何?' + options: + - '低 - 锦上添花' + - '中 - 比较实用' + - '高 - 强烈需要' + validations: + required: false + + - type: checkboxes + id: terms + attributes: + label: '确认' + description: '请确认您已阅读相关内容' + options: + - label: '我已搜索过现有的 issue,确认没有重复' + required: true + - label: '我理解这是一个开源项目,维护者会根据情况处理反馈' + required: true + + - type: textarea + id: additional-info + attributes: + label: '其他信息(可选)' + description: '任何其他您认为有用的信息,如相关链接、截图说明等' + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/03-general-feedback.yaml b/.github/ISSUE_TEMPLATE/03-general-feedback.yaml new file mode 100644 index 0000000..7538919 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03-general-feedback.yaml @@ -0,0 +1,53 @@ +name: '💬 其他反馈' +description: '其他类型的反馈、建议或讨论' +title: '【其他反馈】' +labels: ['feedback'] +assignees: [] +body: + - type: markdown + attributes: + value: | + 感谢您的反馈!这里是其他类型反馈的通用模板,包括但不限于:文档问题、使用建议、项目讨论等。 + + - type: dropdown + id: feedback-type + attributes: + label: '反馈类型' + description: '请选择您的反馈类型' + options: + - '使用疑问' + - '文档问题' + - '配置问题' + - '部署相关' + - '项目讨论' + - '其他' + validations: + required: true + + - type: textarea + id: feedback-content + attributes: + label: '反馈内容' + description: '请详细描述您的反馈' + placeholder: '请详细描述您的想法、建议或问题...' + validations: + required: true + + - type: checkboxes + id: terms + attributes: + label: '确认' + description: '请确认您已阅读相关内容' + options: + - label: '我已搜索过现有的 issue,确认没有重复' + required: true + - label: '我理解这是一个开源项目,维护者会根据情况处理反馈' + required: true + + - type: textarea + id: additional-info + attributes: + label: '其他信息(可选)' + description: '任何其他您认为有用的信息,如相关链接、截图说明等' + validations: + required: false diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..b97068c --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,29 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + permissions: + id-token: write + contents: write + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: '.node-version' + registry-url: 'https://registry.npmjs.org' + + - name: Generate changelog + run: npx changelogithub + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d1219c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +node_modules + +issue.md diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..9e2934a --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.11.1 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..1ae8d85 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "cSpell.words": ["Flexbox", "jsquash", "Topbar"] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f974160 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,137 @@ +# CLAUDE.md + +本文档为 Claude Code (claude.ai/code) 提供仓库开发指南。 + +## 项目速览 + +**R2 Web** — 纯客户端 Cloudflare R2 存储桶文件管理器,零构建、零框架、零后端。 + +**核心特性** 文件上传、目录浏览、文件预览、文件操作、图片压缩、PWA、多语言(zh/zh_TW/en/ja)、浅色/深色主题、多选批量操作。 + +**快速启动** + +```bash +npx serve src +# 或 +python3 -m http.server 5500 --directory src +``` + +## 代码地图 + +### 快速定位表 + +| 任务 | 文件 | +| ------------------ | --------------------------------------------------------------------------- | +| 修改文件名模板逻辑 | `src/js/utils.js` — `applyFilenameTemplate` | +| 修改图片压缩逻辑 | `src/js/upload-manager.js` — `compressFile` | +| 添加 i18n 文案 | `src/js/i18n.js` — `const I18N` | +| 修改按钮样式 | `src/css/components.css` — `.btn` | +| 添加设计 Token | `src/css/tokens.css` | +| 修改 R2 API 操作 | `src/js/r2-client.js` | +| 修改文件浏览逻辑 | `src/js/file-explorer.js` | +| 修改上传管理逻辑 | `src/js/upload-manager.js` | +| 多选批量操作逻辑 | `src/js/file-explorer.js` — `toggleSelect` / `selectAll` / `clearSelection` | + +### 工具函数列表 + +以下函数定义在 `src/js/utils.js`,供各模块导入: + +- `t(key, vars)` — i18n 翻译(来自 `src/js/i18n.js`) +- `applyFilenameTemplate(tpl, file)` — 文件名模板处理,占位符:`[name]`、`[ext]`、`[hash:N]`、`[date:FORMAT]`、`[timestamp]`、`[uuid]`、`/` +- `compressFile(file, config, onStatus)` — 图片压缩(定义在 `upload-manager.js`) + - PNG 特殊处理:直接优化缓冲区,不重新编码 + - 自适应逻辑:压缩后更大则使用原文件 + +## 项目结构 + +``` +r2-web/ +├── readme.md — 项目说明、使用指南 +├── package.json — 依赖声明(仅用于类型提示) +├── jsconfig.json — JSDoc 类型检查配置 +└── src/ — 源码目录(即部署目录) + ├── index.html — 应用外壳、import map、对话框模板 + ├── main.js — 入口(仅 new App()) + ├── manifest.json — PWA 配置 + ├── style.css — 样式主入口(仅导入 css 子目录) + ├── js/ — 业务逻辑模块 + │ ├── app.js — 主协调器 + │ ├── config-manager.js — 配置持久化、Base64 分享 + │ ├── r2-client.js — S3 API 客户端 + │ ├── ui-manager.js — 主题、Toast、对话框、Tooltip + │ ├── file-explorer.js — 目录导航、排序、分页、缩略图 + │ ├── upload-manager.js — 上传、文件名模板、图片压缩 + │ ├── file-preview.js — 图片/视频/音频/文本预览 + │ ├── file-operations.js — 重命名、复制、移动、删除 + │ ├── i18n.js — 多语言(zh/zh_TW/en/ja) + │ ├── constants.js — 常量 + │ └── utils.js — 工具函数 + └── css/ — 样式模块(CSS Layers) + ├── reset.css — CSS Reset + ├── tokens.css — 设计 Token(定义所有变量) + ├── base.css — 全局基础样式 + ├── layout.css — 布局容器 + ├── components.css — 通用 UI 组件 + ├── utilities.css — 工具类 + └── animations.css — 动画与过渡 +``` + +## 架构速查 + +### JavaScript 类架构 + +每个类独立为一个模块,位于 `src/js/`: + +| 类 | 文件 | 职责 | +| ---------------- | -------------------- | -------------------------------------------- | +| `ConfigManager` | `config-manager.js` | localStorage 持久化、Base64 配置分享 | +| `R2Client` | `r2-client.js` | S3 API 客户端(基于 `aws4fetch` 签名) | +| `UIManager` | `ui-manager.js` | 主题、Toast、对话框、上下文菜单、Tooltip | +| `FileExplorer` | `file-explorer.js` | 目录导航、排序、分页、懒加载缩略图、列表缓存 | +| `UploadManager` | `upload-manager.js` | 拖拽/粘贴上传、文件名模板、图片压缩 | +| `FilePreview` | `file-preview.js` | 图片/视频/音频/文本预览 | +| `FileOperations` | `file-operations.js` | 重命名、复制、移动、删除(递归删除目录) | +| `App` | `app.js` | 主协调器、i18n 处理 | + +## i18n 速查 + +### 多语言机制 + +- **I18N 对象** `src/js/i18n.js`(zh / zh_TW / en / ja 四语言) +- **翻译函数** `t(key, vars)` 支持变量替换 +- **支持语言** zh(中文)、zh_TW(繁体)、en(英语)、ja(日语) +- **语言切换** `App.updateLanguage()` 自动更新所有文案 + +### 添加新文案 + +1. 在 `src/js/i18n.js` 的 `I18N` 对象添加 zh / zh_TW / en / ja 键值 +2. 代码中使用 `t('key')` 或 `t('key', { var: 'value' })` +3. HTML 元素使用 `data-tooltip-key="key"` 支持动态更新 + +**示例** + +```javascript +// 1. 在 I18N 对象添加 +const I18N = { + zh: { + deleteConfirm: '确定删除 {name} 吗?', + deleteSuccess: '删除成功', + }, + en: { + deleteConfirm: 'Delete {name}?', + deleteSuccess: 'Deleted successfully', + }, + ja: { + deleteConfirm: '{name} を削除しますか?', + deleteSuccess: '削除しました', + }, +} + +// 2. 代码中使用 +const message = t('deleteConfirm', { name: fileName }) +uiManager.toast(t('deleteSuccess'), 'success') + +// 3. Tooltip 使用 +button.dataset.tooltipKey = 'deleteConfirm' +button.dataset.tooltip = t('deleteConfirm') +``` diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..2b29445 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`vikiboss/r2-web` +- 原始仓库:https://github.com/vikiboss/r2-web +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/bump.config.ts b/bump.config.ts new file mode 100644 index 0000000..13bfdb8 --- /dev/null +++ b/bump.config.ts @@ -0,0 +1,26 @@ +/// + +import { defineConfig } from 'bumpp' +import path from 'node:path' +import fs from 'node:fs/promises' + +export default defineConfig({ + all: true, + async execute(config) { + const constantsFile = path.resolve(import.meta.dirname, './src/js/constants.js') + + let constantsString = await fs.readFile(constantsFile, 'utf-8') + + constantsString = constantsString.replace( + /export const VERSION = ['"](.*?)['"];?/g, + `export const VERSION = '${config.results.newVersion}'`, + ) + + constantsString = constantsString.replace( + /export const UPDATED_AT = ['"](.*?)['"];?/g, + `export const UPDATED_AT = '${new Date().toISOString()}'`, + ) + + await fs.writeFile(constantsFile, constantsString) + }, +}) diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..3579e79 --- /dev/null +++ b/global.d.ts @@ -0,0 +1 @@ +declare module 'qrcode' diff --git a/license b/license new file mode 100644 index 0000000..12ba1c0 --- /dev/null +++ b/license @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026-Present Viki + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..3c00a14 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,3 @@ +[build] + base = "./" + publish = "src/" diff --git a/package.json b/package.json new file mode 100644 index 0000000..2b6334a --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "r2-web", + "private": true, + "version": "1.12.3", + "type": "module", + "packageManager": "pnpm@10.30.3", + "author": "Viki (https://github.com/vikiboss)", + "license": "MIT", + "scripts": { + "dev": "npx serve ./src -p 5500", + "format": "prettier --write ./src/**/*.{ts,tsx,js,jsx,json,css,md,html} --no-error-on-unmatched-pattern", + "typecheck": "tsc --noEmit --project jsconfig.json", + "release": "bumpp" + }, + "prettier": { + "semi": false, + "printWidth": 120, + "singleQuote": true, + "trailingComma": "all" + }, + "changelogithub": { + "types": { + "feat": { + "title": "🚀 新功能" + }, + "fix": { + "title": "🐞 问题修复" + }, + "perf": { + "title": "🏎 性能优化" + }, + "chore": { + "title": "🧹 杂项" + }, + "style": { + "title": "🎨 样式" + }, + "refactor": { + "title": "🔨 重构" + }, + "docs": { + "title": "📚 文档" + }, + "ci": { + "title": "⚙️ CI/CD" + } + } + }, + "dependencies": { + "@jsquash/avif": "^2.1.1", + "@jsquash/jpeg": "^1.6.0", + "@jsquash/oxipng": "^2.3.0", + "@jsquash/webp": "^1.5.0", + "aws4fetch": "^1.0.20", + "dayjs": "^1.11.19", + "filesize": "^11.0.13", + "qrcode": "^1.5.4" + }, + "devDependencies": { + "@types/node": "^25.3.5", + "bumpp": "^10.4.1", + "changelogithub": "^14.0.0", + "prettier": "^3.8.1", + "serve": "^14.2.6", + "typescript": "^5.9.3" + } +} diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..3aba37c --- /dev/null +++ b/plan.md @@ -0,0 +1,65 @@ +--- +在当前目录下,使用纯 HTML 5、CSS 3、ES6+ 实现 cloudflare R2 管理器。 +--- + +# R2 Web + +## 基础功能 + +- 本地配置:浏览器本地存储访问密钥、秘密密钥和存储桶名称。 +- 文件上传:支持单文件、多文件上传、拖放、粘贴上传。 +- 目录预览:显示 R2 存储桶中的文件和目录结构,支持分页和懒加载。 +- 文件操作:支持文件增删改查等基本操作,如重命名、移动、复制和删除文件。 +- 文件预览:支持常见文件类型的预览,如图片、文本、音频和视频。 + +## 配置内容 + +- R2 基本信息:访问密钥 ID、秘密访问密钥、存储桶名称。 +- 偏好设置:默认语言(EN/CN/JP)、主题(浅色/深色)。 +- 上传设置:支持文件名上传模版,需要支持: + - `[name]` 和 `[ext]` 分别表示原文件名和扩展名。 + - `[date:format]`, 使用 `dayjs` 来格式化当前日期,format 是格式字符串,例如 `YYYY-MM-DD`。 + - `[hash:N]`, 其中 N 是一个数字,表示使用文件内容的前 N 位哈希值。 + - `[timestamp]`, 10 位的 Unix 时间戳。 + - `[uuid]`, 唯一的 UUID 值。 + - `/` 任意位置的斜杠都表示目录层级,例如 `images/[date:YYYY/MM/DD]/[name]_[hash:6].[ext]`。 + - 默认使用 `[name]_[hash:6].[ext]` 作为上传模版。 + +## UI 要求 + +- 未配置时首页显示 Hero 区:项目名称、简要介绍和快速操作入口。 +- 使用 R2 的品牌颜色作为唯一强调色,突出重要元素。 +- Apple 风格:简洁、优雅、注重细节的设计风格。 +- 表单信息清晰,可选项明确,操作流程简单直观。 +- 合适的高斯模糊、半透明、毛玻璃效果,提升界面层次感和视觉效果。 +- 微动画、CSS 过渡、合适的 SVG icon 提升用户体验。 +- 黑白灰、小圆角、扁平化、无阴影、紧凑小边距。 +- select、popover 等使用原生元素,但适当主题化,保持系统一致性和性能。 +- 支持 EN/CN/JP 多语言切换,满足不同用户的语言需求。 +- 界面适应不同屏幕尺寸,包括桌面和移动设备。 +- 清晰的导航结构,方便用户浏览和管理文件。 +- 提供上传进度、操作成功或失败的反馈信息。 +- 提供浅色和深色模式,满足用户的视觉偏好。 + +## 技术要求 + +- 维护一套 CSS 主题,使用 css var 方式,放在 style.css 文件中,通过不同的 layer 等封装常见的 UI 组件,如按钮、输入框、模态框等,确保代码的复用性和样式一致性。 +- 使用 import map 从 esm.sh 引入模块,代码放在 script.js 文件中,使用模块化方式组织代码。 +- 使用原生 fetch、现代 CSS 属性和现代 JavaScript,减少对第三方库或框架的依赖,确保代码轻量高效。 +- 使用 JSDoc + jsonconfig.json 来提供类型安全和开发提示,提升开发效率和代码质量。 +- 语义化的 HTML 结构,使用 CSS Flexbox 或 Grid 来布局,确保响应式设计。 +- 使用最新的 HTML5、CSS3 和 ES6+ 技术,确保性能和体验优先,不考虑兼容性问题。 +- 合适的 prefetch、preconnect 和 lazy loading 技术来优化性能,尤其是在目录预览和文件预览功能中。 +- 使用 ES6 特性来编写代码,确保代码的可读性和维护性,包括但不限于 const、箭头函数、toSorted、Object.groupBy、async/await、Promise.all、模块化等。 +- 尝试 ViewTransition API 等现代化的 Web API 来实现流畅的界面过渡和动画效果,提升用户体验。 + +## 目录参考 + +``` +- readme.md +- plan.md +- src + - index.html + - style.css + - script.js +``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..edabe16 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1735 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@jsquash/avif': + specifier: ^2.1.1 + version: 2.1.1 + '@jsquash/jpeg': + specifier: ^1.6.0 + version: 1.6.0 + '@jsquash/oxipng': + specifier: ^2.3.0 + version: 2.3.0 + '@jsquash/webp': + specifier: ^1.5.0 + version: 1.5.0 + aws4fetch: + specifier: ^1.0.20 + version: 1.0.20 + dayjs: + specifier: ^1.11.19 + version: 1.11.19 + filesize: + specifier: ^11.0.13 + version: 11.0.13 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + devDependencies: + '@types/node': + specifier: ^25.3.5 + version: 25.3.5 + bumpp: + specifier: ^10.4.1 + version: 10.4.1 + changelogithub: + specifier: ^14.0.0 + version: 14.0.0 + prettier: + specifier: ^3.8.1 + version: 3.8.1 + serve: + specifier: ^14.2.6 + version: 14.2.6 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@jsquash/avif@2.1.1': + resolution: {integrity: sha512-LMRxd0fMgfCLtobDh0/sFYJMMiRJTNYSEEWvRDKXlAeZ08t3gI5V+1thIT0XjXJ+SVG7Zug9B0XPyx0Ti5VRNA==} + + '@jsquash/jpeg@1.6.0': + resolution: {integrity: sha512-zwN46Awh1VM6gXlIcALwb5WzqK5H2e6+Awcs1QP8AvS8ohsK/sbE4esvmH4jhlhW7+CgiUUww66vg0aTnlSIMA==} + + '@jsquash/oxipng@2.3.0': + resolution: {integrity: sha512-aQ8wiEp6ztlTMXc+RMt/CG8crU3mEHDU+h+JYkIi6ctMhlh8+Ltj5XwQFfBuyzKYrp8NxaFW80Dp824bqjr+zA==} + + '@jsquash/webp@1.5.0': + resolution: {integrity: sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@types/node@25.3.5': + resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==} + + '@zeit/schemas@2.36.0': + resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + args-tokenizer@0.3.0: + resolution: {integrity: sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q==} + + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + boxen@7.0.0: + resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} + engines: {node: '>=14.16'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + bumpp@10.4.1: + resolution: {integrity: sha512-X/bwWs5Gbb/D7rN4aHLB7zdjiA6nGdjckM1sTHhI9oovIbEw2L5pw5S4xzk8ZTeOZ8EnwU/Ze4SoZ6/Vr3pM2Q==} + engines: {node: '>=18'} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + c12@1.11.2: + resolution: {integrity: sha512-oBs8a4uvSDO9dm8b7OCFW7+dgtVrwmwnrVXYzLm43ta7ep2jCn/0MhoUFygIWtxhyy6+/MG7/agvpY0U1Iemew==} + peerDependencies: + magicast: ^0.3.4 + peerDependenciesMeta: + magicast: + optional: true + + c12@3.3.3: + resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + + chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.0.1: + resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + changelogen@0.5.7: + resolution: {integrity: sha512-cTZXBcJMl3pudE40WENOakXkcVtrbBpbkmSkM20NdRiUqa4+VYRdXdEsgQ0BNQ6JBE2YymTNWtPKVF7UCTN5+g==} + hasBin: true + + changelogithub@14.0.0: + resolution: {integrity: sha512-VBuDqqU2si6tNjraFIuUEIVP++y77w+jt5CMnLCXjdP6OEBWUcbNYgIPq+aKQH4Yp2KkGKAjqP8dDmJutICk6g==} + engines: {node: '>=12.0.0'} + hasBin: true + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + + citty@0.2.1: + resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + clipboardy@3.0.0: + resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + + convert-gitmoji@0.1.5: + resolution: {integrity: sha512-4wqOafJdk2tqZC++cjcbGcaJ13BZ3kwldf06PTiAQRAB76Z1KJwZNL1SaRZMi2w1FM9RYTgZ6QErS8NUl/GBmQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + filesize@11.0.13: + resolution: {integrity: sha512-mYJ/qXKvREuO0uH8LTQJ6v7GsUvVOguqxg2VTwQUkyTPXXRRWPdjuUPVqdBrJQhvci48OHlNGRnux+Slr2Rnvw==} + engines: {node: '>= 10.8.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + giget@1.2.5: + resolution: {integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==} + hasBin: true + + giget@2.0.0: + resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + hasBin: true + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-port-reachable@4.0.0: + resolution: {integrity: sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mime-db@1.33.0: + resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.18: + resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} + engines: {node: '>= 0.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + mlly@1.8.1: + resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + nypm@0.5.4: + resolution: {integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + + nypm@0.6.5: + resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==} + engines: {node: '>=18'} + hasBin: true + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@1.1.6: + resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@10.2.0: + resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} + engines: {node: '>=18'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + range-parser@1.2.0: + resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} + engines: {node: '>= 0.6'} + + rc9@2.1.2: + resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + registry-auth-token@3.3.2: + resolution: {integrity: sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==} + + registry-url@3.1.0: + resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==} + engines: {node: '>=0.10.0'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + serve-handler@6.1.7: + resolution: {integrity: sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==} + + serve@14.2.6: + resolution: {integrity: sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q==} + engines: {node: '>= 14'} + hasBin: true + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + update-check@1.5.4: + resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wasm-feature-detect@1.8.0: + resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + +snapshots: + + '@jsquash/avif@2.1.1': + dependencies: + wasm-feature-detect: 1.8.0 + + '@jsquash/jpeg@1.6.0': {} + + '@jsquash/oxipng@2.3.0': + dependencies: + wasm-feature-detect: 1.8.0 + + '@jsquash/webp@1.5.0': + dependencies: + wasm-feature-detect: 1.8.0 + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@types/node@25.3.5': + dependencies: + undici-types: 7.18.2 + + '@zeit/schemas@2.36.0': {} + + acorn@8.16.0: {} + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arch@2.2.0: {} + + arg@5.0.2: {} + + args-tokenizer@0.3.0: {} + + aws4fetch@1.0.20: {} + + balanced-match@1.0.2: {} + + binary-extensions@2.3.0: {} + + boxen@7.0.0: + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.0.1 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + bumpp@10.4.1: + dependencies: + ansis: 4.2.0 + args-tokenizer: 0.3.0 + c12: 3.3.3 + cac: 6.7.14 + escalade: 3.2.0 + jsonc-parser: 3.3.1 + package-manager-detector: 1.6.0 + semver: 7.7.4 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + yaml: 2.8.2 + transitivePeerDependencies: + - magicast + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.0.0: {} + + bytes@3.1.2: {} + + c12@1.11.2: + dependencies: + chokidar: 3.6.0 + confbox: 0.1.8 + defu: 6.1.4 + dotenv: 16.6.1 + giget: 1.2.5 + jiti: 1.21.7 + mlly: 1.8.1 + ohash: 1.1.6 + pathe: 1.1.2 + perfect-debounce: 1.0.0 + pkg-types: 1.3.1 + rc9: 2.1.2 + + c12@3.3.3: + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.4 + dotenv: 17.3.1 + exsolve: 1.0.8 + giget: 2.0.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 2.1.2 + + cac@6.7.14: {} + + camelcase@5.3.1: {} + + camelcase@7.0.1: {} + + chalk-template@0.4.0: + dependencies: + chalk: 4.1.2 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.0.1: {} + + changelogen@0.5.7: + dependencies: + c12: 1.11.2 + colorette: 2.0.20 + consola: 3.4.2 + convert-gitmoji: 0.1.5 + mri: 1.2.0 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + open: 10.2.0 + pathe: 1.1.2 + pkg-types: 1.3.1 + scule: 1.3.0 + semver: 7.7.4 + std-env: 3.10.0 + yaml: 2.8.2 + transitivePeerDependencies: + - magicast + + changelogithub@14.0.0: + dependencies: + ansis: 4.2.0 + c12: 3.3.3 + cac: 6.7.14 + changelogen: 0.5.7 + convert-gitmoji: 0.1.5 + execa: 9.6.1 + ofetch: 1.5.1 + semver: 7.7.4 + tinyglobby: 0.2.15 + transitivePeerDependencies: + - magicast + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + chownr@2.0.0: {} + + citty@0.1.6: + dependencies: + consola: 3.4.2 + + citty@0.2.1: {} + + cli-boxes@3.0.0: {} + + clipboardy@3.0.0: + dependencies: + arch: 2.2.0 + execa: 5.1.1 + is-wsl: 2.2.0 + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colorette@2.0.20: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + consola@3.4.2: {} + + content-disposition@0.5.2: {} + + convert-gitmoji@0.1.5: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + dayjs@1.11.19: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + decamelize@1.2.0: {} + + deep-extend@0.6.0: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + defu@6.1.4: {} + + destr@2.0.5: {} + + dijkstrajs@1.0.3: {} + + dotenv@16.6.1: {} + + dotenv@17.3.1: {} + + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + escalade@3.2.0: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + exsolve@1.0.8: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + filesize@11.0.13: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + giget@1.2.5: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.5.4 + pathe: 2.0.3 + tar: 6.2.1 + + giget@2.0.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + defu: 6.1.4 + node-fetch-native: 1.6.7 + nypm: 0.6.5 + pathe: 2.0.3 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + has-flag@4.0.0: {} + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + ini@1.3.8: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-port-reachable@4.0.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + jiti@1.21.7: {} + + jiti@2.6.1: {} + + json-schema-traverse@1.0.0: {} + + jsonc-parser@3.3.1: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + merge-stream@2.0.0: {} + + mime-db@1.33.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.18: + dependencies: + mime-db: 1.33.0 + + mimic-fn@2.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimist@1.2.8: {} + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + mlly@1.8.1: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + mri@1.2.0: {} + + ms@2.0.0: {} + + negotiator@0.6.4: {} + + node-fetch-native@1.6.7: {} + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + nypm@0.5.4: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 1.3.1 + tinyexec: 0.3.2 + ufo: 1.6.3 + + nypm@0.6.5: + dependencies: + citty: 0.2.1 + pathe: 2.0.3 + tinyexec: 1.0.2 + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.3 + + ohash@1.1.6: {} + + ohash@2.0.11: {} + + on-headers@1.1.0: {} + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@10.2.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-inside-container: 1.0.0 + wsl-utils: 0.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + package-manager-detector@1.6.0: {} + + parse-ms@4.0.0: {} + + path-exists@4.0.0: {} + + path-is-inside@1.0.2: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-to-regexp@3.3.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + perfect-debounce@2.1.0: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.1 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + pngjs@5.0.0: {} + + prettier@3.8.1: {} + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + range-parser@1.2.0: {} + + rc9@2.1.2: + dependencies: + defu: 6.1.4 + destr: 2.0.5 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + readdirp@5.0.0: {} + + registry-auth-token@3.3.2: + dependencies: + rc: 1.2.8 + safe-buffer: 5.2.1 + + registry-url@3.1.0: + dependencies: + rc: 1.2.8 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-main-filename@2.0.0: {} + + run-applescript@7.1.0: {} + + safe-buffer@5.2.1: {} + + scule@1.3.0: {} + + semver@7.7.4: {} + + serve-handler@6.1.7: + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + mime-types: 2.1.18 + minimatch: 3.1.5 + path-is-inside: 1.0.2 + path-to-regexp: 3.3.0 + range-parser: 1.2.0 + + serve@14.2.6: + dependencies: + '@zeit/schemas': 2.36.0 + ajv: 8.18.0 + arg: 5.0.2 + boxen: 7.0.0 + chalk: 5.0.1 + chalk-template: 0.4.0 + clipboardy: 3.0.0 + compression: 1.8.1 + is-port-reachable: 4.0.0 + serve-handler: 6.1.7 + update-check: 1.5.4 + transitivePeerDependencies: + - supports-color + + set-blocking@2.0.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-json-comments@2.0.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + tinyexec@0.3.2: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + type-fest@2.19.0: {} + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + undici-types@7.18.2: {} + + unicorn-magic@0.3.0: {} + + update-check@1.5.4: + dependencies: + registry-auth-token: 3.3.2 + registry-url: 3.1.0 + + vary@1.1.2: {} + + wasm-feature-detect@1.8.0: {} + + which-module@2.0.1: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + widest-line@4.0.1: + dependencies: + string-width: 5.1.2 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.1 + + y18n@4.0.3: {} + + yallist@4.0.0: {} + + yaml@2.8.2: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + + yoctocolors@2.1.2: {} diff --git a/readme-en.md b/readme-en.md new file mode 100644 index 0000000..5c59988 --- /dev/null +++ b/readme-en.md @@ -0,0 +1,214 @@ +![93c1205d.png](https://image.viki.moe/github/93c1205d.png) + +**[中文](./readme.md) | English** + +# R2 Web + +📁 A lightweight, elegant, pure-browser Cloudflare R2 file manager. Everything happens right in your browser. + +> This project was recommended by Ruanyifeng in _[Tech Enthusiast Weekly (Issue 387)][ruanyifeng-weekly]_, many thanks for the support! +> +> Also welcome everyone to try it out and provide valuable feedback to make this tool even better and more user-friendly! + +## Live Demo + +Follow the [CORS setup guide](#1-configure-r2-bucket-cors) below, then visit **[r2.viki.moe](https://r2.viki.moe)** to start managing your R2 bucket immediately. + +## Self-Hosting + +Here are some common static hosting platforms for deployment. Click the buttons for one-click deployment: + +| Platform | Quick Deploy | +| ---------------- | ------------------------------------------------------------------------------------------ | +| Vercel | [![Deploy with Vercel](https://vercel.com/button)][vercel-deploy] | +| Netlify | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)][netlify-deploy] | +| Cloudflare Pages | [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)][cloudflare-deploy] | + +Other services can simply deploy the `src` directory. After deployment, remember to update your CORS rules to allow your domain to access the R2 API. + +## Feedback + +- [GitHub Issues](https://github.com/vikiboss/r2-web/issues) — bug reports, feature requests +- [QQ Group](https://qm.qq.com/q/e47kAlbdsc) — real-time discussion (Group ID: 1091212613) + +## Why R2 Web? + +**Pain points with traditional solutions:** + +- The official Cloudflare console is basic and cumbersome to use, especially for managing large numbers of files (copying, moving, renaming, etc.) +- Third-party desktop clients require installation, painful across platforms +- CLI tools have a steep learning curve, not suited for quick ad-hoc tasks +- Other web tools aren't R2-focused, leaving gaps in features and experience + +**What R2 Web solves:** + +- Open in a browser and start immediately — zero installation, zero platform friction +- Drag & paste upload with image compression — save bandwidth and time +- PWA support — install to your home screen and use like a native app +- Pure client-side — your data never passes through a third-party server + +**Where R2 Web falls short:** + +- Very large file uploads (>300 MB) — use rclone or similar tools instead +- Complex permission management — use the official Cloudflare console +- Automated scripts — use the official SDK or CLI +- API integration — no backend; use the official SDK or call the R2 API directly + +## Use Cases + +- **File management**: Browse directories, rename, move, delete — easily handle large collections of files. +- **File browsing**: Built-in image/video/audio/text preview — quickly inspect content without downloading. +- **Private image hosting**: Drag & paste upload, auto compression, one-click copy as Markdown/HTML. + +## Design Philosophy + +- Zero build — source is the artifact, no compilation needed +- Zero framework — native Web APIs first, no framework dependency +- Zero backend — all logic runs in the browser, direct R2 API access +- Minimal aesthetic — black/white/grey + R2 orange, small radius, flat design +- Performance first — lazy loading, debounce/throttle, request caching +- Detail-oriented — smooth animations, immediate feedback, keyboard navigation + +## Screenshots + +![9392ee.png](https://image.viki.moe/github/9392ee.png) + +![ea7dd6.png](https://image.viki.moe/github/ea7dd6.png) + +## Feature Overview + +| Category | Details | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| **File Management** | Directory browsing, paginated loading, lazy thumbnail loading; Sort by name / date / size; Rename, move, copy, delete (recursive); Batch operations with multi-select | +| **File Upload** | Drag / paste / picker upload; Filename templates (hash, date, UUID placeholders); Auto image compression before upload (WebAssembly) | +| **File Preview** | Image preview (common formats); Inline video / audio player; Text file preview with syntax highlighting | +| **Link Copy** | Direct URL, Markdown, HTML, pre-signed URL | +| **Personalization** | Simplified / Traditional Chinese, English, Japanese; Dark mode (follows system); Config share link / QR code | +| **PWA** | Install to desktop, native-like experience | + +## Quick Start + +### 1. Configure R2 Bucket CORS + +In the Cloudflare dashboard, go to R2 → Bucket → Settings → CORS Policy and add: + +```json +[ + { + "AllowedOrigins": ["https://r2.viki.moe"], + "AllowedMethods": ["GET", "POST", "PUT", "DELETE", "HEAD"], + "AllowedHeaders": ["*"], + "ExposeHeaders": ["etag"], + "MaxAgeSeconds": 86400 + } +] +``` + +> [!TIP] +> Self-hosting? Just replace `AllowedOrigins` with your own domain. + +### 2. Enter Credentials + +Visit [r2.viki.moe](https://r2.viki.moe), enter your R2 credentials, and connect. Credentials are stored only in your browser's localStorage and never uploaded anywhere. + +### 3. Start Using + +Browse files, drag & drop or press Ctrl+V to upload, right-click any file to rename, copy link, and more. + +For image hosting, set a filename template with a hash placeholder, enable image compression for better performance and security. + +## Tips & Tricks + +### Filename Template Examples + +- `[name]_[hash:6].[ext]` — original name + 6-char hash (default) +- `images/[date:YYYY/MM/DD]/[uuid].[ext]` — date-based directory structure +- `backup/[timestamp]-[name].[ext]` — timestamp-prefixed backup + +### Config Share Link + +Generate a "Config Share Link" or "Config QR Code" to quickly sync your settings across devices. + +> [!CAUTION] +> The link contains your R2 access credentials. Do not share it on public platforms. + +### Cache Optimization + +R2 Web has built-in request caching for common operations like directory listings. For CDN caching, configure cache rules in the Cloudflare dashboard to improve load speeds. + +![fca0bf44.png](https://image.viki.moe/github/fca0bf44.png) + +## Technical Details + +A pure frontend application with no build step — write code and deploy immediately. + +**Core technologies:** HTML5/CSS3/ES6+, CSS Layers, native ``, native Fetch, Import Maps, WebAssembly + +**Dependencies:** + +- `aws4fetch` — AWS4 request signing for R2 S3 API +- `dayjs` — date formatting +- `@jsquash/*` — WebAssembly image compression (MozJPEG, OxiPNG, libwebp, libavif) +- `qrcode` — QR code generation + +**Not required:** Node.js, Webpack, Vite, React, Vue or any other build tool or framework — keeping the project lightweight and dependency-free. + +## Local Development + +```bash +git clone https://github.com/vikiboss/r2-web.git +cd r2-web + +# Install dependencies (for type hints only) +pnpm install + +# Start local dev server +npx serve src +# or +python3 -m http.server 5500 --directory src +``` + +See [CLAUDE.md](./CLAUDE.md) for the full development guide. + +## FAQ + +**Q: Are my credentials safe?** + +A: Credentials are stored only in your browser's localStorage and are never sent to any server. It is recommended to use API tokens with permissions limited to the specific bucket and non-admin read/write access. + +**Q: Which browsers are supported?** + +A: Modern browsers (latest Chrome/Edge/Firefox/Safari). No IE support. + +**Q: Where does image compression happen?** + +A: Local compression uses WebAssembly and runs entirely in your browser — no files are sent to any third-party server. If you use cloud compression (Tinify), images are sent to Tinify's servers. + +**Q: Can I self-host?** + +A: Yes — fork the repo, update `AllowedOrigins` in the CORS config to your domain, then deploy to any static hosting service (Cloudflare Pages, Vercel, Netlify, etc.). + +**Q: What does the config share link contain?** + +A: It includes your Access Key ID, Secret Access Key, bucket name, and other sensitive information. Do not share it publicly. + +**Q: Why is my upload failing?** + +A: Check that your CORS policy is correct, your credentials are valid, and that the file is under 300 MB (use rclone for large files). + +## Roadmap + +- Continuous UI/UX improvements and more shortcut actions + +## Development Story + +This project was built with Claude Opus 4.6 via vibe coding — entirely prompt-driven from requirements to implementation. See [plan.md](./plan.md) for the initial architecture and design prompts. + +## License + +MIT License + +[ruanyifeng-weekly]: https://www.ruanyifeng.com/blog/2026/03/weekly-issue-387.html +[vercel-deploy]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvikiboss%2Fr2-web&project-name=r2-web&repository-name=r2-web +[netlify-deploy]: https://app.netlify.com/start/deploy?repository=https%3A%2F%2Fgithub.com%2Fvikiboss%2Fr2-web&integrationName=r2-web&integrationSlug=r2-web +[cloudflare-deploy]: https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2Fvikiboss%2Fr2-web diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..c633557 --- /dev/null +++ b/readme.md @@ -0,0 +1,219 @@ +![93c1205d.png](https://image.viki.moe/github/93c1205d.png) + +**中文 | [English](./readme-en.md)** + +# R2 Web + +📁 轻盈优雅的 Web 原生 Cloudflare R2 文件管理器,一切皆在浏览器中完成。 + +Featured|HelloGitHub + +> 本项目有幸被 [《科技爱好者周刊(第 387 期)》][ruanyifeng-weekly] 和 [《HelloGitHub(第 123 期)》][hellogithub-123] 推荐,在此表示感谢!同时也欢迎大家试用并提出宝贵意见,一起把这个工具做得更好用、更顺手! + +## 在线使用 + +跟随 [下方指引](#1-配置-r2-桶-cors) 开启 CORS,然后访问 **[r2.viki.moe](https://r2.viki.moe)** 立即开始管理 R2 桶。 + +## 私有部署 + +这里提供几个常见的静态托管平台部署选项,点击按钮即可一键部署: + +| 平台 | 快速部署 | +| ---------------- | ------------------------------------------------------------------------------------------ | +| Vercel | [![Deploy with Vercel](https://vercel.com/button)][vercel-deploy] | +| Netlify | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)][netlify-deploy] | +| Cloudflare Pages | [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)][cloudflare-deploy] | + +> 如果用得不错,别忘了点个免费的小星星 ✨,对这个项目很重要,非常感谢~ + +其他服务只需部署 `src` 目录即可,部署后记得更新 CORS 规则允许你的域名访问 R2 API。 + +## 反馈途径 + +- [GitHub Issues](https://github.com/vikiboss/r2-web/issues) - 提交 bug 报告、功能建议 +- [反馈 QQ 群](https://qm.qq.com/q/e47kAlbdsc) - 即时交流、使用反馈(群号:1091212613) + +## 为什么是 R2 Web? + +**传统方案痛点:** + +- 官方控制台功能基础,登录、管理麻烦,无法高效管理大量文件(复制、移动、重命名等) +- 第三方客户端要下载安装,跨平台麻烦 +- 命令行工具上手门槛高,不适合临时操作 +- 其他 Web 项目不专注 R2,功能不完善,体验欠佳 + +**R2 Web 解决的问题:** + +- 打开浏览器就能用,跨平台零成本 +- 拖拽、粘贴上传 + 图片压缩,省流量省时间 +- PWA 支持,装到桌面像原生应用 +- 纯前端实现,数据不经过第三方服务器 + +**R2 Web 无法替代的场景:** + +- 超大文件上传(>300MB),建议使用 rclone 等工具 +- 复杂权限管理,建议使用官方控制台或 API +- 自动化脚本,建议使用官方 SDK 或 CLI +- API 集成,无后端服务,建议使用官方 SDK 或直接调用 R2 API + +## 使用场景 + +- **文件管理**: 目录浏览、重命名、移动、删除,轻松管理大量文件。 +- **文件浏览**: 内置图片/视频/音频/文本预览,快速查看内容无需下载。 +- **私有图床**: 拖拽/粘贴上传,自动压缩,复制为 Markdown/HTML 格式。 + +## 设计理念 + +- 零构建,源码即产物,无需编译打包 +- 零框架,原生 Web 技术优先,不依赖框架 +- 零后端,所有逻辑在浏览器中完成,直连 R2 API +- 极简美学,黑白灰 + R2 橙色,小圆角、扁平化 +- 性能至上,懒加载、防抖节流、请求缓存 +- 细节优先,流畅动画、及时反馈、键盘导航 + +## 界面截图 + +![9392ee.png](https://image.viki.moe/github/9392ee.png) + +![ea7dd6.png](https://image.viki.moe/github/ea7dd6.png) + +## 功能速览 + +| 功能类别 | 具体功能 | +| ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| **文件管理** | 目录浏览、分页加载、懒加载缩略图;按名称/日期/大小排序;重命名、移动、复制、删除(支持递归);多选批量删除、复制、移动 | +| **文件上传** | 拖拽/粘贴/选择器上传;文件名模板(哈希、日期、UUID 等占位符);上传前自动压缩图片(WebAssembly) | +| **文件预览** | 图片预览(常见格式);视频/音频内嵌播放器;文本文件预览(代码高亮) | +| **链接复制** | URL 直链、Markdown、HTML、预签名 URL | +| **个性化** | 简体/繁体/英语/日语;深色模式(跟随系统);配置分享链接/二维码 | +| **PWA** | 安装到桌面,原生体验 | + +## 快速开始 + +### 1. 配置 R2 桶 CORS + +在 Cloudflare 控制台配置 CORS 规则(路径:R2 → 存储桶 → 设置 → CORS 策略): + +```json +[ + { + "AllowedOrigins": ["https://r2.viki.moe"], + "AllowedMethods": ["GET", "POST", "PUT", "DELETE", "HEAD"], + "AllowedHeaders": ["*"], + "ExposeHeaders": ["ETag"], + "MaxAgeSeconds": 86400 + } +] +``` + +> [!TIP] +> 私有部署? 把 `AllowedOrigins` 改成你的域名即可。 + +### 2. 填写凭证连接 + +访问 [r2.viki.moe](https://r2.viki.moe),填写 R2 凭证进行连接。凭证只存储在浏览器 localStorage,不会上传。 + +### 3. 开始使用 + +开始管理文件、目录,拖拽文件、直接 Ctrl + V 即可上传,右键文件可进行重命名、复制链接等操作。 + +如果当作图床使用,建议设置文件名模板,生成带哈希的唯一文件名、开启图片压缩,提升性能和安全性。 + +## 实用技巧 + +### 文件名模板示例 + +- `[name]_[hash:6].[ext]` - 原文件名 + 6 位哈希(默认) +- `images/[date:YYYY/MM/DD]/[uuid].[ext]` - 按日期分目录 +- `backup/[timestamp]-[name].[ext]` - 时间戳前缀备份 + +### 配置分享链接 + +生成「配置分享链接」或「配置分享二维码」,快速在多设备同步配置。 + +> [!CAUTION] +> 链接包含 R2 访问凭证,请不要直接分享到公共平台。 + +### 缓存优化 + +项目内置支持请求缓存,对目录内容等常见频繁请求返回数据进行了缓存。 + +对于 CDN 缓存,建议在 Cloudflare 控制台配置缓存规则提升加载速度。 + +![fca0bf44.png](https://image.viki.moe/github/fca0bf44.png) + +## 技术实现 + +纯前端应用,无构建步骤,代码写完即可部署。 + +**核心技术:** HTML5/CSS3/ES6+,CSS Layers、原生 ``、原生 Fetch、Import Maps、WebAssembly + +**依赖库:** + +- `aws4fetch` - AWS4 请求签名,处理 R2 S3 API +- `dayjs` - 日期格式化 +- `@jsquash/*` - WebAssembly 图片压缩(MozJPEG、OxiPNG、libwebp、libavif) +- `qrcode` - 二维码生成 + +**无需:** Node.js、Webpack、Vite、React、Vue 等构建工具和框架,保持项目轻盈和零依赖。 + +## 本地开发 + +```bash +git clone https://github.com/vikiboss/r2-web.git +cd r2-web + +# 安装依赖(仅用于类型提示) +pnpm install + +# 启动本地服务器 +npx serve src +# 或 +python3 -m http.server 5500 --directory src +``` + +详细开发指南见 [CLAUDE.md](./CLAUDE.md)。 + +## FAQ + +**Q: 凭证安全吗?** + +A: 凭证只存储在浏览器 localStorage,不会上传到任何服务器。建议使用指定 bucket、非管理员读写权限的 API 令牌。 + +**Q: 支持哪些浏览器?** + +A: 现代浏览器(Chrome/Edge/Firefox/Safari 最新版),不考虑 IE 兼容。 + +**Q: 图片压缩在哪里进行?** + +A: 本地压缩使用 WebAssembly,完全在浏览器中完成,文件不会上传到第三方服务器。如果使用云压缩(Tinify 服务),则会将图片上传到 Tinify 服务器进行压缩。 + +**Q: 可以私有部署吗?** + +A: 可以,fork 仓库后修改 CORS 配置中的 `AllowedOrigins`,部署到任意静态托管服务(Cloudflare Pages、Vercel、Netlify 等)。 + +**Q: 配置分享链接包含什么信息?** + +A: 包含访问密钥 ID、秘密访问密钥、存储桶名称等敏感信息,请勿公开分享。 + +**Q: 为什么上传失败?** + +A: 检查 CORS 配置是否正确、凭证是否有效、文件是否超过 300MB(大文件建议用 rclone)。 + +## 后续计划 + +- 持续优化 UI/UX,增加更多快捷操作 + +## 开发故事 + +项目使用 Claude 4.6 Opus 模型 Vibe Coding 完成,需求到实现纯手工提示词驱动。初始架构和开发设计的提示词可以参考 [plan.md](./plan.md)。 + +## License + +MIT License + +[ruanyifeng-weekly]: https://www.ruanyifeng.com/blog/2026/03/weekly-issue-387.html +[vercel-deploy]: https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvikiboss%2Fr2-web&project-name=r2-web&repository-name=r2-web +[netlify-deploy]: https://app.netlify.com/start/deploy?repository=https%3A%2F%2Fgithub.com%2Fvikiboss%2Fr2-web&integrationName=r2-web&integrationSlug=r2-web +[cloudflare-deploy]: https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2Fvikiboss%2Fr2-web +[hellogithub-123]: https://hellogithub.com/periodical/volume/123 diff --git a/src/css/animations.css b/src/css/animations.css new file mode 100644 index 0000000..de0384b --- /dev/null +++ b/src/css/animations.css @@ -0,0 +1,125 @@ +/* --- Animations --- */ +@layer animations { + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes toast-in { + from { + opacity: 0; + transform: translateY(12px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + @keyframes toast-out { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } + } + + @keyframes skeleton-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } + } + + @keyframes slide-up { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + @keyframes indeterminate { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } + } + + /* Refresh button spin */ + #refresh-btn.refreshing .icon { + animation: spin 0.6s ease-in-out; + } + + /* Staggered file card entrance */ + .file-card { + animation: fade-in var(--duration-normal) var(--ease-out) both; + + &:nth-child(1) { + animation-delay: 0ms; + } + &:nth-child(2) { + animation-delay: 20ms; + } + &:nth-child(3) { + animation-delay: 40ms; + } + &:nth-child(4) { + animation-delay: 60ms; + } + &:nth-child(5) { + animation-delay: 80ms; + } + &:nth-child(6) { + animation-delay: 100ms; + } + &:nth-child(7) { + animation-delay: 120ms; + } + &:nth-child(8) { + animation-delay: 140ms; + } + &:nth-child(9) { + animation-delay: 160ms; + } + &:nth-child(10) { + animation-delay: 180ms; + } + &:nth-child(11) { + animation-delay: 200ms; + } + &:nth-child(12) { + animation-delay: 220ms; + } + } + + /* View Transition customization */ + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: var(--duration-normal); + } +} diff --git a/src/css/base.css b/src/css/base.css new file mode 100644 index 0000000..dfeb4ee --- /dev/null +++ b/src/css/base.css @@ -0,0 +1,49 @@ +/* --- Base --- */ +@layer base { + html { + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: 1.5; + color: var(--text-primary); + background: var(--bg-primary); + } + + h1, + h2, + h3 { + font-weight: 600; + line-height: 1.3; + text-wrap: balance; + } + + h2 { + font-size: var(--text-lg); + } + + ::selection { + background: var(--accent); + color: #fff; + } + + :focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + + /* Scrollbar */ + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: var(--radius-sm); + + &:hover { + background: var(--text-tertiary); + } + } +} diff --git a/src/css/components.css b/src/css/components.css new file mode 100644 index 0000000..50221a0 --- /dev/null +++ b/src/css/components.css @@ -0,0 +1,1985 @@ +/* --- Components --- */ +@layer components { + /* Buttons */ + .btn { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + padding: var(--sp-2) var(--sp-3); + font-size: var(--text-base); + font-weight: 500; + border-radius: var(--radius-md); + transition: + background var(--duration-fast), + color var(--duration-fast); + white-space: nowrap; + + &.primary { + background: var(--accent); + color: #fff; + + &:hover { + background: var(--accent-hover); + } + } + + &.secondary { + background: var(--bg-tertiary); + color: var(--text-primary); + + &:hover { + background: var(--bg-active); + } + } + + &.danger { + background: var(--text-danger); + color: #fff; + + &:hover { + opacity: 0.9; + } + } + + &.sm { + padding: var(--sp-1) var(--sp-2); + font-size: var(--text-sm); + } + } + + .icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius-md); + transition: background var(--duration-fast); + color: var(--text-secondary); + flex-shrink: 0; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + &.sm { + width: 24px; + height: 24px; + } + } + + /* Icons */ + .icon { + width: 16px; + height: 16px; + flex-shrink: 0; + } + .icon-lg { + width: 20px; + height: 20px; + } + .icon-xl { + width: 48px; + height: 48px; + } + + /* Enhanced icon styling for toolbar buttons */ + .toolbar .icon-btn svg.icon { + stroke-width: 2; + transition: all var(--duration-normal) var(--ease-out); + } + + .toolbar .icon-btn:hover svg.icon { + stroke-width: 2.2; + } + + /* Theme toggle icons */ + [data-theme='light'] .icon-moon, + [data-theme='dark'] .icon-sun { + display: none; + } + + /* Form Fields */ + .field { + display: flex; + flex-direction: column; + gap: var(--sp-1); + + & input, + & select { + padding: var(--sp-1) var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-primary); + font-size: var(--text-base); + transition: border-color var(--duration-fast); + + &:focus { + outline: none; + border-color: var(--accent); + } + } + + & .field-error { + font-size: var(--text-xs); + color: var(--text-danger); + margin: 0; + } + + & input:has(~ .field-error:not([hidden])) { + border-color: var(--text-danger); + + &:focus { + border-color: var(--text-danger); + } + } + + &:not(:last-child) { + margin-bottom: var(--sp-3); + } + } + + .choice-list { + display: flex; + flex-direction: column; + gap: var(--sp-2); + margin-top: var(--sp-2); + } + + .choice-item { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-secondary); + cursor: pointer; + transition: + border-color var(--duration-fast), + background var(--duration-fast); + + &:hover { + border-color: color-mix(in oklab, var(--border) 60%, var(--accent) 40%); + background: var(--bg-tertiary); + } + + & input { + appearance: none; + width: 16px; + height: 16px; + border: 1.5px solid var(--border); + border-radius: 999px; + background: var(--bg-primary); + display: grid; + place-content: center; + transition: border-color var(--duration-fast); + + &:focus-visible { + outline: 2px solid color-mix(in oklab, var(--accent) 60%, transparent); + outline-offset: 2px; + } + + &::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 999px; + transform: scale(0); + transition: transform var(--duration-fast) var(--ease-out); + background: var(--accent); + } + + &:checked { + border-color: var(--accent); + } + + &:checked::before { + transform: scale(1); + } + } + + & span { + font-size: var(--text-sm); + color: var(--text-primary); + font-family: var(--font-mono); + } + } + + /* File QR Dialog */ + #file-qr-dialog .dialog-body { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding-block: var(--sp-4); + gap: var(--sp-2); + + & .field { + width: min(520px, 100%); + } + } + + .file-qr-filename { + font-size: var(--text-sm); + color: var(--text-secondary); + font-family: var(--font-mono); + margin-top: calc(var(--sp-1) * -1); + } + + #file-qr-desc { + margin: 0; + } + + #file-qr-url { + text-align: center; + font-family: var(--font-mono); + } + + #file-qr-copy { + width: auto; + align-self: center; + } + + /* Field Label Container (label + help icon) */ + .field-label { + display: flex; + align-items: center; + gap: var(--sp-1); + + & label { + font-size: var(--text-sm); + font-weight: 500; + color: var(--text-secondary); + } + } + + /* Field Hint (for links and help text below input) */ + .field-hint { + display: flex; + align-items: center; + gap: var(--sp-1); + font-size: var(--text-xs); + color: var(--text-tertiary); + + & a { + color: var(--accent); + text-decoration: none; + transition: opacity var(--duration-fast) var(--ease-out); + + &:hover { + opacity: 0.8; + text-decoration: underline; + } + } + } + + /* Help icon for form field tooltips */ + .help-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + min-width: 16px; + min-height: 16px; + padding: 0; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: var(--text-xs); + font-weight: 600; + line-height: 1; + cursor: help; + flex-shrink: 0; + transition: all var(--duration-fast) var(--ease-out); + + &:hover { + background: var(--accent); + color: white; + border-color: var(--accent); + transform: scale(1.1); + } + } + + /* Dialog — base state (layout persists during close transition) */ + dialog { + align-items: center; + justify-content: center; + position: fixed; + inset: 0; + margin: 0; + width: 100%; + height: 100%; + max-width: 100%; + max-height: 100%; + background: transparent; + padding: 0; + z-index: var(--z-dialog); + opacity: 0; + transition: + opacity var(--duration-normal) var(--ease-out), + display var(--duration-normal) allow-discrete, + overlay var(--duration-normal) allow-discrete; + + &::backdrop { + background: var(--backdrop); + opacity: 0; + transition: + opacity var(--duration-normal), + display var(--duration-normal) allow-discrete, + overlay var(--duration-normal) allow-discrete; + } + } + + /* Dialog — open state */ + dialog[open] { + display: flex; + opacity: 1; + + @starting-style { + opacity: 0; + } + + &::backdrop { + opacity: 1; + @starting-style { + opacity: 0; + } + } + } + + /* Dialog inner panel — base styles */ + dialog > form, + dialog > .dialog-panel { + background: var(--bg-secondary); + border-radius: var(--radius-xl); + border: 1px solid var(--border); + padding: 0; + width: min(440px, calc(100vw - 48px)); + max-height: calc(100dvh - 48px); + overflow: hidden; + display: flex; + flex-direction: column; + opacity: 0; + transform: scale(0.98); + transition: + opacity var(--duration-normal) var(--ease-out), + transform var(--duration-normal) var(--ease-out); + + @media (width <= 640px) { + width: calc(100vw - 24px); + max-height: calc(100vh - 24px); + } + } + + /* Dialog header */ + .dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--sp-4) var(--sp-5); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + gap: var(--sp-3); + min-width: 0; + + & h2, + & > span { + margin: 0; + font-size: var(--text-md); + font-weight: 600; + color: var(--text-primary); + } + + & > span { + font-size: var(--text-sm); + font-weight: 500; + } + + /* Actions container for multiple buttons */ + .dialog-header-actions { + display: flex; + align-items: center; + gap: var(--sp-1); + flex-shrink: 0; + } + + .icon-btn { + flex-shrink: 0; + opacity: 0.6; + transition: opacity var(--duration-fast); + + &:hover { + opacity: 1; + } + } + + /* Truncate long text in header */ + & > *:first-child { + min-width: 0; + flex: 1; + } + } + + /* Dialog body */ + .dialog-body { + padding: var(--sp-5); + overflow-y: auto; + flex: 1; + min-height: 0; + + & p { + margin: 0 0 var(--sp-3) 0; + font-size: var(--text-sm); + line-height: 1.5; + color: var(--text-secondary); + + &:last-child { + margin-bottom: 0; + } + } + + /* Media content styling */ + & img, + & video, + & audio { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + & pre { + margin: 0; + padding: var(--sp-4); + font-family: var(--font-mono); + font-size: var(--text-sm); + background: var(--bg-secondary); + border-radius: var(--radius-md); + white-space: pre-wrap; + word-break: break-all; + align-self: flex-start; + max-width: 100%; + } + + @media (width <= 640px) { + padding: var(--sp-3); + } + } + + /* Dialog footer */ + .dialog-footer { + display: flex; + justify-content: flex-end; + align-items: center; + gap: var(--sp-2); + padding: 0 var(--sp-5) var(--sp-4); + flex-shrink: 0; + flex-wrap: wrap; + + /* When footer has border-top (like preview info) */ + &.bordered { + border-top: 1px solid var(--border); + padding: var(--sp-3) var(--sp-5) var(--sp-4); + font-size: var(--text-xs); + color: var(--text-tertiary); + justify-content: flex-start; + gap: var(--sp-4); + } + } + + /* Small dialogs (prompt, confirm, overwrite) */ + #prompt-dialog > form, + #confirm-dialog > form, + #overwrite-dialog > .dialog-panel { + width: min(420px, calc(100vw - 48px)); + + @media (width <= 640px) { + width: calc(100vw - 24px); + } + } + + #prompt-hint { + font-size: var(--text-xs); + color: var(--text-secondary); + margin: 0 0 var(--sp-2); + } + + #prompt-preview { + font-size: var(--text-xs); + color: var(--text-tertiary); + font-family: var(--font-mono, monospace); + margin: 0; + word-break: break-all; + } + + /* Config dialog — wider for two-column layout */ + #config-dialog > #config-form { + width: min(820px, calc(100vw - 48px)); + + @media (width <= 768px) { + width: min(560px, calc(100vw - 32px)); + } + + @media (width <= 640px) { + width: calc(100vw - 24px); + } + } + + /* Share dialog */ + #share-dialog > .dialog-panel { + width: min(820px, calc(100vw - 48px)); + + @media (width <= 768px) { + width: min(560px, calc(100vw - 32px)); + } + + @media (width <= 640px) { + width: calc(100vw - 24px); + } + + .share-subtitle { + margin: 0 0 var(--sp-8) 0; + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: 1.5; + } + + .config-transfer-grid { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 0; + align-items: stretch; + + @media (width <= 768px) { + grid-template-columns: 1fr; + gap: 0; + } + } + + .share-divider { + position: relative; + width: 1px; + background: var(--border-subtle); + margin: 0 var(--sp-8); + align-self: stretch; + + .share-divider-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: var(--text-xs); + font-weight: 500; + color: var(--text-tertiary); + background: var(--bg-secondary); + padding: var(--sp-1) var(--sp-2); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + @media (width <= 768px) { + width: auto; + height: 1px; + background: var(--border-subtle); + margin: var(--sp-5) 0; + + .share-divider-text { + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + } + } + } + + .config-transfer-section { + display: flex; + flex-direction: column; + gap: var(--sp-2); + min-width: 0; + } + + .config-transfer-section-header { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + margin-bottom: var(--sp-2); + + .config-transfer-section-icon { + flex-shrink: 0; + color: var(--accent); + } + + & h3 { + margin: 0; + font-size: var(--text-md); + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.01em; + } + } + + .config-transfer-section-desc { + font-size: var(--text-sm); + color: var(--text-secondary); + margin: 0 0 var(--sp-4) 0; + line-height: 1.5; + text-align: center; + } + + .share-link-wrapper { + margin-top: var(--sp-4); + display: flex; + flex-direction: column; + gap: var(--sp-3); + } + + .share-url-input { + width: 100%; + padding: var(--sp-3) var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-secondary); + font-size: var(--text-xs); + font-family: var(--font-mono); + line-height: 1.6; + word-break: break-all; + transition: + border-color var(--duration-fast), + background var(--duration-fast); + resize: vertical; + + &:focus { + outline: none; + border-color: var(--accent); + background: var(--bg-secondary); + } + + &:hover { + border-color: var(--border-primary); + } + } + + #copy-share-url-btn { + width: 100%; + justify-content: center; + padding: var(--sp-3) var(--sp-4); + font-weight: 500; + font-size: var(--text-sm); + + & .icon { + width: 16px; + height: 16px; + } + } + + .share-warning { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + font-size: var(--text-xs); + color: var(--text-secondary); + margin: var(--sp-3) 0 0; + padding: var(--sp-2) var(--sp-3); + background: color-mix(in oklch, var(--accent) 5%, transparent); + border: 1px solid color-mix(in oklch, var(--accent) 15%, transparent); + border-radius: var(--radius-md); + line-height: 1.5; + + .share-warning-icon { + width: 14px; + height: 14px; + flex-shrink: 0; + color: var(--accent); + opacity: 0.8; + } + } + + .config-transfer-section-qr { + display: flex; + flex-direction: column; + align-items: center; + + .config-transfer-section-header { + width: 100%; + } + + .config-transfer-section-desc { + margin-bottom: var(--sp-5); + } + } + + .share-qr-wrapper { + display: flex; + justify-content: center; + align-items: center; + padding: var(--sp-4); + background: light-dark(#ffffff, #0a0a0a); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + width: 100%; + max-width: 208px; + margin: 0 auto; + transition: transform var(--duration-fast); + + &:hover { + transform: scale(1.02); + } + + & canvas { + display: block; + width: 100%; + height: auto; + } + } + + .config-transfer-qr { + display: flex; + align-items: center; + justify-content: center; + padding: var(--sp-4); + background: light-dark(#ffffff, #0a0a0a); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + width: 100%; + max-width: 208px; + margin: 0 auto; + + & canvas { + display: block; + width: 100%; + height: auto; + } + } + + .share-qr-hint { + font-size: var(--text-xs); + color: var(--text-tertiary); + text-align: center; + margin: var(--sp-4) 0 0; + line-height: 1.5; + } + } + + dialog[open] > form, + dialog[open] > .dialog-panel { + opacity: 1; + transform: scale(1); + + @starting-style { + opacity: 0; + transform: scale(0.98); + } + } + + /* Breadcrumb */ + .breadcrumb { + display: flex; + align-items: center; + gap: var(--sp-1); + flex-wrap: wrap; + min-height: 28px; + + & li { + display: flex; + align-items: center; + gap: var(--sp-1); + + &:not(:last-child)::after { + content: ''; + display: inline-block; + width: 12px; + height: 12px; + background: currentColor; + opacity: 0.3; + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'/%3E%3C/svg%3E") + center / contain no-repeat; + } + + &:last-child .breadcrumb-btn { + color: var(--text-primary); + font-weight: 500; + } + } + } + + .breadcrumb-btn { + font-size: var(--text-sm); + padding: var(--sp-1); + border-radius: var(--radius-sm); + color: var(--text-secondary); + transition: + color var(--duration-fast), + background var(--duration-fast); + + &:hover { + color: var(--text-primary); + background: var(--bg-hover); + } + } + + /* File Card */ + .file-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: var(--fb-pad); + border-radius: var(--radius-lg); + cursor: pointer; + transition: background var(--duration-fast); + position: relative; + min-height: 100px; + overflow: hidden; + + &:hover { + background: var(--bg-hover); + } + &:active { + background: var(--bg-active); + } + &:hover .file-card-actions { + opacity: 1; + } + } + + /* List view card */ + [data-view='list'] .file-card { + flex-direction: row; + justify-content: flex-start; + min-height: unset; + border-radius: var(--radius-sm); + gap: var(--sp-3); + + & .file-card-icon, + & .file-card-thumb { + flex-shrink: 0; + } + & .file-card-thumb { + width: var(--fb-icon); + height: var(--fb-icon); + } + & .file-card-name { + flex: 1; + min-width: 0; + text-align: left; + -webkit-line-clamp: 1; + line-clamp: 1; + } + & .file-card-size { + flex-shrink: 0; + width: 72px; + text-align: right; + } + & .file-card-date { + display: block; + flex-shrink: 0; + width: 140px; + text-align: right; + } + & .file-card-actions { + position: static; + opacity: 1; + } + } + + /* Grid view mobile → collapse to rows */ + @media (width <= 640px) { + [data-view='grid'] .file-card { + flex-direction: row; + min-height: unset; + justify-content: flex-start; + + & .file-card-icon { + width: 28px; + height: 28px; + flex-shrink: 0; + } + & .file-card-name { + text-align: left; + -webkit-line-clamp: 1; + line-clamp: 1; + } + & .file-card-size { + display: none; + } + & .file-card-actions { + opacity: 1; + position: static; + } + & .file-card-thumb { + width: 40px; + aspect-ratio: 1; + flex-shrink: 0; + } + } + + [data-view='list'] .file-card .file-card-date { + display: none; + } + } + + .file-card-icon { + width: var(--fb-icon); + height: var(--fb-icon); + color: var(--text-tertiary); + display: flex; + align-items: center; + justify-content: center; + + &.folder { + color: var(--accent); + } + &.video { + color: light-dark(#e74c3c, #ff6b6b); + } + &.audio { + color: light-dark(#9b59b6, #b794f4); + } + &.document { + color: light-dark(#3498db, #4299e1); + } + &.archive { + color: light-dark(#f39c12, #f6ad55); + } + &.code { + color: light-dark(#27ae60, #48bb78); + } + &.text { + color: light-dark(#34495e, #718096); + } + & svg { + width: 100%; + height: 100%; + } + } + + .file-card-thumb { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: var(--radius-md); + background: var(--bg-secondary); + animation: skeleton-pulse 1.5s ease-in-out infinite; + filter: blur(12px); + scale: 1.08; + transition: + filter var(--duration-slow) var(--ease-out), + scale var(--duration-slow) var(--ease-out); + + &.loaded { + animation: none; + filter: none; + scale: 1; + } + } + + .file-card-name { + font-size: var(--fb-name); + text-align: center; + word-break: break-all; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + width: 100%; + } + + .file-card-size, + .file-card-date { + font-size: var(--text-xs); + color: var(--text-tertiary); + } + + .file-card-date { + display: none; + } + + .file-card-actions { + position: absolute; + top: var(--sp-1); + right: var(--sp-1); + opacity: 0; + transition: opacity var(--duration-fast); + + & .icon-btn { + background: color-mix(in oklch, var(--bg-primary) 70%, transparent); + backdrop-filter: blur(8px) saturate(160%); + -webkit-backdrop-filter: blur(8px) saturate(160%); + border: 1px solid color-mix(in oklch, var(--border) 50%, transparent); + border-radius: var(--radius-md); + color: var(--text-secondary); + + &:hover { + background: color-mix(in oklch, var(--bg-primary) 90%, transparent); + color: var(--text-primary); + border-color: var(--border); + } + } + } + + /* Button Group (view toggle) */ + .btn-group { + display: inline-flex; + border: 1px solid var(--border); + border-radius: var(--radius-md); + + &.view-toggle { + @media (width <= 640px) { + display: none; + } + } + + & > .icon-btn { + border-radius: 0; + + &:first-child { + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + } + &:last-child { + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + } + &[aria-pressed='true'] { + background: var(--bg-active); + color: var(--text-primary); + } + } + } + + /* Toast */ + .toast-container { + position: fixed; + bottom: var(--sp-4); + right: var(--sp-4); + display: flex; + flex-direction: column; + gap: var(--sp-2); + z-index: var(--z-toast); + pointer-events: none; + } + + .toast { + --_clr: var(--accent); + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + background: color-mix(in oklch, var(--_clr) 8%, var(--bg-primary) 90%); + backdrop-filter: blur(16px) saturate(180%); + -webkit-backdrop-filter: blur(16px) saturate(180%); + border: 1px solid color-mix(in oklch, var(--_clr) 15%, transparent); + border-radius: var(--radius-lg); + font-size: var(--text-sm); + color: var(--text-primary); + max-width: 400px; + pointer-events: auto; + animation: toast-in var(--duration-normal) var(--ease-out); + + & .toast-icon { + flex-shrink: 0; + width: 16px; + height: 16px; + color: var(--_clr); + } + + &.success { + --_clr: #4caf50; + } + &.error { + --_clr: var(--text-danger); + } + &.info { + --_clr: var(--accent); + } + &.removing { + animation: toast-out var(--duration-normal) var(--ease-out) forwards; + } + } + + /* Context Menu — Popover API with @starting-style */ + .context-menu { + position: fixed; + inset: unset; + margin: 0; + background: color-mix(in oklch, var(--bg-primary) 85%, transparent); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--sp-1); + min-width: 160px; + overflow: visible; + opacity: 1; + transform: scale(1); + transition: + opacity var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out), + display var(--duration-fast) allow-discrete, + overlay var(--duration-fast) allow-discrete; + + @starting-style { + opacity: 0; + transform: scale(0.95); + } + + &.instant { + transition: none; + } + } + + .context-menu-item { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; + padding: var(--sp-2) var(--sp-3); + font-size: var(--text-sm); + border-radius: var(--radius-sm); + transition: background var(--duration-fast); + text-align: left; + + &:hover { + background: var(--bg-hover); + } + + &.danger { + color: var(--text-danger); + + &:hover { + background: color-mix(in oklch, var(--text-danger) 8%, transparent); + } + } + } + + .context-menu-sep { + height: 1px; + background: var(--border-subtle); + margin: var(--sp-1) var(--sp-2); + } + + .has-submenu { + position: relative; + cursor: default; + + & .submenu-arrow { + margin-left: auto; + width: 14px; + height: 14px; + opacity: 0.5; + } + + & .context-submenu { + position: absolute; + left: 100%; + top: -4px; + min-width: 160px; + background: color-mix(in oklch, var(--bg-primary) 98%, transparent); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: var(--sp-1); + opacity: 0; + pointer-events: none; + transform: translateX(-4px); + transition: + opacity var(--duration-fast) var(--ease-out), + transform var(--duration-fast) var(--ease-out); + } + + &:hover > .context-submenu, + &:focus-within > .context-submenu { + opacity: 1; + pointer-events: auto; + transform: translateX(0); + } + } + + .context-submenu.flip-left { + left: auto; + right: 100%; + transform: translateX(4px); + } + + .has-submenu:hover > .context-submenu.flip-left, + .has-submenu:focus-within > .context-submenu.flip-left { + transform: translateX(0); + } + + /* Dropzone */ + .dropzone { + position: fixed; + inset: 0; + z-index: var(--z-dropzone); + background: var(--backdrop); + display: flex; + align-items: center; + justify-content: center; + animation: fade-in var(--duration-fast) ease; + + & .dropzone-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-12); + border: 2px dashed var(--accent); + border-radius: var(--radius-xl); + background: var(--bg-primary); + color: var(--text-secondary); + font-size: var(--text-md); + font-weight: 500; + + & .icon { + color: var(--accent); + } + } + } + + /* Upload Panel */ + .upload-panel { + position: fixed; + bottom: var(--sp-4); + left: var(--sp-4); + width: 320px; + max-height: 300px; + background: color-mix(in oklch, var(--bg-primary) 85%, transparent); + backdrop-filter: blur(20px) saturate(180%); + -webkit-backdrop-filter: blur(20px) saturate(180%); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + z-index: var(--z-upload-panel); + animation: slide-up var(--duration-normal) var(--ease-out); + display: flex; + flex-direction: column; + } + + .upload-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--border-subtle); + font-size: var(--text-sm); + font-weight: 500; + } + + .upload-panel-body { + padding: var(--sp-2) var(--sp-4); + overflow-y: auto; + flex: 1; + } + + .upload-item { + padding: var(--sp-2) 0; + } + + .upload-item-header { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: var(--sp-1); + min-height: 2.5em; + } + + .upload-item-name { + font-size: var(--text-xs); + color: var(--text-primary); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.4; + } + + .upload-item-status { + font-size: 10px; + color: var(--text-tertiary); + line-height: 1.4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-mono); + } + + .upload-progress { + width: 100%; + height: 4px; + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + overflow: hidden; + } + + .upload-progress-bar { + height: 100%; + background: var(--accent); + border-radius: var(--radius-sm); + transition: width var(--duration-fast); + width: 0%; + + &.indeterminate { + width: 100%; + background: linear-gradient(90deg, transparent, var(--accent), transparent); + background-size: 200% 100%; + animation: indeterminate 1.5s ease-in-out infinite; + } + + &.done { + background: #4caf50; + width: 100%; + } + &.error { + background: var(--text-danger); + width: 100%; + } + } + + /* Preview Dialog */ + #preview-dialog > .dialog-panel { + outline: none; + width: min(1000px, calc(100vw - 48px)); + height: min(720px, calc(100dvh - 48px)); + + @media (width <= 1024px) { + width: min(800px, calc(100vw - 32px)); + height: min(600px, calc(100dvh - 32px)); + } + + @media (width <= 768px) { + width: calc(100vw - 24px); + height: calc(100dvh - 24px); + } + + .dialog-body { + display: flex; + align-items: center; + justify-content: center; + + & img, + & video { + max-width: 100%; + max-height: 100%; + width: 100%; + height: 100%; + object-fit: contain; + border-radius: var(--radius-sm); + } + + & audio { + width: 100%; + max-width: 600px; + border-radius: var(--radius-sm); + } + + & pre { + width: 100%; + max-height: 100%; + overflow: auto; + } + } + } + + /* Skeleton — inherits file-browser layout variables */ + .skeleton-card { + border-radius: var(--radius-lg); + background: var(--bg-secondary); + min-height: 120px; + animation: skeleton-pulse 1.5s ease-in-out infinite; + } + + /* List view — flat rows */ + [data-view='list'] .skeleton-card { + min-height: calc(var(--fb-icon) + var(--fb-pad) * 2); + border-radius: var(--radius-sm); + } + + /* Grid on mobile — also flat rows */ + @media (width <= 640px) { + [data-view='grid'] .skeleton-card { + min-height: calc(var(--fb-icon) + var(--fb-pad) * 2); + } + } + + /* Empty State */ + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + gap: var(--sp-3); + padding: var(--sp-4); + color: var(--text-tertiary); + text-align: center; + + & p { + font-size: var(--text-md); + } + } + + /* Load More */ + .load-more { + display: flex; + justify-content: center; + padding: var(--sp-6) 0 var(--sp-2); + } + + /* Paste hint — absolute positioning within file-browser container */ + .paste-hint { + position: fixed; + bottom: var(--sp-8); + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + text-wrap: nowrap; + gap: var(--sp-1); + font-size: var(--text-xs); + color: var(--text-tertiary); + pointer-events: none; + z-index: 10; + + & kbd { + display: inline-block; + padding: 1px 5px; + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.4; + color: var(--text-tertiary); + background: var(--bg-secondary); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + } + + /* Hide on mobile — no keyboard shortcuts available */ + @media (width <= 640px) { + display: none; + } + } + + /* Hero Section */ + #hero { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100dvh; + padding: var(--sp-8) var(--sp-4); + animation: fade-in var(--duration-slow) var(--ease-out); + } + + .hero-inner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + max-width: 480px; + } + + .hero-badge { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-xl); + background: var(--accent-subtle); + margin-bottom: var(--sp-6); + } + + .hero-badge-icon { + width: 36px; + height: 36px; + object-fit: contain; + } + + .hero-title { + font-size: 28px; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: var(--sp-3); + } + + .hero-desc { + font-size: var(--text-md); + color: var(--text-secondary); + line-height: 1.6; + margin-bottom: var(--sp-8); + text-wrap: balance; + } + + .hero-actions { + display: flex; + align-items: center; + gap: var(--sp-3); + margin-bottom: var(--sp-10); + + & .btn { + padding: var(--sp-3) var(--sp-5); + font-size: var(--text-md); + } + } + + .hero-features { + display: grid; + grid-template-columns: repeat(4, max-content); + gap: var(--sp-5) var(--sp-12); + justify-content: center; + + @media (width <= 640px) { + grid-template-columns: repeat(2, max-content); + } + } + + .hero-feature { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--text-sm); + color: var(--text-tertiary); + letter-spacing: 0.02em; + white-space: nowrap; + + & .icon { + color: var(--accent); + flex-shrink: 0; + } + } + + .hero-footer { + position: fixed; + bottom: var(--sp-4); + display: flex; + align-items: center; + gap: var(--sp-2); + } + + /* Config Tabs Navigation */ + .config-tabs { + display: flex; + gap: var(--sp-1); + padding: var(--sp-2); + background: var(--bg-secondary); + border-radius: var(--radius-md); + overflow-x: auto; + scroll-behavior: smooth; + + /* Hide scrollbar but keep functionality */ + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + } + + .config-tab-btn { + flex-shrink: 0; /* Prevent button compression */ + padding: var(--sp-2) var(--sp-4); + border: 1px solid transparent; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-size: var(--text-sm); + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: all var(--duration-fast) var(--ease-out); + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + + &[aria-selected='true'] { + background: var(--bg-primary); + color: var(--accent); + border-color: var(--border-subtle); + } + + @media (width <= 640px) { + padding: var(--sp-1) var(--sp-2); + } + } + + /* Config Tab Panels */ + .config-tab-panels { + margin-top: var(--sp-2); + padding: var(--sp-2); + height: 480px; + overflow-y: auto; + overflow-x: hidden; + + @media (width <= 768px) { + height: 380px; + } + + @media (width <= 640px) { + height: 300px; + } + } + + .config-tab-panel { + display: flex; + flex-direction: column; + gap: var(--sp-6); + min-height: 100%; + + &[hidden] { + display: none; + } + } + + /* About Section Styles */ + .about-section { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-6) var(--sp-4); + text-align: center; + + @media (width <= 640px) { + gap: var(--sp-2); + padding: var(--sp-2); + } + } + + .about-header { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-3); + + @media (width <= 640px) { + gap: var(--sp-2); + } + } + + .about-meta { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-1); + } + + .about-icon { + width: 64px; + height: 64px; + border-radius: var(--radius-lg); + } + + .about-title { + font-size: var(--text-xl); + font-weight: 700; + color: var(--text-primary); + margin: 0; + } + + .about-version { + font-size: var(--text-sm); + color: var(--text-secondary); + font-family: var(--font-mono); + padding: var(--sp-1) var(--sp-2); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + } + + .about-updated { + font-size: var(--text-xs); + color: var(--text-tertiary); + font-family: var(--font-mono); + padding: var(--sp-1) var(--sp-2); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + } + + .about-desc { + font-size: var(--text-base); + color: var(--text-secondary); + max-width: 400px; + } + + .about-links { + display: flex; + flex-direction: row; + gap: var(--sp-2); + + @media (width <= 480px) { + gap: var(--sp-1); + } + } + + .about-link { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-3); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text-primary); + text-decoration: none; + transition: all var(--duration-fast) var(--ease-out); + + & .icon { + width: 18px; + height: 18px; + } + + &:hover { + background: var(--bg-secondary); + border-color: var(--accent); + color: var(--accent); + } + + @media (width <= 480px) { + padding: var(--sp-1) var(--sp-2); + + & .icon { + width: 16px; + height: 16px; + } + } + } + + .about-license { + font-size: var(--text-sm); + color: var(--text-secondary); + + & #about-license-label { + margin-right: var(--sp-2); + } + + & strong { + color: var(--text-primary); + font-family: var(--font-mono); + } + } + + .config-section { + display: flex; + flex-direction: column; + gap: var(--sp-3); + + & h3 { + font-size: var(--text-sm); + font-weight: 600; + color: var(--text-secondary); + letter-spacing: -0.01em; + margin-bottom: var(--sp-3); + } + } + + .field-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--sp-3); + + @media (width <= 480px) { + grid-template-columns: 1fr; + } + } + + /* Toolbar Select */ + .toolbar-select { + padding: var(--sp-1) var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-secondary); + font-size: var(--text-sm); + cursor: pointer; + transition: border-color var(--duration-fast); + + &:focus { + outline: none; + border-color: var(--accent); + } + &:hover { + border-color: var(--text-tertiary); + } + } + + /* Tooltip */ + .tooltip { + position: fixed; + z-index: var(--z-tooltip); + padding: var(--sp-1) var(--sp-2); + background: var(--text-primary); + color: var(--bg-primary); + font-size: var(--text-xs); + border-radius: var(--radius-sm); + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity var(--duration-fast) var(--ease-out); + + &.visible { + opacity: 1; + } + } + + /* File card checkbox */ + .file-card-checkbox-wrap { + position: absolute; + top: var(--sp-1); + left: var(--sp-1); + z-index: 1; + opacity: 0; + line-height: 0; + transition: opacity var(--duration-fast) var(--ease-out); + + & input[type='checkbox'] { + appearance: none; + width: 18px; + height: 18px; + border: 1.5px solid color-mix(in oklch, var(--text-secondary) 50%, transparent); + border-radius: 4px; + cursor: pointer; + flex-shrink: 0; + transition: border-color var(--duration-fast) var(--ease-out); + + &:checked { + background: var(--accent) + url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 8'%3E%3Cpolyline points='1 4 4 7 9 1' fill='none' stroke='white' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") + no-repeat center / 10px 8px; + border-color: var(--accent); + } + } + } + + .file-card.selected .file-card-checkbox-wrap { + opacity: 1; + } + + /* 批量模式下始终显示 checkbox */ + #app.batch-mode .file-card-checkbox-wrap { + opacity: 1; + } + + .file-card.selected { + background: color-mix(in oklch, var(--accent) 10%, var(--bg-primary)); + outline: 1.5px solid color-mix(in oklch, var(--accent) 50%, transparent); + outline-offset: -1.5px; + } + + [data-view='list'] .file-card-checkbox-wrap { + position: static; + display: flex; + align-items: center; + flex-shrink: 0; + width: 0; + overflow: hidden; + opacity: 0; + transition: + width var(--duration-fast) var(--ease-out), + opacity var(--duration-fast) var(--ease-out); + } + + #app.batch-mode [data-view='list'] .file-card-checkbox-wrap { + width: 18px; + } + + @media (width <= 640px) { + .file-card-checkbox-wrap { + position: static; + display: flex; + align-items: center; + flex-shrink: 0; + width: 0; + overflow: hidden; + opacity: 0; + transition: + width var(--duration-fast) var(--ease-out), + opacity var(--duration-fast) var(--ease-out); + } + + #app.batch-mode .file-card-checkbox-wrap { + width: 18px; + } + } + + #batch-toggle-btn { + & #batch-toggle-icon-exit { + display: none; + } + + &[aria-pressed='true'] { + & #batch-toggle-icon-enter { + display: none; + } + + & #batch-toggle-icon-exit { + display: inline; + } + } + } + + /* Batch action bar */ + .batch-action-bar { + display: flex; + align-items: center; + gap: var(--sp-3); + white-space: nowrap; + transition: opacity var(--duration-normal) var(--ease-out); + + @starting-style { + opacity: 0; + } + + @media (width <= 640px) { + position: fixed; + bottom: var(--sp-3); + left: var(--sp-3); + right: var(--sp-3); + padding: var(--sp-2) var(--sp-3); + background: var(--bg-primary); + border-radius: var(--radius-lg); + z-index: var(--z-context-menu); + justify-content: space-between; + transition: + opacity var(--duration-normal) var(--ease-out), + transform var(--duration-normal) var(--ease-out); + + @starting-style { + opacity: 0; + transform: translateY(8px); + } + } + + & #batch-cancel-btn { + display: none; + + @media (width <= 640px) { + display: inline-flex; + } + } + + &.hiding { + opacity: 0; + pointer-events: none; + + @media (width <= 640px) { + transform: translateY(8px); + } + } + } + + .batch-count { + font-size: var(--text-sm); + color: var(--text-secondary); + font-weight: 500; + white-space: nowrap; + } + + .batch-actions { + display: flex; + align-items: center; + gap: var(--sp-2); + } +} diff --git a/src/css/layout.css b/src/css/layout.css new file mode 100644 index 0000000..2579e3e --- /dev/null +++ b/src/css/layout.css @@ -0,0 +1,102 @@ +/* --- Layout --- */ +@layer layout { + #app { + display: grid; + grid-template-rows: auto auto 1fr; + height: 100dvh; + } + + .topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-2) var(--sp-4); + border-bottom: 1px solid var(--border); + background: color-mix(in oklch, var(--bg-primary) 80%, transparent); + backdrop-filter: blur(12px) saturate(180%); + -webkit-backdrop-filter: blur(12px) saturate(180%); + position: sticky; + top: 0; + z-index: 10; + + & h1 { + font-size: var(--text-md); + font-weight: 600; + } + } + + .topbar-logo { + width: 20px; + height: 20px; + object-fit: contain; + } + + .toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-2) var(--sp-4); + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-primary); + gap: var(--sp-2); + flex-wrap: wrap; + + @media (width <= 640px) { + flex-direction: column; + align-items: stretch; + + & > .flex:last-child { + justify-content: flex-end; + } + } + } + + .file-browser { + position: relative; + display: flex; + flex-direction: column; + padding: var(--sp-4); + overflow-y: auto; + + /* Density tokens */ + --fb-grid-min: 160px; + --fb-gap: var(--sp-3); + --fb-pad: var(--sp-3); + --fb-icon: 40px; + --fb-name: var(--text-sm); + + &[data-density='compact'] { + --fb-grid-min: 120px; + --fb-gap: var(--sp-2); + --fb-pad: var(--sp-2); + --fb-icon: 32px; + --fb-name: var(--text-xs); + } + + &[data-density='loose'] { + --fb-grid-min: 200px; + --fb-gap: var(--sp-4); + --fb-pad: var(--sp-4); + --fb-icon: 48px; + --fb-name: var(--text-base); + } + } + + .file-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--fb-grid-min), 1fr)); + gap: var(--fb-gap); + + [data-view='list'] > & { + grid-template-columns: 1fr; + } + + @media (320px <= width <= 640px) { + grid-template-columns: 2fr; + } + + @media (width <= 320px) { + grid-template-columns: 1fr; + } + } +} diff --git a/src/css/reset.css b/src/css/reset.css new file mode 100644 index 0000000..a18abb9 --- /dev/null +++ b/src/css/reset.css @@ -0,0 +1,57 @@ +/* --- Reset --- */ +@layer reset { + *, + *::before, + *::after { + box-sizing: border-box; + } + * { + margin: 0; + padding: 0; + } + html { + -webkit-text-size-adjust: 100%; + } + body { + min-height: 100dvh; + } + img, + svg, + video, + canvas { + display: block; + max-width: 100%; + } + button, + input, + select, + textarea { + font: inherit; + color: inherit; + } + button { + cursor: pointer; + border: none; + background: none; + } + a { + color: inherit; + text-decoration: none; + } + ul, + ol { + list-style: none; + } + [hidden] { + display: none !important; + } + dialog { + border: none; + padding: 0; + max-width: unset; + max-height: unset; + } + dialog::backdrop { + background: transparent; + } +} diff --git a/src/css/tokens.css b/src/css/tokens.css new file mode 100644 index 0000000..8b3cd6f --- /dev/null +++ b/src/css/tokens.css @@ -0,0 +1,75 @@ +/* --- Design Tokens --- */ +@layer tokens { + :root { + color-scheme: light dark; + + /* Spacing */ + --sp-1: 4px; + --sp-2: 8px; + --sp-3: 12px; + --sp-4: 16px; + --sp-5: 20px; + --sp-6: 24px; + --sp-8: 32px; + --sp-10: 40px; + --sp-12: 48px; + + /* Typography */ + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; + --text-xs: 11px; + --text-sm: 12px; + --text-base: 13px; + --text-md: 14px; + --text-lg: 16px; + --text-xl: 18px; + + /* Radii */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-full: 9999px; + + /* Accent */ + --accent: #f6821f; + --accent-hover: color-mix(in oklch, var(--accent) 82%, black); + --accent-subtle: color-mix(in oklch, var(--accent) 12%, transparent); + + /* Transitions */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --duration-fast: 120ms; + --duration-normal: 200ms; + --duration-slow: 300ms; + + /* Z-index */ + --z-dropzone: 100; + --z-upload-panel: 90; + --z-context-menu: 200; + --z-dialog: 300; + --z-toast: 2147483647; + --z-tooltip: 2147483647; + + /* Theme colors — single declaration via light-dark() */ + --bg-primary: light-dark(#ffffff, #1a1a1a); + --bg-secondary: light-dark(#f5f5f5, #222222); + --bg-tertiary: light-dark(#ebebeb, #2a2a2a); + --bg-hover: light-dark(color-mix(in oklch, black 4%, transparent), color-mix(in oklch, white 5%, transparent)); + --bg-active: light-dark(color-mix(in oklch, black 7%, transparent), color-mix(in oklch, white 8%, transparent)); + --border: light-dark(#e0e0e0, #333333); + --border-subtle: light-dark(#eeeeee, #2a2a2a); + --text-primary: light-dark(#1a1a1a, #e8e8e8); + --text-secondary: light-dark(#666666, #999999); + --text-tertiary: light-dark(#999999, #666666); + --text-danger: light-dark(#d32f2f, #ef5350); + --backdrop: light-dark(color-mix(in oklch, black 30%, transparent), color-mix(in oklch, black 50%, transparent)); + } + + /* Explicit theme override */ + [data-theme='light'] { + color-scheme: light; + } + [data-theme='dark'] { + color-scheme: dark; + } +} diff --git a/src/css/utilities.css b/src/css/utilities.css new file mode 100644 index 0000000..f998961 --- /dev/null +++ b/src/css/utilities.css @@ -0,0 +1,30 @@ +/* --- Utilities --- */ +@layer utilities { + .sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .text-secondary { + color: var(--text-secondary); + } + .flex { + display: flex; + } + .items-center { + align-items: center; + } +} diff --git a/src/favicon.ico b/src/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ed1668d8749b5f62b6a86bfe0ff2a005b5ea7665 GIT binary patch literal 9423 zcmZ{Kbx<2X(>D@I2ow)4!HNVcu7%)M8YmE;KyfQx+zC($#odd$m*T}exRheWi@QUC zx6d=*Kkqm5?%dt(?Qd`Qc5d#EyV-EKy5e&X6$lLt?YXj&oF*C?2J}B2i1|-L>BLU; zkM+aVwB-Ns^I-bZP`WE5{dp*ztO(#Nl;tdh;VPK^Z#cu0Gu1^9ALm=jY_;0ug{s~v1|12RFA&?6s1i?e_&&E>d`DHNV zzm5JehVeG^Kk`!w^TmJ1-5}KrZ7MOW$_AKL!}4WfUFV!R8Xe+YwMu?3O+%S6&uQ~fO(JL4k5zabo_aVFYnhYFgx1^;c7YD@~4<*TUys z$3wRM7ls~!2I8{Ed*_Am7W4U1H2A@tzVSC*JpLCY(L5=*TM9kj)6_R4R_yaVCUf!G zw~P`47&+XQ1pHVQEL$a_i>a!*W;Bv~Rr8recV!dDnXWWn_+Pjo$n!rm88~ZPSeD{h zut$gf%~F4mR8`qkw5ithiTv>?hZU4bOfuQgyy(7^BRUgVv8&OkH?0X#-0d;#pr&iD~ zFB{e#FHfaW71t29_d&BnNtDatW-d0A9tXbJzZ$gLxl#Cce&cRlyc1-fUOo!YEa`7i z>NZH;xR6uNOo8YX%ER@v^CSo~<{N_#OUiS1TgsJ<%iwZe+%j95SP9w{xm_0)Lds=5 ze&c#>THHeecC`}|YL3Zq?3Lb{W7TxzyZ7@3vKtg$WJ52sTvP^MCsVia&aydA;!-1q z39iQ6%I#?)x@!yNio%bhW!G_==@dNGk@=+aIvjK zPRHl8g)F`d*x%;ai}I2@u5g+WL9sM_R67(T7KP zUo@Pwn|kNiTc9}Fz5HQu79?4MNmo_nKb;_Ik-2QB_2bS<3UIPsRjARkJN_lYNFp|P zwP#swMF-x=>-UT1baxxe{oy>T9jGnzcWC_ij9KxSfWE`CmN%T@T7OKQs}Db6O=cB~ z>8og4XmqIEW?Y&U2y+#_q~XdL3(-%m8w|*valXC9?#+0=knAF}RphY!b(?Uh!rOaV z(^E_QpfdXOEU1Rdfb3zjf+E2}oBlD88>MzF8=5+jkvm*Pf9JL`H8@3==}46~ypixN z==Yk6i#qj9F`hz2BJw8|pKy{ORUOuP z1zM*w)l#|CALT7kTXq6UF6!GjUg@mC=JlC}1>pv(B_s+c#!3|+h@m$s&srj*sFXy6 zW?F!9z!Sr4Wb_q9 zE0DzJ#Op+Jdn~ssg?829ghGo`hej_XQ7qX=f-MDu>+hV$1wYn{-;{tDR>(B>$3jI`MK-8Khx(*0IkwUPR^uOa!jm zR#=FJZ~#P{c~IP0o%N*}bTdM<#TodVD5z@64^D4N?+h1hhR)sVzmd;^Y~cB>P28|_ z{6A*ooxydhbt9Lwp~9lyhV5}TP6a-``lyj;B0jb)@%oQR_?nE9;Oz3myRt-Y?5_Rm z_@!N5+mcWhL%Wp-v%oWRJzG7aEmu9w+DNq3=wh1*=Z2z^u)1+h`c9YAoPKIobDjg}I~x$CR!%D{t@RY-PmJJzO5 z`MwW%qpXI{550Up>xWcYf1ANq%hwd~YPbM3$bG)6Jm=R2D%BDsX*BjkM!>SvJxIwT z$p{ess^{b$Si(?1x85I-Bt5_EhKPAi&KI~{)DQVSPwN+m$x)S=d|DlO?Iz9&GqoPm zcLsI0h2q<^R_A#cNg_BLlpHY=Ig9&>q2`l_uXLi+;LM7qbBAK$?M*~p=%oQ0+Islo zYVqNKQ1jU~007J<5-hhH#{VWBp~RrKq_L4un8htkx0xG1IZPX1)LAsYgvAL#G1}kB z9oZd$HCWErzekyM7R|&21`7OcTPvS%T15)bHMQH*$(GsGrXBJ`&}8@MT;0oWG2G?$T9nX?2T!J7t=zQv#d&jmJ6@REOJ%;?U&qRC`P;-=C|&*4|6&A=S*IDRq0+2SHeSJphp?aY}M5~z6W6t zXZdmEv$I3=@B)rgqVl?=KAK(#c30SoDw#&8mv*!z+BIY15NdrbY2KDSrGhhU zHFaeKtk43+)9xVnxiel9i{UKojD*nQ4|XER#E>U&jiUhd9M0tc#?Bz02j$ zTN64F$)O4!Zeni-cPBrP&mHrSd^3aIBg9jZ4QhIymxhj2ZE88cwJ4Q&ose<14z=+} zD_dlG$sgBJ6J;V>HMr~pk==y0@+!Y(r958pR8A4eEfcf1)eJx<_8bK!Pgeta8R8tF z_s90`2^45g!V?M&Vyg3lSU5^d$Sr@SY$!0-k>3IuwALzZ^zOkon9?@K(t`{s5_J=# zW{9OGmJ5~KW$oA*Ly-k%PD?TSj8xpR>x{K;`z}(;+rmENZiifPh|z+avF^`+nHS{T8Km!h?XZ%vTb*nIRck zF-((_b;a_A`o7~MA3`fy`4xHwZ4tVZJ{@LNMN=?i`J2jxvsR}HNNijWUNlG>;PT$B zj@Jqh2m!2X+dICE7yw!Q)IJ-{Eix`Uuy~o|E2-}BjAv@(ntuHalF(G1oJcLBd_9OQ zyN)r=eM*;6H&RFP)*Zh)($11CaK05^i3S5#ewmP80gyepWE`i9{L^fv#Ppt=E>EdI z{S`TWa!WgyI0lTNYz&0xk`D(B>u*CCfXJDP+krz649|3nn0j&DZ7nE5?%U$IG+tZj z7URY1n^FcD#*<;gCT>!Bzqp+OBJ`I(b&9p6ral5-{TQ`*dEA?W;PA69#y!yQrSE$K z#m_$zjlB(e2bFQlrP=!;#}EGLDOvTtCiAiO1q2O44@3$>y5*AoL+CgeK5O)h?>dgl z^HhcC$fVWE8yd0^XfWZT%rxTO>|Kc#9qua$=yb;ol|*R8A^wFQJjlj1tr!q&JrlSv z4A-qKFOR4*Z;piFNl?!0Q=y!wkU-!O#Q2$_n;~0Fah{TSk!Q`tKA5w3k}3r9(EGb@ zxNE=2}>*ok<8F>;4tkj7mT%BAoOLz=}eFr zASa#b5vj;AiGHZ@p1IDK^Ty44!yBNQk+JUGJw(qMQ0H-UXtM~XQAn;C>?F5~WcY<& zn?)Fw9E)mk+ZXM;e$os+`psfvPA_fh_4RT7hT-DyjiOlm7FEtLed7)hig+`ktL{p@ z8!Z0l=G347-{3*G+K|5*&!!4bcBu&|y;bGBns%+#GmBz^(atesp|jWHYUesKKWjF{ zKQG+vB9?F``v3sARoypCR2tq8Z$`oVfl( z(+bb1SrfBKL*1NS;ng_o`J5V9&v8p+a+?0e6r5Osl{`2F1jEVkvqolX3~%@{b(0}c zq*ArDiW!PA!q83l*K8J>w|!xLGQX7)gG_5y|KwUQZnQ317Ihk%;&Ls9hN+O56ce!p zNn-lQ98OL@7rGl*gX<(09d@VWI54X;7VDZUrwfy`C^-rb<`PL{;~Fl8^1zmqNY5!A z*9CBsuXg_8q32R&_0Bf_%ceJ~7osg*69Mfps&RVn$RJ z;v#?VzNR`MNL*?AOoig&RZZ)A#ODbbd_UE_6>i_xWa;wF#uyx{MOK6DAz#ijo--15 z=5rD*G~&L07OvwYANwf>gKB+P&`iUCm63BT1WMgNw^(zZgSR!#6E}NBstbc}^0=~oW<&0Us z0m%ksSDhq8rz}J58RTTtcEL?kSwGZ9rdDsv1w7r)Abh)&T#t5=>vc{KQWhYA( zQO*@<*xtN5s>P@M?fWE7Tj3Jg#y?q{3xO~KPa8qkJ|bIc_RCD5q5nL*42fn`4eC{H zCMFx@AsN&%SkpHm{P@x^PAJGdh_(s&qxH4* ziKcMnnPX+LrGf7W`8&(|jHe`~|T^4)s&TtM7!QpV4 z5&6PoCb!(!58rrq+R`(>^zRPhYRvZmt$gzk!qqJsgh+DFpfeNK9Sj(!n(Ge$Rdh($ z2!Ns-h;oT&(8PY!*Kx9A<+HMh>~yY5KqxdZ!RJJCRz3Fvt;uBd0l%b~e9_og_2#~( z*0EjOBW~uT?r-;dQu8d=M{62H1-2n9vtilNtp2#^{^D z32YN4CuP^$oOKqtNg_sWGO{Jrwvi2YnvGj_dXfE= z8%}f908rTWR5J3A-ewC8SRLGHW!PH2GknEr`RHK*3tWnqBYz_gC&Az0xlgIgKk=HC zPJ-o5s5pEA)}6K19cbYc)Is@n#=4IssL_Wx31_NGg3+4(I?p)Jrk|Pz!Bd=mrI3k} zcB?Z*1FHkqDqKPf4*Z$+94Y+3ZK&3zNv^^8q5!A zww+C}ZCaQr`x(HFe6oTSRPP;tm#e&=(_DYSh^ADG^iD6?G2pwShkh$lCtz|y16BV3Gy#k&{p{`>0;K8)RS7Ocn9|S`HyeGQu^AdYK+!+>vyQ_ z;Q1;46WiYk*cu$9TDgfV8Km#+ABvnsqevL$lhdmXVwaC9E*|d>c6{UG8sum{fJR{k z)p@dl;r)nx@3IpcDz*}t@Qr8l1qYsJPa`QdQgK!0&kaqcM(ZvX=P2a1!+{1#wY71FvLtS#<~@)uhB}G;?Eh{u=5`^quEUD07qm_J9S>R0yrob}z7n_KxqJCnF% z7P_wHo!k7}B}!XdtQZ7^mNopNzv4qsJyf~ROt>+-qQAr@QvU?gF z=C76>@7HF2VWCklqjnm8qV;}6fz_<`np}*L+1TbcH@n4>Jg5hQe4-GvxETX5lfjT| zPVmMkkI$jx`=6havN&))rv6S{RT;;WAr$_4q#uwEt2odaDmW@jU5!wExD%%*_wb<( zQ$UH;@L(bOL`!1Q51!I3a_Jj#Ckh|m6IwJ-mZh#m zqz+^nk@aRMu4665?0sl3vFoK7q(HcTbs_LLW6)e-I6udjksb_XV2Wk0`Ydf{0fU6L z?=Q6@j8?Ww+5e;DU5m)XwB|jeu#yRaqE?-12=D)Xf#^#_H2KDmrFR_ za(j%aIv{kIJq@H$zkT*}4@QD^Sh)GI%?wP`+A|VrnwXml;&W%Mk1kpY6ej2I04qETjls2wx}M1cUiOJqEWMfOqk| z59(HE;vxb7XW5sa?_bGsdM}#=ICMrnZpH8`yT>vo_;*kc_u>wGOYKY=r-q}M#M*Xz zT=7M;uQ$Cj`7>Ma9?#S4-OrvFKnhhEt?-5FBB%C<+ZMDN^vBM{2rrki$)U|x{D^0U zW6{L)C(yhF+JR_2H`c_@crL$m9Fx)F@|?r?rLawVMG91%#PFb!>(^lNKI&6d;s>~i z$mD@kmU5LqPgZdjjO1O7NsWQ8hBMZRp(vxw!VsyKz24327R-=+biUEWrHc0tz~-fd zPE9@kcNYs0oK&04SZFs9UzSS=ju0r>c?A>vi6p_DDKitQCZg%e8r(8j7rhwf(iax} zQoQcXbW#>qm74e^he7Wm>V?%?S-3G_2BIo67245((Ne@&<7GQ*DPRzcpY|VUvw&$BeE!{N2sIVL~`5Ip>b_5{^6P5JV)dGsEironV8%gQ*I|Df~ zsT(^3^8TI5q8~m@qMqbWF8lMR78lcUDC-``Y-(>-1by_h!6(*cLO`&_KvWwMvURn` zthziclV9%GE73J|rDbT+UFUK+L|Aq7!NFAw6KvTX>3I6I1~P-`U?3TrTnW~OZ!;+e zcU!ub3NXu7=Qkb=yF3sAvLX&*X!1^O0f7cMW*Ps*LuN;lP>XH@4yDuTvnuaM5I1iY z!2RYz=ddX6Fv0;67FKZu^8SGETsP5XFG=JTcqMO*eKb>QgMCOg!z6pWg$}kDp#g9qhDJ?VtU6#yN4# za&pTsUuP%DGbEq0+m_cDA4UZH71CqYPPcnzQQhmxh=4KEg?wjpne28U}YNod_@9*ZUOWRJ$7KKrhTj8@`@l zzOUYCw(~)+j2-`})wn^HpSJ8om}uK4c}p(BodXtSq0^on(NRCf(69CG6-+jcqj$%j zI^D{_wJDU`p3xb8znHzEpSgITV^FUCTZG}Y%%X_!K9pRns_BQQ(qF}I4nDb+pMNvw zewQ(fLUj+;yO34t*8KnspyVGS?=t5l#3d4RdcA=W#xF&79+N&)Mqf8o=v26`uzru% zvfsk%h<9SIyii~-Owo+y4@uGjQFqept0yteAmOT5)75Xnd*Mcn}mE917HF?h+rG5TNb#zHr=q)2! zp8-l)$x|Ik4wpXg6nmsjpNo*m-EIJ&Xa_vev#)4v{0fO;e5fW=eqO_Jx9pDelS$`@ z9%GOza|fAcpvt9jd|z(8nOT?6K9{~t7ORdzs$v=YEe0t&C@i=AWJ1v;DWAxGwdcd^W`eXW+Hp98c?nu>Ee zoNEn-lYGi2p!7H?JO7?}tpAc=~4B zNKuUKlYiTIqDNXaKmd-(%ZA%=eeWxC;!mi3c?|=u9;Z56pSu*9AJ1h@`1AJ-?P2R- z=||7*T9b`he;Bn+m+Ch9WLF+$-m%Q*XWdLv)({(cNaszJ{;c#>?{9GHP`E`l>IdfW zo3XTcX+pwfwrNa7d3sSvtG1mgJK!9R@aQ)g;~5ZtR))Pl6_HcO#CYd2W! zC3Xdk^BY=3tlIab$$+W@XN+j8UY#8ydy&t7x<_P2*P=AxfG?Rz7bse5(mXh7PHHSc zmw<_oO=?6}IcBqb5pC$#h|Ft-EC9L@_vZkI+PdEd(hZ5}$2TJMhobhn^ZvuSHs5@( zsPLs`T=x&>o#jFJ$isoiB=V!3+5(c-QtVEa20rSN$&qy}nh$$qK=`je%@e!L@nRK_ z$6OVt;|hDMysF`~%~z7S-8;y7SR*AT8je!`j25bY$lW(?XeM^jOvIqF+DWd02dQztD_ zL&5l;W|FRW6|&G~Icu@3v<9^_x8a^ln7h!L8D%OD%6bO5lc@XkBAJOKTO6Bm1YReH(=_IqUA>}Bs@ckv4N_Wu7`A12o7h8SDE`O3*DU^rmlk8v;cVb+-ep;^3n@~B z^pD2+q>^KzOi;veSW^0D0L>O#s%~_N0Y~QpQ@Qef7;7Os`K$WK2U$SUiyR6TeH@{V zx`lcpdpK7YIr6!IEND}pjJP_Djb`|ZvKsLV%K$#O4jcs_UJm1 zSUh>uj0xyg?c5z160TkX#r&=r-kFodj3BDn-5p0@fzPt3iu_*#)Edl zm|68*;;?Eh#17?;!Iqf!dsMel|KK!hy##_*vlFwJ-5gZaoR}?~b&saV?t9(dw$lcuk=vV+^`wq0I{?qS_T-APqrti4$wt*nL&MLh!RC7(`)z-OGKIdl>C{%t$a zH22p!26N2kj7P@>7dkyN*UcEqar!3w0p)P)&>w>_8|5Yxaf@HcG0ndwwoO`#PweA$4Y1-y3$IW8n9FL;(i z|Km`vsW0w|AEZFjf=ZLOzJGSv#~m>LJzV~yiv1r^@lSfSFDtEI+b2_sN9)xIrf^bi zESmR6ZyhfGjO1L<{CmVDo?bzZ{esN!6k=DqVKeH0L7~46#kVDOE9;?~J=pTl%#^yh7KL1wB^^t+l(U51q8Ly`KrxqWJLo z)h%zjn*A5$ekum~J4@@^s4SmFdlFzbTBK8)g^Im?Je3O(GD9BdBN`g)T=(tdk!KdH zGb>Ox!-g%PdmiK&I9qMvt{X)0i*usp#8D>f-(xT6$45a4iAJ@OK6#vwU=EH5go5M* z49mAukLgPUNEQB5OFyF^F8(z<@)YpAme<8Niz48{uPW}U8d+)|@y)8{C_MXVMuj8j&UP119|CZT^ zv@WE0-q-I)Pq2CTqPZVS6ip8f#oh1QY)xo9#Ravrlq-_>)zS$ekZZ$($_S9Df#h+-k8j-MMvMT5!V7 zYO&f}s;x?>W%z1HO&T#LJiD2|cT#RiuBZw3yS>Q-?&NzK8Y*c%^M@aPnJ$JjEGtKdKyPI8`{D$SF?JXR;W z;qYBmm?t7kqQib>nIL2#x}+&NYVp^X1GZ0!G*#7I9@ zN9C0LFY2bF0sgnT<+eiz`M;3=e=_y|EP-c@besRGl>+n8f~@JJ(R_C6{^LMdUR|zA H#u)iO|Dh0E literal 0 HcmV?d00001 diff --git a/src/index.html b/src/index.html new file mode 100644 index 0000000..5d434ff --- /dev/null +++ b/src/index.html @@ -0,0 +1,1532 @@ + + + + + + + + + R2 Web + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

R2 Web

+
+ +
+
+ +
+ +
+ + + + + +
+ + +
+ +
+
+
+
+ + +
+ +
+
+ +
+
+
+ + +
+ +
+
+ +
+
+
+ + +
+ +
+
+
+ + + + + + + + + + + + +
+
+ + +
+
+ + + + + + + + + +
+
+ +
+ + + + + +
+
+
+ +
+
+ + +
+ + + + +
+ + + + +
+ +
+ + + +
+
+

提示

+
+
+ +
+ + + + +
+
+ +
+
+ + + +
+
+

路径选择

+
+
+

+
+ + + +
+
+ +
+
+ + + +
+
+

确认

+
+
+

+
+ +
+
+ + + +
+
+

文件已存在

+
+
+

+
+ +
+
+ + + +
+
+

二维码

+
+ +
+
+
+
+ +
+

+
+
+ +
+ +
+
+
+ + + +
+
+

分享配置

+
+ +
+
+ +
+ +
+ + + + +
+
+ + + + + + +

扫描二维码

+
+

使用移动设备相机扫描

+ + +
+
+
+
+
+ + +
+ + + + + + + + diff --git a/src/js/app.js b/src/js/app.js new file mode 100644 index 0000000..a0577ba --- /dev/null +++ b/src/js/app.js @@ -0,0 +1,854 @@ +import { + VERSION, + UPDATED_AT, + THEME_KEY, + LANG_KEY, + VIEW_KEY, + DENSITY_KEY, + SORT_BY_KEY, + SORT_ORDER_KEY, +} from './constants.js' +import { ConfigManager } from './config-manager.js' +import { FileExplorer } from './file-explorer.js' +import { FileOperations } from './file-operations.js' +import { FilePreview } from './file-preview.js' +import { UploadManager } from './upload-manager.js' +import { R2Client } from './r2-client.js' +import { UIManager } from './ui-manager.js' +import { getCurrentLang, setLang, t } from './i18n.js' +import { $, getErrorMessage } from './utils.js' +import dayjs from 'dayjs' + +/** @typedef {'zh'|'en'|'ja'} Lang */ + +class App { + /** @type {ConfigManager} */ + #config + /** @type {R2Client} */ + #r2 + /** @type {UIManager} */ + #ui + /** @type {FileExplorer | null} */ + #explorer = null + /** @type {UploadManager | null} */ + #upload = null + /** @type {FilePreview | null} */ + #preview = null + /** @type {FileOperations | null} */ + #ops = null + #appEventsBound = false + #batchMode = false + /** @type {((e: TransitionEvent) => void) | null} */ + #onBarTransitionEnd = null + + constructor() { + this.#config = new ConfigManager() + this.#r2 = new R2Client() + this.#ui = new UIManager() + + this.#ui.initTheme() + this.#ui.initTooltip() + + const urlParams = new URLSearchParams(window.location.search) + const configParam = urlParams.get('config') + if (configParam) { + if (this.#config.loadFromBase64(configParam)) { + const cleanUrl = new URL(window.location.href) + cleanUrl.searchParams.delete('config') + window.history.replaceState({}, '', cleanUrl.toString()) + const lang = localStorage.getItem(LANG_KEY) + if (lang) setLang(/** @type {Lang} */ (lang)) + const theme = localStorage.getItem(THEME_KEY) + if (theme) this.#ui.setTheme(theme) + } + } + + this.#applyI18nToHTML() + + if (this.#config.isValid()) { + this.#connectAndLoad() + if (configParam) { + setTimeout(() => this.#ui.toast(t('configLoadedFromUrl'), 'success'), 500) + } + } else { + this.#showHero() + } + + this.#bindGlobalEvents() + this.#bindHeroEvents() + } + + #applyI18nToHTML() { + document.title = t('appTitle') + $('.topbar-title').textContent = t('appTitle') + + const heroTitle = $('#hero-title') + if (heroTitle) heroTitle.textContent = t('appTitle') + const heroDesc = $('#hero-desc') + if (heroDesc) heroDesc.textContent = t('heroDesc') + const heroConnectText = $('#hero-connect-text') + if (heroConnectText) heroConnectText.textContent = t('heroConnect') + const heroF1 = $('#hero-f1') + if (heroF1) heroF1.textContent = t('heroF1') + const heroF2 = $('#hero-f2') + if (heroF2) heroF2.textContent = t('heroF2') + const heroF3 = $('#hero-f3') + if (heroF3) heroF3.textContent = t('heroF3') + const heroF4 = $('#hero-f4') + if (heroF4) heroF4.textContent = t('heroF4') + const heroF5 = $('#hero-f5') + if (heroF5) heroF5.textContent = t('heroF5') + const heroF6 = $('#hero-f6') + if (heroF6) heroF6.textContent = t('heroF6') + const heroF7 = $('#hero-f7') + if (heroF7) heroF7.textContent = t('heroF7') + const heroF8 = $('#hero-f8') + if (heroF8) heroF8.textContent = t('heroF8') + + $('#tab-preferences').textContent = t('configTabPreferences') + $('#tab-r2').textContent = t('configTabR2') + $('#tab-upload').textContent = t('configTabUpload') + $('#tab-compression').textContent = t('configTabCompression') + $('#tab-about').textContent = t('configTabAbout') + + $('#lbl-theme').textContent = t('lblTheme') + const themeSelect = $('#cfg-theme') + if (themeSelect) { + $('option[value="light"]', themeSelect).textContent = t('themeLight') + $('option[value="dark"]', themeSelect).textContent = t('themeDark') + $('option[value="auto"]', themeSelect).textContent = t('themeAuto') + } + + $('#lbl-language').textContent = t('lblLanguage') + + $('#lbl-density').textContent = t('lblDensity') + const densitySelect = $('#cfg-density') + if (densitySelect) { + $('option[value="compact"]', densitySelect).textContent = t('densityCompact') + $('option[value="normal"]', densitySelect).textContent = t('densityNormal') + $('option[value="loose"]', densitySelect).textContent = t('densityLoose') + } + + $('#config-title').textContent = t('appTitle') + $('#lbl-account-id').textContent = t('accountId') + $('#lbl-access-key').textContent = t('accessKeyId') + $('#lbl-secret-key').textContent = t('secretAccessKey') + $('#lbl-bucket').textContent = t('bucketName') + $('#lbl-custom-domain').textContent = t('customDomain') + $('#lbl-bucket-access').textContent = t('bucketAccess') + const bucketAccessSelect = $('#cfg-bucket-access') + if (bucketAccessSelect) { + $('option[value="public"]', bucketAccessSelect).textContent = t('bucketAccessPublic') + $('option[value="private"]', bucketAccessSelect).textContent = t('bucketAccessPrivate') + } + + $('#lbl-filename-tpl').textContent = t('filenameTpl') + $('#lbl-filename-tpl-scope').textContent = t('filenameTplScope') + $('#lbl-upload-concurrency').textContent = t('uploadConcurrency') + const filenameScopeSelect = $('#cfg-filename-tpl-scope') + if (filenameScopeSelect) { + $('option[value="images"]', filenameScopeSelect).textContent = t('filenameTplScopeImages') + $('option[value="all"]', filenameScopeSelect).textContent = t('filenameTplScopeAll') + } + $('#filename-tpl-hint').textContent = t('filenameTplHintDetailed') + + $('#lbl-compress-mode').textContent = t('compressMode') + + const compressModeSelect = $('#cfg-compress-mode') + if (compressModeSelect) { + $('option[value="none"]', compressModeSelect).textContent = t('compressModeNone') + $('option[value="local"]', compressModeSelect).textContent = t('compressModeLocal') + $('option[value="tinify"]', compressModeSelect).textContent = t('compressModeTinify') + } + + $('#lbl-compress-level').textContent = t('compressLevel') + + const compressLevelSelect = $('#cfg-compress-level') + if (compressLevelSelect) { + $('option[value="balanced"]', compressLevelSelect).textContent = t('compressLevelBalanced') + $('option[value="extreme"]', compressLevelSelect).textContent = t('compressLevelExtreme') + } + + $('#lbl-tinify-key').textContent = 'Tinify API Key' + $('#tinify-key-hint-text').textContent = t('tinifyKeyHintText') + $('#tinify-key-link').textContent = t('tinifyKeyLink') + + $('#config-cancel').textContent = t('cancel') + $('#config-submit').textContent = t('save') + $('#config-dialog-close').dataset.tooltip = t('close') + + $('#about-version').textContent = `v${VERSION}` + $('#about-updated').textContent = `${t('aboutUpdatedLabel')}: ${dayjs(UPDATED_AT).format('YYYY-MM-DD HH:mm:ss')}` + $('#about-description').textContent = t('aboutDescription') + $('#about-github').textContent = t('aboutGithub') + $('#about-changelog').textContent = t('aboutChangelog') + $('#about-qq-group').textContent = t('aboutQQGroup') + $('#about-license-label').textContent = t('aboutLicense') + + $('#help-theme').dataset.tooltip = t('tooltipTheme') + $('#help-language').dataset.tooltip = t('tooltipLanguage') + $('#help-density').dataset.tooltip = t('tooltipDensity') + + $('#help-account-id').dataset.tooltip = t('tooltipAccountId') + $('#help-access-key').dataset.tooltip = t('tooltipAccessKeyId') + $('#help-secret-key').dataset.tooltip = t('tooltipSecretAccessKey') + $('#help-bucket').dataset.tooltip = t('tooltipBucket') + $('#help-custom-domain').dataset.tooltip = t('tooltipCustomDomain') + $('#help-bucket-access').dataset.tooltip = t('tooltipBucketAccess') + + $('#help-filename-tpl').dataset.tooltip = t('tooltipFilenameTpl') + $('#help-filename-tpl-scope').dataset.tooltip = t('tooltipFilenameTplScope') + $('#help-compress-mode').dataset.tooltip = t('tooltipCompressMode') + $('#help-compress-level').dataset.tooltip = t('tooltipCompressLevel') + $('#help-tinify-key').dataset.tooltip = t('tooltipTinifyKey') + + $('#sort-asc-btn').dataset.tooltip = t('sortAsc') + $('#sort-desc-btn').dataset.tooltip = t('sortDesc') + + $('#view-grid-btn').dataset.tooltip = t('viewGrid') + $('#view-list-btn').dataset.tooltip = t('viewList') + + $('#sort-name-btn').dataset.tooltip = t('sortName') + $('#sort-date-btn').dataset.tooltip = t('sortDate') + $('#sort-size-btn').dataset.tooltip = t('sortSize') + + $('#new-folder-btn span').textContent = t('newFolder') + $('#upload-btn span').textContent = t('upload') + + $('#dropzone-text').textContent = t('dropToUpload') + + $('#empty-state p').textContent = t('emptyFolder') + $('#empty-upload-btn').lastChild.textContent = ' ' + t('uploadFiles') + $('#empty-upload-hint').textContent = t('uploadHint') + $('#paste-hint-text').textContent = t('pasteHint') + + $('#load-more-btn').textContent = t('loadMore') + this.#explorer?.updateCountDisplay() + + $('[data-action="preview"] span').textContent = t('preview') + $('[data-action="download"] span').textContent = t('download') + $('#ctx-copy-link > span').textContent = t('copyLink') + $('[data-action="copyPath"] span').textContent = t('copyPath') + $('[data-action="copyUrl"] span').textContent = t('copyUrl') + $('[data-action="copyMarkdown"] span').textContent = t('copyMarkdown') + $('[data-action="copyHtml"] span').textContent = t('copyHtml') + $('[data-action="copyImage"] span').textContent = t('copyImage') + $('[data-action="copyPresigned"] span').textContent = t('copyPresigned') + $('[data-action="shareQr"] span').textContent = t('shareQr') + $('[data-action="editContentType"] span').textContent = t('editContentType') + $('[data-action="rename"] span').textContent = t('rename') + $('[data-action="copy"] span').textContent = t('copy') + $('[data-action="move"] span').textContent = t('move') + $('[data-action="delete"] span').textContent = t('delete') + + $('#share-btn').dataset.tooltip = t('shareConfig') + $('#settings-btn').dataset.tooltip = t('settings') + $('#logout-btn').dataset.tooltip = t('logout') + $('#refresh-btn').dataset.tooltip = t('refresh') + $('#preview-copy-text').dataset.tooltip = t('copyText') + $('#preview-copy-image').dataset.tooltip = t('copyImage') + $('#preview-copy').dataset.tooltip = t('copyLink') + $('#preview-download').dataset.tooltip = t('download') + $('#preview-close').dataset.tooltip = t('close') + $('#file-qr-close').dataset.tooltip = t('close') + $('#view-grid-btn').dataset.tooltip = t('viewGrid') + $('#view-list-btn').dataset.tooltip = t('viewList') + $('#upload-panel-close').dataset.tooltip = t('close') + + $('#prompt-cancel').textContent = t('cancel') + $('#prompt-ok').textContent = t('ok') + + $('#confirm-cancel').textContent = t('cancel') + $('#confirm-ok').textContent = t('confirm') + + $('#filename-path-title').textContent = t('filenameTplPathTitle') + $('#filename-path-desc').textContent = t('filenameTplPathDesc') + $('#filename-path-cancel').textContent = t('cancel') + $('#filename-path-ok').textContent = t('confirm') + + $('#share-dialog-title').textContent = t('shareDialogTitle') + $('#share-dialog-subtitle').textContent = t('shareDialogSubtitle') + $('#share-divider-text').textContent = t('shareDividerText') + $('#share-link-title').textContent = t('shareLinkTitle') + $('#share-link-desc').textContent = t('shareLinkDesc') + $('#share-qr-title').textContent = t('shareQrTitle') + $('#share-qr-desc').textContent = t('shareQrDesc') + $('#share-qr-hint').textContent = t('shareQrHint') + $('#copy-share-url-text').textContent = t('copyShareUrl') + $('#share-warning').textContent = t('shareWarning') + $('#share-dialog-close').dataset.tooltip = t('close') + + $('#file-qr-title').textContent = t('fileQrTitle') + $('#file-qr-desc').textContent = t('fileQrDesc') + $('#file-qr-copy-text').textContent = t('copyLink') + + this.#updateSelectAllBtn() + $('#batch-move-text').textContent = t('move') + $('#batch-copy-text').textContent = t('copy') + $('#batch-delete-text').textContent = t('delete') + $('#batch-toggle-text').textContent = t(this.#batchMode ? 'exitBatchMode' : 'batchMode') + + this.#ui.initTooltip() + } + + async #connectAndLoad() { + try { + this.#r2.init(this.#config) + this.#explorer = new FileExplorer(this.#r2, this.#ui) + this.#upload = new UploadManager(this.#r2, this.#ui, this.#explorer, this.#config) + this.#preview = new FilePreview(this.#r2, this.#ui) + this.#ops = new FileOperations(this.#r2, this.#ui, this.#explorer) + + this.#hideHero() + $('#app').hidden = false + this.#restoreViewPrefs() + this.#explorer.setOnSelectionChange((count) => { + if (!this.#batchMode) return + $('#batch-count').textContent = t('batchSelected', { count }) + this.#updateSelectAllBtn() + }) + if (!this.#appEventsBound) { + this.#upload.initDragDrop() + this.#bindAppEvents() + this.#appEventsBound = true + } + await this.#explorer.navigate('') + } catch (/** @type {any} */ err) { + if (err.message === 'AUTH_FAILED') { + this.#config.clear() + /** @type {HTMLElement} */ + $('#app').hidden = true + this.#showHero() + } + } + } + + #restoreViewPrefs() { + const view = localStorage.getItem(VIEW_KEY) || 'grid' + const density = localStorage.getItem(DENSITY_KEY) || 'normal' + const sortBy = localStorage.getItem(SORT_BY_KEY) || 'name' + const sortOrder = /** @type {'asc' | 'desc'} */ (localStorage.getItem(SORT_ORDER_KEY) || 'asc') + this.#setView(view) + this.#setDensity(density) + this.#setSortBy(sortBy) + this.#setSortOrder(sortOrder) + } + + #enterBatchMode() { + this.#batchMode = true + const app = /** @type {HTMLElement} */ ($('#app')) + app.classList.add('batch-mode') + const btn = /** @type {HTMLElement} */ ($('#batch-toggle-btn')) + btn.setAttribute('aria-pressed', 'true') + $('#batch-toggle-text').textContent = t('exitBatchMode') + const bar = /** @type {HTMLElement} */ ($('#batch-action-bar')) + if (this.#onBarTransitionEnd) { + bar.removeEventListener('transitionend', this.#onBarTransitionEnd) + this.#onBarTransitionEnd = null + } + bar.classList.remove('hiding') + bar.hidden = false + $('#batch-count').textContent = t('batchSelected', { count: 0 }) + this.#updateSelectAllBtn() + } + + #exitBatchMode() { + this.#batchMode = false + const explorer = /** @type {FileExplorer} */ (this.#explorer) + explorer.clearSelection() + const app = /** @type {HTMLElement} */ ($('#app')) + app.classList.remove('batch-mode') + const btn = /** @type {HTMLElement} */ ($('#batch-toggle-btn')) + btn.setAttribute('aria-pressed', 'false') + $('#batch-toggle-text').textContent = t('batchMode') + const bar = /** @type {HTMLElement} */ ($('#batch-action-bar')) + bar.classList.add('hiding') + this.#onBarTransitionEnd = (/** @type {TransitionEvent} */ e) => { + if (e.propertyName !== 'opacity') return + bar.removeEventListener('transitionend', this.#onBarTransitionEnd) + this.#onBarTransitionEnd = null + bar.hidden = true + bar.classList.remove('hiding') + } + bar.addEventListener('transitionend', this.#onBarTransitionEnd) + } + + #updateSelectAllBtn() { + if (!this.#explorer) { + $('#batch-select-all-text').textContent = t('batchSelectAll') + return + } + const total = document.querySelectorAll('#file-grid .file-card').length + const selected = this.#explorer.selectionCount + const allSelected = total > 0 && selected === total + $('#batch-select-all-text').textContent = allSelected ? t('batchDeselectAll') : t('batchSelectAll') + } + + /** @param {string} view */ + #setView(view) { + $('#file-browser').dataset.view = view + $('#view-grid-btn').setAttribute('aria-pressed', String(view === 'grid')) + $('#view-list-btn').setAttribute('aria-pressed', String(view === 'list')) + localStorage.setItem(VIEW_KEY, view) + } + + /** @param {string} density */ + #setDensity(density) { + $('#file-browser').dataset.density = density + localStorage.setItem(DENSITY_KEY, density) + } + + /** @param {string} sortBy */ + #setSortBy(sortBy) { + $('#sort-name-btn').setAttribute('aria-pressed', String(sortBy === 'name')) + $('#sort-date-btn').setAttribute('aria-pressed', String(sortBy === 'date')) + $('#sort-size-btn').setAttribute('aria-pressed', String(sortBy === 'size')) + if (this.#explorer) this.#explorer.setSortBy(sortBy) + localStorage.setItem(SORT_BY_KEY, sortBy) + } + + /** @param {'asc' | 'desc'} order */ + #setSortOrder(order) { + $('#sort-asc-btn').setAttribute('aria-pressed', String(order === 'asc')) + $('#sort-desc-btn').setAttribute('aria-pressed', String(order === 'desc')) + if (this.#explorer) this.#explorer.setSortOrder(order) + localStorage.setItem(SORT_ORDER_KEY, order) + } + + #showHero() { + $('#hero').hidden = false + $('#app').hidden = true + } + + #hideHero() { + $('#hero').hidden = true + } + + #bindHeroEvents() { + $('#hero-connect-btn').addEventListener('click', () => { + this.#showConfigDialog('r2') + }) + } + + /** + * Show Config Dialog + * @param {string} [defaultTab='preferences'] + */ + #showConfigDialog(defaultTab = 'preferences') { + const dialog = /** @type {HTMLDialogElement} */ ($('#config-dialog')) + + const tabButtons = /** @type {NodeListOf} */ (dialog.querySelectorAll('.config-tab-btn')) + const tabPanels = /** @type {NodeListOf} */ (dialog.querySelectorAll('.config-tab-panel')) + + /** @param {string} tabId */ + const switchTab = (tabId) => { + tabButtons.forEach((btn) => { + const isActive = btn.dataset.tab === tabId + btn.setAttribute('aria-selected', String(isActive)) + }) + tabPanels.forEach((panel) => { + panel.hidden = panel.dataset.panel !== tabId + }) + } + + tabButtons.forEach((btn) => { + btn.addEventListener('click', () => { + const tabId = btn.dataset.tab + if (tabId) switchTab(tabId) + }) + }) + + switchTab(defaultTab) + + const themeInput = /** @type {HTMLSelectElement | null} */ ($('#cfg-theme')) + const languageInput = /** @type {HTMLSelectElement | null} */ ($('#cfg-language')) + const densityInput = /** @type {HTMLSelectElement | null} */ ($('#cfg-density')) + + const cfg = this.#config.get() + const accountInput = /** @type {HTMLInputElement} */ ($('#cfg-account-id')) + const accessInput = /** @type {HTMLInputElement} */ ($('#cfg-access-key')) + const secretInput = /** @type {HTMLInputElement} */ ($('#cfg-secret-key')) + const bucketInput = /** @type {HTMLInputElement} */ ($('#cfg-bucket')) + const tplInput = /** @type {HTMLInputElement} */ ($('#cfg-filename-tpl')) + const tplScopeInput = /** @type {HTMLSelectElement | null} */ ($('#cfg-filename-tpl-scope')) + const domainInput = /** @type {HTMLInputElement} */ ($('#cfg-custom-domain')) + const bucketAccessInput = /** @type {HTMLSelectElement} */ ($('#cfg-bucket-access')) + + const compressModeInput = /** @type {HTMLSelectElement} */ ($('#cfg-compress-mode')) + const compressLevelInput = /** @type {HTMLSelectElement} */ ($('#cfg-compress-level')) + const tinifyKeyInput = /** @type {HTMLInputElement} */ ($('#cfg-tinify-key')) + const uploadConcurrencyInput = /** @type {HTMLSelectElement} */ ($('#cfg-upload-concurrency')) + + const currentTheme = localStorage.getItem(THEME_KEY) || 'auto' + const savedLang = getCurrentLang() + const currentDensityValue = localStorage.getItem(DENSITY_KEY) || 'normal' + + if (themeInput) themeInput.value = currentTheme + if (languageInput) languageInput.value = savedLang + if (densityInput) densityInput.value = currentDensityValue + + if (cfg.accountId) accountInput.value = cfg.accountId + if (cfg.accessKeyId) accessInput.value = cfg.accessKeyId + if (cfg.secretAccessKey) secretInput.value = cfg.secretAccessKey + if (cfg.bucket) bucketInput.value = cfg.bucket + if (cfg.filenameTpl) tplInput.value = cfg.filenameTpl + if (tplScopeInput) tplScopeInput.value = cfg.filenameTplScope || 'images' + if (cfg.customDomain) domainInput.value = cfg.customDomain + if (bucketAccessInput) bucketAccessInput.value = cfg.bucketAccess || 'public' + + if (compressModeInput) compressModeInput.value = cfg.compressMode || 'none' + if (compressLevelInput) compressLevelInput.value = cfg.compressLevel || 'balanced' + if (tinifyKeyInput) tinifyKeyInput.value = cfg.tinifyKey || '' + if (uploadConcurrencyInput) uploadConcurrencyInput.value = String(cfg.uploadConcurrency ?? 3) + + const updateCompressVisibility = () => { + const mode = compressModeInput ? compressModeInput.value : 'none' + const localOpts = $('#compress-local-options') + const tinifyOpts = $('#compress-tinify-options') + if (localOpts) localOpts.hidden = mode !== 'local' + if (tinifyOpts) tinifyOpts.hidden = mode !== 'tinify' + } + + if (compressModeInput) { + compressModeInput.onchange = updateCompressVisibility + updateCompressVisibility() + } + + $('#config-cancel').onclick = () => dialog.close() + $('#config-dialog-close').onclick = () => dialog.close() + + const onBackdropClick = (/** @type {Event} */ e) => { + if (e.target === dialog) dialog.close() + } + dialog.addEventListener('click', onBackdropClick) + + dialog.addEventListener( + 'close', + () => { + dialog.removeEventListener('click', onBackdropClick) + if (!this.#config.isValid()) { + this.#showHero() + } + }, + { once: true }, + ) + + $('#config-submit').onclick = async () => { + const newTheme = themeInput ? themeInput.value : 'auto' + if (newTheme !== currentTheme) { + this.#ui.setTheme(newTheme) + } + + const newLang = languageInput ? languageInput.value : 'zh' + if (newLang !== savedLang) { + setLang(/** @type {Lang} */ (newLang)) + this.#applyI18nToHTML() + } + + const newDensity = densityInput ? densityInput.value : 'normal' + if (newDensity !== currentDensityValue) { + this.#setDensity(newDensity) + } + + this.#config.save({ + accountId: accountInput.value.trim(), + accessKeyId: accessInput.value.trim(), + secretAccessKey: secretInput.value.trim(), + bucket: bucketInput.value.trim(), + filenameTpl: tplInput ? tplInput.value.trim() : '', + filenameTplScope: tplScopeInput ? tplScopeInput.value : 'images', + customDomain: domainInput ? domainInput.value.trim().replace(/\/+$/, '') : '', + bucketAccess: bucketAccessInput ? /** @type {'public' | 'private'} */ (bucketAccessInput.value) : 'public', + compressMode: compressModeInput ? compressModeInput.value : 'none', + compressLevel: compressLevelInput ? compressLevelInput.value : 'balanced', + tinifyKey: tinifyKeyInput ? tinifyKeyInput.value.trim() : '', + uploadConcurrency: uploadConcurrencyInput ? Number(uploadConcurrencyInput.value) : 3, + }) + + dialog.close() + await this.#connectAndLoad() + } + + dialog.querySelectorAll('form.config-tab-panel').forEach((form) => { + const formElement = /** @type {HTMLFormElement} */ (form) + formElement.onsubmit = (/** @type {Event} */ e) => e.preventDefault() + }) + + dialog.showModal() + } + + #bindGlobalEvents() { + $('#settings-btn').addEventListener('click', () => this.#showConfigDialog()) + + $('#logout-btn').addEventListener('click', async () => { + const ok = await this.#ui.confirm(t('logoutConfirmTitle'), t('logoutConfirmMsg')) + if (!ok) return + this.#config.clear() + $('#app').hidden = true + this.#showHero() + }) + + $('#share-btn').addEventListener('click', async () => { + if (!this.#config.isValid()) { + this.#ui.toast(t('authFailed'), 'error') + return + } + const url = this.#config.getShareUrl() + await this.#ui.showShareDialog(url) + }) + + document.addEventListener('click', (e) => { + const target = /** @type {HTMLElement} */ (e.target) + if (!target.closest('.context-menu') && !target.closest('.file-card-menu')) { + this.#ui.hideContextMenu(true) + } + }) + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.#ui.hideContextMenu() + } + }) + } + + #bindAppEvents() { + $('#refresh-btn').addEventListener('click', async () => { + const btn = /** @type {HTMLElement} */ ($('#refresh-btn')) + btn.classList.add('refreshing') + btn.addEventListener('animationend', () => btn.classList.remove('refreshing'), { once: true }) + await this.#explorer.refresh() + }) + + $('#breadcrumb').addEventListener('click', (e) => { + const btn = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.breadcrumb-btn')) + if (btn) { + /** @type {FileExplorer} */ this.#explorer.navigate(btn.dataset.prefix ?? '') + } + }) + + $('#file-grid').addEventListener('click', (e) => { + const target = /** @type {HTMLElement} */ (e.target) + const menuBtn = /** @type {HTMLElement | null} */ (target.closest('.file-card-menu')) + if (menuBtn) { + e.stopPropagation() + const card = /** @type {HTMLElement} */ (menuBtn.closest('.file-card')) + const rect = menuBtn.getBoundingClientRect() + this.#ui.showContextMenu(rect.right, rect.bottom, card.dataset.key ?? '', card.dataset.isFolder === 'true', { + size: Number(card.dataset.size ?? 0), + mod: Number(card.dataset.mod ?? 0), + }) + return + } + + const checkboxWrap = /** @type {HTMLElement | null} */ (target.closest('.file-card-checkbox-wrap')) + if (checkboxWrap) { + e.stopPropagation() + const card = /** @type {HTMLElement | null} */ (checkboxWrap.closest('.file-card')) + if (card) /** @type {FileExplorer} */ (this.#explorer).toggleSelect(card.dataset.key ?? '', card.dataset.isFolder === 'true') + return + } + + const card = /** @type {HTMLElement | null} */ (target.closest('.file-card')) + if (card) { + if (this.#batchMode) { + /** @type {FileExplorer} */ (this.#explorer).toggleSelect(card.dataset.key ?? '', card.dataset.isFolder === 'true') + return + } + if (e.ctrlKey || e.metaKey) { + /** @type {FileExplorer} */ (this.#explorer).toggleSelect(card.dataset.key ?? '', card.dataset.isFolder === 'true') + return + } + /** @type {FileExplorer} */ (this.#explorer).clearSelection() + if (card.dataset.isFolder === 'true') { + /** @type {FileExplorer} */ this.#explorer.navigate(card.dataset.key ?? '') + } else { + /** @type {FilePreview} */ this.#preview.preview({ + key: card.dataset.key ?? '', + size: Number(card.dataset.size ?? 0), + lastModified: Number(card.dataset.mod ?? 0), + }) + } + } + }) + + $('#file-grid').addEventListener('contextmenu', (e) => { + const card = /** @type {HTMLElement | null} */ (/** @type {HTMLElement} */ (e.target).closest('.file-card')) + if (card) { + e.preventDefault() + this.#ui.showContextMenu(e.clientX, e.clientY, card.dataset.key ?? '', card.dataset.isFolder === 'true', { + size: Number(card.dataset.size ?? 0), + mod: Number(card.dataset.mod ?? 0), + }) + } + }) + + $('#context-menu').addEventListener('click', (e) => { + const item = /** @type {HTMLElement | null} */ ( + /** @type {HTMLElement} */ (e.target).closest('.context-menu-item') + ) + if (!item) return + + const action = item.dataset.action + if (!action) return + + const menu = /** @type {HTMLElement} */ ($('#context-menu')) + const key = menu.dataset.key ?? '' + const isFolder = menu.dataset.isFolder === 'true' + + this.#ui.hideContextMenu() + + switch (action) { + case 'preview': + /** @type {FilePreview} */ this.#preview.preview({ + key, + size: Number(menu.dataset.size ?? 0), + lastModified: Number(menu.dataset.mod ?? 0), + }) + break + case 'download': + /** @type {FileOperations} */ this.#ops.download(key) + break + case 'copyPath': + /** @type {FileOperations} */ this.#ops.copyAs(key, 'path') + break + case 'copyUrl': + /** @type {FileOperations} */ this.#ops.copyAs(key, 'url') + break + case 'copyMarkdown': + /** @type {FileOperations} */ this.#ops.copyAs(key, 'markdown') + break + case 'copyHtml': + /** @type {FileOperations} */ this.#ops.copyAs(key, 'html') + break + case 'copyImage': + /** @type {FileOperations} */ this.#ops.copyImage(key) + break + case 'copyPresigned': + /** @type {FileOperations} */ this.#ops.copyAs(key, 'presigned') + break + case 'shareQr': + /** @type {FileOperations} */ this.#ops.shareQr(key) + break + case 'editContentType': + /** @type {FileOperations} */ this.#ops.editContentType(key) + break + case 'rename': + /** @type {FileOperations} */ this.#ops.rename(key, isFolder) + break + case 'copy': + /** @type {FileOperations} */ this.#ops.copy(key, isFolder) + break + case 'move': + /** @type {FileOperations} */ this.#ops.move(key, isFolder) + break + case 'delete': + /** @type {FileOperations} */ this.#ops.delete(key, isFolder) + break + } + }) + + const fileInput = /** @type {HTMLInputElement} */ ($('#file-input')) + $('#upload-btn').addEventListener('click', () => fileInput.click()) + $('#empty-upload-btn').addEventListener('click', () => fileInput.click()) + + fileInput.addEventListener('change', () => { + if (fileInput.files && fileInput.files.length > 0) { + /** @type {UploadManager} */ this.#upload.uploadFiles([...fileInput.files]) + fileInput.value = '' + } + }) + + $('#new-folder-btn').addEventListener('click', async () => { + const name = await this.#ui.prompt(t('newFolderTitle'), t('newFolderLabel')) + if (!name) return + try { + const key = this.#explorer.currentPrefix + name + await this.#r2.createFolder(key) + this.#ui.toast(t('folderCreated', { name }), 'success') + await this.#explorer.refresh() + } catch (/** @type {any} */ err) { + const errorKey = getErrorMessage(err) + if (errorKey === 'networkError') { + this.#ui.toast(t('networkError', { msg: err.message }), 'error') + } else { + this.#ui.toast(t(/** @type {any} */ (errorKey)), 'error') + } + } + }) + + $('#load-more-btn').addEventListener('click', () => /** @type {FileExplorer} */ (this.#explorer).loadMore()) + + const previewDialog = /** @type {HTMLDialogElement} */ ($('#preview-dialog')) + $('#preview-close').addEventListener('click', () => previewDialog.close()) + previewDialog.addEventListener('click', (e) => { + if (e.target === previewDialog) previewDialog.close() + }) + $('#preview-download').addEventListener('click', () => /** @type {FilePreview} */ (this.#preview).downloadCurrent()) + $('#preview-copy-text').addEventListener('click', () => + /** @type {FilePreview} */ (this.#preview).copyCurrentText(), + ) + $('#preview-copy-image').addEventListener('click', () => + /** @type {FilePreview} */ (this.#preview).copyCurrentImage(), + ) + $('#preview-copy').addEventListener('click', () => /** @type {FilePreview} */ (this.#preview).copyCurrentLink()) + + $('#upload-panel-close').addEventListener('click', () => { + $('#upload-panel').hidden = true + }) + + $('#sort-name-btn').addEventListener('click', () => this.#setSortBy('name')) + $('#sort-date-btn').addEventListener('click', () => this.#setSortBy('date')) + $('#sort-size-btn').addEventListener('click', () => this.#setSortBy('size')) + + $('#sort-asc-btn').addEventListener('click', () => this.#setSortOrder('asc')) + $('#sort-desc-btn').addEventListener('click', () => this.#setSortOrder('desc')) + + $('#view-grid-btn').addEventListener('click', () => this.#setView('grid')) + $('#view-list-btn').addEventListener('click', () => this.#setView('list')) + + $('#batch-delete-btn').addEventListener('click', async () => { + const items = /** @type {FileExplorer} */ (this.#explorer).getSelection() + if (!items.length) return + const executed = await /** @type {FileOperations} */ (this.#ops).deleteMany(items) + if (executed) this.#exitBatchMode() + }) + + $('#batch-move-btn').addEventListener('click', async () => { + const items = /** @type {FileExplorer} */ (this.#explorer).getSelection() + if (!items.length) return + const executed = await /** @type {FileOperations} */ (this.#ops).moveMany(items) + if (executed) this.#exitBatchMode() + }) + + $('#batch-copy-btn').addEventListener('click', async () => { + const items = /** @type {FileExplorer} */ (this.#explorer).getSelection() + if (!items.length) return + const executed = await /** @type {FileOperations} */ (this.#ops).copyMany(items) + if (executed) this.#exitBatchMode() + }) + + $('#batch-cancel-btn').addEventListener('click', () => { + this.#exitBatchMode() + }) + + $('#batch-select-all-btn').addEventListener('click', () => { + const total = document.querySelectorAll('#file-grid .file-card').length + const selected = /** @type {FileExplorer} */ (this.#explorer).selectionCount + if (total > 0 && selected === total) { + /** @type {FileExplorer} */ (this.#explorer).clearSelection() + } else { + /** @type {FileExplorer} */ (this.#explorer).selectAll() + } + }) + + $('#batch-toggle-btn').addEventListener('click', () => { + if (this.#batchMode) { + this.#exitBatchMode() + } else { + this.#enterBatchMode() + } + }) + } +} + +export { App } diff --git a/src/js/config-manager.js b/src/js/config-manager.js new file mode 100644 index 0000000..f8370b0 --- /dev/null +++ b/src/js/config-manager.js @@ -0,0 +1,92 @@ +import { STORAGE_KEY, THEME_KEY, LANG_KEY, VIEW_KEY, DENSITY_KEY, SORT_BY_KEY, SORT_ORDER_KEY } from './constants.js' + +/** @typedef {{ accountId?: string; accessKeyId?: string; secretAccessKey?: string; bucket?: string; filenameTpl?: string; filenameTplScope?: string; customDomain?: string; bucketAccess?: 'public' | 'private'; compressMode?: string; compressLevel?: string; tinifyKey?: string; uploadConcurrency?: number }} AppConfig */ +/** @typedef {AppConfig & { theme?: string; lang?: string; view?: string; density?: string; sortBy?: string; sortOrder?: string }} SharePayload */ + +class ConfigManager { + /** @returns {AppConfig} */ + load() { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') || {} + } catch { + return /** @type {AppConfig} */ ({}) + } + } + + /** @param {AppConfig} cfg */ + save(cfg) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)) + } + + /** @returns {AppConfig} */ + get() { + return this.load() + } + + clear() { + localStorage.removeItem(STORAGE_KEY) + } + + isValid() { + const c = this.load() + return !!(c.accountId && c.accessKeyId && c.secretAccessKey && c.bucket) + } + + getEndpoint() { + const c = this.load() + return `https://${c.accountId}.r2.cloudflarestorage.com` + } + + getBucketUrl() { + const c = this.load() + return `${this.getEndpoint()}/${c.bucket}` + } + + toBase64() { + /** @type {SharePayload} */ + const payload = { + ...this.load(), + theme: localStorage.getItem(THEME_KEY) || undefined, + lang: localStorage.getItem(LANG_KEY) || undefined, + view: localStorage.getItem(VIEW_KEY) || undefined, + density: localStorage.getItem(DENSITY_KEY) || undefined, + sortBy: localStorage.getItem(SORT_BY_KEY) || undefined, + sortOrder: localStorage.getItem(SORT_ORDER_KEY) || undefined, + } + return btoa(unescape(encodeURIComponent(JSON.stringify(payload)))) + } + + /** @param {string} b64 @returns {boolean} */ + loadFromBase64(b64) { + try { + const json = decodeURIComponent(escape(atob(b64))) + /** @type {SharePayload} */ + const payload = JSON.parse(json) + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false + + const { theme, lang, view, density, sortBy, sortOrder, ...r2Config } = payload + if (theme) localStorage.setItem(THEME_KEY, theme) + if (lang) localStorage.setItem(LANG_KEY, lang) + if (view) localStorage.setItem(VIEW_KEY, view) + if (density) localStorage.setItem(DENSITY_KEY, density) + if (sortBy) localStorage.setItem(SORT_BY_KEY, sortBy) + if (sortOrder) localStorage.setItem(SORT_ORDER_KEY, sortOrder) + + if (Object.values(r2Config).some(Boolean)) this.save(r2Config) + return true + } catch { + /* invalid base64 or JSON */ + } + return false + } + + getShareUrl() { + const b64 = this.toBase64() + const url = new URL(window.location.href) + url.searchParams.set('config', b64) + url.hash = '' + return url.toString() + } +} + +export { ConfigManager } diff --git a/src/js/constants.js b/src/js/constants.js new file mode 100644 index 0000000..b1399b6 --- /dev/null +++ b/src/js/constants.js @@ -0,0 +1,22 @@ +export const VERSION = '1.12.3' +export const UPDATED_AT = '2026-05-19T06:55:36.933Z' +export const STORAGE_KEY = 'r2-manager-config' +export const THEME_KEY = 'r2-manager-theme' +export const LANG_KEY = 'r2-manager-lang' +export const VIEW_KEY = 'r2-manager-view' +export const DENSITY_KEY = 'r2-manager-density' +export const SORT_BY_KEY = 'r2-manager-sort-by' +export const SORT_ORDER_KEY = 'r2-manager-sort-order' +export const PAGE_SIZE = 300 +export const TOAST_DURATION = 3000 +export const MAX_UPLOAD_SIZE = 300 * 1024 * 1024 // 300 MB + +export const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp|svg|ico|bmp|avif)$/i +export const COMPRESSIBLE_IMAGE_RE = /\.(jpe?g|png|webp|avif)$/i +export const TEXT_RE = + /\.(txt|md|json|xml|csv|html|css|js|ts|jsx|tsx|yaml|yml|toml|ini|cfg|conf|log|sh|bash|py|rb|go|rs|java|c|cpp|h|hpp|sql|env|gitignore|dockerfile)$/i +export const AUDIO_RE = /\.(mp3|wav|ogg|flac|aac|m4a|wma)$/i +export const VIDEO_RE = /\.(mp4|webm|ogg|mov|avi|mkv|m4v)$/i +export const DOCUMENT_RE = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|odt|ods|odp|rtf)$/i +export const ARCHIVE_RE = /\.(zip|rar|7z|tar|gz|bz2|xz|tgz)$/i +export const CODE_RE = /\.(js|ts|jsx|tsx|py|rb|go|rs|java|c|cpp|h|hpp|sh|bash)$/i diff --git a/src/js/file-explorer.js b/src/js/file-explorer.js new file mode 100644 index 0000000..35900e1 --- /dev/null +++ b/src/js/file-explorer.js @@ -0,0 +1,406 @@ +import { filesize } from 'filesize' +import { IMAGE_RE } from './constants.js' +import { t } from './i18n.js' +import { R2Client } from './r2-client.js' +import { UIManager } from './ui-manager.js' +import { $, formatDate, getErrorMessage, getFileIconSvg, extractFileName, getFileType } from './utils.js' + +/** @typedef {{ key: string; isFolder: boolean; size?: number; lastModified?: string }} FileItem */ +/** @typedef {{ data: { folders: FileItem[], files: FileItem[], isTruncated: boolean, nextToken: string }, ts: number }} CacheEntry */ +/** @typedef {{ key: string; isFolder: boolean }} SelectionItem */ + +const CACHE_TTL = 5 * 60 * 1000 // 5 minutes + +class FileExplorer { + /** @type {R2Client} */ + #r2 + /** @type {UIManager} */ + #ui + #prefix = '' + #continuationToken = '' + /** @type {IntersectionObserver} */ + #thumbnailObserver + #sortBy = 'name' + #sortOrder = /** @type {'asc' | 'desc'} */ ('asc') + /** @type {Map} */ + #cache = new Map() + /** @type {FileItem[]} */ + #loadedItems = [] + /** @type {Map} */ + #selection = new Map() + /** @type {((count: number) => void) | null} */ + #onSelectionChange = null + #loadGen = 0 + + /** @param {R2Client} r2 @param {UIManager} ui */ + constructor(r2, ui) { + this.#r2 = r2 + this.#ui = ui + + this.#thumbnailObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const card = /** @type {HTMLElement} */ (entry.target) + const key = card.dataset.key ?? '' + this.#thumbnailObserver.unobserve(card) + this.#lazyLoadThumbnail(card, key) + } + } + }, + { rootMargin: '120px' }, + ) + } + + get currentPrefix() { + return this.#prefix + } + + get currentSortBy() { + return this.#sortBy + } + + get currentSortOrder() { + return this.#sortOrder + } + + /** @param {(count: number) => void} cb */ + setOnSelectionChange(cb) { + this.#onSelectionChange = cb + } + + /** @returns {SelectionItem[]} */ + getSelection() { + return [...this.#selection.values()] + } + + get selectionCount() { + return this.#selection.size + } + + clearSelection() { + if (this.#selection.size === 0) return + this.#selection.clear() + document.querySelectorAll('#file-grid .file-card.selected').forEach((el) => { + el.classList.remove('selected') + const cb = /** @type {HTMLInputElement|null} */ (el.querySelector('.file-card-checkbox-wrap input')) + if (cb) cb.checked = false + }) + this.#onSelectionChange?.(0) + } + + /** + * @param {string} key + * @param {boolean} isFolder + */ + toggleSelect(key, isFolder) { + if (this.#selection.has(key)) { + this.#selection.delete(key) + } else { + this.#selection.set(key, { key, isFolder }) + } + const card = /** @type {HTMLElement|null} */ ( + document.querySelector(`#file-grid .file-card[data-key="${CSS.escape(key)}"]`) + ) + const selected = this.#selection.has(key) + card?.classList.toggle('selected', selected) + const cb = /** @type {HTMLInputElement|null} */ (card?.querySelector('.file-card-checkbox-wrap input')) + if (cb) cb.checked = selected + this.#onSelectionChange?.(this.#selection.size) + } + + selectAll() { + const cards = /** @type {NodeListOf} */ (document.querySelectorAll('#file-grid .file-card')) + cards.forEach((card) => { + const key = card.dataset.key ?? '' + const isFolder = card.dataset.isFolder === 'true' + this.#selection.set(key, { key, isFolder }) + card.classList.add('selected') + const cb = /** @type {HTMLInputElement|null} */ (card.querySelector('.file-card-checkbox-wrap input')) + if (cb) cb.checked = true + }) + this.#onSelectionChange?.(this.#selection.size) + } + + /** @param {string} sortBy */ + setSortBy(sortBy) { + this.#sortBy = sortBy + this.#resortAndRender() + } + + /** @param {'asc' | 'desc'} order */ + setSortOrder(order) { + this.#sortOrder = order + this.#resortAndRender() + } + + #resortAndRender() { + if (this.#loadedItems.length === 0) return + $('#file-grid').innerHTML = '' + this.#renderItems(this.#sortItems(this.#loadedItems)) + this.#restoreSelectionUI() + } + + #restoreSelectionUI() { + if (this.#selection.size === 0) return + this.#selection.forEach((_, key) => { + const card = /** @type {HTMLElement|null} */ ( + document.querySelector(`#file-grid .file-card[data-key="${CSS.escape(key)}"]`) + ) + if (!card) return + card.classList.add('selected') + const cb = /** @type {HTMLInputElement|null} */ (card.querySelector('.file-card-checkbox-wrap input')) + if (cb) cb.checked = true + }) + } + + /** @param {string} prefix */ + async navigate(prefix) { + this.clearSelection() + this.#prefix = prefix + this.#continuationToken = '' + this.#loadedItems = [] + this.#loadGen++ + $('#file-grid').innerHTML = '' + $('#load-more').hidden = true + $('#item-count').hidden = true + this.#updateBreadcrumb() + await this.#loadAllPages(true) + } + + async loadMore() { + if (!this.#continuationToken) return + await this.#loadAllPages(false) + } + + /** @param {boolean} isInitial @param {boolean} [bypassCache] */ + async #loadAllPages(isInitial, bypassCache = false) { + const gen = this.#loadGen + if (isInitial) this.#ui.showSkeleton() + try { + do { + const cacheKey = `${this.#prefix}::${this.#continuationToken}` + const cached = this.#cache.get(cacheKey) + let result + + if (!bypassCache && cached && Date.now() - cached.ts < CACHE_TTL) { + result = cached.data + } else { + result = await this.#r2.listObjects(this.#prefix, this.#continuationToken) + this.#cache.set(cacheKey, { data: result, ts: Date.now() }) + } + + if (gen !== this.#loadGen) return + + this.#continuationToken = result.isTruncated ? result.nextToken : '' + this.#loadedItems.push(...result.folders, ...result.files) + } while (this.#continuationToken) + + if (gen !== this.#loadGen) return + + if (isInitial) this.#ui.hideSkeleton() + + const sorted = this.#sortItems(this.#loadedItems) + if (sorted.length === 0) { + this.#ui.showEmptyState() + } else { + this.#ui.hideEmptyState() + $('#file-grid').innerHTML = '' + this.#renderItems(sorted) + this.#restoreSelectionUI() + } + + const countEl = $('#item-count') + countEl.textContent = t('itemsTotal', { count: this.#loadedItems.length }) + countEl.hidden = this.#loadedItems.length === 0 + $('#load-more').hidden = true + } catch (/** @type {any} */ err) { + if (isInitial) this.#ui.hideSkeleton() + + const errorKey = getErrorMessage(err) + if (errorKey === 'networkError') { + this.#ui.toast(t('networkError', { msg: err.message }), 'error') + } else { + this.#ui.toast(t(/** @type {any} */ (errorKey)), 'error') + } + + if (err.message === 'HTTP_401' || err.message === 'HTTP_403') { + throw err + } + } + } + + invalidateCache(prefix = '') { + if (!prefix) { + this.#cache.clear() + return + } + for (const key of this.#cache.keys()) { + if (key.startsWith(prefix + '::') || key.startsWith(prefix)) { + this.#cache.delete(key) + } + } + } + + updateCountDisplay() { + if (this.#loadedItems.length === 0) return + const isTruncated = !!this.#continuationToken + $('#item-count').textContent = isTruncated + ? t('itemsPartial', { count: this.#loadedItems.length }) + : t('itemsTotal', { count: this.#loadedItems.length }) + } + + /** @param {FileItem[]} items @returns {FileItem[]} */ + #sortItems(items) { + const { true: folders = [], false: files = [] } = Object.groupBy(items, (i) => String(i.isFolder)) + + /** @type {(a: FileItem, b: FileItem) => number} */ + const byName = (a, b) => extractFileName(a.key).localeCompare(extractFileName(b.key)) + + /** @type {Record number>} */ + const comparators = { + name: byName, + date: (a, b) => new Date(a.lastModified ?? 0).getTime() - new Date(b.lastModified ?? 0).getTime(), + size: (a, b) => (a.size ?? 0) - (b.size ?? 0), + } + + const cmp = comparators[this.#sortBy] ?? byName + const directedCmp = + this.#sortOrder === 'asc' ? cmp : (/** @type {FileItem} */ a, /** @type {FileItem} */ b) => cmp(b, a) + const directedByName = + this.#sortOrder === 'asc' ? byName : (/** @type {FileItem} */ a, /** @type {FileItem} */ b) => byName(b, a) + return [...folders.toSorted(directedByName), ...files.toSorted(directedCmp)] + } + + /** @param {FileItem[]} items */ + #renderItems(items) { + const grid = $('#file-grid') + const frag = document.createDocumentFragment() + + for (const item of items) { + const card = this.#createFileCard(item) + frag.appendChild(card) + } + + grid.appendChild(frag) + } + + /** @param {FileItem} item @returns {HTMLDivElement} */ + #createFileCard(item) { + const card = document.createElement('div') + card.className = 'file-card' + card.dataset.key = item.key + card.dataset.isFolder = String(item.isFolder) + if (!item.isFolder) { + card.dataset.size = String(item.size ?? 0) + if (item.lastModified) { + card.dataset.mod = String(new Date(item.lastModified).getTime()) + } + } + + const name = extractFileName(item.key) + const isImage = !item.isFolder && IMAGE_RE.test(item.key) + + let iconHtml + if (item.isFolder) { + iconHtml = `
+ +
` + } else if (isImage) { + iconHtml = `` + } else { + const fileType = getFileType(item.key) + iconHtml = `
+ ${getFileIconSvg(fileType)} +
` + } + + const checkboxLabel = t('selectFile') + card.innerHTML = ` + + + + ${iconHtml} + + ${ + !item.isFolder + ? ` + ${filesize(item.size ?? 0)} + ${formatDate(item.lastModified ?? '')} + ` + : '' + } +
+ +
+ ` + const nameEl = /** @type {HTMLElement} */ (card.querySelector('.file-card-name')) + nameEl.textContent = name + nameEl.setAttribute('title', name) + + if (isImage) { + this.#thumbnailObserver.observe(card) + } + + return card + } + + /** @param {HTMLElement} card @param {string} key */ + async #lazyLoadThumbnail(card, key) { + try { + const url = this.#r2.getPublicUrl(key) ?? (await this.#r2.getPresignedUrl(key)) + const img = /** @type {HTMLImageElement} */ ($('img', card)) + if (!img) return + img.onload = () => img.classList.add('loaded') + img.onerror = () => img.classList.add('loaded') + img.src = url + } catch { + /* ignore thumbnail failures */ + } + } + + #updateBreadcrumb() { + const ol = $('#breadcrumb') + ol.innerHTML = '' + + const rootLi = document.createElement('li') + const rootBtn = document.createElement('button') + rootBtn.type = 'button' + rootBtn.className = 'breadcrumb-btn' + rootBtn.dataset.prefix = '' + rootBtn.textContent = t('root') + rootLi.appendChild(rootBtn) + ol.appendChild(rootLi) + + if (this.#prefix) { + const parts = this.#prefix.replace(/\/$/, '').split('/') + let accumulated = '' + for (const part of parts) { + accumulated += part + '/' + const li = document.createElement('li') + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'breadcrumb-btn' + btn.dataset.prefix = accumulated + btn.textContent = part + li.appendChild(btn) + ol.appendChild(li) + } + } + } + + async refresh() { + this.clearSelection() + this.invalidateCache(this.#prefix) + this.#continuationToken = '' + this.#loadedItems = [] + this.#loadGen++ + $('#file-grid').innerHTML = '' + this.#updateBreadcrumb() + await this.#loadAllPages(true, true) + } +} + +export { FileExplorer } diff --git a/src/js/file-operations.js b/src/js/file-operations.js new file mode 100644 index 0000000..008d465 --- /dev/null +++ b/src/js/file-operations.js @@ -0,0 +1,541 @@ +import { IMAGE_RE, VIDEO_RE, AUDIO_RE } from './constants.js' +import { t } from './i18n.js' +import { FileExplorer } from './file-explorer.js' +import { R2Client } from './r2-client.js' +import { UIManager } from './ui-manager.js' +import { getErrorMessage, extractFileName } from './utils.js' + +class FileOperations { + /** @type {R2Client} */ + #r2 + /** @type {UIManager} */ + #ui + /** @type {FileExplorer} */ + #explorer + + /** + * 将用户输入的路径规范化为文件夹前缀。 + * 去除前导斜杠,根目录返回 `''`,否则确保以 `/` 结尾。 + * @param {string} v + * @returns {string} + */ + static #toFolderPrefix(v) { + const norm = v.replace(/^\/+/, '') + return norm === '' ? '' : norm.endsWith('/') ? norm : norm + '/' + } + + /** @param {R2Client} r2 @param {UIManager} ui @param {FileExplorer} explorer */ + constructor(r2, ui, explorer) { + this.#r2 = r2 + this.#ui = ui + this.#explorer = explorer + } + + /** @param {string} key @param {boolean} isFolder */ + async rename(key, isFolder) { + const oldName = extractFileName(key) + const newName = await this.#ui.prompt(t('renameTitle'), t('renameLabel'), oldName, { + validate: (v) => { + const illegal = v.match(/[/\\:*?"<>|\x00-\x1F]/g) + if (illegal) return t('renameInvalidChars', { chars: [...new Set(illegal)].join(' ') }) + return null + }, + }) + if (!newName || newName === oldName) return + + try { + this.#ui.toast(t('renaming', { name: oldName, destName: newName }), 'info') + + const prefix = key.substring(0, key.lastIndexOf(oldName)) + if (isFolder) { + const dest = prefix + newName + '/' + await this.#recursiveOperation( + key, + async (/** @type {string} */ srcKey) => { + const relative = srcKey.substring(key.length) + await this.#r2.copyObject(srcKey, dest + relative) + }, + true, + ) + } else { + const dest = prefix + newName + await this.#r2.copyObject(key, dest) + await this.#r2.deleteObject(key) + } + this.#ui.toast(t('renameSuccess', { name: newName }), 'success') + if (isFolder) this.#explorer.invalidateCache(key) + await this.#explorer.refresh() + } catch (/** @type {any} */ err) { + const errorKey = getErrorMessage(err) + if (errorKey === 'networkError') { + this.#ui.toast(t('networkError', { msg: err.message }), 'error') + } else { + this.#ui.toast(t(/** @type {any} */ (errorKey)), 'error') + } + } + } + + /** @param {string} key @param {boolean} isFolder */ + async copy(key, isFolder) { + const name = extractFileName(key) + const currentPrefix = this.#explorer.currentPrefix + + const dest = await this.#ui.prompt(t('copyTitle'), t('copyLabel'), currentPrefix + name + (isFolder ? '/' : ''), { + hint: isFolder ? t('copyFolderHint') : undefined, + validate: (v) => { + const illegal = v.match(/[\\:*?"<>|\x00-\x1F]/g) + if (illegal) return t('pathInvalidChars', { chars: [...new Set(illegal)].join(' ') }) + if (v === key) return t('copySamePath') + return null + }, + }) + if (!dest) return + + try { + this.#ui.toast(t('copying', { name, destName: dest }), 'info') + + if (isFolder) { + await this.#recursiveOperation( + key, + async (/** @type {string} */ srcKey) => { + const relative = srcKey.substring(key.length) + const destKey = (dest.endsWith('/') ? dest : dest + '/') + relative + await this.#r2.copyObject(srcKey, destKey) + }, + false, + ) + } else { + await this.#r2.copyObject(key, dest) + } + this.#ui.toast(t('copySuccess', { name: dest }), 'success') + const destPrefix = dest.substring(0, dest.lastIndexOf('/') + 1) + if (isFolder) this.#explorer.invalidateCache() + else this.#explorer.invalidateCache(destPrefix) + if (destPrefix.startsWith(this.#explorer.currentPrefix)) await this.#explorer.refresh() + } catch (/** @type {any} */ err) { + const errorKey = getErrorMessage(err) + if (errorKey === 'networkError') { + this.#ui.toast(t('networkError', { msg: err.message }), 'error') + } else { + this.#ui.toast(t(/** @type {any} */ (errorKey)), 'error') + } + } + } + + /** @param {string} key @param {boolean} isFolder */ + async move(key, isFolder) { + const name = extractFileName(key) + const currentPrefix = this.#explorer.currentPrefix + + /** @param {string} v */ + const resolveDest = (v) => FileOperations.#toFolderPrefix(v) + name + (isFolder ? '/' : '') + + const destFolder = await this.#ui.prompt(t('moveTitle'), t('moveLabel'), currentPrefix, { + preview: (v) => '→ ' + resolveDest(v), + validate: (v) => (resolveDest(v) === key ? t('moveSamePath') : null), + }) + + if (destFolder === null) return + + const dest = resolveDest(destFolder) + + if (dest === key) return + + try { + this.#ui.toast(t('moving', { name, destName: dest }), 'info') + + if (isFolder) { + await this.#recursiveOperation( + key, + async (/** @type {string} */ srcKey) => { + const relative = srcKey.substring(key.length) + await this.#r2.copyObject(srcKey, dest + relative) + }, + true, + ) + } else { + await this.#r2.copyObject(key, dest) + await this.#r2.deleteObject(key) + } + this.#ui.toast(t('moveSuccess', { name: dest }), 'success') + if (isFolder) this.#explorer.invalidateCache() + else this.#explorer.invalidateCache(dest.substring(0, dest.lastIndexOf('/') + 1)) + await this.#explorer.refresh() + } catch (/** @type {any} */ err) { + const errorKey = getErrorMessage(err) + if (errorKey === 'networkError') { + this.#ui.toast(t('networkError', { msg: err.message }), 'error') + } else { + this.#ui.toast(t(/** @type {any} */ (errorKey)), 'error') + } + } + } + + /** @param {string} key @param {boolean} isFolder */ + async delete(key, isFolder) { + const name = extractFileName(key) + const msg = isFolder ? t('deleteFolderConfirmMsg', { name }) : t('deleteConfirmMsg', { name }) + + const ok = await this.#ui.confirm(t('deleteConfirmTitle'), msg) + if (!ok) return + + try { + this.#ui.toast(t('deleting', { name }), 'info') + + if (isFolder) { + await this.#recursiveOperation( + key, + async (srcKey) => { + await this.#r2.deleteObject(srcKey) + }, + false, + ) + try { + await this.#r2.deleteObject(key) + } catch {} + } else { + await this.#r2.deleteObject(key) + } + this.#ui.toast(t('deleteSuccess', { name }), 'success') + if (isFolder) this.#explorer.invalidateCache(key) + await this.#explorer.refresh() + } catch (/** @type {any} */ err) { + const errorKey = getErrorMessage(err) + if (errorKey === 'networkError') { + this.#ui.toast(t('networkError', { msg: err.message }), 'error') + } else { + this.#ui.toast(t(/** @type {any} */ (errorKey)), 'error') + } + } + } + + /** + * @param {import('./file-explorer.js').SelectionItem[]} items + * + * @returns {Promise} 用户取消返回 false,实际执行返回 true + */ + async deleteMany(items) { + const count = items.length + const ok = await this.#ui.confirm(t('batchDeleteTitle'), t('batchDeleteConfirmMsg', { count })) + if (!ok) return false + + this.#ui.toast(t('batchDeleting', { count }), 'info') + let success = 0 + let fail = 0 + + for (const item of items) { + try { + if (item.isFolder) { + await this.#recursiveOperation(item.key, async (k) => this.#r2.deleteObject(k), false) + try { + await this.#r2.deleteObject(item.key) + } catch {} + } else { + await this.#r2.deleteObject(item.key) + } + success++ + } catch { + fail++ + } + } + + this.#ui.toast( + fail === 0 ? t('batchDeleteSuccess', { count: success }) : t('batchDeletePartial', { success, fail }), + fail === 0 ? 'success' : 'error', + ) + this.#explorer.invalidateCache(this.#explorer.currentPrefix) + await this.#explorer.refresh() + return true + } + + /** + * @param {import('./file-explorer.js').SelectionItem[]} items + * @returns {Promise} 用户取消返回 false,实际执行返回 true + */ + async moveMany(items) { + const count = items.length + const currentPrefix = this.#explorer.currentPrefix + + const destFolder = await this.#ui.prompt(t('batchMoveTitle'), t('batchMoveLabel'), currentPrefix, { + validate: (v) => { + const folder = FileOperations.#toFolderPrefix(v) + const allSame = items.every((item) => { + const name = extractFileName(item.key) + return folder + name + (item.isFolder ? '/' : '') === item.key + }) + return allSame ? t('batchMoveSamePath') : null + }, + }) + if (destFolder === null) return false + + const folder = FileOperations.#toFolderPrefix(destFolder) + this.#ui.toast(t('batchMoving', { count }), 'info') + let success = 0 + let fail = 0 + + for (const item of items) { + const name = extractFileName(item.key) + const dest = folder + name + (item.isFolder ? '/' : '') + if (dest === item.key) continue + + try { + if (item.isFolder) { + await this.#recursiveOperation( + item.key, + async (/** @type {string} */ srcKey) => { + await this.#r2.copyObject(srcKey, dest + srcKey.substring(item.key.length)) + }, + true, + ) + } else { + await this.#r2.copyObject(item.key, dest) + await this.#r2.deleteObject(item.key) + } + success++ + } catch { + fail++ + } + } + + this.#ui.toast( + fail === 0 + ? t('batchMoveSuccess', { count: success, dest: folder || '/' }) + : t('batchMovePartial', { success, fail }), + fail === 0 ? 'success' : 'error', + ) + this.#explorer.invalidateCache() + await this.#explorer.refresh() + return true + } + + /** + * @param {import('./file-explorer.js').SelectionItem[]} items + * @returns {Promise} 用户取消返回 false,实际执行返回 true + */ + async copyMany(items) { + const count = items.length + const currentPrefix = this.#explorer.currentPrefix + + const destFolder = await this.#ui.prompt(t('batchCopyTitle'), t('batchCopyLabel'), currentPrefix, { + validate: (v) => { + const folder = FileOperations.#toFolderPrefix(v) + const allSame = items.every((item) => { + const name = extractFileName(item.key) + return folder + name + (item.isFolder ? '/' : '') === item.key + }) + return allSame ? t('batchCopySamePath') : null + }, + }) + if (destFolder === null) return false + + const folder = FileOperations.#toFolderPrefix(destFolder) + this.#ui.toast(t('batchCopying', { count }), 'info') + let success = 0 + let fail = 0 + + for (const item of items) { + const name = extractFileName(item.key) + const dest = folder + name + (item.isFolder ? '/' : '') + if (dest === item.key) continue + + try { + if (item.isFolder) { + await this.#recursiveOperation( + item.key, + async (/** @type {string} */ srcKey) => { + await this.#r2.copyObject(srcKey, dest + srcKey.substring(item.key.length)) + }, + false, + ) + } else { + await this.#r2.copyObject(item.key, dest) + } + success++ + } catch { + fail++ + } + } + + this.#ui.toast( + fail === 0 + ? t('batchCopySuccess', { count: success, dest: folder || '/' }) + : t('batchCopyPartial', { success, fail }), + fail === 0 ? 'success' : 'error', + ) + this.#explorer.invalidateCache() + await this.#explorer.refresh() + return true + } + + /** @param {string} key */ + async editContentType(key) { + const name = extractFileName(key) + let currentType = '' + try { + const head = await this.#r2.headObject(key) + currentType = head.contentType ?? '' + } catch {} + + const newType = await this.#ui.prompt(t('editContentTypeTitle'), t('editContentTypeLabel'), currentType, { + validate: (v) => { + if (v.trim() && !v.includes('/')) return t('editContentTypeInvalid') + return null + }, + }) + if (newType === null || newType.trim() === currentType.trim()) return + + try { + this.#ui.toast(t('editingContentType', { name }), 'info') + await this.#r2.updateContentType(key, newType.trim()) + this.#ui.toast(t('editContentTypeSuccess', { name, type: newType.trim() }), 'success') + } catch (/** @type {any} */ err) { + const errorKey = getErrorMessage(err) + if (errorKey === 'networkError') { + this.#ui.toast(t('networkError', { msg: err.message }), 'error') + } else { + this.#ui.toast(t(/** @type {any} */ (errorKey)), 'error') + } + } + } + + /** @param {string} key */ + async download(key) { + try { + const url = await this.#r2.getDownloadUrl(key, extractFileName(key)) + const a = document.createElement('a') + a.href = url + document.body.appendChild(a) + a.click() + a.remove() + } catch (/** @type {any} */ err) { + const errorKey = getErrorMessage(err) + if (errorKey === 'networkError') { + this.#ui.toast(t('networkError', { msg: err.message }), 'error') + } else { + this.#ui.toast(t(/** @type {any} */ (errorKey)), 'error') + } + } + } + + /** @param {string} key @param {'path'|'url'|'markdown'|'html'|'presigned'} format */ + async copyAs(key, format) { + const name = extractFileName(key) + const isImage = IMAGE_RE.test(key) + const isVideo = VIDEO_RE.test(key) + const isAudio = AUDIO_RE.test(key) + + if (format === 'path') { + try { + await navigator.clipboard.writeText(key) + this.#ui.toast(t('linkCopied'), 'success') + } catch { + await this.#ui.prompt(t('copyLink'), '', key) + } + return + } + + let url + if (format === 'presigned') { + url = await this.#r2.getPresignedUrl(key) + } else { + url = this.#r2.getPublicUrl(key) ?? (await this.#r2.getPresignedUrl(key)) + } + + let text + switch (format) { + case 'markdown': + text = isImage ? `![${name}](${url})` : `[${name}](${url})` + break + case 'html': + if (isImage) text = `${name}` + else if (isVideo) text = `` + else if (isAudio) text = `` + else text = `${name}` + break + default: + text = url + break + } + + try { + await navigator.clipboard.writeText(text) + this.#ui.toast(t('linkCopied'), 'success') + } catch { + await this.#ui.prompt(t('copyLink'), '', text) + } + } + + /** @param {string} key */ + async copyImage(key) { + if (!IMAGE_RE.test(key)) { + this.#ui.toast(t('copyImageNotSupportedType'), 'error') + return + } + if (!navigator.clipboard || !window.ClipboardItem) { + this.#ui.toast(t('copyImageNotSupported'), 'error') + return + } + try { + const url = this.#r2.getPublicUrl(key) ?? (await this.#r2.getPresignedUrl(key)) + const res = await fetch(url) + const blob = await res.blob() + const item = new ClipboardItem({ [blob.type || 'image/png']: blob }) + await navigator.clipboard.write([item]) + this.#ui.toast(t('copyImageSuccess'), 'success') + } catch { + this.#ui.toast(t('copyImageFailed'), 'error') + } + } + + /** @param {string} key */ + async shareQr(key) { + const url = this.#r2.getPublicUrl(key) + if (!url) { + this.#ui.toast(t('shareQrNeedDomain'), 'error') + return + } + await this.#ui.showFileQrDialog(url, extractFileName(key)) + } + + /** @param {string} prefix @param {(key: string) => Promise} operation @param {boolean} deleteSource */ + async #recursiveOperation(prefix, operation, deleteSource) { + const allKeys = await this.#collectAllKeys(prefix) + + for (let i = 0; i < allKeys.length; i += 5) { + const batch = allKeys.slice(i, i + 5) + await Promise.all(batch.map((k) => operation(k))) + } + + if (deleteSource) { + for (let i = 0; i < allKeys.length; i += 5) { + const batch = allKeys.slice(i, i + 5) + await Promise.all(batch.map((k) => this.#r2.deleteObject(k))) + } + try { + await this.#r2.deleteObject(prefix) + } catch {} + } + } + + /** @param {string} prefix @returns {Promise} */ + async #collectAllKeys(prefix) { + /** @type {string[]} */ + let allKeys = [] + let token = '' + do { + const result = await this.#r2.listObjects(prefix, token) + for (const file of result.files) { + allKeys.push(file.key) + } + for (const folder of result.folders) { + allKeys.push(folder.key) + const subKeys = await this.#collectAllKeys(folder.key) + allKeys.push(...subKeys) + } + token = result.isTruncated ? result.nextToken : '' + } while (token) + return allKeys + } +} + +export { FileOperations } diff --git a/src/js/file-preview.js b/src/js/file-preview.js new file mode 100644 index 0000000..f1ae2f4 --- /dev/null +++ b/src/js/file-preview.js @@ -0,0 +1,202 @@ +import { filesize } from 'filesize' +import { AUDIO_RE, IMAGE_RE, TEXT_RE, VIDEO_RE } from './constants.js' +import { t } from './i18n.js' +import { R2Client } from './r2-client.js' +import { UIManager } from './ui-manager.js' +import { $, formatDate, getErrorMessage, extractFileName, getMimeType } from './utils.js' + +class FilePreview { + /** @type {R2Client} */ + #r2 + /** @type {UIManager} */ + #ui + #currentKey = '' + #currentText = '' + #currentUrl = '' + + /** @param {R2Client} r2 @param {UIManager} ui */ + constructor(r2, ui) { + this.#r2 = r2 + this.#ui = ui + const dialog = /** @type {HTMLDialogElement} */ ($('#preview-dialog')) + dialog.addEventListener('close', () => { + dialog.querySelectorAll('video, audio').forEach((el) => /** @type {HTMLMediaElement} */ (el).pause()) + }) + } + + get currentKey() { + return this.#currentKey + } + + /** @param {{key: string, size?: number, lastModified?: number}} item */ + async preview(item) { + const key = item.key + this.#currentKey = key + this.#currentText = '' + this.#currentUrl = '' + const dialog = /** @type {HTMLDialogElement} */ ($('#preview-dialog')) + const body = $('#preview-body') + const footer = $('#preview-footer') + const filename = $('#preview-filename') + const copyBtn = /** @type {HTMLElement} */ ($('#preview-copy')) + const copyTextBtn = /** @type {HTMLElement} */ ($('#preview-copy-text')) + const copyImageBtn = /** @type {HTMLElement} */ ($('#preview-copy-image')) + + filename.textContent = extractFileName(key) + body.innerHTML = '
Loading...
' + footer.innerHTML = '' + footer.classList.remove('bordered') + copyBtn.hidden = true + copyTextBtn.hidden = true + copyImageBtn.hidden = true + dialog.showModal() + + try { + let realContentType = getMimeType(key) + try { + const head = await this.#r2.headObject(key) + if (head.contentType) realContentType = head.contentType + } catch {} + + const meta = { + contentLength: item.size ?? 0, + contentType: realContentType, + lastModified: item.lastModified ? new Date(item.lastModified) : undefined, + } + + footer.classList.add('bordered') + footer.innerHTML = ` + ${t('size')}: ${filesize(meta.contentLength)} + ${t('contentType')}: ${meta.contentType || 'unknown'} + ${meta.lastModified ? `${t('lastModified')}: ${formatDate(meta.lastModified)}` : ''} + ` + + if (IMAGE_RE.test(key)) { + const url = this.#r2.getPublicUrl(key) ?? (await this.#r2.getPresignedUrl(key)) + this.#currentUrl = url + body.innerHTML = '' + const img = document.createElement('img') + img.src = url + img.alt = extractFileName(key) + body.appendChild(img) + copyImageBtn.dataset.tooltip = t('copyImage') + copyImageBtn.hidden = false + copyBtn.dataset.tooltip = t('copyLink') + copyBtn.hidden = false + } else if (VIDEO_RE.test(key)) { + const url = this.#r2.getPublicUrl(key) ?? (await this.#r2.getPresignedUrl(key)) + this.#currentUrl = url + body.innerHTML = '' + const video = document.createElement('video') + video.src = url + video.controls = true + body.appendChild(video) + copyBtn.dataset.tooltip = t('copyLink') + copyBtn.hidden = false + } else if (AUDIO_RE.test(key)) { + const url = this.#r2.getPublicUrl(key) ?? (await this.#r2.getPresignedUrl(key)) + this.#currentUrl = url + body.innerHTML = '' + const audio = document.createElement('audio') + audio.src = url + audio.controls = true + body.appendChild(audio) + copyBtn.dataset.tooltip = t('copyLink') + copyBtn.hidden = false + } else if (TEXT_RE.test(key)) { + const url = this.#r2.getPublicUrl(key) ?? (await this.#r2.getPresignedUrl(key)) + this.#currentUrl = url + const res = await this.#r2.getObject(key) + const text = await res.text() + this.#currentText = text + body.innerHTML = '' + const pre = document.createElement('pre') + pre.textContent = text + body.appendChild(pre) + copyBtn.dataset.tooltip = t('copyLink') + copyBtn.hidden = false + copyTextBtn.dataset.tooltip = t('copyText') + copyTextBtn.hidden = false + } else { + body.innerHTML = `

${t('previewNotAvailable')}

` + } + } catch (/** @type {any} */ err) { + const errP = document.createElement('p') + errP.style.color = 'var(--text-danger)' + errP.textContent = err.message + body.innerHTML = '' + body.appendChild(errP) + } + } + + async downloadCurrent() { + if (!this.#currentKey) return + try { + const filename = extractFileName(this.#currentKey) + const url = await this.#r2.getDownloadUrl(this.#currentKey, filename) + const a = document.createElement('a') + a.href = url + document.body.appendChild(a) + a.click() + a.remove() + } catch (/** @type {any} */ err) { + const errorKey = getErrorMessage(err) + if (errorKey === 'networkError') { + this.#ui.toast(t('networkError', { msg: err.message }), 'error') + } else { + this.#ui.toast(t(/** @type {any} */ (errorKey)), 'error') + } + } + } + + async copyCurrentLink() { + if (!this.#currentUrl) return + try { + await navigator.clipboard.writeText(this.#currentUrl) + this.#ui.toast(t('linkCopied'), 'success') + } catch { + await this.#ui.prompt(t('copyLink'), t('copyUrl'), this.#currentUrl) + } + } + + async copyCurrentImage() { + if (!this.#currentUrl) return + if (!navigator.clipboard?.write) { + this.#ui.toast(t('copyImageNotSupported'), 'error') + return + } + try { + const res = await fetch(this.#currentUrl) + const blob = await res.blob() + const pngBlob = await new Promise((resolve, reject) => { + const img = new Image() + img.onload = () => { + const canvas = document.createElement('canvas') + canvas.width = img.naturalWidth + canvas.height = img.naturalHeight + canvas.getContext('2d').drawImage(img, 0, 0) + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error())), 'image/png') + URL.revokeObjectURL(img.src) + } + img.onerror = reject + img.src = URL.createObjectURL(blob) + }) + await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]) + this.#ui.toast(t('copyImageSuccess'), 'success') + } catch { + this.#ui.toast(t('copyImageFailed'), 'error') + } + } + + async copyCurrentText() { + if (!this.#currentText) return + try { + await navigator.clipboard.writeText(this.#currentText) + this.#ui.toast(t('copyTextSuccess'), 'success') + } catch { + await this.#ui.prompt(t('copyTextTitle'), t('copyTextLabel'), this.#currentText) + } + } +} + +export { FilePreview } diff --git a/src/js/i18n.js b/src/js/i18n.js new file mode 100644 index 0000000..b279042 --- /dev/null +++ b/src/js/i18n.js @@ -0,0 +1,1063 @@ +import { LANG_KEY } from './constants.js' + +const I18N = { + zh: { + appTitle: 'R2 Web', + connectTitle: '连接到 R2', + connectDesc: '填写你的 R2 凭据即可开始,放心,数据只留在你的浏览器里。', + accountId: '账户 ID(Account ID)', + accessKeyId: '访问密钥 ID(Access Key ID)', + secretAccessKey: '秘密访问密钥(Secret Access Key)', + bucketName: '存储桶名称(Bucket Name)', + filenameTpl: '文件名模板', + filenameTplHint: + '占位符: [name] 原始名, [ext] 扩展名, [hash:N] 哈希, [date:FORMAT] 日期, [timestamp] 时间戳, [uuid], 斜杠代表目录 (hash 默认 6 位)', + filenameTplScope: '生效范围', + filenameTplScopeImages: '仅图片', + filenameTplScopeAll: '全部文件', + filenameTplPathTitle: '路径选择', + filenameTplPathDesc: '当前不在根目录(/),且模板结果包含子目录。请确认目标上传路径。', + filenameTplPathDescWithTpl: '当前目录:{prefix},模板:{template}。请确认目标上传路径。', + cancel: '取消', + connect: '连接', + newFolder: '新目录', + upload: '上传', + dropToUpload: '松手即可上传', + pasteToUpload: '已粘贴 {count} 个文件', + uploadHint: '拖放、粘贴或点击即可上传', + dropInvalidHint: '未检测到可上传内容,请从文件管理器直接拖拽', + pasteHint: '粘贴文件到当前目录', + pasteMixedNotSupported: '还不支持图文混合粘贴哦,请只复制文本或图片粘贴', + pasteTextConfirmTitle: '粘贴文本上传', + pasteTextConfirmMsg: '检测到文本内容,是否上传为 TXT 文件?', + pasteTextPathTitle: '上传路径', + pasteTextPathLabel: '保存路径', + pasteFileConfirmTitle: '粘贴文件上传', + pasteFileConfirmMsg: '检测到文件“{name}”,是否上传?', + pasteFilePathTitle: '上传路径', + pasteFilePathLabel: '保存路径', + pasteFilesConfirmTitle: '粘贴文件上传', + pasteFilesConfirmMsg: '检测到 {count} 个文件,是否上传到当前目录?', + uploadWaiting: '等待中...', + uploading: '正在上传...', + uploadPreparing: '准备中...', + uploadProgress: '上传进度', + root: '根目录', + emptyFolder: '这里空空的什么也没有,上传点什么吧', + uploadFiles: '上传文件', + loadMore: '加载更多', + itemsPartial: '已加载 {count} 项', + itemsTotal: '共 {count} 项', + preview: '预览', + download: '下载', + rename: '重命名', + copy: '复制', + move: '移动', + delete: '删除', + confirm: '确认', + ok: '好的', + deleteConfirmTitle: '删除确认', + deleteConfirmMsg: '确定要删除 "{name}" 吗?删除后无法恢复哦。', + deleteFolderConfirmMsg: '确定要删除目录 "{name}" 及其所有内容吗?删除后无法恢复哦。', + renameTitle: '重命名', + renameLabel: '新名称', + renameInvalidChars: '文件名包含非法字符:{chars}', + copyTitle: '复制到', + copyLabel: '目标路径', + copyFolderHint: '目录将被递归复制,包含所有子目录和文件。', + pathInvalidChars: '路径包含非法字符:{chars}', + moveTitle: '移动到', + moveLabel: '目标目录', + moveSamePath: '无法移动:目标目录与当前位置相同', + copySamePath: '无法复制:目标路径与源路径相同', + newFolderTitle: '新建目录', + newFolderLabel: '目录名称', + authFailed: '认证失败,请检查 Access Key 和 Secret Key 是否正确', + customDomain: '自定义域名(Custom Domain)', + customDomainHint: '可选,配置后可一键复制文件的公开链接', + bucketAccess: '存储桶可见性(Bucket Visibility)', + bucketAccessPublic: '公开(Public)', + bucketAccessPrivate: '私有(Private)', + copyLink: '复制链接', + copyPath: '复制文件路径', + copyUrl: '复制 URL 直链', + copyMarkdown: '复制为 Markdown 格式', + copyHtml: '复制为 HTML 格式', + copyPresigned: '复制 R2 预签名 URL', + copyText: '复制文本', + copyTextTitle: '复制文本', + copyTextLabel: '文本内容', + copyTextSuccess: '文本已复制', + copyImage: '复制图片', + copyImageSuccess: '图片已复制', + copyImageFailed: '复制失败,请使用下载', + copyImageNotSupported: '当前浏览器不支持复制图片', + copyImageNotSupportedType: '仅支持图片文件复制', + shareQr: '二维码', + fileQrTitle: '分享二维码', + fileQrDesc: '使用手机扫码打开文件链接', + shareQrNeedDomain: '请先配置自定义域名,才能生成二维码', + linkCopied: '链接已复制好啦', + corsError: '你好像还没有开启 CORS 哦。请到参考 Readme 完成配置。', + networkError: '网络连接失败: {msg}', + http401Error: '认证失败 (401),Access Key 或 Secret Key 可能已失效或被删除', + http403Error: '访问被拒绝 (403),请检查 API 密钥权限', + http404Error: '存储桶不存在 (404),请检查 Bucket Name 是否正确', + uploadSuccess: '{count} 个文件上传成功', + uploadPartialFail: '{success} 个成功,{fail} 个没能上传', + fileTooLarge: '文件 "{name}" 太大了(超过 5GB),试试 rclone 等工具吧', + overwriteTitle: '文件已存在', + overwriteMsg: '"{name}" 已存在,是否覆盖原文件?', + overwriteSkip: '跳过', + overwriteSkipAll: '全部跳过', + overwriteConfirm: '覆盖', + overwriteAll: '全部覆盖', + uploadSkipped: '已跳过 {count} 个同名文件', + deleteSuccess: '"{name}" 已删除', + renameSuccess: '已重命名为 "{name}"', + copySuccess: '已复制到 "{name}"', + moveSuccess: '已移动到 "{name}"', + folderCreated: '目录 "{name}" 创建好了', + previewNotAvailable: '这种文件类型暂时还不能预览', + size: '大小', + lastModified: '最后修改', + contentType: '类型', + settings: '设置', + toggleTheme: '切换主题', + close: '关闭', + shareConfig: '分享配置', + shareConfigCopied: '分享链接已复制', + configLoadedFromUrl: '已从链接加载配置,开始使用吧', + shareDialogTitle: '分享配置', + shareDialogSubtitle: '选择以下任一方式进行多设备迁移', + shareDividerText: '或', + shareLinkTitle: '配置链接', + shareLinkDesc: '复制链接并在其他设备浏览器中打开', + shareQrTitle: '扫描二维码', + shareQrDesc: '使用移动设备相机扫描', + shareQrHint: '扫码后在浏览器中打开链接', + copyShareUrl: '复制链接', + shareWarning: '链接包含账户凭据,请谨慎分享', + preferences: '偏好设置', + compressMode: '压缩模式', + compressModeNone: '暂不开启', + compressModeLocal: '本地压缩', + compressModeTinify: 'Tinify 服务', + compressLevel: '压缩程度', + compressLevelBalanced: '平衡模式', + compressLevelExtreme: '极致压缩', + compressLevelHint: '平衡:JPEG/WebP 90%、AVIF 60%;极致:JPEG/WebP 75%、AVIF 50%', + compressNotSupported: '格式不支持压缩,保持原文件', + compressModeHint: '本地:MozJPEG、libwebp、libavif、OxiPNG 优化器;Tinify:云服务', + compressTinifyHint: 'Key 存储在本地,但 Tinify API 存在跨域问题,会经过一层代理服务中转。', + theme: '主题', + sort: '排序', + sortName: '按名称', + sortDate: '按日期', + sortSize: '按大小', + sortAsc: '升序', + sortDesc: '降序', + viewGrid: '网格', + viewList: '列表', + density: '密度', + densityCompact: '紧凑', + densityNormal: '标准', + densityLoose: '宽松', + save: '保存', + heroDesc: '轻盈优雅的 Web 原生 Cloudflare R2 文件管理器,一切皆在浏览器中完成。', + heroConnect: '开始连接', + heroF1: '简单优雅高效', + heroF2: '纯本地客户端', + heroF3: '目录文件管理', + heroF4: '常见类型预览', + heroF5: '拖拽粘贴上传', + heroF6: '上传自动压缩', + heroF7: '一键复制外链', + heroF8: '一键分享配置', + refresh: '刷新', + logout: '安全退出', + logoutConfirmTitle: '安全退出', + logoutConfirmMsg: '退出后会清除浏览器中的凭据,桶文件不受影响。确定退出吗?', + copying: '正在复制 "{name}" 到 "{destName}"...', + moving: '正在移动 "{name}" 到 "{destName}"...', + deleting: '正在删除 "{name}"...', + renaming: '正在重命名 "{name}" 为 "{destName}"...', + // Config dialog tooltips + tooltipAccountId: 'Cloudflare 账户 ID,在 R2 控制台右下角可找到', + tooltipAccessKeyId: 'R2 API 访问密钥 ID,在 R2 控制台右下角可创建和管理', + tooltipSecretAccessKey: 'R2 API 秘密访问密钥,在 R2 控制台右下角可创建和管理,仅本地不上传到任何服务器', + tooltipBucket: 'R2 存储桶名称,所有文件操作将在此桶下进行', + tooltipCustomDomain: '自定义域名(可选),配置后可复制公开访问链接', + tooltipBucketAccess: '声明存储桶的实际可见性。公开时优先使用自定义域名直链;私有时始终使用预签名 URL', + tooltipFilenameTpl: '文件名模板,支持多种占位符自动生成文件名和目录结构', + tooltipFilenameTplScope: '选择模板生效范围,默认仅图片', + filenameTplHintDetailed: + '占位符: [name] [ext] [hash:N] [date:FORMAT] [timestamp] [uuid]\n\n例一:[name]_[hash:6].[ext]\n结果:photo_a1b2c3.jpg\n\n例二:[date:YYYY-MM-DD]_[name].[ext]\n结果:2024-03-15_photo.jpg\n\n例三:[date:YYYY/MM]/[name].[ext]\n结果:2024/03/photo.jpg', + tooltipCompressMode: '上传前压缩图片,支持 JPEG/PNG/WebP/AVIF 格式,可节省存储空间和带宽', + tooltipCompressLevel: '压缩质量,平衡模式保持高质量(90%),极致压缩更省空间(75%)', + tooltipTinifyKey: 'Tinify API Key,存储在本地,需通过代理访问以避免跨域问题', + tooltipTheme: '选择界面主题,支持浅色、深色或跟随系统设置自动切换', + tooltipLanguage: '切换界面显示语言,支持简体中文、繁体公文、英语和日语', + tooltipDensity: '调整文件列表的行高与间距,紧凑模式可显示更多内容', + tinifyKeyHintText: '还没有 API Key?', + tinifyKeyLink: '前往获取', + // Tab labels + configTabPreferences: '偏好设置', + configTabR2: 'R2 设置', + uploadConcurrency: '并发上传数', + configTabUpload: '上传设置', + configTabCompression: '压缩设置', + configTabAbout: '关于', + // Preferences section + lblTheme: '主题', + lblLanguage: '界面语言', + lblDensity: '紧凑度', + themeLight: '浅色', + themeDark: '深色', + themeAuto: '跟随系统', + // About page + aboutDescription: '轻盈优雅的 Web 原生 Cloudflare R2 文件管理器', + aboutGithub: 'GitHub', + aboutChangelog: '更新日志', + aboutQQGroup: '反馈 QQ 群', + aboutLicense: '开源协议', + aboutUpdatedLabel: '更新日期', + // Batch operations + selectFile: '选择文件', + batchMode: '批量操作', + exitBatchMode: '退出批量', + batchSelected: '已选 {count} 项', + batchSelectAll: '全选', + batchDeselectAll: '取消全选', + batchDeleteTitle: '批量删除', + batchDeleteConfirmMsg: '确定要删除选中的 {count} 项吗?删除后无法恢复。', + batchDeleting: '正在删除 {count} 项...', + batchDeleteSuccess: '已删除 {count} 项', + batchDeletePartial: '{success} 项已删除,{fail} 项失败', + batchMoveTitle: '批量移动', + batchMoveLabel: '目标目录', + batchMoveSamePath: '目标目录与当前位置相同', + batchMoving: '正在移动 {count} 项...', + batchMoveSuccess: '{count} 项已移动到 {dest}', + batchMovePartial: '{success} 项已移动,{fail} 项失败', + batchCopyTitle: '批量复制', + batchCopyLabel: '目标目录', + batchCopySamePath: '目标目录与当前位置相同', + batchCopying: '正在复制 {count} 项...', + batchCopySuccess: '{count} 项已复制到 {dest}', + batchCopyPartial: '{success} 项已复制,{fail} 项失败', + editContentType: '修改类型', + editContentTypeTitle: '修改 Content-Type', + editContentTypeLabel: 'Content-Type', + editContentTypeInvalid: '格式不正确,示例:image/jpeg', + editingContentType: '正在修改 "{name}" 的 Content-Type...', + editContentTypeSuccess: '已将 "{name}" 的 Content-Type 修改为 {type}', + }, + zh_TW: { + appTitle: 'R2 Web', + connectTitle: '連接到 R2', + connectDesc: '填寫你的 R2 權杖即可開始,放心,資料只留在你的瀏覽器裡。', + accountId: '帳戶 ID(Account ID)', + accessKeyId: '訪問金鑰 ID(Access Key ID)', + secretAccessKey: '秘密訪問金鑰(Secret Access Key)', + bucketName: '儲存桶名稱(Bucket Name)', + filenameTpl: '檔案名模板', + filenameTplHint: + '占位符:[name] 原始名,[ext] 副檔名,[hash:N] 哈希,[date:FORMAT] 日期,[timestamp] 時間戳,[uuid],斜槓代表目錄 (hash 默認 6 位)', + filenameTplScope: '生效範圍', + filenameTplScopeImages: '僅圖片', + filenameTplScopeAll: '全部檔案', + filenameTplPathTitle: '路徑選擇', + filenameTplPathDesc: '當前不在根目錄(/),且模板結果包含子目錄。請確認目標上傳路徑。', + filenameTplPathDescWithTpl: '當前目錄:{prefix},模板:{template}。請確認目標上傳路徑。', + cancel: '取消', + connect: '連接', + newFolder: '新目錄', + upload: '上傳', + dropToUpload: '鬆手即可上傳', + pasteToUpload: '已貼上 {count} 個檔案', + uploadHint: '拖放、貼上或點擊即可上傳', + dropInvalidHint: '未檢測到可上傳內容,請從檔案管理器直接拖拽', + pasteHint: '貼上檔案到當前目錄', + pasteMixedNotSupported: '還不支持圖文混合貼上哦,請只複製文本或圖片黏貼', + pasteTextConfirmTitle: '貼上文字上傳', + pasteTextConfirmMsg: '檢測到文字內容,是否上傳為 TXT 檔案?', + pasteTextPathTitle: '上傳路徑', + pasteTextPathLabel: '保存路徑', + pasteFileConfirmTitle: '貼上檔案上傳', + pasteFileConfirmMsg: '檢測到檔案「{name}」,是否上傳?', + pasteFilePathTitle: '上傳路徑', + pasteFilePathLabel: '保存路徑', + pasteFilesConfirmTitle: '貼上檔案上傳', + pasteFilesConfirmMsg: '檢測到 {count} 個檔案,是否上傳到當前目錄?', + uploadWaiting: '等待中……', + uploading: '正在上傳……', + uploadPreparing: '準備中……', + uploadProgress: '上傳進度', + root: '根目錄', + emptyFolder: '這裡空空的什麼也沒有,上傳點什麼吧', + uploadFiles: '上傳檔案', + loadMore: '載入更多', + itemsPartial: '已載入 {count} 項', + itemsTotal: '共 {count} 項', + preview: '預覽', + download: '下載', + rename: '重新命名', + copy: '複製', + move: '移動', + delete: '刪除', + confirm: '確認', + ok: '好的', + deleteConfirmTitle: '刪除確認', + deleteConfirmMsg: '確定要刪除「{name}」嗎?刪除後無法恢復哦。', + deleteFolderConfirmMsg: '確定要刪除目錄「{name}」及其所有內容嗎?刪除後無法恢復哦。', + renameTitle: '重新命名', + renameLabel: '新名稱', + renameInvalidChars: '檔案名包含非法字元:{chars}', + copyTitle: '複製到', + copyLabel: '目標路徑', + copyFolderHint: '目錄將被遞迴複製,包含所有子目錄和檔案。', + pathInvalidChars: '路徑包含非法字元:{chars}', + moveTitle: '移動到', + moveLabel: '目標目錄', + moveSamePath: '無法移動:目標目錄與當前位置相同', + copySamePath: '無法複製:目標目錄與源路徑相同', + newFolderTitle: '新建目錄', + newFolderLabel: '目錄名稱', + authFailed: '認證失敗,請檢查 Access Key 和 Secret Key 是否正確', + customDomain: '自訂域名(Custom Domain)', + customDomainHint: '可選,配置後可一鍵複製檔案的公開連結', + bucketAccess: '儲存桶可見性(Bucket Visibility)', + bucketAccessPublic: '公開(Public)', + bucketAccessPrivate: '私有(Private)', + copyLink: '複製連結', + copyPath: '複製檔案路徑', + copyUrl: '複製 URL 直鏈', + copyMarkdown: '複製為 Markdown 格式', + copyHtml: '複製為 HTML 格式', + copyPresigned: '複製 R2 預簽名 URL', + copyText: '複製文本', + copyTextTitle: '複製文字', + copyTextLabel: '文字內容', + copyTextSuccess: '文本已複製', + copyImage: '複製圖片', + copyImageSuccess: '圖片已複製', + copyImageFailed: '複製失敗,請使用下載', + copyImageNotSupported: '當前瀏覽器不支持複製圖片', + copyImageNotSupportedType: '僅支持圖片檔案複製', + shareQr: '二維碼', + fileQrTitle: '分享二維碼', + fileQrDesc: '使用手機掃碼打開檔案連結', + shareQrNeedDomain: '請先配置自訂域名,才能生成二維碼', + linkCopied: '連結已複製好啦', + corsError: '你好像還沒有開啟 CORS 哦。請到參考 Readme 完成配置。', + networkError: '網路連接失敗: {msg}', + http401Error: '認證失敗 (401),Access Key 或 Secret Key 可能已失效或被刪除', + http403Error: '訪問被拒絕 (403),請檢查 API 金鑰權限', + http404Error: '儲存桶不存在 (404),請檢查 Bucket Name 是否正確', + uploadSuccess: '{count} 個檔案上傳成功', + uploadPartialFail: '{success} 個成功,{fail} 個沒能上傳', + fileTooLarge: '檔案「{name}」太大了(超過 5GB),試試 rclone 等工具吧', + overwriteTitle: '檔案已存在', + overwriteMsg: '「{name}」已存在,是否覆蓋原檔案?', + overwriteSkip: '跳過', + overwriteSkipAll: '全部跳過', + overwriteConfirm: '覆蓋', + overwriteAll: '全部覆蓋', + uploadSkipped: '已跳過 {count} 個同名檔案', + deleteSuccess: '「{name}」已刪除', + renameSuccess: '已重命名為「{name}」', + copySuccess: '已複製到「{name}」', + moveSuccess: '已移動到「{name}」', + folderCreated: '目錄「{name}」創建好了', + previewNotAvailable: '這種檔案類型暫時還不能預覽', + size: '大小', + lastModified: '最後修改', + contentType: '類型', + settings: '設置', + toggleTheme: '切換主題', + close: '關閉', + shareConfig: '分享配置', + shareConfigCopied: '分享連結已複製', + configLoadedFromUrl: '已從連結載入配置,開始使用吧', + shareDialogTitle: '分享配置', + shareDialogSubtitle: '選擇以下任一方式進行多設備遷移', + shareDividerText: '或', + shareLinkTitle: '配置連結', + shareLinkDesc: '複製連結並在其他設備瀏覽器中打開', + shareQrTitle: '掃描二維碼', + shareQrDesc: '使用行動裝置相機掃描', + shareQrHint: '掃碼後在瀏覽器中打開連結', + copyShareUrl: '複製連結', + shareWarning: '連結包含帳戶權杖,請謹慎分享', + preferences: '偏好設置', + compressMode: '壓縮模式', + compressModeNone: '暫不開啟', + compressModeLocal: '本機壓縮', + compressModeTinify: 'Tinify 服務', + compressLevel: '壓縮程度', + compressLevelBalanced: '平衡模式', + compressLevelExtreme: '極致壓縮', + compressLevelHint: '平衡:JPEG/WebP 90%、AVIF 60%;極致:JPEG/WebP 75%、AVIF 50%', + compressNotSupported: '格式不支持壓縮,保持原檔案', + compressModeHint: '本機:MozJPEG、libwebp、libavif、OxiPNG 最佳化器;Tinify:雲服務', + compressTinifyHint: 'Key 儲存在本機,但 Tinify API 存在跨域問題,會經過一層代理服務中轉。', + theme: '主題', + sort: '排序', + sortName: '按名稱', + sortDate: '按日期', + sortSize: '按大小', + sortAsc: '升序', + sortDesc: '降序', + viewGrid: '網格', + viewList: '列表', + density: '密度', + densityCompact: '緊湊', + densityNormal: '標準', + densityLoose: '寬鬆', + save: '保存', + heroDesc: '輕盈優雅的 Web 原生 Cloudflare R2 檔案管理器,一切皆在瀏覽器中完成。', + heroConnect: '開始連接', + heroF1: '簡單優雅高效', + heroF2: '純本機客戶端', + heroF3: '目錄檔案管理', + heroF4: '常見類型預覽', + heroF5: '拖拽黏貼上傳', + heroF6: '上傳自動壓縮', + heroF7: '一鍵複製外鏈', + heroF8: '一鍵分享配置', + refresh: '刷新', + logout: '安全退出', + logoutConfirmTitle: '安全退出', + logoutConfirmMsg: '退出後會清除瀏覽器中的資料,桶內的檔案不受影響。確定退出嗎?', + copying: '正在複製「{name}」到「{destName}」…', + moving: '正在移動「{name}」到「{destName}」…', + deleting: '正在刪除「{name}」…', + renaming: '正在重命名「{name}」為「{destName}」…', + // Config dialog tooltips + tooltipAccountId: 'Cloudflare 帳戶 ID,在 R2 控制台右下角可找到', + tooltipAccessKeyId: 'R2 API 訪問金鑰 ID,在 R2 設置中創建', + tooltipSecretAccessKey: 'R2 API 金鑰,僅儲存在瀏覽器本機,不會上傳到任何伺服器', + tooltipBucket: 'R2 儲存桶名稱,所有檔案操作將在此桶下進行', + tooltipCustomDomain: '自訂域名(可選),配置後可一鍵複製檔案的公開訪問連結', + tooltipBucketAccess: '聲明儲存桶的實際可見性。公開時優先使用自訂域名直鏈;私有時始終使用預簽名 URL', + tooltipFilenameTpl: '檔案名模板,支持多種占位符自動生成檔案名和目錄結構', + tooltipFilenameTplScope: '選擇模板生效範圍,默認僅圖片', + filenameTplHintDetailed: + '占位符: [name] [ext] [hash:N] [date:FORMAT] [timestamp] [uuid]\n\n例一:[name]_[hash:6].[ext]\n結果:photo_a1b2c3.jpg\n\n例二:[date:YYYY-MM-DD]_[name].[ext]\n結果:2024-03-15_photo.jpg\n\n例三:[date:YYYY/MM]/[name].[ext]\n結果:2024/03/photo.jpg', + tooltipCompressMode: '上傳前壓縮圖片,支持 JPEG/PNG/WebP/AVIF 格式,可節省儲存空間和頻寬', + tooltipCompressLevel: '壓縮品質,平衡模式保持高品質(90%),極致壓縮更省空間(75%)', + tooltipTinifyKey: 'Tinify API Key,儲存在本機,需通過代理訪問以避免跨域問題', + tooltipTheme: '選擇界面主題,支持淺色、深色或跟隨系統設置自動切換', + tooltipLanguage: '切換界面顯示語言,支持簡體中文、正體中文、英語和日語', + tooltipDensity: '調整檔案列表的行高與間距,緊湊模式可顯示更多內容', + tinifyKeyHintText: '還沒有 API Key?', + tinifyKeyLink: '前往獲取', + // Tab labels + configTabPreferences: '偏好設置', + configTabR2: 'R2 設置', + uploadConcurrency: '並發上傳數', + configTabUpload: '上傳設置', + configTabCompression: '壓縮設置', + configTabAbout: '關於', + // Preferences section + lblTheme: '主題', + lblLanguage: '界面語言', + lblDensity: '緊湊度', + themeLight: '淺色', + themeDark: '深色', + themeAuto: '跟隨系統', + // About page + aboutDescription: '輕盈優雅的 Web 原生 Cloudflare R2 檔案管理器', + aboutGithub: 'GitHub', + aboutChangelog: '更新日誌', + aboutQQGroup: '回饋 QQ 群', + aboutLicense: '開源協議', + aboutUpdatedLabel: '更新日期', + // Batch operations + selectFile: '選擇檔案', + batchMode: '批量操作', + exitBatchMode: '退出批量', + batchSelected: '已選 {count} 項', + batchSelectAll: '全選', + batchDeselectAll: '取消全選', + batchDeleteTitle: '批量刪除', + batchDeleteConfirmMsg: '確定要刪除選取的 {count} 項嗎?刪除後無法復原。', + batchDeleting: '正在刪除 {count} 項...', + batchDeleteSuccess: '已刪除 {count} 項', + batchDeletePartial: '{success} 項已刪除,{fail} 項失敗', + batchMoveTitle: '批量移動', + batchMoveLabel: '目標目錄', + batchMoveSamePath: '目標目錄與目前位置相同', + batchMoving: '正在移動 {count} 項...', + batchMoveSuccess: '{count} 項已移動至 {dest}', + batchMovePartial: '{success} 項已移動,{fail} 項失敗', + batchCopyTitle: '批量複製', + batchCopyLabel: '目標目錄', + batchCopySamePath: '目標目錄與目前位置相同', + batchCopying: '正在複製 {count} 項...', + batchCopySuccess: '{count} 項已複製至 {dest}', + batchCopyPartial: '{success} 項已複製,{fail} 項失敗', + editContentType: '修改類型', + editContentTypeTitle: '修改 Content-Type', + editContentTypeLabel: 'Content-Type', + editContentTypeInvalid: '格式不正確,範例:image/jpeg', + editingContentType: '正在修改「{name}」的 Content-Type…', + editContentTypeSuccess: '已將「{name}」的 Content-Type 修改為 {type}', + }, + en: { + appTitle: 'R2 Web', + connectTitle: 'Connect to R2', + connectDesc: 'Enter your R2 credentials to get started. Everything stays safely in your browser.', + accountId: 'Account ID', + accessKeyId: 'Access Key ID', + secretAccessKey: 'Secret Access Key', + bucketName: 'Bucket Name', + filenameTpl: 'Filename Template', + filenameTplHint: + 'Placeholders: [name] original, [ext] extension, [hash:N] hash, [date:FORMAT] date, [timestamp] ts, [uuid], / = directory (hash default 6 chars)', + filenameTplScope: 'Applies To', + filenameTplScopeImages: 'Images Only', + filenameTplScopeAll: 'All Files', + filenameTplPathTitle: 'Choose Path', + filenameTplPathDesc: + 'You are not at the root (/), and the template output contains subfolders. Please confirm the upload path.', + filenameTplPathDescWithTpl: 'Current directory: {prefix}, template: {template}. Please confirm the upload path.', + cancel: 'Cancel', + connect: 'Connect', + newFolder: 'New Folder', + upload: 'Upload', + dropToUpload: 'Drop to upload', + pasteToUpload: 'Pasted {count} file(s)', + uploadHint: 'Drag, paste, or click to upload', + dropInvalidHint: 'No uploadable files detected. Please drag directly from your file manager.', + pasteHint: 'Paste files to current directory', + pasteMixedNotSupported: 'Mixed text and images are not supported. Please paste only text or images.', + pasteTextConfirmTitle: 'Paste Text Upload', + pasteTextConfirmMsg: 'Text detected. Upload as a TXT file?', + pasteTextPathTitle: 'Upload Path', + pasteTextPathLabel: 'Save path', + pasteFileConfirmTitle: 'Paste File Upload', + pasteFileConfirmMsg: 'Detected file “{name}”. Upload it?', + pasteFilePathTitle: 'Upload Path', + pasteFilePathLabel: 'Save path', + pasteFilesConfirmTitle: 'Paste Files Upload', + pasteFilesConfirmMsg: 'Detected {count} files. Upload to the current directory?', + uploadWaiting: 'Waiting...', + uploading: 'Uploading...', + uploadPreparing: 'Preparing...', + uploadProgress: 'Upload Progress', + root: 'Root', + emptyFolder: 'Nothing here yet — upload something!', + uploadFiles: 'Upload Files', + loadMore: 'Load More', + itemsPartial: '{count} items loaded', + itemsTotal: '{count} items', + preview: 'Preview', + download: 'Download', + rename: 'Rename', + copy: 'Copy', + move: 'Move', + delete: 'Delete', + confirm: 'Confirm', + ok: 'OK', + deleteConfirmTitle: 'Delete Confirmation', + deleteConfirmMsg: 'Delete "{name}"? This can\'t be undone.', + deleteFolderConfirmMsg: 'Delete folder "{name}" and everything inside? This can\'t be undone.', + renameTitle: 'Rename', + renameLabel: 'New name', + renameInvalidChars: 'File name contains illegal characters: {chars}', + copyTitle: 'Copy to', + copyLabel: 'Destination path', + copyFolderHint: 'The folder and all its contents will be copied recursively.', + pathInvalidChars: 'Path contains illegal characters: {chars}', + moveTitle: 'Move to', + moveLabel: 'Destination folder', + moveSamePath: 'Cannot move: destination is the same as the current location', + copySamePath: 'Cannot copy: destination path is the same as the source', + newFolderTitle: 'New Folder', + newFolderLabel: 'Folder name', + authFailed: "Couldn't connect — double-check your credentials", + customDomain: 'Custom Domain', + customDomainHint: 'Optional. Enables one-click public URL copying.', + bucketAccess: 'Bucket Visibility', + bucketAccessPublic: 'Public', + bucketAccessPrivate: 'Private', + copyLink: 'Copy Link', + copyPath: 'Copy File Path', + copyUrl: 'Copy Direct URL', + copyMarkdown: 'Copy as Markdown', + copyHtml: 'Copy as HTML', + copyPresigned: 'Copy R2 Pre-signed URL', + copyText: 'Copy Text', + copyTextTitle: 'Copy Text', + copyTextLabel: 'Text content', + copyTextSuccess: 'Text copied', + copyImage: 'Copy Image', + copyImageSuccess: 'Image copied', + copyImageFailed: 'Copy failed. Please use download instead.', + copyImageNotSupported: 'Image copy is not supported in this browser', + copyImageNotSupportedType: 'Only image files can be copied as images', + shareQr: 'QR Code', + fileQrTitle: 'Share QR', + fileQrDesc: 'Scan with your phone to open the file link', + shareQrNeedDomain: 'Please set a custom domain to generate a QR code', + linkCopied: 'Link copied!', + corsError: + "CORS isn't set up yet. Head to Cloudflare Dashboard → R2 → Bucket Settings and add a CORS rule from current origin.", + networkError: 'Network connection failed: {msg}', + http401Error: 'Authentication failed (401), Access Key or Secret Key may be invalid or deleted', + http403Error: 'Access denied (403), please check API key permissions', + http404Error: 'Bucket not found (404), please verify the Bucket Name', + uploadSuccess: '{count} file(s) uploaded!', + uploadPartialFail: "{success} uploaded, {fail} didn't make it", + fileTooLarge: '"{name}" is too large (over 5GB) — try rclone for big uploads', + overwriteTitle: 'File Already Exists', + overwriteMsg: '"{name}" already exists. Overwrite it?', + overwriteSkip: 'Skip', + overwriteSkipAll: 'Skip All', + overwriteConfirm: 'Overwrite', + overwriteAll: 'Overwrite All', + uploadSkipped: 'Skipped {count} duplicate(s)', + deleteSuccess: '"{name}" deleted', + renameSuccess: 'Renamed to "{name}"', + copySuccess: 'Copied to "{name}"', + moveSuccess: 'Moved to "{name}"', + folderCreated: 'Folder "{name}" created!', + previewNotAvailable: "Can't preview this file type yet", + size: 'Size', + lastModified: 'Last Modified', + contentType: 'Type', + settings: 'Settings', + toggleTheme: 'Toggle Theme', + close: 'Close', + shareConfig: 'Share Config', + shareConfigCopied: 'Share link copied', + configLoadedFromUrl: 'Config loaded, ready to go!', + shareDialogTitle: 'Share Configuration', + shareDialogSubtitle: 'Choose one of the following methods for cross-device migration', + shareDividerText: 'Or', + shareLinkTitle: 'Configuration Link', + shareLinkDesc: 'Copy the link and open it in a browser on another device', + shareQrTitle: 'Scan QR Code', + shareQrDesc: 'Scan with mobile device camera', + shareQrHint: 'Open the link in a browser after scanning', + copyShareUrl: 'Copy Link', + shareWarning: 'Link contains account credentials, share with caution', + preferences: 'Preferences', + compressMode: 'Compression Mode', + compressModeNone: 'None', + compressModeLocal: 'Local', + compressModeTinify: 'Tinify Service', + compressLevel: 'Compression Level', + compressLevelBalanced: 'Balanced', + compressLevelExtreme: 'Extreme', + compressLevelHint: 'Balanced: JPEG/WebP 90%, AVIF 60%; Extreme: JPEG/WebP 75%, AVIF 50%', + compressNotSupported: 'Format not supported, using original', + compressModeHint: 'Local: MozJPEG, libwebp, libavif, OxiPNG optimizer; Tinify: Cloud API', + compressTinifyHint: + 'Key is stored locally in your browser. Requests are proxied to avoid CORS issues with the Tinify API.', + theme: 'Theme', + sort: 'Sort', + sortName: 'By Name', + sortDate: 'By Date', + sortSize: 'By Size', + sortAsc: 'Ascending', + sortDesc: 'Descending', + viewGrid: 'Grid', + viewList: 'List', + density: 'Density', + densityCompact: 'Compact', + densityNormal: 'Standard', + densityLoose: 'Loose', + save: 'Save', + heroDesc: 'A lightweight & elegant R2 bucket manager, all in your browser.', + heroConnect: 'Get Started', + heroF1: 'Simple & elegant', + heroF2: 'Pure local client', + heroF3: 'File management', + heroF4: 'Common type preview', + heroF5: 'Drag & paste upload', + heroF6: 'Auto compress upload', + heroF7: 'One-click copy link', + heroF8: 'One-click share config', + refresh: 'Refresh', + logout: 'Logout', + logoutConfirmTitle: 'Logout', + logoutConfirmMsg: "This will clear your saved credentials. Files in the bucket won't be affected. Continue?", + copying: 'Copying "{name}" to "{destName}"...', + moving: 'Moving "{name}" to "{destName}"...', + deleting: 'Deleting "{name}"...', + renaming: 'Renaming "{name}" to "{destName}"...', + // Config dialog tooltips + tooltipAccountId: 'Cloudflare Account ID, found in the bottom-right corner of R2 console', + tooltipAccessKeyId: 'R2 API Access Key ID, create one in R2 settings', + tooltipSecretAccessKey: 'R2 API Secret Key, stored locally in browser only, never uploaded', + tooltipBucket: 'R2 Bucket Name, all file operations will be performed in this bucket', + tooltipCustomDomain: 'Custom Domain (optional), enables one-click public URL copying for files', + tooltipBucketAccess: + 'Declares actual bucket visibility. Public uses custom domain URL; Private always uses presigned URLs.', + tooltipFilenameTpl: 'Filename template, supports placeholders for auto-generating names and folders', + tooltipFilenameTplScope: 'Choose template scope; default is images only', + filenameTplHintDetailed: + 'Placeholders: [name] [ext] [hash:N] [date:FORMAT] [timestamp] [uuid]\n\nExample 1: [name]_[hash:6].[ext]\nResult: photo_a1b2c3.jpg\n\nExample 2: [date:YYYY-MM-DD]_[name].[ext]\nResult: 2024-03-15_photo.jpg\n\nExample 3: [date:YYYY/MM]/[name].[ext]\nResult: 2024/03/photo.jpg', + tooltipCompressMode: 'Compress images before upload, supports JPEG/PNG/WebP/AVIF to save storage and bandwidth', + tooltipCompressLevel: 'Compression quality, Balanced maintains high quality (90%), Extreme saves more space (75%)', + tooltipTinifyKey: 'Tinify API Key, stored locally, proxied to avoid CORS issues', + tooltipTheme: 'Choose interface theme: light, dark, or follow system settings automatically', + tooltipLanguage: 'Switch interface display language, supports Chinese, English, and Japanese', + tooltipDensity: 'Adjust file list row height and spacing, compact mode shows more items', + tinifyKeyHintText: "Don't have an API Key?", + tinifyKeyLink: 'Get one here', + // Tab labels + configTabPreferences: 'Preferences', + configTabR2: 'R2 Config', + uploadConcurrency: 'Concurrency', + configTabUpload: 'Upload', + configTabCompression: 'Compression', + configTabAbout: 'About', + // Preferences section + lblTheme: 'Theme', + lblLanguage: 'Interface Language', + lblDensity: 'Density', + themeLight: 'Light', + themeDark: 'Dark', + themeAuto: 'Follow System', + // About page + aboutDescription: 'A pure client-side Cloudflare R2 bucket file manager', + aboutGithub: 'GitHub', + aboutChangelog: 'Changelog', + aboutQQGroup: 'QQ Group', + aboutLicense: 'License', + aboutUpdatedLabel: 'Updated', + // Batch operations + selectFile: 'Select file', + batchMode: 'Batch actions', + exitBatchMode: 'Exit batch', + batchSelected: '{count} selected', + batchSelectAll: 'Select all', + batchDeselectAll: 'Deselect all', + batchDeleteTitle: 'Batch Delete', + batchDeleteConfirmMsg: 'Delete {count} selected items? This cannot be undone.', + batchDeleting: 'Deleting {count} items...', + batchDeleteSuccess: 'Deleted {count} items', + batchDeletePartial: '{success} deleted, {fail} failed', + batchMoveTitle: 'Batch Move', + batchMoveLabel: 'Target folder', + batchMoveSamePath: 'Target is the same as current location', + batchMoving: 'Moving {count} items...', + batchMoveSuccess: 'Moved {count} items to {dest}', + batchMovePartial: '{success} moved, {fail} failed', + batchCopyTitle: 'Batch Copy', + batchCopyLabel: 'Target folder', + batchCopySamePath: 'Target is the same as current location', + batchCopying: 'Copying {count} items...', + batchCopySuccess: 'Copied {count} items to {dest}', + batchCopyPartial: '{success} copied, {fail} failed', + editContentType: 'Edit Type', + editContentTypeTitle: 'Edit Content-Type', + editContentTypeLabel: 'Content-Type', + editContentTypeInvalid: 'Invalid format, e.g. image/jpeg', + editingContentType: 'Updating Content-Type for "{name}"...', + editContentTypeSuccess: 'Content-Type of "{name}" updated to {type}', + }, + ja: { + appTitle: 'R2 Web', + connectTitle: 'R2 に接続', + connectDesc: 'R2 の認証情報を入力して始めましょう。データはブラウザにのみ保存されます。', + accountId: 'アカウント ID(Account ID)', + accessKeyId: 'アクセスキー ID(Access Key ID)', + secretAccessKey: 'シークレットアクセスキー(Secret Access Key)', + bucketName: 'バケット名(Bucket Name)', + filenameTpl: 'ファイル名テンプレート', + filenameTplHint: + 'プレースホルダ: [name] 元名, [ext] 拡張子, [hash:N] ハッシュ, [date:FORMAT] 日付, [timestamp] タイムスタンプ, [uuid], / ディレクトリ (hash デフォルト 6 文字)', + filenameTplScope: '適用範囲', + filenameTplScopeImages: '画像のみ', + filenameTplScopeAll: 'すべてのファイル', + filenameTplPathTitle: 'パス選択', + filenameTplPathDesc: + '現在ルート(/)ではなく、テンプレート結果にサブフォルダがあります。アップロード先のパスを確認してください。', + filenameTplPathDescWithTpl: + '現在のディレクトリ:{prefix}、テンプレート:{template}。アップロード先のパスを確認してください。', + cancel: 'キャンセル', + connect: '接続', + newFolder: '新規フォルダ', + upload: 'アップロード', + dropToUpload: 'ドロップしてアップロード', + pasteToUpload: '{count} 個のファイルを貼り付けました', + uploadHint: 'ドラッグ、貼り付け、クリックでアップロード', + dropInvalidHint: + 'アップロード可能なファイルが検出されませんでした。ファイルマネージャーから直接ドラッグしてください。', + pasteHint: '現在のディレクトリにファイルを貼り付け', + pasteMixedNotSupported: + '画像とテキストの混在貼り付けは未対応です。テキストのみ、または画像のみで貼り付けてください。', + pasteTextConfirmTitle: 'テキストをアップロード', + pasteTextConfirmMsg: 'テキストを検出しました。TXT ファイルとしてアップロードしますか?', + pasteTextPathTitle: 'アップロード先', + pasteTextPathLabel: '保存パス', + pasteFileConfirmTitle: 'ファイルをアップロード', + pasteFileConfirmMsg: 'ファイル「{name}」を検出しました。アップロードしますか?', + pasteFilePathTitle: 'アップロード先', + pasteFilePathLabel: '保存パス', + pasteFilesConfirmTitle: 'ファイルをアップロード', + pasteFilesConfirmMsg: '{count} 個のファイルを検出しました。現在のディレクトリにアップロードしますか?', + uploadWaiting: '待機中...', + uploading: 'アップロード中...', + uploadPreparing: '準備中...', + uploadProgress: 'アップロード進行状況', + root: 'ルート', + emptyFolder: 'まだ何もありません — アップロードしてみましょう', + uploadFiles: 'ファイルをアップロード', + loadMore: 'もっと読み込む', + itemsPartial: '{count} 件を読み込み済み', + itemsTotal: '合計 {count} 件', + preview: 'プレビュー', + download: 'ダウンロード', + rename: '名前変更', + copy: 'コピー', + move: '移動', + delete: '削除', + confirm: '確認', + ok: 'OK', + deleteConfirmTitle: '削除の確認', + deleteConfirmMsg: '"{name}" を削除しますか?元に戻せません。', + deleteFolderConfirmMsg: 'フォルダ "{name}" とその中身をすべて削除しますか?元に戻せません。', + renameTitle: '名前変更', + renameLabel: '新しい名前', + renameInvalidChars: 'ファイル名に使用できない文字が含まれています:{chars}', + copyTitle: 'コピー先', + copyLabel: 'コピー先パス', + copyFolderHint: 'フォルダーとそのすべての内容が再帰的にコピーされます。', + pathInvalidChars: 'パスに使用できない文字が含まれています:{chars}', + moveTitle: '移動先', + moveLabel: '移動先フォルダー', + moveSamePath: '移動できません:移動先が現在の場所と同じです', + copySamePath: 'コピーできません:コピー先が元のパスと同じです', + newFolderTitle: '新規フォルダ', + newFolderLabel: 'フォルダ名', + authFailed: '接続できませんでした — 認証情報を確認してみてください', + customDomain: 'カスタムドメイン(Custom Domain)', + customDomainHint: '任意。設定するとワンクリックで公開URLをコピーできます。', + bucketAccess: 'バケットの公開設定(Bucket Visibility)', + bucketAccessPublic: '公開(Public)', + bucketAccessPrivate: '非公開(Private)', + copyLink: 'リンクをコピー', + copyPath: 'ファイルパスをコピー', + copyUrl: '直接 URL をコピー', + copyMarkdown: 'Markdown 形式でコピー', + copyHtml: 'HTML 形式でコピー', + copyPresigned: 'R2 署名付き URL をコピー', + copyText: 'テキストをコピー', + copyTextTitle: 'テキストをコピー', + copyTextLabel: 'テキスト内容', + copyTextSuccess: 'テキストをコピーしました', + copyImage: '画像をコピー', + copyImageSuccess: '画像をコピーしました', + copyImageFailed: 'コピーに失敗しました。ダウンロードをご利用ください。', + copyImageNotSupported: 'このブラウザは画像コピーに対応していません', + copyImageNotSupportedType: '画像ファイルのみコピーできます', + shareQr: 'QR コード', + fileQrTitle: '共有 QR', + fileQrDesc: 'スマホで読み取ってファイルリンクを開けます', + shareQrNeedDomain: 'QR を生成するにはカスタムドメインを設定してください', + linkCopied: 'リンクをコピーしました!', + corsError: + 'CORS がまだ設定されていません。Cloudflare ダッシュボード → R2 → バケット設定で CORS ルールを追加してください。', + networkError: 'ネットワーク接続に失敗しました: {msg}', + http401Error: '認証に失敗しました (401)、Access Key または Secret Key が無効または削除された可能性があります', + http403Error: 'アクセスが拒否されました (403)、API キーの権限を確認してください', + http404Error: 'バケットが見つかりません (404)、Bucket Name を確認してください', + uploadSuccess: '{count} 個のファイルをアップロードしました!', + uploadPartialFail: '{success} 個成功、{fail} 個は失敗しました', + fileTooLarge: '"{name}" は大きすぎます(5GB超)— rclone などをお試しください', + overwriteTitle: 'ファイルが既に存在します', + overwriteMsg: '"{name}" は既に存在します。上書きしますか?', + overwriteSkip: 'スキップ', + overwriteSkipAll: '全てスキップ', + overwriteConfirm: '上書き', + overwriteAll: '全て上書き', + uploadSkipped: '{count} 個の重複ファイルをスキップしました', + deleteSuccess: '"{name}" を削除しました', + renameSuccess: '"{name}" に名前を変更しました', + copySuccess: '"{name}" にコピーしました', + moveSuccess: '"{name}" に移動しました', + folderCreated: 'フォルダ "{name}" を作成しました!', + previewNotAvailable: 'このファイルタイプはまだプレビューできません', + size: 'サイズ', + lastModified: '最終更新', + contentType: 'タイプ', + settings: '設定', + toggleTheme: 'テーマ切替', + close: '閉じる', + shareConfig: '設定を共有', + shareConfigCopied: '共有リンクをコピーしました', + configLoadedFromUrl: '設定を読み込みました、始めましょう!', + shareDialogTitle: '設定を共有', + shareDialogSubtitle: '以下のいずれかの方法でデバイス間で移行できます', + shareDividerText: 'または', + shareLinkTitle: '設定リンク', + shareLinkDesc: 'リンクをコピーして他のデバイスのブラウザで開く', + shareQrTitle: 'QRコードをスキャン', + shareQrDesc: 'モバイルデバイスのカメラでスキャン', + shareQrHint: 'スキャン後、ブラウザでリンクを開く', + copyShareUrl: 'リンクをコピー', + shareWarning: 'リンクにはアカウント認証情報が含まれています。注意してください', + preferences: '設定', + compressMode: '圧縮モード', + compressModeNone: 'なし', + compressModeLocal: 'ローカル', + compressModeTinify: 'Tinify サービス', + compressLevel: '圧縮レベル', + compressLevelBalanced: 'バランス', + compressLevelExtreme: '極端', + compressLevelHint: 'バランス: JPEG/WebP 90%、AVIF 60%; 極限: JPEG/WebP 75%、AVIF 50%', + compressNotSupported: 'フォーマット未対応、元ファイル使用', + compressModeHint: 'ローカル: MozJPEG、libwebp、libavif、OxiPNG; Tinify: クラウド', + compressTinifyHint: + 'Tinify API の CORS 問題を回避するため、キーはブラウザにローカル保存され、リクエストはプロキシ経由になります。', + theme: 'テーマ', + sort: '並び替え', + sortName: '名前順', + sortDate: '日付順', + sortSize: 'サイズ順', + sortAsc: '昇順', + sortDesc: '降順', + viewGrid: 'グリッド', + viewList: 'リスト', + density: '密度', + densityCompact: 'コンパクト', + densityNormal: '標準', + densityLoose: 'ルーズ', + save: '保存', + heroDesc: '軽量でエレガントな R2 バケットマネージャー、すべてブラウザで完結。', + heroConnect: '始めましょう', + heroF1: 'シンプル&エレガント', + heroF2: 'ローカルクライアント', + heroF3: 'ファイル管理', + heroF4: '一般プレビュー', + heroF5: 'ドラッグ&ペースト', + heroF6: 'アップロード自動圧縮', + heroF7: 'ワンクリックリンクコピー', + heroF8: 'ワンクリック設定共有', + refresh: 'リフレッシュ', + logout: 'ログアウト', + logoutConfirmTitle: 'ログアウト', + logoutConfirmMsg: '保存された認証情報が削除されます。バケット内のファイルには影響しません。続行しますか?', + copying: '"{name}" を "{destName}" にコピーしています...', + moving: '"{name}" を "{destName}" に移動しています...', + deleting: '"{name}" を削除しています...', + renaming: '"{name}" を "{destName}" に名前変更しています...', + // Config dialog tooltips + tooltipAccountId: 'Cloudflare アカウント ID、R2 コンソールの右下で確認できます', + tooltipAccessKeyId: 'R2 API アクセスキー ID、R2 設定で作成します', + tooltipSecretAccessKey: 'R2 API シークレットキー、ブラウザにのみ保存され、アップロードされません', + tooltipBucket: 'R2 バケット名、すべてのファイル操作はこのバケットで実行されます', + tooltipCustomDomain: 'カスタムドメイン(任意)、設定後ファイルの公開 URL をワンクリックでコピーできます', + tooltipBucketAccess: + 'バケットの実際の公開設定を宣言します。公開: カスタムドメイン URL を優先;非公開: 常にプリサインド URL を使用', + tooltipFilenameTpl: 'ファイル名テンプレート、プレースホルダで名前とフォルダ構造を自動生成', + tooltipFilenameTplScope: 'テンプレートの適用範囲を選択(既定は画像のみ)', + filenameTplHintDetailed: + 'プレースホルダ: [name] [ext] [hash:N] [date:FORMAT] [timestamp] [uuid]\n\n例1: [name]_[hash:6].[ext]\n結果: photo_a1b2c3.jpg\n\n例2: [date:YYYY-MM-DD]_[name].[ext]\n結果: 2024-03-15_photo.jpg\n\n例3: [date:YYYY/MM]/[name].[ext]\n結果: 2024/03/photo.jpg', + tooltipCompressMode: 'アップロード前に画像を圧縮、JPEG/PNG/WebP/AVIF 対応、ストレージと帯域幅を節約', + tooltipCompressLevel: '圧縮品質、バランスは高品質を維持(90%)、極限はさらに容量を節約(75%)', + tooltipTinifyKey: 'Tinify API Key、ローカル保存、CORS 問題を回避するためプロキシ経由', + tooltipTheme: 'インターフェーステーマを選択:ライト、ダーク、またはシステム設定に自動追従', + tooltipLanguage: 'インターフェース表示言語を切り替え、中国語、英語、日本語に対応', + tooltipDensity: 'ファイルリストの行の高さと間隔を調整、コンパクトモードでより多く表示', + tinifyKeyHintText: 'API Key をお持ちでない場合は', + tinifyKeyLink: 'こちらから取得', + // Tab labels + configTabPreferences: '環境設定', + configTabR2: 'R2 設定', + uploadConcurrency: '同時アップロード数', + configTabUpload: 'アップロード', + configTabCompression: '圧縮', + configTabAbout: 'について', + // Preferences section + lblTheme: 'テーマ', + lblLanguage: 'インターフェース言語', + lblDensity: '密度', + themeLight: 'ライト', + themeDark: 'ダーク', + themeAuto: 'システムに従う', + // About page + aboutDescription: '純粋なクライアントサイド Cloudflare R2 バケットファイルマネージャー', + aboutGithub: 'GitHub', + aboutChangelog: '変更履歴', + aboutQQGroup: 'QQ グループ', + aboutLicense: 'ライセンス', + aboutUpdatedLabel: '更新日', + // Batch operations + selectFile: 'ファイルを選択', + batchMode: '一括操作', + exitBatchMode: '一括を終了', + batchSelected: '{count} 件選択中', + batchSelectAll: 'すべて選択', + batchDeselectAll: 'すべて解除', + batchDeleteTitle: '一括削除', + batchDeleteConfirmMsg: '選択した {count} 件を削除しますか?元に戻せません。', + batchDeleting: '{count} 件を削除中...', + batchDeleteSuccess: '{count} 件を削除しました', + batchDeletePartial: '{success} 件削除済み、{fail} 件失敗', + batchMoveTitle: '一括移動', + batchMoveLabel: '移動先フォルダ', + batchMoveSamePath: '移動先が現在の場所と同じです', + batchMoving: '{count} 件を移動中...', + batchMoveSuccess: '{count} 件を {dest} に移動しました', + batchMovePartial: '{success} 件移動済み、{fail} 件失敗', + batchCopyTitle: '一括コピー', + batchCopyLabel: 'コピー先フォルダ', + batchCopySamePath: 'コピー先が現在の場所と同じです', + batchCopying: '{count} 件をコピー中...', + batchCopySuccess: '{count} 件を {dest} にコピーしました', + batchCopyPartial: '{success} 件コピー済み、{fail} 件失敗', + editContentType: 'タイプ編集', + editContentTypeTitle: 'Content-Type を編集', + editContentTypeLabel: 'Content-Type', + editContentTypeInvalid: '形式が正しくありません(例: image/jpeg)', + editingContentType: '"{name}" の Content-Type を更新しています...', + editContentTypeSuccess: '"{name}" の Content-Type を {type} に更新しました', + }, +} + +/** @typedef {keyof typeof I18N} Lang */ +/** @typedef {keyof typeof I18N.en} I18nKey */ + +/** @returns {Lang} */ +function getInitialLang() { + const saved = localStorage.getItem(LANG_KEY) + if (saved) return /** @type {Lang} */ (saved) + + const browserLang = navigator.language || '' + + /** @type {Lang} */ + let detected = 'zh' + if (/^zh-(TW|HK|MO)/i.test(browserLang)) detected = 'zh_TW' + else if (/^zh/i.test(browserLang)) detected = 'zh' + else if (/^ja/i.test(browserLang)) detected = 'ja' + else if (/^en/i.test(browserLang)) detected = 'en' + + localStorage.setItem(LANG_KEY, detected) + return detected +} + +let currentLang = getInitialLang() +/** @param {I18nKey} key @param {Record} [params] @returns {string} */ +function t(key, params = {}) { + let str = I18N[currentLang]?.[key] || I18N.en[key] || key + for (const [k, v] of Object.entries(params)) { + str = str.replace(`{${k}}`, String(v)) + } + return str +} +/** @param {Lang} lang */ +function setLang(lang) { + currentLang = lang + localStorage.setItem(LANG_KEY, lang) +} +function getCurrentLang() { + return currentLang +} +export { I18N, t, setLang, getCurrentLang } diff --git a/src/js/r2-client.js b/src/js/r2-client.js new file mode 100644 index 0000000..7deef1c --- /dev/null +++ b/src/js/r2-client.js @@ -0,0 +1,220 @@ +import { AwsClient } from 'aws4fetch' +import { PAGE_SIZE } from './constants.js' +import { encodeS3Key } from './utils.js' +import { ConfigManager } from './config-manager.js' + +/** @typedef {{ key: string; isFolder: boolean; size?: number; lastModified?: string }} FileItem */ + +class R2Client { + /** @type {AwsClient | null} */ + #client = null + /** @type {ConfigManager | null} */ + #config = null + + /** @param {ConfigManager} configManager */ + init(configManager) { + this.#config = configManager + const cfg = configManager.get() + this.#client = new AwsClient({ + accessKeyId: cfg.accessKeyId, + secretAccessKey: cfg.secretAccessKey, + service: 's3', + region: 'auto', + }) + } + + /** @param {string} [prefix] @param {string} [continuationToken] */ + async listObjects(prefix = '', continuationToken = '') { + const url = new URL(/** @type {ConfigManager} */ (this.#config).getBucketUrl()) + url.searchParams.set('list-type', '2') + url.searchParams.set('delimiter', '/') + url.searchParams.set('max-keys', String(PAGE_SIZE)) + if (prefix) url.searchParams.set('prefix', prefix) + if (continuationToken) url.searchParams.set('continuation-token', continuationToken) + + const res = await /** @type {AwsClient} */ (this.#client).fetch(url.toString()) + if (!res.ok) { + if (res.status === 401) throw new Error('HTTP_401') + if (res.status === 403) throw new Error('HTTP_403') + if (res.status === 404) throw new Error('HTTP_404') + throw new Error(`HTTP ${res.status}`) + } + + const text = await res.text() + const doc = new DOMParser().parseFromString(text, 'application/xml') + + /** @type {FileItem[]} */ + const folders = [...doc.querySelectorAll('CommonPrefixes > Prefix')].map((el) => ({ + key: el.textContent ?? '', + isFolder: true, + })) + + /** @type {FileItem[]} */ + const files = [...doc.querySelectorAll('Contents')] + .map((el) => ({ + key: el.querySelector('Key')?.textContent ?? '', + size: parseInt(el.querySelector('Size')?.textContent ?? '0', 10), + lastModified: el.querySelector('LastModified')?.textContent ?? '', + isFolder: false, + })) + .filter((f) => f.key !== prefix) + + const isTruncated = doc.querySelector('IsTruncated')?.textContent === 'true' + const nextToken = doc.querySelector('NextContinuationToken')?.textContent || '' + + return { folders, files, isTruncated, nextToken } + } + + /** + * 检查对象是否存在,使用 ListObjectsV2 避免 HEAD 404 污染控制台 + * @param {string} key + * @returns {Promise} + */ + async fileExists(key) { + const url = new URL(/** @type {ConfigManager} */ (this.#config).getBucketUrl()) + url.searchParams.set('list-type', '2') + url.searchParams.set('max-keys', '1') + url.searchParams.set('prefix', key) + const res = await /** @type {AwsClient} */ (this.#client).fetch(url.toString()) + if (!res.ok) return false + const text = await res.text() + const doc = new DOMParser().parseFromString(text, 'application/xml') + return [...doc.querySelectorAll('Contents > Key')].some((el) => el.textContent === key) + } + + /** @param {string} key @param {string} contentType */ + async putObjectSigned(key, contentType) { + const url = `${/** @type {ConfigManager} */ (this.#config).getBucketUrl()}/${encodeS3Key(key)}` + const req = await /** @type {AwsClient} */ (this.#client).sign(url, { + method: 'PUT', + headers: { 'Content-Type': contentType }, + }) + return { url: req.url, headers: Object.fromEntries(req.headers.entries()) } + } + + /** @param {string} key */ + async getObject(key) { + const url = `${/** @type {ConfigManager} */ (this.#config).getBucketUrl()}/${encodeS3Key(key)}` + const res = await /** @type {AwsClient} */ (this.#client).fetch(url) + if (!res.ok) { + if (res.status === 401) throw new Error('HTTP_401') + if (res.status === 403) throw new Error('HTTP_403') + if (res.status === 404) throw new Error('HTTP_404') + throw new Error(`HTTP ${res.status}`) + } + return res + } + + /** @param {string} key */ + async getPresignedUrl(key) { + const url = `${/** @type {ConfigManager} */ (this.#config).getBucketUrl()}/${encodeS3Key(key)}` + const signed = await /** @type {AwsClient} */ (this.#client).sign(url, { + method: 'GET', + aws: { signQuery: true }, + }) + return signed.url + } + + /** @param {string} key @param {string} filename */ + async getDownloadUrl(key, filename) { + const base = `${/** @type {ConfigManager} */ (this.#config).getBucketUrl()}/${encodeS3Key(key)}` + const url = new URL(base) + url.searchParams.set('response-content-disposition', `attachment; filename="${encodeURIComponent(filename)}"`) + const signed = await /** @type {AwsClient} */ (this.#client).sign(url.toString(), { + method: 'GET', + aws: { signQuery: true }, + }) + return signed.url + } + + /** @param {string} key */ + getPublicUrl(key) { + const cfg = /** @type {ConfigManager} */ (this.#config).get() + if (cfg.customDomain && cfg.bucketAccess !== 'private') { + return `${cfg.customDomain}/${encodeS3Key(key)}` + } + return null + } + + /** @param {string} key */ + async headObject(key) { + const url = `${/** @type {ConfigManager} */ (this.#config).getBucketUrl()}/${encodeS3Key(key)}` + const res = await /** @type {AwsClient} */ (this.#client).fetch(url, { method: 'HEAD' }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return { + contentType: res.headers.get('content-type'), + contentLength: parseInt(res.headers.get('content-length') || '0', 10), + lastModified: res.headers.get('last-modified'), + etag: res.headers.get('etag'), + } + } + + /** @param {string} key */ + async deleteObject(key) { + const url = `${/** @type {ConfigManager} */ (this.#config).getBucketUrl()}/${encodeS3Key(key)}` + const res = await /** @type {AwsClient} */ (this.#client).fetch(url, { method: 'DELETE' }) + if (!res.ok) { + if (res.status === 401) throw new Error('HTTP_401') + if (res.status === 403) throw new Error('HTTP_403') + if (res.status === 404) throw new Error('HTTP_404') + throw new Error(`HTTP ${res.status}`) + } + } + + /** @param {string} src @param {string} dest */ + async copyObject(src, dest) { + const cfg = /** @type {ConfigManager} */ (this.#config).get() + const url = `${/** @type {ConfigManager} */ (this.#config).getBucketUrl()}/${encodeS3Key(dest)}` + const res = await /** @type {AwsClient} */ (this.#client).fetch(url, { + method: 'PUT', + headers: { + 'x-amz-copy-source': `/${cfg.bucket}/${encodeS3Key(src)}`, + }, + }) + if (!res.ok) { + if (res.status === 401) throw new Error('HTTP_401') + if (res.status === 403) throw new Error('HTTP_403') + if (res.status === 404) throw new Error('HTTP_404') + throw new Error(`HTTP ${res.status}`) + } + } + + /** @param {string} key @param {string} contentType */ + async updateContentType(key, contentType) { + const cfg = /** @type {ConfigManager} */ (this.#config).get() + const url = `${/** @type {ConfigManager} */ (this.#config).getBucketUrl()}/${encodeS3Key(key)}` + const res = await /** @type {AwsClient} */ (this.#client).fetch(url, { + method: 'PUT', + headers: { + 'x-amz-copy-source': `/${cfg.bucket}/${encodeS3Key(key)}`, + 'x-amz-metadata-directive': 'REPLACE', + 'Content-Type': contentType, + }, + }) + if (!res.ok) { + if (res.status === 401) throw new Error('HTTP_401') + if (res.status === 403) throw new Error('HTTP_403') + if (res.status === 404) throw new Error('HTTP_404') + throw new Error(`HTTP ${res.status}`) + } + } + + /** @param {string} prefix */ + async createFolder(prefix) { + const key = prefix.endsWith('/') ? prefix : prefix + '/' + const url = `${/** @type {ConfigManager} */ (this.#config).getBucketUrl()}/${encodeS3Key(key)}` + const res = await /** @type {AwsClient} */ (this.#client).fetch(url, { + method: 'PUT', + headers: { 'Content-Length': '0' }, + body: '', + }) + if (!res.ok) { + if (res.status === 401) throw new Error('HTTP_401') + if (res.status === 403) throw new Error('HTTP_403') + if (res.status === 404) throw new Error('HTTP_404') + throw new Error(`HTTP ${res.status}`) + } + } +} + +export { R2Client } diff --git a/src/js/ui-manager.js b/src/js/ui-manager.js new file mode 100644 index 0000000..813c9c4 --- /dev/null +++ b/src/js/ui-manager.js @@ -0,0 +1,689 @@ +import QRCode from 'qrcode' +import { IMAGE_RE, VIDEO_RE, AUDIO_RE, TOAST_DURATION, THEME_KEY } from './constants.js' +import { t } from './i18n.js' +import { $, extractFileName } from './utils.js' + +class UIManager { + initTheme() { + const saved = localStorage.getItem(THEME_KEY) || 'auto' + + if (saved === 'auto') { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light') + } else { + document.documentElement.setAttribute('data-theme', saved) + } + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + if (localStorage.getItem(THEME_KEY) === 'auto') { + const apply = () => { + document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light') + } + if (document.startViewTransition) { + document.startViewTransition(apply) + } else { + apply() + } + } + }) + } + + toggleTheme() { + const current = document.documentElement.getAttribute('data-theme') + const next = current === 'dark' ? 'light' : 'dark' + const apply = () => { + document.documentElement.setAttribute('data-theme', next) + localStorage.setItem(THEME_KEY, next) + } + if (document.startViewTransition) { + document.startViewTransition(apply) + } else { + apply() + } + } + + /** @param {string} theme - 'light' | 'dark' | 'auto' */ + setTheme(theme) { + let effectiveTheme = theme + + if (theme === 'auto') { + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + effectiveTheme = prefersDark ? 'dark' : 'light' + localStorage.setItem(THEME_KEY, 'auto') + } else { + localStorage.setItem(THEME_KEY, theme) + } + + const apply = () => { + document.documentElement.setAttribute('data-theme', effectiveTheme) + } + + if (document.startViewTransition) { + document.startViewTransition(apply) + } else { + apply() + } + } + + /** @param {string} message @param {'info' | 'success' | 'error'} [type] */ + toast(message, type = 'info') { + const icons = { + success: + '', + error: + '', + info: '', + } + + const openDialog = /** @type {HTMLDialogElement | null} */ (document.querySelector('dialog[open]')) + + let container + if (openDialog) { + container = openDialog.querySelector('.toast-container-dialog') + if (!container) { + container = document.createElement('div') + container.className = 'toast-container-dialog' + container.style.cssText = + 'position:fixed;bottom:16px;right:16px;display:flex;flex-direction:column;gap:8px;z-index:2147483647;pointer-events:none;' + openDialog.appendChild(container) + } + } else { + container = $('#toast-container') + } + + const el = document.createElement('div') + el.className = `toast ${type}` + const iconSpan = document.createElement('span') + iconSpan.className = 'toast-icon' + iconSpan.innerHTML = icons[type] + const msgSpan = document.createElement('span') + msgSpan.textContent = message + el.appendChild(iconSpan) + el.appendChild(msgSpan) + + container.appendChild(el) + const duration = message.length > 80 ? TOAST_DURATION * 2 : TOAST_DURATION + setTimeout(() => { + el.classList.add('removing') + el.addEventListener('animationend', () => { + el.remove() + if (container.classList.contains('toast-container-dialog') && !container.children.length) { + container.remove() + } + }) + }, duration) + } + + showSkeleton() { + const skeletonGrid = $('#skeleton-grid') + const browser = $('#file-browser') + const view = browser.dataset.view + const density = browser.dataset.density + const isMobile = window.innerWidth <= 640 + + let count + if (view === 'list' || isMobile) { + count = density === 'compact' ? 8 : density === 'loose' ? 5 : 6 + } else { + const gridMin = density === 'compact' ? 120 : density === 'loose' ? 200 : 160 + const availableWidth = Math.max(window.innerWidth - 320, 600) + const cols = Math.floor(availableWidth / (gridMin + 16)) + const rows = density === 'compact' ? 2 : 1.5 + count = Math.ceil(cols * rows) + } + + skeletonGrid.innerHTML = Array(count).fill('
').join('') + skeletonGrid.hidden = false + $('#file-grid').hidden = true + $('#empty-state').hidden = true + } + + hideSkeleton() { + $('#skeleton-grid').hidden = true + $('#file-grid').hidden = false + } + + showEmptyState() { + $('#empty-state').hidden = false + $('#file-grid').hidden = true + } + + hideEmptyState() { + $('#empty-state').hidden = true + } + + /** @param {number} x @param {number} y @param {string} key @param {boolean} isFolder @param {{size?: number, mod?: number}} [meta] */ + showContextMenu(x, y, key, isFolder, meta = {}) { + const menu = $('#context-menu') + menu.dataset.key = key + menu.dataset.isFolder = String(isFolder) + if (meta.size !== undefined) menu.dataset.size = String(meta.size) + if (meta.mod !== undefined) menu.dataset.mod = String(meta.mod) + + const previewBtn = $('[data-action="preview"]', menu) + const downloadBtn = $('[data-action="download"]', menu) + const shareQrBtn = $('#ctx-share-qr', menu) + const copyLinkBtn = $('#ctx-copy-link', menu) + const copyMarkdownBtn = $('#ctx-copy-markdown', menu) + const copyHtmlBtn = $('#ctx-copy-html', menu) + const copyImageBtn = $('#ctx-copy-image', menu) + const copyImageSep = $('#ctx-sep-copy-image', menu) + const fileSep = $('#ctx-sep-file', menu) + const editContentTypeBtn = /** @type {HTMLElement} */ ($('#ctx-edit-content-type', menu)) + previewBtn.hidden = isFolder + downloadBtn.hidden = isFolder + shareQrBtn.hidden = isFolder + copyLinkBtn.hidden = isFolder + const isImage = !isFolder && IMAGE_RE.test(key) + const isVideo = !isFolder && VIDEO_RE.test(key) + const isAudio = !isFolder && AUDIO_RE.test(key) + copyMarkdownBtn.hidden = !isImage + copyHtmlBtn.hidden = !(isImage || isVideo || isAudio) + copyImageBtn.hidden = !isImage + copyImageSep.hidden = !isImage + editContentTypeBtn.hidden = isFolder + fileSep.hidden = isFolder + + menu.style.left = x + 'px' + menu.style.top = y + 'px' + menu.showPopover() + + const rect = menu.getBoundingClientRect() + const vw = window.innerWidth + const vh = window.innerHeight + if (rect.right > vw) menu.style.left = vw - rect.width - 8 + 'px' + if (rect.bottom > vh) menu.style.top = vh - rect.height - 8 + 'px' + + const submenu = $('.context-submenu', menu) + if (submenu) { + submenu.classList.toggle('flip-left', rect.right + 160 > vw) + } + } + + /** @param {boolean} [instant=false] Skip animation for instant close */ + hideContextMenu(instant = false) { + const menu = $('#context-menu') + try { + if (instant) { + menu.classList.add('instant') + menu.offsetHeight + } + menu.hidePopover() + if (instant) { + setTimeout(() => menu.classList.remove('instant'), 0) + } + } catch { + /* already hidden */ + } + } + + /** + * @param {string} title + * @param {string} label + * @param {string} [defaultValue] + * @param {{ validate?: (v: string) => string | null, hint?: string, preview?: (v: string) => string }} [options] + * @returns {Promise} + */ + prompt(title, label, defaultValue = '', { validate, hint, preview } = {}) { + return new Promise((resolve) => { + const dialog = /** @type {HTMLDialogElement} */ ($('#prompt-dialog')) + const form = $('#prompt-form') + const input = /** @type {HTMLInputElement} */ ($('#prompt-input')) + const errorEl = $('#prompt-error') + const hintEl = $('#prompt-hint') + const previewEl = $('#prompt-preview') + $('#prompt-title').textContent = title + $('#prompt-label').textContent = label + input.value = defaultValue + errorEl.textContent = '' + errorEl.hidden = true + hintEl.textContent = hint ?? '' + hintEl.hidden = !hint + + const updatePreview = () => { + if (!preview) { + previewEl.hidden = true + return + } + previewEl.textContent = preview(input.value) + previewEl.hidden = false + } + updatePreview() + + /** @type {string | null} */ + let result = null + + /** @returns {boolean} */ + const checkValid = () => { + if (!validate) return true + const err = validate(input.value) + errorEl.textContent = err ?? '' + errorEl.hidden = !err + return !err + } + + const onInput = () => { + checkValid() + updatePreview() + } + + /** @param {Event} e */ + const onSubmit = (e) => { + e.preventDefault() + if (!checkValid()) { + input.focus() + return + } + result = input.value.trim() || null + dialog.close() + } + + const onCancel = () => dialog.close() + + /** @param {Event} e */ + const onBackdropClick = (e) => { + if (e.target === dialog) dialog.close() + } + + const onClose = () => { + form.removeEventListener('submit', onSubmit) + input.removeEventListener('input', onInput) + $('#prompt-cancel').removeEventListener('click', onCancel) + dialog.removeEventListener('click', onBackdropClick) + previewEl.hidden = true + resolve(result) + } + + form.addEventListener('submit', onSubmit) + input.addEventListener('input', onInput) + $('#prompt-cancel').addEventListener('click', onCancel) + dialog.addEventListener('click', onBackdropClick) + dialog.addEventListener('close', onClose, { once: true }) + dialog.showModal() + input.focus() + input.select() + }) + } + + /** @param {string} title @param {string} message @returns {Promise} */ + confirm(title, message) { + return new Promise((resolve) => { + const dialog = /** @type {HTMLDialogElement} */ ($('#confirm-dialog')) + const form = $('#confirm-form') + $('#confirm-title').textContent = title + $('#confirm-message').textContent = message + + let result = false + + /** @param {Event} e */ + const onSubmit = (e) => { + e.preventDefault() + result = true + dialog.close() + } + + const onCancel = () => dialog.close() + + /** @param {Event} e */ + const onBackdropClick = (e) => { + if (e.target === dialog) dialog.close() + } + + const onClose = () => { + form.removeEventListener('submit', onSubmit) + $('#confirm-cancel').removeEventListener('click', onCancel) + dialog.removeEventListener('click', onBackdropClick) + resolve(result) + } + + form.addEventListener('submit', onSubmit) + $('#confirm-cancel').addEventListener('click', onCancel) + dialog.addEventListener('click', onBackdropClick) + dialog.addEventListener('close', onClose, { once: true }) + dialog.showModal() + }) + } + + /** + * Show overwrite confirmation dialog when a file already exists. + * @param {string} name - Filename to display + * @param {boolean} [showApplyAll] - Whether to show "Apply to all" buttons (batch upload) + * @returns {Promise<'overwrite' | 'skip' | 'overwrite-all' | 'skip-all'>} + */ + confirmOverwrite(name, showApplyAll = false) { + return new Promise((resolve) => { + const dialog = /** @type {HTMLDialogElement} */ ($('#overwrite-dialog')) + $('#overwrite-title').textContent = t('overwriteTitle') + $('#overwrite-message').textContent = t('overwriteMsg', { name }) + $('#overwrite-skip').textContent = t('overwriteSkip') + $('#overwrite-skip-all').textContent = t('overwriteSkipAll') + $('#overwrite-ok').textContent = t('overwriteConfirm') + $('#overwrite-ok-all').textContent = t('overwriteAll') + $('#overwrite-skip-all').hidden = !showApplyAll + $('#overwrite-ok-all').hidden = !showApplyAll + + /** @type {'overwrite' | 'skip' | 'overwrite-all' | 'skip-all'} */ + let result = 'skip' + + const onSkip = () => { + result = 'skip' + dialog.close() + } + const onSkipAll = () => { + result = 'skip-all' + dialog.close() + } + const onOverwrite = () => { + result = 'overwrite' + dialog.close() + } + const onOverwriteAll = () => { + result = 'overwrite-all' + dialog.close() + } + + /** @param {Event} e */ + const onBackdropClick = (e) => { + if (e.target === dialog) dialog.close() + } + + const onClose = () => { + $('#overwrite-skip').removeEventListener('click', onSkip) + $('#overwrite-skip-all').removeEventListener('click', onSkipAll) + $('#overwrite-ok').removeEventListener('click', onOverwrite) + $('#overwrite-ok-all').removeEventListener('click', onOverwriteAll) + dialog.removeEventListener('click', onBackdropClick) + resolve(result) + } + + $('#overwrite-skip').addEventListener('click', onSkip) + $('#overwrite-skip-all').addEventListener('click', onSkipAll) + $('#overwrite-ok').addEventListener('click', onOverwrite) + $('#overwrite-ok-all').addEventListener('click', onOverwriteAll) + dialog.addEventListener('click', onBackdropClick) + dialog.addEventListener('close', onClose, { once: true }) + dialog.showModal() + }) + } + + /** @param {string} shareUrl */ + async showShareDialog(shareUrl) { + const dialog = /** @type {HTMLDialogElement} */ ($('#share-dialog')) + const urlInput = /** @type {HTMLInputElement} */ ($('#share-url-input')) + const qrCanvas = /** @type {HTMLCanvasElement} */ ($('#share-qr-canvas')) + + urlInput.value = shareUrl + + const isDark = document.documentElement.getAttribute('data-theme') === 'dark' + + try { + await QRCode.toCanvas(qrCanvas, shareUrl, { + width: 168, + margin: 0, + color: { + dark: isDark ? '#FFFFFF' : '#000000', + light: isDark ? '#0a0a0a' : '#ffffff', + }, + errorCorrectionLevel: 'M', + }) + } catch (err) { + console.error('Failed to generate QR code:', err) + } + + const onCopy = async () => { + try { + await navigator.clipboard.writeText(shareUrl) + this.toast(t('shareConfigCopied'), 'success') + } catch { + urlInput.select() + } + } + + const onClose = () => dialog.close() + + /** @param {Event} e */ + const onBackdropClick = (e) => { + if (e.target === dialog) dialog.close() + } + + const onDialogClose = () => { + $('#copy-share-url-btn').removeEventListener('click', onCopy) + $('#share-dialog-close').removeEventListener('click', onClose) + dialog.removeEventListener('click', onBackdropClick) + } + + $('#copy-share-url-btn').addEventListener('click', onCopy) + $('#share-dialog-close').addEventListener('click', onClose) + dialog.addEventListener('click', onBackdropClick) + dialog.addEventListener('close', onDialogClose, { once: true }) + + dialog.showModal() + } + + /** @param {string} fileUrl @param {string} fileName */ + async showFileQrDialog(fileUrl, fileName) { + const dialog = /** @type {HTMLDialogElement} */ ($('#file-qr-dialog')) + const urlInput = /** @type {HTMLInputElement} */ ($('#file-qr-url')) + const qrCanvas = /** @type {HTMLCanvasElement} */ ($('#file-qr-canvas')) + const nameEl = $('#file-qr-filename') + const copyBtn = $('#file-qr-copy') + + urlInput.value = fileUrl + nameEl.textContent = fileName + + const isDark = document.documentElement.getAttribute('data-theme') === 'dark' + + try { + await QRCode.toCanvas(qrCanvas, fileUrl, { + width: 168, + margin: 0, + color: { + dark: isDark ? '#FFFFFF' : '#000000', + light: isDark ? '#0a0a0a' : '#ffffff', + }, + errorCorrectionLevel: 'M', + }) + } catch (err) { + console.error('Failed to generate QR code:', err) + } + + const onCopy = async () => { + try { + await navigator.clipboard.writeText(fileUrl) + this.toast(t('linkCopied'), 'success') + } catch { + urlInput.select() + } + } + + const onClose = () => dialog.close() + + /** @param {Event} e */ + const onBackdropClick = (e) => { + if (e.target === dialog) dialog.close() + } + + const onDialogClose = () => { + $('#file-qr-close').removeEventListener('click', onClose) + copyBtn.removeEventListener('click', onCopy) + dialog.removeEventListener('click', onBackdropClick) + } + + copyBtn.addEventListener('click', onCopy) + $('#file-qr-close').addEventListener('click', onClose) + dialog.addEventListener('click', onBackdropClick) + dialog.addEventListener('close', onDialogClose, { once: true }) + + dialog.showModal() + } + + /** + * Choose how to apply a template path when current directory isn't root. + * @param {string} currentPrefix + * @param {string} processedName + * @param {string} template + * @returns {Promise<'template'|'prefix-template'|'prefix-basename'|null>} + */ + chooseFilenameTemplatePath(currentPrefix, processedName, template) { + return new Promise((resolve) => { + const dialog = /** @type {HTMLDialogElement} */ ($('#filename-path-dialog')) + const form = $('#filename-path-form') + const optionTemplate = $('#filename-path-option-template') + const optionPrefixTemplate = $('#filename-path-option-prefix-template') + const optionPrefixBasename = $('#filename-path-option-prefix-basename') + const choicePrefixTemplate = /** @type {HTMLInputElement} */ ($('#filename-path-choice-prefix-template')) + + /** @param {string} str */ + const trimSlashes = (str) => str.replace(/^\/+|\/+$/g, '') + const prefixClean = trimSlashes(currentPrefix) + const nameClean = processedName.replace(/^\/+/, '') + const baseName = extractFileName(processedName).replace(/^\/+/, '') + const prefixLabel = prefixClean ? `/${prefixClean}` : '/' + + const templatePath = '/' + nameClean + const prefixTemplatePath = prefixClean ? `/${prefixClean}/${nameClean}` : `/${nameClean}` + const prefixBasenamePath = prefixClean ? `/${prefixClean}/${baseName}` : `/${baseName}` + + $('#filename-path-title').textContent = t('filenameTplPathTitle') + $('#filename-path-desc').textContent = t('filenameTplPathDescWithTpl', { + prefix: prefixLabel, + template: template || '-', + }) + optionTemplate.textContent = templatePath + optionPrefixTemplate.textContent = prefixTemplatePath + optionPrefixBasename.textContent = prefixBasenamePath + + // Default choice: current directory + template path + choicePrefixTemplate.checked = true + + /** @type {'template'|'prefix-template'|'prefix-basename'|null} */ + let result = null + + /** @param {Event} e */ + const onSubmit = (e) => { + e.preventDefault() + const selected = dialog.querySelector('input[name=\"filenamePathChoice\"]:checked') + result = /** @type {any} */ (selected)?.value || 'prefix-template' + dialog.close() + } + + const onCancel = () => dialog.close() + + /** @param {Event} e */ + const onBackdropClick = (e) => { + if (e.target === dialog) dialog.close() + } + + const onClose = () => { + form.removeEventListener('submit', onSubmit) + $('#filename-path-cancel').removeEventListener('click', onCancel) + dialog.removeEventListener('click', onBackdropClick) + resolve(result) + } + + form.addEventListener('submit', onSubmit) + $('#filename-path-cancel').addEventListener('click', onCancel) + dialog.addEventListener('click', onBackdropClick) + dialog.addEventListener('close', onClose, { once: true }) + dialog.showModal() + }) + } + + initTooltip() { + if (this.tooltipInitialized) return + this.tooltipInitialized = true + + const tip = /** @type {HTMLElement} */ ($('#tooltip')) + /** @type {number | null} */ + let showTimer = null + /** @type {HTMLElement | null} */ + let currentTarget = null + + const show = (/** @type {HTMLElement} */ target) => { + const text = target.dataset.tooltip + if (!text) return + tip.textContent = text + + const parentDialog = target.closest('dialog[open]') + if (parentDialog && tip.parentElement !== parentDialog) { + parentDialog.appendChild(tip) + } else if (!parentDialog && tip.parentElement !== document.body) { + document.body.appendChild(tip) + } + + tip.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:1;z-index:2147483647' + const tipRect = tip.getBoundingClientRect() + + const rect = target.getBoundingClientRect() + const GAP = 8 + + let top = rect.bottom + GAP + let left = rect.left + rect.width / 2 + + if (top + tipRect.height > window.innerHeight) { + top = rect.top - GAP - tipRect.height + } + + left = Math.max(GAP, Math.min(left - tipRect.width / 2, window.innerWidth - tipRect.width - GAP)) + + tip.style.cssText = `position:fixed;left:${left}px;top:${top}px;z-index:2147483647;pointer-events:none` + tip.offsetHeight + tip.classList.add('visible') + } + + const hide = () => { + if (showTimer) { + clearTimeout(showTimer) + showTimer = null + } + currentTarget = null + tip.classList.remove('visible') + + if (tip.parentElement !== document.body) { + document.body.appendChild(tip) + } + } + + document.addEventListener('mouseover', (e) => { + const eventTarget = e.target + const target = /** @type {HTMLElement | null} */ ( + eventTarget instanceof Element ? eventTarget.closest('[data-tooltip]') : null + ) + + if (target) { + if (target !== currentTarget) { + if (showTimer) clearTimeout(showTimer) + currentTarget = target + const delay = tip.classList.contains('visible') ? 0 : 100 + showTimer = /** @type {any} */ (setTimeout(() => show(target), delay)) + } + } else if (currentTarget) { + hide() + } + }) + + document.addEventListener('mouseout', (e) => { + const eventTarget = e.target + const target = /** @type {HTMLElement | null} */ ( + eventTarget instanceof Element ? eventTarget.closest('[data-tooltip]') : null + ) + + if (target === currentTarget && target) { + const relatedTarget = e.relatedTarget + + const movingToTooltip = relatedTarget instanceof Element && relatedTarget.closest('[data-tooltip]') + + const stillInside = relatedTarget instanceof Node && target.contains(relatedTarget) + + if (!movingToTooltip && !stillInside) { + hide() + } + } + }) + + document.addEventListener('pointerdown', hide) + document.addEventListener('scroll', hide, true) + } +} + +export { UIManager } diff --git a/src/js/upload-manager.js b/src/js/upload-manager.js new file mode 100644 index 0000000..81b7d85 --- /dev/null +++ b/src/js/upload-manager.js @@ -0,0 +1,537 @@ +import { encode as encodeJpeg } from '@jsquash/jpeg' +import { optimise as optimisePng } from '@jsquash/oxipng' +import { encode as encodeWebp } from '@jsquash/webp' +import { encode as encodeAvif } from '@jsquash/avif' +import dayjs from 'dayjs' +import { filesize } from 'filesize' +import { COMPRESSIBLE_IMAGE_RE, IMAGE_RE, MAX_UPLOAD_SIZE } from './constants.js' +import { t } from './i18n.js' +import { ConfigManager } from './config-manager.js' +import { FileExplorer } from './file-explorer.js' +import { R2Client } from './r2-client.js' +import { UIManager } from './ui-manager.js' +import { $, applyFilenameTemplate, computeFileHash, extractFileName, getMimeType } from './utils.js' + +/** @typedef {{ accountId?: string; accessKeyId?: string; secretAccessKey?: string; bucket?: string; filenameTpl?: string; filenameTplScope?: string; customDomain?: string; compressMode?: string; compressLevel?: string; tinifyKey?: string }} AppConfig */ + +/** + * Compress image file based on configuration + * @param {File} file - Original file + * @param {AppConfig} config - AppConfig object + * @param {function(string):void} onStatus - Callback to update status text + * @returns {Promise} + */ +async function compressFile(file, config, onStatus) { + const allowCompress = COMPRESSIBLE_IMAGE_RE.test(file.name) + + if (!allowCompress || !config.compressMode || config.compressMode === 'none') { + return file + } + + try { + const originalSize = file.size + + if (config.compressMode === 'local') { + onStatus && onStatus('压缩中...') + + const level = config.compressLevel || 'balanced' + + const jpegQuality = level === 'extreme' ? 75 : 90 + const avifQuality = level === 'extreme' ? 50 : 60 + + const ext = file.name.toLowerCase().match(/\.(jpe?g|png|webp|avif)$/i)?.[1] + let compressedBuffer + let outputType = file.type + + if (ext === 'png') { + const oxipngLevel = level === 'extreme' ? 4 : 2 + compressedBuffer = await optimisePng(await file.arrayBuffer(), { + level: oxipngLevel, + interlace: false, + optimiseAlpha: true, + }) + outputType = 'image/png' + } else { + const img = new Image() + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + await new Promise((resolve, reject) => { + img.onload = resolve + img.onerror = reject + img.src = URL.createObjectURL(file) + }) + + canvas.width = img.width + canvas.height = img.height + ctx.drawImage(img, 0, 0) + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) + URL.revokeObjectURL(img.src) + + if (ext === 'jpg' || ext === 'jpeg') { + compressedBuffer = await encodeJpeg(imageData, { quality: jpegQuality }) + outputType = 'image/jpeg' + } else if (ext === 'webp') { + compressedBuffer = await encodeWebp(imageData, { quality: jpegQuality }) + outputType = 'image/webp' + } else if (ext === 'avif') { + const avifSpeed = level === 'extreme' ? 4 : 6 + compressedBuffer = await encodeAvif(imageData, { + quality: avifQuality, + speed: avifSpeed, + }) + outputType = 'image/avif' + } else { + return file + } + } + + const compressedBlob = new Blob([compressedBuffer], { type: outputType }) + + const savings = Math.round((1 - compressedBlob.size / originalSize) * 100) + + if (savings > 0) { + const msg = `本地压缩: ${filesize(originalSize)} → ${filesize(compressedBlob.size)} (省 ${savings}%)` + onStatus && onStatus(msg) + return new File([compressedBlob], file.name, { type: outputType }) + } else { + const msg = `本地压缩: 原图更优 (${filesize(originalSize)})` + onStatus && onStatus(msg) + return file + } + } + + if (config.compressMode === 'tinify') { + if (!config.tinifyKey) return file + + onStatus && onStatus('云端压缩中...') + + const apiUrl = new URL('https://api.tinify.com/shrink') + apiUrl.searchParams.set('proxy-host', 'api.tinify.com') + apiUrl.host = 'proxy.nioi.in' + + const response = await fetch(apiUrl.toString(), { + method: 'POST', + headers: { + Authorization: 'Basic ' + btoa('api:' + config.tinifyKey), + }, + body: file, + }) + + if (!response.ok) throw new Error('Tinify API Error') + + const data = await response.json() + const url = new URL(data.output.url) + url.searchParams.set('proxy-host', 'api.tinify.com') + url.host = 'proxy.nioi.in' + + const compressedRes = await fetch(url.toString()) + const compressedBlob = await compressedRes.blob() + + const savings = Math.round((1 - compressedBlob.size / originalSize) * 100) + if (savings > 0) { + onStatus && onStatus(`Tinify: ${filesize(originalSize)} → ${filesize(compressedBlob.size)} (省 ${savings}%)`) + } else { + onStatus && onStatus(`Tinify: 已优化 (${filesize(compressedBlob.size)})`) + } + + return new File([compressedBlob], file.name, { type: file.type }) + } + } catch { + onStatus && onStatus('压缩失败,使用原图') + } + + return file +} + +/** + * 并发限制执行异步任务,返回 PromiseSettledResult 数组 + * @template T + * @param {Array<() => Promise>} tasks + * @param {number} limit + * @returns {Promise[]>} + */ +async function runWithConcurrency(tasks, limit) { + const results = /** @type {PromiseSettledResult[]} */ (new Array(tasks.length)) + let nextIndex = 0 + const worker = async () => { + while (nextIndex < tasks.length) { + const i = nextIndex++ + try { + results[i] = { status: 'fulfilled', value: await tasks[i]() } + } catch (e) { + results[i] = { status: 'rejected', reason: e } + } + } + } + await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker)) + return results +} + +class UploadManager { + /** @type {R2Client} */ + #r2 + /** @type {UIManager} */ + #ui + /** @type {FileExplorer} */ + #explorer + /** @type {ConfigManager} */ + #config + #dragCounter = 0 + + /** @param {R2Client} r2 @param {UIManager} ui @param {FileExplorer} explorer @param {ConfigManager} config */ + constructor(r2, ui, explorer, config) { + this.#r2 = r2 + this.#ui = ui + this.#explorer = explorer + this.#config = config + } + + initDragDrop() { + const app = $('#app') + const dropzone = $('#dropzone') + + const showDropzone = () => { + this.#dragCounter++ + dropzone.hidden = false + } + + const hideDropzone = () => { + this.#dragCounter-- + if (this.#dragCounter <= 0) { + this.#dragCounter = 0 + dropzone.hidden = true + } + } + + const handleDrop = (/** @type {DragEvent} */ e) => { + e.preventDefault() + e.stopPropagation() + this.#dragCounter = 0 + dropzone.hidden = true + const files = [...(e.dataTransfer?.files ?? [])] + if (files.length > 0) { + this.uploadFiles(files) + } else { + this.#ui.toast(t('dropInvalidHint'), 'info') + } + } + + app.addEventListener('dragenter', (e) => { + e.preventDefault() + showDropzone() + }) + + app.addEventListener('dragleave', (e) => { + e.preventDefault() + hideDropzone() + }) + + app.addEventListener('dragover', (/** @type {DragEvent} */ e) => { + e.preventDefault() + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' + }) + + app.addEventListener('drop', handleDrop) + + dropzone.addEventListener('dragenter', (e) => { + e.preventDefault() + showDropzone() + }) + + dropzone.addEventListener('dragleave', (e) => { + e.preventDefault() + hideDropzone() + }) + + dropzone.addEventListener('dragover', (/** @type {DragEvent} */ e) => { + e.preventDefault() + if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy' + }) + + dropzone.addEventListener('drop', handleDrop) + + document.addEventListener('paste', async (e) => { + const target = /** @type {HTMLElement} */ (e.target) + const tag = target.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return + + const items = [...(e.clipboardData?.items || [])] + const htmlText = e.clipboardData?.getData('text/html') || '' + const plainText = e.clipboardData?.getData('text/plain') || '' + // 去除 HTML 标签后的纯文字内容,用于判断是否有实质文字(避免把图片的 HTML 包装误判为文字) + const htmlTextContent = htmlText.replace(/<[^>]+>/g, '').trim() + const hasText = Boolean(plainText.trim() || htmlTextContent) + const hasImageItem = items.some((item) => item.kind === 'file' && item.type.startsWith('image/')) + const hasHtmlImage = Boolean(htmlText.trim()) && /]/i.test(htmlText) + + /** @type {File[]} */ + const files = items + .filter((item) => item.kind === 'file') + .map((item) => item.getAsFile()) + .filter(/** @returns {f is File} */ (f) => f !== null) + + if ((hasImageItem || hasHtmlImage) && hasText) { + this.#ui.toast(t('pasteMixedNotSupported'), 'info') + return + } + + if (files.length > 0) { + e.preventDefault() + const allImages = files.every((f) => f.type.startsWith('image/')) + if (allImages) { + this.#ui.toast(t('pasteToUpload', { count: files.length }), 'success') + this.uploadFiles(files) + return + } + + if (files.length === 1) { + const file = files[0] + const ok = await this.#ui.confirm(t('pasteFileConfirmTitle'), t('pasteFileConfirmMsg', { name: file.name })) + if (!ok) return + const suggestedPath = this.#explorer.currentPrefix + file.name + const targetPath = await this.#ui.prompt(t('pasteFilePathTitle'), t('pasteFilePathLabel'), suggestedPath) + if (!targetPath) return + await this.uploadFiles([file], targetPath) + return + } + + const ok = await this.#ui.confirm( + t('pasteFilesConfirmTitle'), + t('pasteFilesConfirmMsg', { count: files.length }), + ) + if (!ok) return + this.uploadFiles(files) + return + } + + if (hasText) { + e.preventDefault() + let content = plainText + if (!content.trim() && htmlTextContent) { + const doc = new DOMParser().parseFromString(htmlTextContent, 'text/html') + content = doc.body?.textContent || '' + } + content = content.trim() + if (!content) return + + const ok = await this.#ui.confirm(t('pasteTextConfirmTitle'), t('pasteTextConfirmMsg')) + if (!ok) return + + const cfg = this.#config.get() + let filename = `pasted-${dayjs().format('YYYYMMDD-HHmmss')}.txt` + if ((cfg.filenameTplScope || 'images') === 'images') { + const tempFile = new File([content], 'pasted.txt', { type: 'text/plain' }) + const hash = await computeFileHash(tempFile) + filename = `${hash.slice(0, 6)}.txt` + } + + const suggestedPath = this.#explorer.currentPrefix + filename + const targetPath = await this.#ui.prompt(t('pasteTextPathTitle'), t('pasteTextPathLabel'), suggestedPath) + if (!targetPath) return + + const file = new File([content], filename, { type: 'text/plain' }) + await this.uploadFiles([file], targetPath) + } + }) + } + + /** @param {File[]} files @param {string} [overrideKey] */ + async uploadFiles(files, overrideKey) { + const panel = $('#upload-panel') + const body = $('#upload-panel-body') + const title = $('#upload-panel-title') + + body.innerHTML = '' + panel.hidden = false + title.textContent = t('uploadPreparing') + + const cfg = this.#config.get() + const filenameTpl = cfg.filenameTpl || '' + const filenameTplScope = cfg.filenameTplScope || 'images' + const currentPrefix = this.#explorer.currentPrefix + /** @type {'template'|'prefix-template'|'prefix-basename'} */ + let pathStrategy = 'prefix-template' + let pathStrategyChosen = false + const useOverrideKey = Boolean(overrideKey && files.length === 1) + + const uploads = [] + /** @type {null | 'overwrite-all' | 'skip-all'} */ + let conflictDecision = null + let skippedCount = 0 + + for (let i = 0; i < files.length; i++) { + let file = files[i] + + if (file.size > MAX_UPLOAD_SIZE) { + this.#ui.toast(t('fileTooLarge', { name: file.name }), 'error') + continue + } + + const shouldApplyTpl = filenameTplScope === 'all' ? true : IMAGE_RE.test(file.name) + const processedName = shouldApplyTpl ? await applyFilenameTemplate(filenameTpl, file) : file.name + + if ( + !pathStrategyChosen && + currentPrefix && + shouldApplyTpl && + (processedName.includes('/') || filenameTpl.includes('/')) + ) { + const choice = await this.#ui.chooseFilenameTemplatePath(currentPrefix, processedName, filenameTpl) + if (!choice) { + panel.hidden = true + return + } + pathStrategy = choice + pathStrategyChosen = true + } + + let key + if (useOverrideKey) { + key = /** @type {string} */ (overrideKey) + } else if (pathStrategy === 'template') { + key = processedName + } else if (pathStrategy === 'prefix-basename') { + key = currentPrefix + extractFileName(processedName) + } else { + key = currentPrefix + processedName + } + + // Check for existing file conflict + if (conflictDecision !== 'overwrite-all') { + const exists = await this.#r2.fileExists(key) + if (exists) { + if (conflictDecision === 'skip-all') { + skippedCount++ + continue + } + const choice = await this.#ui.confirmOverwrite(extractFileName(key), files.length > 1) + if (choice === 'skip') { + skippedCount++ + continue + } + if (choice === 'skip-all') { + conflictDecision = 'skip-all' + skippedCount++ + continue + } + if (choice === 'overwrite-all') conflictDecision = 'overwrite-all' + // 'overwrite': fall through to upload + } + } + + const contentType = file.type || getMimeType(file.name) + + const id = `upload-${i}-${Date.now()}` + const displayName = key.length > 40 ? '...' + key.slice(-37) : key + + const item = document.createElement('div') + item.className = 'upload-item' + item.id = id + item.innerHTML = ` +
+
+
+
+
+
+
+ ` + const nameEl = /** @type {HTMLElement} */ (item.querySelector('.upload-item-name')) + nameEl.textContent = displayName + nameEl.setAttribute('title', displayName) + body.appendChild(item) + + const updateStatus = /** @param {string} msg */ (msg) => { + const statusEl = $(`#${id}-status`) + if (statusEl) statusEl.textContent = msg + } + updateStatus(t('uploadWaiting')) + + uploads.push({ id, key, file, contentType, updateStatus }) + } + + // All files were skipped + if (uploads.length === 0) { + panel.hidden = true + if (skippedCount > 0) { + this.#ui.toast(t('uploadSkipped', { count: skippedCount }), 'info') + } + return + } + + panel.hidden = false + title.textContent = `${t('uploadProgress')} 0/${uploads.length}` + + let completed = 0 + const results = await runWithConcurrency( + uploads.map((u) => async () => { + let compressionStatus = '' + const compressed = await compressFile(u.file, cfg, (msg) => { + compressionStatus = msg + u.updateStatus(msg) + }) + u.updateStatus(t('uploading')) + try { + const result = await this.#uploadSingleFile(u.id, u.key, compressed, u.contentType) + u.updateStatus(compressionStatus || filesize(compressed.size)) + return result + } catch (e) { + u.updateStatus('') + throw e + } finally { + completed++ + title.textContent = `${t('uploadProgress')} ${completed}/${uploads.length}` + } + }), + cfg.uploadConcurrency ?? 3, + ) + + const success = results.filter((r) => r.status === 'fulfilled').length + const fail = results.filter((r) => r.status === 'rejected').length + + if (fail === 0) { + this.#ui.toast(t('uploadSuccess', { count: success }), 'success') + } else { + this.#ui.toast(t('uploadPartialFail', { success, fail }), 'error') + } + if (skippedCount > 0) { + this.#ui.toast(t('uploadSkipped', { count: skippedCount }), 'info') + } + + await this.#explorer.refresh() + } + + /** @param {string} id @param {string} key @param {File} file @param {string} contentType */ + async #uploadSingleFile(id, key, file, contentType) { + const signed = await this.#r2.putObjectSigned(key, contentType) + const bar = $(`#${id}-bar`) + + if (bar) bar.classList.add('indeterminate') + + const headers = new Headers() + for (const [k, v] of Object.entries(signed.headers)) { + if (k.toLowerCase() !== 'host') headers.set(k, v) + } + + const res = await fetch(signed.url, { + method: 'PUT', + headers, + body: file, + }) + + if (bar) bar.classList.remove('indeterminate') + + if (!res.ok) { + if (bar) bar.classList.add('error') + throw new Error(`HTTP ${res.status}`) + } + + if (bar) { + bar.classList.add('done') + bar.style.width = '100%' + } + } +} + +export { UploadManager } diff --git a/src/js/utils.js b/src/js/utils.js new file mode 100644 index 0000000..27267e1 --- /dev/null +++ b/src/js/utils.js @@ -0,0 +1,237 @@ +import dayjs from 'dayjs' +import { IMAGE_RE, VIDEO_RE, AUDIO_RE, DOCUMENT_RE, ARCHIVE_RE, CODE_RE, TEXT_RE } from './constants.js' +import { getCurrentLang } from './i18n.js' + +/** @type {(sel: string, ctx?: ParentNode) => T} */ +const $ = (sel, ctx = document) => /** @type {*} */ (ctx.querySelector(sel)) + +/** @param {string|number|Date} dateStr @returns {string} */ +function formatDate(dateStr) { + const d = new Date(dateStr) + const currentLang = getCurrentLang() + return d.toLocaleDateString(currentLang === 'zh' ? 'zh-CN' : currentLang === 'ja' ? 'ja-JP' : 'en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +/** @param {string} key @returns {string} */ +function extractFileName(key) { + const parts = key.replace(/\/$/, '').split('/') + return parts[parts.length - 1] +} + +/** @param {string} key @returns {'image'|'video'|'audio'|'text'|'document'|'archive'|'code'|'file'} */ +function getFileType(key) { + if (IMAGE_RE.test(key)) return 'image' + if (VIDEO_RE.test(key)) return 'video' + if (AUDIO_RE.test(key)) return 'audio' + if (DOCUMENT_RE.test(key)) return 'document' + if (ARCHIVE_RE.test(key)) return 'archive' + if (CODE_RE.test(key)) return 'code' + if (TEXT_RE.test(key)) return 'text' + return 'file' +} + +/** @typedef {'http401Error' | 'http403Error' | 'http404Error' | 'corsError' | 'networkError'} ErrorMessageKey */ + +/** + * Get user-friendly error message based on error type + * @param {Error} err - Error object + * @returns {ErrorMessageKey} - i18n key for the error message + */ +function getErrorMessage(err) { + const msg = err.message + if (msg === 'HTTP_401') return 'http401Error' + if (msg === 'HTTP_403') return 'http403Error' + if (msg === 'HTTP_404') return 'http404Error' + if (err instanceof TypeError && msg.includes('Failed to fetch')) { + return 'corsError' + } + return 'networkError' +} + +/** @param {'image'|'video'|'audio'|'text'|'document'|'archive'|'code'|'file'} type @returns {string} */ +function getFileIconSvg(type) { + const svgBase = + 'viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"' + switch (type) { + case 'video': + return `` + case 'audio': + return `` + case 'document': + return `` + case 'archive': + return `` + case 'code': + return `` + case 'text': + return `` + case 'image': + default: + return `` + } +} + +/** @param {string} name @returns {string} */ +function getExtension(name) { + const i = name.lastIndexOf('.') + return i > 0 ? name.slice(i + 1) : '' +} + +/** @param {string} name @returns {string} */ +function getBaseName(name) { + const i = name.lastIndexOf('.') + return i > 0 ? name.slice(0, i) : name +} + +/** @param {string} key @returns {string} */ +function getMimeType(key) { + const ext = getExtension(key).toLowerCase() + /** @type {Record} */ + // export const IMAGE_RE = /\.(jpg|jpeg|png|gif|webp|svg|ico|bmp|avif)$/i + // export const COMPRESSIBLE_IMAGE_RE = /\.(jpe?g|png|webp|avif)$/i + // export const TEXT_RE = + // /\.(txt|md|json|xml|csv|html|css|js|ts|jsx|tsx|yaml|yml|toml|ini|cfg|conf|log|sh|bash|py|rb|go|rs|java|c|cpp|h|hpp|sql|env|gitignore|dockerfile)$/i + // export const AUDIO_RE = /\.(mp3|wav|ogg|flac|aac|m4a|wma)$/i + // export const VIDEO_RE = /\.(mp4|webm|ogg|mov|avi|mkv|m4v)$/i + // export const DOCUMENT_RE = /\.(pdf|doc|docx|xls|xlsx|ppt|pptx|odt|ods|odp|rtf)$/i + // export const ARCHIVE_RE = /\.(zip|rar|7z|tar|gz|bz2|xz|tgz)$/i + // export const CODE_RE = /\.(js|ts|jsx|tsx|py|rb|go|rs|java|c|cpp|h|hpp|sh|bash)$/i + + const map = { + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + ico: 'image/x-icon', + bmp: 'image/bmp', + avif: 'image/avif', + mp4: 'video/mp4', + avi: 'video/x-msvideo', + mkv: 'video/x-matroska', + m4v: 'video/x-m4v', + webm: 'video/webm', + ogg: 'video/ogg', + mov: 'video/quicktime', + mp3: 'audio/mpeg', + wav: 'audio/wav', + flac: 'audio/flac', + aac: 'audio/aac', + m4a: 'audio/mp4', + wma: 'audio/x-ms-wma', + json: 'application/json', + xml: 'application/xml', + pdf: 'application/pdf', + html: 'text/html', + css: 'text/css', + js: 'text/javascript', + ts: 'text/typescript', + jsx: 'text/jsx', + tsx: 'text/tsx', + mjs: 'text/javascript', + mts: 'text/typescript', + yaml: 'application/x-yaml', + yml: 'application/x-yaml', + toml: 'application/toml', + ini: 'text/plain', + log: 'text/plain', + sh: 'application/x-sh', + bash: 'application/x-sh', + py: 'text/x-python', + rb: 'text/x-ruby', + go: 'text/x-go', + rs: 'text/x-rust', + java: 'text/x-java', + c: 'text/x-c', + cpp: 'text/x-c++', + h: 'text/x-c', + hpp: 'text/x-c++', + txt: 'text/plain', + md: 'text/markdown', + csv: 'text/csv', + env: 'text/plain', + gitignore: 'text/plain', + dockerfile: 'text/plain', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + odt: 'application/vnd.oasis.opendocument.text', + ods: 'application/vnd.oasis.opendocument.spreadsheet', + odp: 'application/vnd.oasis.opendocument.presentation', + rtf: 'application/rtf', + zip: 'application/zip', + rar: 'application/vnd.rar', + '7z': 'application/x-7z-compressed', + tar: 'application/x-tar', + gz: 'application/gzip', + bz2: 'application/x-bzip2', + xz: 'application/x-xz', + tgz: 'application/gzip', + lrc: 'application/lrc', + cert: 'application/x-x509-ca-cert', + pem: 'application/x-x509-ca-cert', + key: 'application/pgp-keys', + } + + return map[ext] || 'application/octet-stream' +} + +/** @param {string} key @returns {string} */ +function encodeS3Key(key) { + return key.split('/').map(encodeURIComponent).join('/') +} + +/** @param {File} file @returns {Promise} */ +async function computeFileHash(file) { + const buffer = await file.arrayBuffer() + const hashBuffer = await crypto.subtle.digest('SHA-256', buffer) + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +/** @param {string} template @param {File} file @returns {Promise} */ +async function applyFilenameTemplate(template, file) { + if (!template?.trim()) return file.name + + const originalName = file.name + const ext = getExtension(originalName) + const base = getBaseName(originalName) + const fileHash = await computeFileHash(file) + + let result = template + result = result.replace(/\[name\]/g, base) + result = result.replace(/\[ext\]/g, ext) + result = result.replace(/\[timestamp\]/g, String(Math.floor(Date.now() / 1000))) + result = result.replace(/\[uuid\]/g, crypto.randomUUID()) + result = result.replace(/\[hash:(\d+)\]/g, (_, n) => fileHash.slice(0, parseInt(/** @type {string} */ (n), 10))) + result = result.replace(/\[hash\]/g, fileHash.slice(0, 6)) + result = result.replace(/\[date:([^\]]+)\]/g, (_, format) => dayjs().format(/** @type {string} */ (format))) + + return result +} + +export { + $, + formatDate, + extractFileName, + getFileType, + getErrorMessage, + getFileIconSvg, + getExtension, + getBaseName, + getMimeType, + encodeS3Key, + computeFileHash, + applyFilenameTemplate, +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..f65b327 --- /dev/null +++ b/src/main.js @@ -0,0 +1,4 @@ +import { App } from './js/app.js' + +// Boot +new App() diff --git a/src/manifest.json b/src/manifest.json new file mode 100644 index 0000000..f7e4c09 --- /dev/null +++ b/src/manifest.json @@ -0,0 +1,37 @@ +{ + "id": "./", + "name": "R2 Web", + "short_name": "R2 Web", + "description": "轻盈优雅的 Web 原生 Cloudflare R2 文件管理器,一切皆在浏览器中完成。", + "lang": "zh-Hans", + "dir": "ltr", + "start_url": "./", + "scope": "./", + "display": "standalone", + "display_override": ["window-controls-overlay", "standalone"], + "orientation": "any", + "theme_color": "#F6821F", + "background_color": "#ffffff", + "categories": ["utilities", "productivity"], + "icons": [ + { + "src": "r2-web.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "r2-web.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "r2-web.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "screenshots": [] +} diff --git a/src/r2-web.png b/src/r2-web.png new file mode 100644 index 0000000000000000000000000000000000000000..25aa75380b7d3b0b72ce4ca37fb14dbbcdbca91f GIT binary patch literal 8061 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