chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:34:40 +08:00
commit 7f30dabfa0
45 changed files with 11666 additions and 0 deletions
+90
View File
@@ -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
@@ -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
@@ -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
+29
View File
@@ -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 }}
+4
View File
@@ -0,0 +1,4 @@
.DS_Store
node_modules
issue.md
+1
View File
@@ -0,0 +1 @@
24.11.1
+3
View File
@@ -0,0 +1,3 @@
{
"cSpell.words": ["Flexbox", "jsquash", "Topbar"]
}
+137
View File
@@ -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')
```
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`vikiboss/r2-web`
- 原始仓库:https://github.com/vikiboss/r2-web
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+26
View File
@@ -0,0 +1,26 @@
/// <reference types="node" />
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)
},
})
Vendored
+1
View File
@@ -0,0 +1 @@
declare module 'qrcode'
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026-Present Viki <hi@viki.moe>
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.
+3
View File
@@ -0,0 +1,3 @@
[build]
base = "./"
publish = "src/"
+67
View File
@@ -0,0 +1,67 @@
{
"name": "r2-web",
"private": true,
"version": "1.12.3",
"type": "module",
"packageManager": "pnpm@10.30.3",
"author": "Viki <hi@viki.moe> (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"
}
}
+65
View File
@@ -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
```
+1735
View File
File diff suppressed because it is too large Load Diff
+214
View File
@@ -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 `<dialog>`, 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
+219
View File
@@ -0,0 +1,219 @@
![93c1205d.png](https://image.viki.moe/github/93c1205d.png)
**中文 | [English](./readme-en.md)**
# R2 Web
📁 轻盈优雅的 Web 原生 Cloudflare R2 文件管理器,一切皆在浏览器中完成。
<a href="https://hellogithub.com/repository/vikiboss/r2-web" target="_blank"><img src="https://api.hellogithub.com/v1/widgets/recommend.svg?rid=bd21b5fa51c94603a53054b5a3becc27&claim_uid=wXMelR56paDoO2x&theme=dark" alt="FeaturedHelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
> 本项目有幸被 [《科技爱好者周刊(第 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、原生 `<dialog>`、原生 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
+125
View File
@@ -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);
}
}
+49
View File
@@ -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);
}
}
}
File diff suppressed because it is too large Load Diff
+102
View File
@@ -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;
}
}
}
+57
View File
@@ -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;
}
}
+75
View File
@@ -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;
}
}
+30
View File
@@ -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;
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

+1532
View File
File diff suppressed because it is too large Load Diff
+854
View File
@@ -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<HTMLButtonElement>} */ (dialog.querySelectorAll('.config-tab-btn'))
const tabPanels = /** @type {NodeListOf<HTMLElement>} */ (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 }
+92
View File
@@ -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 }
+22
View File
@@ -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
+406
View File
@@ -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<string, CacheEntry>} */
#cache = new Map()
/** @type {FileItem[]} */
#loadedItems = []
/** @type {Map<string, SelectionItem>} */
#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<HTMLElement>} */ (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<string, (a: FileItem, b: FileItem) => 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 = `<div class="file-card-icon folder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
</div>`
} else if (isImage) {
iconHtml = `<img class="file-card-thumb" alt="" loading="lazy">`
} else {
const fileType = getFileType(item.key)
iconHtml = `<div class="file-card-icon ${fileType}">
${getFileIconSvg(fileType)}
</div>`
}
const checkboxLabel = t('selectFile')
card.innerHTML = `
<span class="file-card-checkbox-wrap" title="${checkboxLabel}">
<input type="checkbox" aria-label="${checkboxLabel}">
</span>
${iconHtml}
<span class="file-card-name"></span>
${
!item.isFolder
? `
<span class="file-card-size">${filesize(item.size ?? 0)}</span>
<span class="file-card-date">${formatDate(item.lastModified ?? '')}</span>
`
: ''
}
<div class="file-card-actions">
<button type="button" class="icon-btn sm file-card-menu" title="More">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</button>
</div>
`
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 }
+541
View File
@@ -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<boolean>} 用户取消返回 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<boolean>} 用户取消返回 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<boolean>} 用户取消返回 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 = `<img src="${url}" alt="${name}">`
else if (isVideo) text = `<video src="${url}" controls></video>`
else if (isAudio) text = `<audio src="${url}" controls></audio>`
else text = `<a href="${url}">${name}</a>`
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<void>} 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<string[]>} */
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 }
+202
View File
@@ -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 = '<div style="color:var(--text-tertiary)">Loading...</div>'
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 = `
<span>${t('size')}: ${filesize(meta.contentLength)}</span>
<span>${t('contentType')}: ${meta.contentType || 'unknown'}</span>
${meta.lastModified ? `<span>${t('lastModified')}: ${formatDate(meta.lastModified)}</span>` : ''}
`
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 = `<p style="color:var(--text-tertiary)">${t('previewNotAvailable')}</p>`
}
} 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 }
+1063
View File
File diff suppressed because it is too large Load Diff
+220
View File
@@ -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<boolean>}
*/
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 }
+689
View File
@@ -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:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
error:
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
info: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
}
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('<div class="skeleton-card"></div>').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<string | null>}
*/
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<boolean>} */
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 }
+537
View File
@@ -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<File>}
*/
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<T>>} tasks
* @param {number} limit
* @returns {Promise<PromiseSettledResult<T>[]>}
*/
async function runWithConcurrency(tasks, limit) {
const results = /** @type {PromiseSettledResult<T>[]} */ (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()) && /<img[\s>]/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 = `
<div class="upload-item-header">
<div class="upload-item-name"></div>
<div class="upload-item-status" id="${id}-status"></div>
</div>
<div class="upload-progress">
<div class="upload-progress-bar" id="${id}-bar"></div>
</div>
`
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 }
+237
View File
@@ -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 {<T extends HTMLElement = HTMLElement>(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 `<svg ${svgBase}><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg>`
case 'audio':
return `<svg ${svgBase}><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>`
case 'document':
return `<svg ${svgBase}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>`
case 'archive':
return `<svg ${svgBase}><path d="M21 8v13H3V8"/><path d="M1 3h22v5H1z"/><path d="M10 12h4"/></svg>`
case 'code':
return `<svg ${svgBase}><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`
case 'text':
return `<svg ${svgBase}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>`
case 'image':
default:
return `<svg ${svgBase}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>`
}
}
/** @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<string, string>} */
// 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<string>} */
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<string>} */
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,
}
+4
View File
@@ -0,0 +1,4 @@
import { App } from './js/app.js'
// Boot
new App()
+37
View File
@@ -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": []
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+40
View File
@@ -0,0 +1,40 @@
/* ========================================================================
R2 Web — Modern CSS
Features: CSS nesting, light-dark(), color-mix(), @starting-style,
Popover API, range media queries, text-wrap: balance
======================================================================== */
@layer reset, tokens, base, layout, components, utilities, animations;
@import './css/reset.css';
@import './css/tokens.css';
@import './css/base.css';
@import './css/layout.css';
@import './css/components.css';
@import './css/utilities.css';
@import './css/animations.css';
/* ========================================================================
Global Tooltip — Outside layers for maximum priority
======================================================================== */
.tooltip {
position: fixed !important;
z-index: var(--z-tooltip) !important;
pointer-events: none;
padding: var(--sp-1) var(--sp-2);
font-size: var(--text-xs);
font-weight: 500;
line-height: 1.4;
white-space: nowrap;
color: var(--text-primary);
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
opacity: 0;
transition: opacity var(--duration-normal) var(--ease-out);
}
.tooltip.visible {
opacity: 1 !important;
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"checkJs": true,
"strict": true,
"strictNullChecks": false, // TODO: 完善类型定义后开启
"target": "esnext",
"noEmit": true,
"module": "esnext",
"allowJs": true,
"moduleResolution": "bundler"
},
"include": ["src/**/*.js", "./global.d.ts", "bump.config.ts", "changelog.config.ts"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"outputDirectory": "src"
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "r2-web",
"compatibility_date": "2026-02-11",
"assets": {
"directory": "./src"
}
}