Merge pull request 'dev/0.11' (#3) from dev/0.11 into main

Reviewed-on: https://gitea.fudaoyuan.icu/keven/015/pulls/3
This commit is contained in:
keven
2026-05-24 18:21:29 +08:00
196 changed files with 10111 additions and 4015 deletions

View File

@@ -13,10 +13,11 @@ jobs:
node-version: '24' node-version: '24'
- uses: pnpm/action-setup@v4 - uses: pnpm/action-setup@v4
with: with:
cache: true version: latest
cache: true
- name: Install dependencies - name: Install dependencies
run: | run: |
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
- name: Run frontend lint - name: Run frontend lint
run: pnpm lint:front run: pnpm lint:front

5
.gitignore vendored
View File

@@ -22,7 +22,7 @@ logs
.env .env
.env.* .env.*
!.env.example !.env.example
config.yaml /config.yaml
# Serwist # Serwist
/front/public/sw* /front/public/sw*
@@ -31,3 +31,6 @@ config.yaml
# backend # backend
**/uploads/** **/uploads/**
**/tmp/** **/tmp/**
# worker
pkg/mail/out/

View File

@@ -1,25 +1,23 @@
FROM node:22-alpine AS front-base FROM node:22-alpine AS front-base
WORKDIR /app
# Install dependencies only when needed # Install dependencies only when needed
FROM front-base AS front-deps
RUN apk add --no-cache gcompat
WORKDIR /app
COPY . .
RUN corepack enable pnpm && pnpm i && pnpm --filter=015-front deploy dist --legacy
FROM front-base AS front-builder FROM front-base AS front-builder
WORKDIR /app RUN apk add --no-cache gcompat
COPY --from=front-deps /app/dist/ . ENV CI=true
RUN corepack enable pnpm && pnpm build ENV NODE_OPTIONS="--max-old-space-size=4096"
COPY . .
RUN corepack enable pnpm && pnpm i && pnpm --filter=015-front build && pnpm --dir pkg/mail export
FROM golang:1.25.5 AS backend-builder FROM golang:1.26.3 AS backend-builder
WORKDIR /app WORKDIR /app
# Workspace and module manifests for cache # Workspace and module manifests for cache
COPY go.work go.work.sum ./ COPY go.work go.work.sum ./
COPY backend/ ./backend/ COPY backend/ ./backend/
COPY worker/ ./worker/ COPY worker/ ./worker/
COPY pkg/ ./pkg/ COPY pkg/ ./pkg/
# Inject built email templates so Go can embed them
COPY --from=front-builder /app/pkg/mail/out/ ./pkg/mail/out/
RUN go env -w GO111MODULE=on && go env -w GOPROXY=https://goproxy.cn,direct && \ RUN go env -w GO111MODULE=on && go env -w GOPROXY=https://goproxy.cn,direct && \
go mod download go mod download
# Build from workspace root so pkg/utils, pkg/models, pkg/services resolve # Build from workspace root so pkg/utils, pkg/models, pkg/services resolve
@@ -29,7 +27,6 @@ RUN CGO_ENABLED=0 GOOS=linux go build -o backend-bin ./backend
FROM front-base AS runner FROM front-base AS runner
ARG VERSION ARG VERSION
ARG BUILD_TIME ARG BUILD_TIME
WORKDIR /app
RUN apk add --no-cache curl openssl RUN apk add --no-cache curl openssl
ENV NODE_ENV production ENV NODE_ENV production
@@ -37,7 +34,7 @@ RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nuxtjs RUN adduser --system --uid 1001 nuxtjs
# Only `.output` folder is needed from the build stage # Only `.output` folder is needed from the build stage
COPY --from=front-builder --chown=nuxtjs:nodejs /app/.output/ ./ COPY --from=front-builder --chown=nuxtjs:nodejs /app/front/.output/ ./
COPY --from=backend-builder /app/backend-bin /bin/backend COPY --from=backend-builder /app/backend-bin /bin/backend
COPY 015.sh /app/015.sh COPY 015.sh /app/015.sh

View File

@@ -41,16 +41,16 @@
## 📸 截图预览 ## 📸 截图预览
| 选择文件上传页面 | 输入文本上传页面 | | 选择文件上传页面 | 输入文本上传页面 |
|---------------------------------------|-----------------------------------------------| | ------------------------- | ------------------------- |
| ![](/.github/image/1.png) | ![](/.github/image/2.png) | | ![](/.github/image/1.png) | ![](/.github/image/2.png) |
| 多选文件上传 | 文件上传进度热力图 | | 多选文件上传 | 文件上传进度热力图 |
|------------------------------------------------|---------------------------------------------------| | ------------------------- | ------------------------- |
| ![](/.github/image/3.png) | ![](/.github/image/4.png) | | ![](/.github/image/3.png) | ![](/.github/image/4.png) |
| 文件上传进度条 | 文件上传成功页面 | | 文件上传进度条 | 文件上传成功页面 |
|------------------------------------------------|-------------------------------------------------| | ------------------------- | ------------------------- |
| ![](/.github/image/5.png) | ![](/.github/image/6.png) | | ![](/.github/image/5.png) | ![](/.github/image/6.png) |
## 🚀 快速开始 ## 🚀 快速开始
@@ -61,31 +61,15 @@
- config.example.yaml - config.example.yaml
- docker-compose.yml - docker-compose.yml
2. 把config.example.yaml配置完成后改为config.yaml 2. config.example.yaml 配置完成后改为 config.yaml
3. 启动
```bash
docker compose up -d
```
## 🚀 快速开始
### Docker
1. 下载文件
- config.example.yaml
- docker-compose.yml
2. 把config.example.yaml配置完成后改为config.yaml
3. 启动 3. 启动
```bash ```bash
docker compose up -d docker compose up -d
``` ```
4. 访问 `http://localhost:8080`
## 🏗️ 技术架构 ## 🏗️ 技术架构
@@ -130,15 +114,15 @@ docker compose up -d
015/ 015/
├── front/ # 前端应用 (Vue 3 + Nuxt 3) ├── front/ # 前端应用 (Vue 3 + Nuxt 3)
│ ├── components/ # Vue 组件 │ ├── components/ # Vue 组件
│ ├── pages/ # 页面路由 │ ├── pages/ # 页面路由
│ ├── composables/ # 组合式函数 │ ├── composables/ # 组合式函数
│ ├── i18n/ # 国际化文件 │ ├── i18n/ # 国际化文件
│ └── assets/ # 静态资源 ── assets/ # 静态资源
── middleware/ # 中间 ── plugins/ # Nuxt 插
│ └── server/ # 服务端路由
├── backend/ # 后端服务 (Go + Echo) ├── backend/ # 后端服务 (Go + Echo)
│ ├── internal/ # 内部包 │ ├── internal/ # 内部包
│ │ ├── controllers/ # 控制器 │ │ ├── controllers/ # 控制器
│ │ ├── models/ # 数据模型
│ │ ├── services/ # 业务逻辑 │ │ ├── services/ # 业务逻辑
│ │ └── utils/ # 工具函数 │ │ └── utils/ # 工具函数
│ └── middleware/ # 中间件 │ └── middleware/ # 中间件
@@ -147,7 +131,7 @@ docker compose up -d
│ │ ├── tasks/ # 任务处理器 │ │ ├── tasks/ # 任务处理器
│ │ └── utils/ # 工具函数 │ │ └── utils/ # 工具函数
│ └── middleware/ # 中间件 │ └── middleware/ # 中间件
└── tmp/ # 临时文件 └── pkg/ # 公共包
``` ```
## 🔧 开发指南 ## 🔧 开发指南
@@ -186,19 +170,19 @@ cd worker && go build -o worker .
- 前端计算哈希和秒传 - 前端计算哈希和秒传
- 并发切片上传 (使用 Web Worker) - 并发切片上传 (使用 Web Worker)
- 文件上传/文本上传和分享 - 文件上传/文本上传和分享
- 支持多文件上传
- 上传统计页面 - 上传统计页面
- 多语言支持 - 多语言支持
- 最大上传限制 - 最大上传限制
- 后端队列系统和 Worker 处理文件 - 后端队列系统和 Worker 处理文件
- 断点续传 (后端计算已上传部分并返回)
- 图片格式转换和压缩
### 计划功能 🚧 ### 计划功能 🚧
- 断点续传 (后端计算已上传部分并返回)
- 图片格式转换和压缩
- 图片 OCR 复制 - 图片 OCR 复制
- 文档转 Markdown - 文档转 Markdown
- 文本翻译/总结 - 文本翻译/总结
- 支持上传多文件
## 🤝 贡献指南 ## 🤝 贡献指南

View File

@@ -41,17 +41,34 @@ English | [中文](README-zh.md)
## 📸 Screenshots ## 📸 Screenshots
| File Selection Upload Page | Text Input Upload Page | | File Selection Upload Page | Text Input Upload Page |
|---------------------------------------|-----------------------------------------------| | -------------------------- | ------------------------- |
| ![](/.github/image/1.png) | ![](/.github/image/2.png) | | ![](/.github/image/1.png) | ![](/.github/image/2.png) |
| Multiple File Upload | Upload Progress Heatmap | | Multiple File Upload | Upload Progress Heatmap |
|------------------------------------------------|---------------------------------------------------| | -------------------------- | ------------------------- |
| ![](/.github/image/3.png) | ![](/.github/image/4.png) | | ![](/.github/image/3.png) | ![](/.github/image/4.png) |
| Upload Progress Bar | Upload Success Page | | Upload Progress Bar | Upload Success Page |
|------------------------------------------------|-------------------------------------------------| | -------------------------- | ------------------------- |
| ![](/.github/image/5.png) | ![](/.github/image/6.png) | | ![](/.github/image/5.png) | ![](/.github/image/6.png) |
## 🚀 Quick Start
### Docker
1. Download files
- config.example.yaml
- docker-compose.yml
2. Rename config.example.yaml to config.yaml after configuration
3. Start
```bash
docker compose up -d
```
4. Visit `http://localhost:8080`
## 🏗️ Technical Architecture ## 🏗️ Technical Architecture
@@ -90,36 +107,21 @@ English | [中文](README-zh.md)
- **Redis Cache** - Share information and file metadata caching - **Redis Cache** - Share information and file metadata caching
- **Queue System** - Asynchronous task processing queue - **Queue System** - Asynchronous task processing queue
## 🚀 Quick Start
### Docker
1. Download files
- config.example.yaml
- docker-compose.yml
2. Rename config.example.yaml to config.yaml after configuration
3. Start
```bash
docker compose up -d
```
## 📁 Project Structure ## 📁 Project Structure
``` ```
015/ 015/
├── front/ # Frontend application (Vue 3 + Nuxt 3) ├── front/ # Frontend application (Vue 3 + Nuxt 3)
│ ├── components/ # Vue components │ ├── components/ # Vue components
│ ├── pages/ # Page routes │ ├── pages/ # Page routes
│ ├── composables/ # Composable functions │ ├── composables/ # Composable functions
│ ├── i18n/ # Internationalization files │ ├── i18n/ # Internationalization files
│ └── assets/ # Static assets ── assets/ # Static assets
── middleware/ # Middleware ── plugins/ # Nuxt plugins
│ └── server/ # Server-side routes
├── backend/ # Backend service (Go + Echo) ├── backend/ # Backend service (Go + Echo)
│ ├── internal/ # Internal packages │ ├── internal/ # Internal packages
│ │ ├── controllers/ # Controllers │ │ ├── controllers/ # Controllers
│ │ ├── models/ # Data models
│ │ ├── services/ # Business logic │ │ ├── services/ # Business logic
│ │ └── utils/ # Utility functions │ │ └── utils/ # Utility functions
│ └── middleware/ # Middleware │ └── middleware/ # Middleware
@@ -128,7 +130,7 @@ docker compose up -d
│ │ ├── tasks/ # Task processors │ │ ├── tasks/ # Task processors
│ │ └── utils/ # Utility functions │ │ └── utils/ # Utility functions
│ └── middleware/ # Middleware │ └── middleware/ # Middleware
└── tmp/ # Temporary files └── pkg/ # Shared packages
``` ```
## 🔧 Development Guide ## 🔧 Development Guide
@@ -167,19 +169,19 @@ cd worker && go build -o worker .
- Frontend hash calculation and instant transfer - Frontend hash calculation and instant transfer
- Concurrent chunked upload (using Web Worker) - Concurrent chunked upload (using Web Worker)
- File upload/text upload and sharing - File upload/text upload and sharing
- Multiple file upload support
- Upload statistics page - Upload statistics page
- Multi-language support - Multi-language support
- Maximum upload limits - Maximum upload limits
- Backend queue system and Worker file processing - Backend queue system and Worker file processing
- Resume upload (backend calculates uploaded parts and returns)
- Image format conversion and compression
### Planned Features 🚧 ### Planned Features 🚧
- Resume upload (backend calculates uploaded parts and returns)
- Image format conversion and compression
- Image OCR copy - Image OCR copy
- Document to Markdown conversion - Document to Markdown conversion
- Text translation/summarization - Text translation/summarization
- Support for multiple file uploads
## 🤝 Contributing ## 🤝 Contributing

View File

@@ -23,7 +23,9 @@ github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pw
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw= github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -35,17 +37,21 @@ github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZ
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -53,10 +59,15 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -10,21 +10,45 @@ import (
"github.com/spf13/cast" "github.com/spf13/cast"
) )
func GetConfig(c *echo.Context) error { var defaultEnabledFeatures = []string{
featureConfig := u.GetEnvMap("features") "file-share",
features := lo.FilterMap(lo.Entries(featureConfig), func(e lo.Entry[string, any], _ int) (string, bool) { "text-share",
}
func getEnabledKeys(config map[string]any) []string {
return lo.FilterMap(lo.Entries(config), func(e lo.Entry[string, any], _ int) (string, bool) {
node, ok := e.Value.(map[string]any) node, ok := e.Value.(map[string]any)
return e.Key, ok && cast.ToBool(node["enabled"]) return e.Key, ok && cast.ToBool(node["enabled"])
}) })
}
func GetConfig(c *echo.Context) error {
featureConfig := u.GetEnvMap("features")
defaultFeatureConfig := lo.SliceToMap(defaultEnabledFeatures, func(item string) (string, any) {
return item, map[string]any{
"enabled": true,
}
})
featureConfig = lo.Assign(defaultFeatureConfig, featureConfig)
features := getEnabledKeys(featureConfig)
// textTranslateProviderConfig := u.GetEnvMap("features.text-translate.provider")
// textTranslateProviders := getEnabledKeys(textTranslateProviderConfig)
return utils.HTTPSuccessHandler(c, map[string]any{ return utils.HTTPSuccessHandler(c, map[string]any{
"site_title": u.GetEnvMap("site.title"), "site_title": u.GetEnvMap("site.title"),
"site_desc": u.GetEnvMap("site.desc"), "site_desc": u.GetEnvMap("site.desc"),
"site_url": u.GetEnv("site.url"), "site_url": u.GetEnv("site.url"),
"site_icon": u.GetEnvWithDefault("site.icon", "/logo.png"), "site_icon": u.GetEnvWithDefault("site.icon", "/logo.png"),
"site_bg_url": u.GetEnvWithDefault("site.bg_url", "https://img.fudaoyuan.icu/api/1/random/?scale_min=1.5&webp=true&md=false&format=302"), "site_bg_url": u.GetEnvWithDefault("site.bg_url", "https://img.fudaoyuan.icu/api/1/random/?scale_min=1.5&webp=true&md=false&format=302"),
"version": u.GetEnvWithDefault("VERSION", "dev"), "site_enable_bg": cast.ToBool(u.GetEnvWithDefault("site.enable_bg", "true")),
"build_time": cast.ToInt(u.GetEnvWithDefault("BUILD_TIME", cast.ToString(time.Now().Unix()))), "version": u.GetEnvWithDefault("VERSION", "dev"),
"features": features, "build_time": cast.ToInt(u.GetEnvWithDefault("BUILD_TIME", cast.ToString(time.Now().Unix()))),
"features": features,
"config": map[string]any{
// "text-translate": map[string]any{
// "provider": textTranslateProviders,
// },
},
}) })
} }

View File

@@ -2,13 +2,17 @@ package controllers
import ( import (
"backend/internal/utils" "backend/internal/utils"
"context"
"encoding/json"
"fmt" "fmt"
"pkg/models" "pkg/models"
u "pkg/utils" u "pkg/utils"
"time" "time"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/hibiken/asynq"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/samber/lo"
"github.com/spf13/cast" "github.com/spf13/cast"
) )
@@ -26,14 +30,13 @@ func DownloadShare(c *echo.Context) error {
t, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) { t, err := jwt.ParseWithClaims(token, &claims, func(token *jwt.Token) (interface{}, error) {
return []byte(u.GetEnv("share.download_secret")), nil return []byte(u.GetEnv("share.download_secret")), nil
}) })
if err != nil { if err != nil || !t.Valid {
return utils.HTTPErrorHandler(c, err) return utils.HTTPErrorHandler(c, lo.Ternary(err != nil, err, ErrInvalidRequest))
} }
if !t.Valid { shareInfo, err := models.GetRedisShareInfo(claims.ShareId)
return utils.HTTPErrorHandler(c, ErrInvalidRequest) if err != nil || shareInfo == nil {
return utils.HTTPErrorHandler(c, lo.Ternary(err != nil, err, ErrShareNotFound))
} }
shareInfo, _ := models.GetRedisShareInfo(claims.ShareId)
if shareInfo.Type == models.ShareTypeFile { if shareInfo.Type == models.ShareTypeFile {
fileInfo, _ := models.GetRedisFileInfo(shareInfo.Data) fileInfo, _ := models.GetRedisFileInfo(shareInfo.Data)
uploadPath, err := u.GetUploadDirPath() uploadPath, err := u.GetUploadDirPath()
@@ -81,64 +84,75 @@ func VaildateShare(c *echo.Context) error {
return utils.HTTPErrorHandler(c, ErrInvalidSharePassword) return utils.HTTPErrorHandler(c, ErrInvalidSharePassword)
} }
} }
// 如果下载次数为0则设置为-1 防止空值问题 return u.WithLocker(context.Background(), "015:shareInfoMap:"+r.ShareId, 0, func(ctx context.Context) error {
if shareInfo.ViewNum < 1 { shareInfo, err := models.GetRedisShareInfo(r.ShareId)
return utils.HTTPErrorHandler(c, ErrInsufficientDownloadQuota) if err != nil || shareInfo == nil {
} return utils.HTTPErrorHandler(c, lo.Ternary(err != nil, err, ErrShareNotFound))
downloadWindow := u.GetEnvWithDefault("share.download_window", "12") }
token := jwt.NewWithClaims(jwt.SigningMethodHS256, DownloadShareClaims{ if shareInfo.ViewNum < 1 {
ShareId: r.ShareId, return utils.HTTPErrorHandler(c, ErrInsufficientDownloadQuota)
RegisteredClaims: jwt.RegisteredClaims{ }
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cast.ToDuration(downloadWindow + "h"))), downloadWindow := u.GetEnvWithDefault("share.download_window", "12")
}, token := jwt.NewWithClaims(jwt.SigningMethodHS256, DownloadShareClaims{
}) ShareId: r.ShareId,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cast.ToDuration(downloadWindow + "h"))),
},
})
// Sign and get the complete encoded token as a string using the secret // Sign and get the complete encoded token as a string using the secret
downloadToken, err := token.SignedString([]byte(u.GetEnv("share.download_secret"))) downloadToken, err := token.SignedString([]byte(u.GetEnv("share.download_secret")))
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
if shareInfo.Type == models.ShareTypeFile {
fileInfo, err := models.GetRedisFileInfo(shareInfo.Data)
if err != nil { if err != nil {
return utils.HTTPErrorHandler(c, err) return utils.HTTPErrorHandler(c, err)
} }
if fileInfo == nil { if shareInfo.Type == models.ShareTypeFile {
return utils.HTTPErrorHandler(c, ErrShareFileNotFound) fileInfo, err := models.GetRedisFileInfo(shareInfo.Data)
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
if fileInfo == nil {
return utils.HTTPErrorHandler(c, ErrShareFileNotFound)
}
if fileInfo.FileType != models.FileTypeUpload {
return utils.HTTPErrorHandler(c, ErrInvalidShareFileState)
}
} }
if fileInfo.FileType != models.FileTypeUpload { // download_nums 必须放在创建token的时候减掉不然多线程下载会导致多次减掉
return utils.HTTPErrorHandler(c, ErrInvalidShareFileState) _, err = models.SetRedisShareInfo(r.ShareId, func(shareInfo *models.RedisShareInfo) *models.RedisShareInfo {
shareInfo.ViewNum -= 1
return shareInfo
})
if err != nil {
return utils.HTTPErrorHandler(c, err)
} }
}
// download_nums 必须放在创建token的时候减掉不然多线程下载会导致多次减掉
latestViewNum := shareInfo.ViewNum - 1
// 如果下载次数为0则设置为-1 防止空值问题
if latestViewNum < 1 {
latestViewNum = -1
}
err = models.SetRedisShareInfo(r.ShareId, models.RedisShareInfo{
ViewNum: latestViewNum,
})
if err != nil {
return utils.HTTPErrorHandler(c, err)
}
// 统计分享数 // 统计分享数
currentDate := time.Now().Format("2006-01-02") currentDate := time.Now().Format("2006-01-02")
err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData { _, err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData {
stat.DownloadNum += 1 stat.DownloadNum += 1
return stat return stat
}) })
if err != nil { if err != nil {
return utils.HTTPErrorHandler(c, err) return utils.HTTPErrorHandler(c, err)
} }
if shareInfo.Type == models.ShareTypeFile { if len(shareInfo.NotifyEmails) > 0 || len(shareInfo.NotifyWebhooks) > 0 {
payload, err := json.Marshal(map[string]string{
"share_id": r.ShareId,
"ip": c.RealIP(),
})
if err == nil {
_, _ = u.GetQueueClient().Enqueue(asynq.NewTask("share:notify", payload))
}
}
if shareInfo.Type == models.ShareTypeFile {
return utils.HTTPSuccessHandler(c, map[string]any{
"token": downloadToken,
})
}
return utils.HTTPSuccessHandler(c, map[string]any{ return utils.HTTPSuccessHandler(c, map[string]any{
"token": downloadToken, "token": downloadToken,
}) })
}
return utils.HTTPSuccessHandler(c, map[string]any{
"token": downloadToken,
}) })
} }

View File

@@ -17,7 +17,7 @@ var (
ErrInvalidFileSliceIndex = errors.New("InvalidFileSliceIndex") // 文件切片索引错误 ErrInvalidFileSliceIndex = errors.New("InvalidFileSliceIndex") // 文件切片索引错误
ErrInvalidFileSliceSize = errors.New("InvalidFileSliceSize") // 文件切片大小错误 ErrInvalidFileSliceSize = errors.New("InvalidFileSliceSize") // 文件切片大小错误
ErrIncompleteFileSlices = errors.New("IncompleteFileSlices") // 文件切片不完整 ErrIncompleteFileSlices = errors.New("IncompleteFileSlices") // 文件切片不完整
ErrFileMD5Mismatch = errors.New("FileMD5Mismatch") // 文件MD5不一致 ErrFileHashMismatch = errors.New("FileHashMismatch") // 文件Hash不一致
// 分享相关 // 分享相关
ErrShareFileNotFound = errors.New("ShareFileNotFound") // 分享文件不存在 ErrShareFileNotFound = errors.New("ShareFileNotFound") // 分享文件不存在

View File

@@ -13,7 +13,6 @@ import (
"time" "time"
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
"github.com/spf13/cast"
) )
func CreateUploadTask(c *echo.Context) error { func CreateUploadTask(c *echo.Context) error {
@@ -78,36 +77,33 @@ func CreateUploadTask(c *echo.Context) error {
for r.FileSize/ChunkSize > 1000 { for r.FileSize/ChunkSize > 1000 {
ChunkSize *= 2 ChunkSize *= 2
} }
uploadTaskExpire := cast.ToInt64(u.GetEnvWithDefault("upload.remove_expire", "2")) * 3600 redisFileInfo, err := models.SetRedisFileInfo(fileId, func(fileInfo *models.RedisFileInfo) *models.RedisFileInfo {
newFileInfo := models.RedisFileInfo{ fileInfo.FileType = models.FileTypeInit
FileType: models.FileTypeInit, fileInfo.FileInfo = models.FileInfo{
FileInfo: models.FileInfo{
FileSize: r.FileSize, FileSize: r.FileSize,
MimeType: r.MimeType, MimeType: r.MimeType,
FileHash: r.FileHash, FileHash: r.FileHash,
ChunkSize: ChunkSize, ChunkSize: ChunkSize,
}, }
CreatedAt: time.Now().Unix(), return fileInfo
Expire: uploadTaskExpire, })
}
err = models.SetRedisFileInfo(fileId, newFileInfo)
if err != nil { if err != nil {
return utils.HTTPErrorHandler(c, err) return utils.HTTPErrorHandler(c, err)
} }
err = s.SetFileRemoveTask(fileId, time.Duration(uploadTaskExpire)*time.Second) err = s.SetFileRemoveTask(fileId, time.Duration(redisFileInfo.Expire)*time.Second)
if err != nil { if err != nil {
return utils.HTTPErrorHandler(c, err) return utils.HTTPErrorHandler(c, err)
} }
return utils.HTTPSuccessHandler(c, map[string]any{ return utils.HTTPSuccessHandler(c, map[string]any{
"size": newFileInfo.FileSize, "size": redisFileInfo.FileSize,
"mime_type": newFileInfo.MimeType, "mime_type": redisFileInfo.MimeType,
"hash": newFileInfo.FileHash, "hash": redisFileInfo.FileHash,
"type": newFileInfo.FileType, "type": redisFileInfo.FileType,
"expire": newFileInfo.Expire, "expire": redisFileInfo.Expire,
"id": fileId, "id": fileId,
"chunk_size": newFileInfo.ChunkSize, "chunk_size": redisFileInfo.ChunkSize,
}) })
} }
@@ -216,32 +212,33 @@ func FinishUploadTask(c *echo.Context) error {
return utils.HTTPErrorHandler(c, err) return utils.HTTPErrorHandler(c, err)
} }
// 计算文件MD5 // 计算文件SHA1
file, err := os.Open(mergeFilePath) file, err := os.Open(mergeFilePath)
if err != nil { if err != nil {
return utils.HTTPErrorHandler(c, err) return utils.HTTPErrorHandler(c, err)
} }
defer file.Close() //nolint:errcheck defer file.Close() //nolint:errcheck
file_hash, err := u.GetFileMd5(file) file_hash, err := u.GetFileSHA1(file)
if err != nil || file_hash != fileInfo.FileHash { if err != nil || file_hash != fileInfo.FileHash {
defer os.Remove(mergeFilePath) //nolint:errcheck defer os.Remove(mergeFilePath) //nolint:errcheck
if err == nil { if err == nil {
return utils.HTTPErrorHandler(c, ErrFileMD5Mismatch) return utils.HTTPErrorHandler(c, ErrFileHashMismatch)
} }
return utils.HTTPErrorHandler(c, err) return utils.HTTPErrorHandler(c, err)
} }
// 更新文件信息 // 更新文件信息
err = models.SetRedisFileInfo(r.FileId, models.RedisFileInfo{ fileInfo, err = models.SetRedisFileInfo(r.FileId, func(fileInfo *models.RedisFileInfo) *models.RedisFileInfo {
FileType: models.FileTypeUpload, fileInfo.FileType = models.FileTypeUpload
return fileInfo
}) })
if err != nil { if err != nil {
return utils.HTTPErrorHandler(c, err) return utils.HTTPErrorHandler(c, err)
} }
// 统计 // 统计
currentDate := time.Now().Format("2006-01-02") currentDate := time.Now().Format("2006-01-02")
err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData { _, err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData {
stat.FileSize += fileInfo.FileSize stat.FileSize += fileInfo.FileSize
stat.FileNum += 1 stat.FileNum += 1
return stat return stat

View File

@@ -23,13 +23,16 @@ type CreateShareProps struct {
} }
type ShareConfig struct { type ShareConfig struct {
ExpireAt int `json:"expire_time"` // 分钟 ExpireAt int `json:"expire_time"` // 分钟
ViewNum int64 `json:"download_nums"` ViewNum int64 `json:"download_nums"`
HasPassword bool `json:"has_password"` HasPassword bool `json:"has_password"`
Password string `json:"password"` Password string `json:"password"`
HasNotify bool `json:"has_notify"` HasNotify bool `json:"has_notify"`
NotifyEmail []string `json:"notify_email"` NotifyTypes []string `json:"notify_types"`
HasPickupCode bool `json:"has_pickup_code"` NotifyEmails []string `json:"notify_emails"`
NotifyWebhooks []models.NotifyWebhook `json:"notify_webhooks"`
Locale string `json:"locale"`
HasPickupCode bool `json:"has_pickup_code"`
} }
func CreateShareInfo(c *echo.Context) error { func CreateShareInfo(c *echo.Context) error {
@@ -73,16 +76,41 @@ func CreateShareInfo(c *echo.Context) error {
password = hash password = hash
} }
err = models.SetRedisShareInfo(id, models.RedisShareInfo{ var notifyEmails []string
Data: r.Data, var notifyWebhooks []models.NotifyWebhook
Type: r.Type, if r.Config.HasNotify {
CreatedAt: time.Now().Unix(), hasEmail, hasWebhook := false, false
Owner: owner, for _, nt := range r.Config.NotifyTypes {
ViewNum: r.Config.ViewNum, switch nt {
Password: password, case "email":
// NotifyEmail: r.Config.NotifyEmail, hasEmail = true
FileName: r.FileName, case "webhook":
ExpireAt: ExpireTime.Unix(), hasWebhook = true
default:
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
}
}
if hasEmail {
notifyEmails = r.Config.NotifyEmails
}
if hasWebhook {
notifyWebhooks = r.Config.NotifyWebhooks
}
}
_, err = models.SetRedisShareInfo(id, func(shareInfo *models.RedisShareInfo) *models.RedisShareInfo {
shareInfo.Data = r.Data
shareInfo.Type = r.Type
shareInfo.CreatedAt = time.Now().Unix()
shareInfo.Owner = owner
shareInfo.ViewNum = r.Config.ViewNum
shareInfo.Password = password
shareInfo.FileName = r.FileName
shareInfo.NotifyEmails = notifyEmails
shareInfo.NotifyWebhooks = notifyWebhooks
shareInfo.Locale = r.Config.Locale
shareInfo.ExpireAt = ExpireTime.Unix()
return shareInfo
}) })
if err != nil { if err != nil {
return utils.HTTPErrorHandler(c, err) return utils.HTTPErrorHandler(c, err)
@@ -128,7 +156,7 @@ func CreateShareInfo(c *echo.Context) error {
// 统计分享数 // 统计分享数
currentDate := time.Now().Format("2006-01-02") currentDate := time.Now().Format("2006-01-02")
err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData { _, err = models.SetRedisStat(currentDate, func(stat *models.StatData) *models.StatData {
stat.ShareNum += 1 stat.ShareNum += 1
return stat return stat
}) })

View File

@@ -13,6 +13,7 @@ import (
var handleTaskMap = map[string]func(c *echo.Context) ([]byte, error){ var handleTaskMap = map[string]func(c *echo.Context) ([]byte, error){
"image:compress": task.HandleImageCompress, "image:compress": task.HandleImageCompress,
"image:convert": task.HandleImageConvert, "image:convert": task.HandleImageConvert,
"text:translate": task.HandleTextTranslate,
} }
func CreateTask(c *echo.Context) error { func CreateTask(c *echo.Context) error {

View File

@@ -0,0 +1,45 @@
package task
import (
"encoding/json"
"github.com/labstack/echo/v5"
)
var validProviders = map[string]bool{
"google": true,
"microsoft": true,
"deeplx": true,
"deepseek": true,
}
var validSources = map[string]bool{
"auto": true,
"zh-CN": true,
"en": true,
"ja": true,
"ko": true,
}
type TranslateTextRequest struct {
Text string `json:"text"`
Source string `json:"source"`
Target string `json:"target"`
Provider string `json:"provider"`
}
func HandleTextTranslate(c *echo.Context) ([]byte, error) {
r := new(TranslateTextRequest)
if err := c.Bind(r); err != nil {
return nil, err
}
if r.Text == "" || r.Target == "" || !validProviders[r.Provider] || !validSources[r.Source] {
return nil, ErrInvalidRequest
}
return json.Marshal(map[string]any{
"text": r.Text,
"source": r.Source,
"target": r.Target,
"provider": r.Provider,
})
}

View File

@@ -71,18 +71,13 @@ func MergeFileSlices(fileId string, uploadPath string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("打开切片文件失败: %v", err) return "", fmt.Errorf("打开切片文件失败: %v", err)
} }
defer sf.Close() //nolint:errcheck
for { if _, err := io.CopyBuffer(destFile, sf, buffer); err != nil {
n, err := sf.Read(buffer) sf.Close() //nolint:errcheck
if err == io.EOF { return "", fmt.Errorf("合并切片文件失败: %v", err)
break }
} if err := sf.Close(); err != nil {
if err != nil { return "", fmt.Errorf("关闭切片文件失败: %v", err)
return "", fmt.Errorf("读取切片文件失败: %v", err)
}
if _, err := destFile.Write(buffer[:n]); err != nil {
return "", fmt.Errorf("写入合并文件失败: %v", err)
}
} }
} }
return mergeFilePath, nil return mergeFilePath, nil

View File

@@ -6,40 +6,36 @@ import (
"github.com/labstack/echo/v5" "github.com/labstack/echo/v5"
) )
type Option interface {
applyTo(*HTTPBaseResponse)
}
type HTTPBaseResponse struct { type HTTPBaseResponse struct {
code int code int
message string message string
data map[string]any data map[string]any
} }
type HTTPBaseResponseProps func(props *HTTPBaseResponse) error type HTTPBaseResponseProps func(props *HTTPBaseResponse)
type WithCode int func WithCode(data int) HTTPBaseResponseProps {
return func(props *HTTPBaseResponse) {
func (o WithCode) applyTo(props *HTTPBaseResponse) { props.code = data
props.code = int(o) }
} }
type WithMessage string func WithMessage(data string) HTTPBaseResponseProps {
return func(props *HTTPBaseResponse) {
func (o WithMessage) applyTo(props *HTTPBaseResponse) { props.message = data
props.message = string(o) }
} }
type WithData map[string]any func WithData(data map[string]any) HTTPBaseResponseProps {
return func(props *HTTPBaseResponse) {
func (o WithData) applyTo(props *HTTPBaseResponse) { props.data = data
props.data = o }
} }
func HTTPBaseHandler(c *echo.Context, options ...Option) error { func HTTPBaseHandler(c *echo.Context, options ...HTTPBaseResponseProps) error {
props := HTTPBaseResponse{code: http.StatusOK, message: "success", data: map[string]any{}} props := HTTPBaseResponse{code: http.StatusOK, message: "success", data: map[string]any{}}
for _, option := range options { for _, option := range options {
option.applyTo(&props) option(&props)
} }
return c.JSON(props.code, map[string]any{ return c.JSON(props.code, map[string]any{
@@ -50,9 +46,11 @@ func HTTPBaseHandler(c *echo.Context, options ...Option) error {
} }
func HTTPSuccessHandler(c *echo.Context, data map[string]any, options ...HTTPBaseResponseProps) error { func HTTPSuccessHandler(c *echo.Context, data map[string]any, options ...HTTPBaseResponseProps) error {
return HTTPBaseHandler(c, WithData(data)) options = append([]HTTPBaseResponseProps{WithData(data)}, options...)
return HTTPBaseHandler(c, options...)
} }
func HTTPErrorHandler(c *echo.Context, err error, options ...HTTPBaseResponseProps) error { func HTTPErrorHandler(c *echo.Context, err error, options ...HTTPBaseResponseProps) error {
return HTTPBaseHandler(c, WithMessage(err.Error()), WithCode(http.StatusBadRequest)) options = append([]HTTPBaseResponseProps{WithMessage(err.Error()), WithCode(http.StatusBadRequest)}, options...)
return HTTPBaseHandler(c, options...)
} }

View File

@@ -18,6 +18,15 @@ func main() {
} }
defer logger.Sync() //nolint:errcheck defer logger.Sync() //nolint:errcheck
zap.ReplaceGlobals(logger) zap.ReplaceGlobals(logger)
// redis
if err := utils.InitRedis(); err != nil {
logger.Fatal("redis init failed", zap.Error(err))
panic(err)
}
if err := utils.InitAsynq(); err != nil {
logger.Fatal("asynq init failed", zap.Error(err))
panic(err)
}
e := echo.New() e := echo.New()
for _, middleware := range middlewares { for _, middleware := range middlewares {

View File

@@ -2,12 +2,15 @@
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import getFileSize from '~/lib/getFileSize' import getFileSize from '~/lib/getFileSize'
import SparkMD5 from 'spark-md5'
import useMyAppConfig from '@/composables/useMyAppConfig' import useMyAppConfig from '@/composables/useMyAppConfig'
import { useFeatureMeta } from '@/composables/useFeatureMeta' import { useFeatureMeta } from '@/composables/useFeatureMeta'
import Progress from '~/components/ui/progress/Progress.vue' import Progress from '~/components/ui/progress/Progress.vue'
import renderI18n from '~/lib/renderI18n' import renderI18n from '~/lib/renderI18n'
import { I18nT } from 'vue-i18n' import { I18nT } from 'vue-i18n'
import { calcNativeHash } from '~/lib/calcFileHash'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@/components/ui/accordion'
import MarkdownRender from '@/components/MarkdownRender.vue'
const { locale } = useI18n() const { locale } = useI18n()
const appConfig = useMyAppConfig() const appConfig = useMyAppConfig()
@@ -34,9 +37,12 @@ const { data, isLoading } = useQuery({
}) })
const { t } = useI18n() const { t } = useI18n()
const genUserAvatar = (email: string) => { const { state: userAvatar } = useAsyncState(async () => {
return `https://www.gravatar.com/avatar/${SparkMD5.hash(email)}?d=retro` if (!data?.value?.email) return null
} const buffer = new TextEncoder().encode(data?.value?.email)
const hash = await calcNativeHash(buffer)
return `https://www.gravatar.com/avatar/${hash}?d=retro`
}, null)
</script> </script>
<template> <template>
@@ -88,7 +94,7 @@ const genUserAvatar = (email: string) => {
" "
> >
<Avatar class="size-10"> <Avatar class="size-10">
<AvatarImage v-if="!!data?.avatar || !!data?.email" :src="data?.avatar || genUserAvatar(data?.email as string)" /> <AvatarImage v-if="!!data?.avatar || !!data?.email" :src="data?.avatar || (userAvatar as string)" />
<AvatarFallback class="bg-black/10 font-bold"> <AvatarFallback class="bg-black/10 font-bold">
{{ data?.name?.charAt(0)?.toUpperCase() }} {{ data?.name?.charAt(0)?.toUpperCase() }}
</AvatarFallback> </AvatarFallback>

View File

@@ -3,6 +3,7 @@ import { cx } from 'class-variance-authority'
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { filesize } from 'filesize'
import { times } from 'lodash-es' import { times } from 'lodash-es'
import type { ChartConfig } from '@/components/ui/chart' import type { ChartConfig } from '@/components/ui/chart'
import { VisArea, VisAxis, VisLine, VisXYContainer } from '@unovis/vue' import { VisArea, VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
@@ -209,8 +210,15 @@ const currentChartData = computed((): AreaChartConfig => {
:key="currentChartTab" :key="currentChartTab"
:template=" :template="
componentToString(currentChartData.config, ChartTooltipContent, { componentToString(currentChartData.config, ChartTooltipContent, {
class: 'w-[14rem]',
labelFormatter: (d) => { labelFormatter: (d) => {
return dayjs(d).format('MMM D') return dayjs(d).format('MM-DD')
},
valueFormatter: (value, key) => {
if (key === 'file_size' && typeof value === 'number') {
return filesize(value)
}
return String(value)
}, },
}) })
" "

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button } from '@/components/ui/button'
const props = defineProps<{ const props = defineProps<{
title?: string title?: string
showBackButton?: boolean showBackButton?: boolean
@@ -21,7 +22,7 @@ const router = useRouter()
} }
" "
> >
<LucideHome /> <LucideHome class="size-4" />
</Button> </Button>
</div> </div>
<slot /> <slot />

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import asyncWait from '~/lib/asyncWait'
import { toast } from 'vue-sonner'
import { LucideCheck, LucideCopy } from '@lucide/vue'
const isCopy = ref(false)
const props = defineProps<{
value: string
}>()
const { copy } = useClipboard()
const { t } = useI18n()
</script>
<template>
<Button
variant="outline"
class="transition-all duration-300"
size="icon"
@click="
async () => {
await copy(props?.value)
isCopy = true
toast.success(t('common.copySuccess'))
await asyncWait(3000)
isCopy = false
}
"
>
<component :is="isCopy ? LucideCheck : LucideCopy" class="size-1/2" />
</Button>
</template>

View File

@@ -1,23 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { cx } from 'class-variance-authority' import { cx } from 'class-variance-authority'
import type { Locale } from '@intlify/core-base'
const props = defineProps<{ const props = defineProps<{
hide: () => void hide: () => void
}>() }>()
const { availableLocales, setLocale, locale: currentLocale, t } = useI18n() const { locales, setLocale, locale: currentLocale, t } = useI18n()
const localeMap = { const switchLocale = async (locale: Locale) => {
'zh-CN': '简体中文', await setLocale(locale)
en: 'English',
// 'ja': '日本語',
// 'ko': '한국어',
// 'fr': 'Français',
// 'de': 'Deutsch',
}
const switchLocale = async (locale: string) => {
await setLocale(locale as keyof typeof localeMap)
props.hide() props.hide()
} }
</script> </script>
@@ -26,12 +18,12 @@ const switchLocale = async (locale: string) => {
<div class="flex flex-col gap-1 py-2"> <div class="flex flex-col gap-1 py-2">
<div class="text-xl font-bold mb-3">{{ t('i18n.switchLocale') }}</div> <div class="text-xl font-bold mb-3">{{ t('i18n.switchLocale') }}</div>
<div <div
v-for="locale in availableLocales" v-for="locale in locales"
:key="locale" :key="locale.code"
:class="cx('rounded-md hover:bg-black/10 p-2 cursor-pointer', currentLocale === locale && 'bg-black/10 font-bold')" :class="cx('rounded-md hover:bg-black/10 p-2 cursor-pointer', currentLocale === locale.code && 'bg-black/10 font-bold')"
@click="() => switchLocale(locale)" @click="() => switchLocale(locale.code)"
> >
{{ localeMap?.[locale as keyof typeof localeMap] }} {{ locale.name }}
</div> </div>
</div> </div>
</template> </template>

View File

@@ -20,6 +20,15 @@ const actionHandlers: Partial<Record<FeatureKey, ActionHandler>> = {
'text-share': { 'text-share': {
onClick: () => showDrawer({ render: ({ hide }) => h(TextShareHandle, { ...props, hide }) }), onClick: () => showDrawer({ render: ({ hide }) => h(TextShareHandle, { ...props, hide }) }),
}, },
// 'text-translate': {
// onClick: () =>
// props.onTextHandle({
// type: 'text-translate',
// config: {
// source: 'auto',
// },
// }),
// },
// 'text-image-generate': { // 'text-image-generate': {
// label: '生成配图', icon: LucideImage, className: 'bg-red-300', // label: '生成配图', icon: LucideImage, className: 'bg-red-300',
// onClick: () => { console.log('复制链接') } // onClick: () => { console.log('复制链接') }

View File

@@ -3,7 +3,7 @@ import FileUpload from '@/components/FileUpload.vue'
import { cx } from 'class-variance-authority' import { cx } from 'class-variance-authority'
import type { RuleExpression } from 'vee-validate' import type { RuleExpression } from 'vee-validate'
import Button from '../ui/button/Button.vue' import Button from '../ui/button/Button.vue'
import { PlusIcon } from 'lucide-vue-next' import { PlusIcon } from '@lucide/vue'
import { isEmpty } from 'lodash-es' import { isEmpty } from 'lodash-es'
const props = defineProps<{ const props = defineProps<{

View File

@@ -1,16 +1,20 @@
<template>
<Field :name="props.name" v-slot="{ field }">
<div class="flex flex-col gap-2">
<Label v-if="props.label">{{ props.label }}</Label>
<Input v-bind="{ ...field, ...$attrs }" />
</div>
</Field>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import type { RuleExpression } from 'vee-validate'
const props = defineProps<{ const props = defineProps<{
name: string name: string
label?: string label?: string
rules?: RuleExpression<string>
}>() }>()
const { value, errorMessage } = useField<string>(props.name, props.rules)
</script> </script>
<template>
<div class="flex flex-col gap-2">
<Label v-if="props.label">{{ props.label }}</Label>
<Input v-model="value" :aria-invalid="!!errorMessage || undefined" v-bind="$attrs" />
</div>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import { Label } from '@/components/ui/label'
import type { RuleExpression } from 'vee-validate'
const props = defineProps<{
name: string
label?: string
rules?: RuleExpression<string[]>
}>()
const { t } = useI18n()
const { value, setValue, errorMessage } = useField<string[]>(props.name, props?.rules)
</script>
<template>
<div class="flex flex-col gap-2">
<Label v-if="label">{{ label }}</Label>
<div v-for="(item, index) in value" class="flex flex-row gap-2 items-center">
<Input
:model-value="item"
@update:model-value="(v: string | number) => setValue(value.map((o, i) => (i === index ? String(v) : o)))"
:aria-invalid="!!errorMessage || undefined"
v-bind="$attrs"
/>
<Button
variant="ghost"
size="icon"
class="bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white"
@click="setValue(value.filter((_, i) => i !== index))"
>
<LucideTrash class="size-4" />
</Button>
</div>
<Button class="self-start" size="sm" @click="() => setValue([...(value || []), ''])">
<LucidePlus class="size-4" />
{{ t('common.add') }}
</Button>
</div>
</template>

View File

@@ -0,0 +1,102 @@
<script setup lang="ts">
import { Label } from '@/components/ui/label'
import { AutocompleteAnchor, AutocompleteContent, AutocompleteInput, AutocompleteItem, AutocompleteRoot, AutocompleteViewport } from 'reka-ui'
import type { Component } from 'vue'
import InputField from '../Field/InputField.vue'
type KvInputValueComponentConfig = [(key: string) => boolean, Component]
type KvInputConfig = {
key?: {
placeholder?: string
enum?: string[]
}
value?: {
placeholder?: string
component?: KvInputValueComponentConfig[]
default?: Component
}
}
const defaultConfig = {
key: {},
value: {
default: InputField,
},
} satisfies Required<KvInputConfig>
const props = defineProps<{
name: string
label?: string
config?: KvInputConfig
}>()
const { t } = useI18n()
const config = computed(() => {
return {
key: { ...defaultConfig.key, ...(props.config?.key ?? {}) },
value: { ...defaultConfig.value, ...(props.config?.value ?? {}) },
}
})
const { value, setValue } = useField<[string, string][]>(props.name)
const updateKey = (index: number, nextKey: string | number) => {
const next = [...(value.value ?? [])]
next[index] = [String(nextKey), next[index]?.[1] ?? '']
setValue(next)
}
</script>
<template>
<div class="flex flex-col gap-2">
<Label v-if="label">{{ label }}</Label>
<div v-for="([key, _], index) in value" class="flex flex-row gap-2 items-center">
<AutocompleteRoot class="basis-40 relative" :model-value="String(key ?? '')" @update:model-value="(v) => updateKey(index, v)">
<AutocompleteAnchor>
<AutocompleteInput
class="w-full placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
:placeholder="config.key.placeholder"
>
</AutocompleteInput>
</AutocompleteAnchor>
<AutocompleteContent
v-if="config.key?.enum"
class="bg-popover border rounded-md shadow-md z-50 w-(--reka-autocomplete-trigger-width) absolute inset-x-0"
>
<AutocompleteViewport class="p-1">
<AutocompleteItem
v-for="opt in config.key?.enum"
:key="opt"
:value="opt"
class="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-highlighted:bg-accent data-highlighted:text-accent-foreground"
>
{{ opt }}
</AutocompleteItem>
</AutocompleteViewport>
</AutocompleteContent>
</AutocompleteRoot>
<div class="flex-1">
<component
:is="config.value.component?.find(([isMatchCom]) => isMatchCom(key))?.[1] ?? config.value.default"
:name="`${props.name}[${index}][1]`"
:placeholder="config.value.placeholder"
class="w-full"
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white"
@click="() => setValue(value?.filter((_, i) => i !== index))"
>
<LucideTrash class="size-4" />
</Button>
</div>
<Button type="button" class="self-start" size="sm" @click="() => setValue([...(value ?? []), ['', '']])">
<LucidePlus class="size-4" />
{{ t('common.add') }}
</Button>
</div>
</template>

View File

@@ -1,13 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select'
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { RuleExpression } from 'vee-validate' import type { RuleExpression } from 'vee-validate'
type SelectValue = string | number type SelectValue = string | number
const props = defineProps<{ const props = defineProps<{
@@ -19,13 +11,14 @@ const props = defineProps<{
label?: string label?: string
value: SelectValue value: SelectValue
}[] }[]
class?: string
}>() }>()
const { value } = useField<SelectValue>(props.name, props?.rules) const { value } = useField<SelectValue>(props.name, props?.rules)
</script> </script>
<template> <template>
<Select v-model="value"> <Select v-model="value">
<SelectTrigger> <SelectTrigger :class="class">
<SelectValue :placeholder="placeholder" /> <SelectValue :placeholder="placeholder" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -37,4 +30,4 @@ const { value } = useField<SelectValue>(props.name, props?.rules)
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>
</template> </template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
const props = withDefaults(
defineProps<{
name: string
label?: string
placeholder?: string
rows?: number
}>(),
{
rows: 3,
}
)
const { value } = useField<string>(props.name)
</script>
<template>
<div class="flex flex-col gap-2">
<Label v-if="label">{{ label }}</Label>
<Textarea v-model="value" :placeholder="placeholder" :rows="rows" v-bind="$attrs" />
</div>
</template>

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import { cx } from 'class-variance-authority'
export type filePreview = {
type: string
name: string
size: number
}
import { LucideFileAudio, LucideFileVideo, LucideFile, LucideFileCode, LucideFileArchive, LucideFileText } from 'lucide-vue-next'
const props = withDefaults(
defineProps<{
file: File | filePreview
class?: string
size?: 'sm' | 'md' | 'lg'
}>(),
{
size: 'md',
}
)
const imageUrl = computed(() => {
if (props?.file?.type?.startsWith('image/') && props?.file instanceof File) {
return URL.createObjectURL(props?.file)
}
return null
})
onUnmounted(() => {
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
}
})
const fileIcon = computed(() => {
const [baseType, type] = props?.file?.type?.split('/')
if (baseType === 'video') {
return LucideFileVideo
}
if (baseType === 'audio') {
return LucideFileAudio
}
if (baseType === 'text' || ['json', 'ld+json', 'html']?.includes(type ?? '')) {
return LucideFileCode
}
if (
[
'pdf',
'msword',
'vnd.openxmlformats-officedocument.wordprocessingml.document',
'vnd.ms-excel',
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'vnd.ms-powerpoint',
'vnd.openxmlformats-officedocument.presentationml.presentation',
].includes(type ?? '')
) {
return LucideFileText
}
if (['zip', 'vnd.rar', 'x-tar', 'gz', 'bz2', 'x-7z-compressed'].includes(type ?? '')) {
return LucideFileArchive
}
return LucideFile
})
</script>
<template>
<div v-if="!!imageUrl" :class="cx('flex overflow-hidden', size === 'sm' && 'max-w-20 max-h-16', size === 'md' && 'max-w-30 max-h-20')">
<img :src="imageUrl" class="block max-w-full max-h-full object-contain border border-black/20 rounded" />
</div>
<div
v-if="!imageUrl"
:class="
cx(
'flex justify-center items-center bg-white/80',
size === 'sm' && 'size-7 rounded-md',
size === 'md' && 'size-16 rounded-xl',
props?.class
)
"
>
<component :is="fileIcon" class="size-[62.5%]" />
</div>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { LucideFileAudio, LucideFileVideo, LucideFile, LucideFileCode, LucideFileArchive, LucideFileText } from '@lucide/vue'
import type { filePreview } from './Index.vue'
const props = defineProps<{
file: File | filePreview
}>()
const fileIcon = computed(() => {
const [baseType, type] = props?.file?.type?.split('/')
// if (baseType === 'video') {
// return LucideFileVideo
// }
if (baseType === 'audio') {
return LucideFileAudio
}
if (baseType === 'text' || ['json', 'ld+json', 'html']?.includes(type ?? '')) {
return LucideFileCode
}
if (
[
'pdf',
'msword',
'vnd.openxmlformats-officedocument.wordprocessingml.document',
'vnd.ms-excel',
'vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'vnd.ms-powerpoint',
'vnd.openxmlformats-officedocument.presentationml.presentation',
].includes(type ?? '')
) {
return LucideFileText
}
if (['zip', 'vnd.rar', 'x-tar', 'gz', 'bz2', 'x-7z-compressed'].includes(type ?? '')) {
return LucideFileArchive
}
return LucideFile
})
</script>
<template>
<component :is="fileIcon" />
</template>

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { isHeic, heicTo } from 'heic-to'
const props = defineProps<{
file: File
}>()
const { state: imageUrl } = useAsyncState(async () => {
let blob: Blob = props?.file
if (await isHeic(props?.file)) {
blob = await heicTo({
blob: props?.file,
type: 'image/jpeg',
quality: 1,
})
}
return URL.createObjectURL(blob)
}, null)
onUnmounted(() => {
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
}
})
</script>
<template>
<img v-if="!!imageUrl" :src="imageUrl" />
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { cx } from 'class-variance-authority'
import FileIcon from './File.vue'
import ImageIcon from './Image.vue'
import VideoIcon from './Video.vue'
import { fileTypeFromBuffer } from 'file-type'
export type filePreview = {
type: string
name: string
size: number
}
const props = withDefaults(
defineProps<{
file: File | filePreview
class?: string
size?: 'sm' | 'md' | 'lg'
}>(),
{
size: 'md',
}
)
const isFile = computed(() => props?.file instanceof File)
const { state: fileType } = useAsyncState(async () => {
if (!isFile.value) {
return null
}
if (!!props?.file?.type) {
return props?.file?.type
}
const { mime } = (await fileTypeFromBuffer(await (props?.file as File)?.arrayBuffer())) || {}
return mime
}, null)
const isImage = computed(() => isFile.value && fileType.value?.startsWith('image/'))
const isVideo = computed(() => isFile.value && fileType.value?.startsWith('video/'))
</script>
<template>
<div v-if="isImage || isVideo" :class="cx('flex overflow-hidden', size === 'sm' && 'max-w-20 max-h-16', size === 'md' && 'max-w-30 max-h-20')">
<component
:is="isImage ? ImageIcon : VideoIcon"
:file="props?.file as File"
class="block max-w-full max-h-full object-contain border border-black/20 rounded"
/>
</div>
<div
v-else
:class="
cx(
'flex justify-center items-center bg-white/80',
size === 'sm' && 'size-7 rounded-md',
size === 'md' && 'size-16 rounded-xl',
props?.class
)
"
>
<component :is="FileIcon" :file="props?.file" class="size-[62.5%]" />
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import getVideoFileThumbnail from '@/lib/getVideoFileThumbnail'
const props = defineProps<{
file: File
}>()
const { state: thumbnailUrl } = useAsyncState(async () => {
if (props.file.type.startsWith('video/')) {
return await getVideoFileThumbnail(props.file)
}
return null
}, null)
onUnmounted(() => {
if (!!thumbnailUrl.value) {
URL.revokeObjectURL(thumbnailUrl.value)
}
})
</script>
<template>
<div v-if="thumbnailUrl" class="relative grayscale-50 overflow-hidden">
<img :src="thumbnailUrl" class="object-contain block max-w-full max-h-full" />
<LucidePlay class="size-[40%] absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-white" />
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import getFileSize from '~/lib/getFileSize' import getFileSize from '~/lib/getFileSize'
import type { filePreview } from './FileIcon.vue' import type { filePreview } from './FileIcon/Index.vue'
const props = defineProps<{ const props = defineProps<{
value: File | filePreview value: File | filePreview
}>() }>()

View File

@@ -1,35 +1,44 @@
<script setup lang="ts"> <script setup lang="ts">
import { Drawer, DrawerContent } from '@/components/ui/drawer' import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { createVNode } from 'vue' import type { VNode } from 'vue'
import useStore from '@/composables/useStore' import useStore from '@/composables/useStore'
import { isFunction } from 'lodash-es'
type DrawerOnclose<T = unknown> = (data?: T) => Promise<void>
type DrawerRender<T = unknown> = VNode | ((props: { hide: DrawerOnclose<T> }) => VNode)
export type DrawerItem<T = unknown> = {
render?: DrawerRender<T>
onClose: DrawerOnclose<T>
key: string
visible: boolean
}
const store = useStore() const store = useStore()
const currentDrawer = computed(() => store.drawer?.[store.drawer?.length - 1])
const render = computed(() => currentDrawer?.value?.render) const Children = ({ drawer }: { drawer: DrawerItem }) => {
const hide = computed(() => currentDrawer?.value?.onClose) if (!drawer.render) {
const Children = () => return null
render.value }
? createVNode(render.value, { return isFunction(drawer.render) ? drawer.render({ hide: drawer.onClose }) : drawer.render
hide: hide?.value, }
})
: null
</script> </script>
<template> <template>
<Drawer <Drawer
:open="!!store.drawer?.[store.drawer?.length - 1]" v-for="item in store.drawer"
:key="item.key"
:open="item.visible"
@update:open=" @update:open="
(open) => { (open) => {
if (!open && store?.drawer?.length && hide) { if (!open) {
hide() item.onClose()
} }
} }
" "
> >
<DrawerContent> <DrawerContent>
<div class="mx-auto min-w-lg max-w-[80vw] pb-10 px-3"> <div class="mx-auto min-w-lg max-w-[80vw] pb-10 px-3">
<Children /> <Children :drawer="item" />
</div> </div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>

View File

@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { LucideSquare, LucideInfo, LucideFolders, LucideArrowUpFromLine, LucideCircleX, LucideCheckCircle, LucideLoaderCircle } from 'lucide-vue-next' import { LucideSquare, LucideInfo, LucideFolders, LucideArrowUpFromLine, LucideCircleX, LucideCheckCircle, LucideLoaderCircle } from '@lucide/vue'
import Button from '@/components/ui/button/Button.vue' import Button from '@/components/ui/button/Button.vue'
import getFileSize from '~/lib/getFileSize' import getFileSize from '~/lib/getFileSize'
import { cx } from 'class-variance-authority' import { cx } from 'class-variance-authority'
import asyncWorker from '@/lib/asyncWorker' import asyncWorker from '@/lib/asyncWorker'
import calcFileHashWorker from '@/lib/calcFileHashWorker?worker' import calcFileHashWorker from '@/lib/calcFileHashWorker?worker'
import { detectSupportedEngines } from '@/lib/calcFileHash'
import { clamp, get, isEmpty, isNumber, sample, shuffle, throttle, times } from 'lodash-es' import { clamp, get, isEmpty, isNumber, sample, shuffle, throttle, times } from 'lodash-es'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -137,11 +138,20 @@ watchEffect(async () => {
} }
}) })
const LARGE_FILE_THRESHOLD = 500 * 1024 * 1024 // 500 MB
const handleHash = async (fileId: string) => { const handleHash = async (fileId: string) => {
const uploadfile = uploadfiles.value.find((item) => item.fileId === fileId) const uploadfile = uploadfiles.value.find((item) => item.fileId === fileId)
if (!uploadfile?.file) return if (!uploadfile?.file) return
uploadfile.procressType = 'hash' uploadfile.procressType = 'hash'
const res = await asyncWorker(calcFileHashWorker, { data: { file: uploadfile.file } }) const supportedEngines = detectSupportedEngines()
if (supportedEngines.length === 0) {
throw new Error(t('page.progress.file.hashEngineNotFound'))
}
const preferredEngine = uploadfile.file.size >= LARGE_FILE_THRESHOLD ? 'wasm' : 'native'
const res = await asyncWorker(calcFileHashWorker, {
data: { file: uploadfile.file, engine: supportedEngines.includes(preferredEngine) ? preferredEngine : supportedEngines?.[0] },
})
const { hash } = res?.data || {} const { hash } = res?.data || {}
uploadfile.hash = hash uploadfile.hash = hash
} }

View File

@@ -44,7 +44,7 @@ const handleTextShare = ({ type, config }: { type: string; config: any }) => {
} }
" "
> >
<LucideX /> <LucideX class="size-4" />
</Button> </Button>
</div> </div>
<div class="flex flex-row gap-3"> <div class="flex flex-row gap-3">

View File

@@ -14,7 +14,7 @@ const renderHtml = computed(() => {
<div <div
:class=" :class="
cx( cx(
'prose prose-sm [&>*]:outline-none prose-p:my-1 prose-headings:my-2 prose-pre:mb-0 prose-blockquote:border-black/50 selection:bg-primary/20 break-all', 'prose prose-sm *:outline-none prose-p:my-1 prose-headings:my-2 prose-pre:mb-0 prose-blockquote:border-black/50 selection:bg-primary/20 break-all',
props?.class props?.class
) )
" "

View File

@@ -22,9 +22,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { cx } from 'class-variance-authority' import { cx } from 'class-variance-authority'
import { LucideClipboardType, LucidePaperclip } from '#components'
import { motion } from 'motion-v' import { motion } from 'motion-v'
import { LucideGlobe } from 'lucide-vue-next' import { LucideGlobe, LucideClipboardType, LucidePaperclip } from '@lucide/vue'
import showDrawer from '@/lib/showDrawer' import showDrawer from '@/lib/showDrawer'
import I18nSwitchDrawer from './Drawer/I18nSwitchDrawer.vue' import I18nSwitchDrawer from './Drawer/I18nSwitchDrawer.vue'
const { t } = useI18n() const { t } = useI18n()

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import SwitchField from '../Field/SwitchField.vue'
import InputField from '../Field/InputField.vue' import InputField from '../Field/InputField.vue'
import SelectField from '../Field/SelectField.vue' import SelectField from '../Field/SelectField.vue'
import SwitchField from '../Field/SwitchField.vue'
import FormButton from '../Field/FormButton.vue' import FormButton from '../Field/FormButton.vue'
import NotifyConfigField from './NotifyConfigField.vue'
import type { FileShareHandleProps } from './types' import type { FileShareHandleProps } from './types'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
@@ -14,76 +15,70 @@ const props = defineProps<{
<template> <template>
<VeeForm v-slot="{ values, setFieldValue }" :initialValues="{ download_nums: 1, expire_time: 1440 }"> <VeeForm v-slot="{ values, setFieldValue }" :initialValues="{ download_nums: 1, expire_time: 1440 }">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3 max-h-[75vh]">
<h2 class="text-lg font-bold">{{ t('page.shareOptions.file.title') }}</h2> <h2 class="text-lg font-bold">{{ t('page.shareOptions.file.title') }}</h2>
<div class="flex flex-row items-center gap-2 text-sm"> <div class="flex flex-col gap-3 flex-1 overflow-y-auto">
<SelectField <div class="flex flex-row items-center gap-2 text-sm">
name="download_nums" <SelectField
:label="t('page.shareOptions.file.downloadNums')" name="download_nums"
:options="[ :label="t('page.shareOptions.file.downloadNums')"
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [1]), value: 1 }, :options="[
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [2]), value: 2 }, { label: t('page.shareOptions.file.downloadOptions.xdownload', [1]), value: 1 },
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [3]), value: 3 }, { label: t('page.shareOptions.file.downloadOptions.xdownload', [2]), value: 2 },
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [5]), value: 5 }, { label: t('page.shareOptions.file.downloadOptions.xdownload', [3]), value: 3 },
{ label: t('page.shareOptions.file.downloadOptions.xdownload', [10]), value: 10 }, { label: t('page.shareOptions.file.downloadOptions.xdownload', [5]), value: 5 },
]" { label: t('page.shareOptions.file.downloadOptions.xdownload', [10]), value: 10 },
/> ]"
{{ t('page.shareOptions.file.or') }}
<SelectField
name="expire_time"
:label="t('page.shareOptions.file.expireTime')"
:options="[
{ label: t('page.shareOptions.file.expireOptions.5min'), value: 5 },
{ label: t('page.shareOptions.file.expireOptions.1hour'), value: 60 },
{ label: t('page.shareOptions.file.expireOptions.1day'), value: 1440 },
{ label: t('page.shareOptions.file.expireOptions.3days'), value: 4320 },
]"
/>
{{ t('page.shareOptions.file.expireAfter') }}
</div>
<div class="flex flex-col gap-1">
<div class="flex flex-row gap-3 min-h-9">
<SwitchField
name="has_pickup_code"
:label="t('page.shareOptions.file.pickupCode')"
:rules="
(value: boolean) => {
if (!!value) {
setFieldValue('has_password', false)
}
return true
}
"
/> />
{{ t('page.shareOptions.file.or') }}
<SelectField
name="expire_time"
:label="t('page.shareOptions.file.expireTime')"
:options="[
{ label: t('page.shareOptions.file.expireOptions.5min'), value: 5 },
{ label: t('page.shareOptions.file.expireOptions.1hour'), value: 60 },
{ label: t('page.shareOptions.file.expireOptions.1day'), value: 1440 },
{ label: t('page.shareOptions.file.expireOptions.3days'), value: 4320 },
]"
/>
{{ t('page.shareOptions.file.expireAfter') }}
</div> </div>
<div class="flex flex-row gap-3 min-h-9"> <div class="flex flex-col gap-1">
<SwitchField <div class="flex flex-row gap-3 min-h-9">
name="has_password" <SwitchField
:label="t('page.shareOptions.file.passwordProtection')" name="has_pickup_code"
:rules=" :label="t('page.shareOptions.file.pickupCode')"
(value: boolean) => { :rules="
if (!!value) { (value: boolean) => {
setFieldValue('has_pickup_code', false) if (!!value) {
setFieldValue('has_password', false)
}
return true
} }
return true "
} />
" </div>
/> <div class="flex flex-row gap-3 min-h-9">
<InputField <SwitchField
v-if="!!values.has_password" name="has_password"
name="password" :label="t('page.shareOptions.file.passwordProtection')"
:placeholder="t('page.shareOptions.file.passwordPlaceholder')" :rules="
rules="required" (value: boolean) => {
/> if (!!value) {
</div> setFieldValue('has_pickup_code', false)
<div class="flex flex-row gap-3 min-h-9"> }
<SwitchField name="has_notify" :label="t('page.shareOptions.file.downloadNotify')" /> return true
<InputField }
v-if="!!values.has_notify" "
name="notify_email" />
:placeholder="t('page.shareOptions.file.emailPlaceholder')" <InputField
rules="required" v-if="!!values.has_password"
/> name="password"
:placeholder="t('page.shareOptions.file.passwordPlaceholder')"
rules="required"
/>
</div>
<NotifyConfigField :switchLabel="t('page.shareOptions.file.downloadNotify')" />
</div> </div>
</div> </div>
<FormButton <FormButton

View File

@@ -0,0 +1,178 @@
<script setup lang="ts">
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import { useFormContext } from 'vee-validate'
import SelectField from '../Field/SelectField.vue'
import SwitchField from '../Field/SwitchField.vue'
import InputGroupField from '../Field/InputGroupField.vue'
import InputField from '../Field/InputField.vue'
import KvInputField from '../Field/KvInputGroupField.vue'
import TextareaField from '../Field/TextareaField.vue'
import { parseCurl } from 'sweet-curl-parser'
interface WebhookItem {
id: string
url: string
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
headers: [string, string][]
body?: string
}
const { t } = useI18n()
const { values, setFieldValue } = useFormContext()
const expandedAdvanced = ref<Set<number>>(new Set())
</script>
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-row gap-3 min-h-9 items-center">
<SwitchField name="has_notify" :label="t('page.shareOptions.file.downloadNotify')" />
<SelectField
v-if="values.has_notify"
name="notify_types"
:placeholder="t('page.shareOptions.notify.notifyVia')"
multiple
:options="[
{ label: t('page.shareOptions.notify.email'), value: 'email' },
{ label: t('page.shareOptions.notify.webhook'), value: 'webhook' },
]"
/>
</div>
<div v-if="!!values.has_notify && values.notify_types?.includes('email')">
<InputGroupField
name="notify_emails"
:placeholder="t('page.shareOptions.notify.emailPlaceholder')"
:label="t('page.shareOptions.notify.email')"
rules="email"
/>
</div>
<div v-if="!!values.has_notify && values.notify_types?.includes('webhook')" class="flex flex-col gap-2">
<Label>Webhook</Label>
<div v-for="(_, index) in (values.notify_webhooks as WebhookItem[]) || []" :key="index" class="flex flex-col gap-2 border rounded-md p-3">
<div class="flex flex-row gap-2 items-end">
<div class="flex flex-col gap-2">
<Label>{{ t('page.shareOptions.notify.webhookMethod') }}</Label>
<SelectField
:name="`notify_webhooks.${index}.method`"
:label="t('page.shareOptions.notify.webhookMethod')"
default-value="POST"
:options="[
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'DELETE', value: 'DELETE' },
]"
class="w-28"
/>
</div>
<div class="flex-1">
<InputField
:name="`notify_webhooks.${index}.url`"
:label="t('page.shareOptions.notify.webhookUrl')"
rules="required|url"
@blur="
(e: FocusEvent) => {
const input = (e?.target as HTMLInputElement)?.value
if (!input.startsWith('curl ')) return
try {
const { success, data } = parseCurl(input) || {}
if (!success) return
const { url, method, headers, body } = data || {}
setFieldValue(`notify_webhooks.${index}.url`, url?.fullUrl)
setFieldValue(`notify_webhooks.${index}.method`, method?.toUpperCase())
setFieldValue(
`notify_webhooks.${index}.headers`,
headers?.map((h: any) => [h.name, h.value])
)
if (body) setFieldValue(`notify_webhooks.${index}.body`, body)
expandedAdvanced = new Set([...expandedAdvanced, index])
} catch {}
}
"
/>
</div>
<Button
type="button"
variant="outline"
size="icon"
@click="
expandedAdvanced = new Set(
expandedAdvanced.has(index)
? [...expandedAdvanced].filter((expandedIndex) => expandedIndex !== index)
: [...expandedAdvanced, index]
)
"
>
<LucideSettings class="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
class="bg-red-500/10 text-red-500 hover:bg-red-500 hover:text-white"
@click="
() => {
setFieldValue(
'notify_webhooks',
((values.notify_webhooks as WebhookItem[]) || []).filter((_, itemIndex) => itemIndex !== index)
)
expandedAdvanced = new Set(
[...expandedAdvanced]
.filter((expandedIndex) => expandedIndex !== index)
.map((expandedIndex) => (expandedIndex > index ? expandedIndex - 1 : expandedIndex))
)
}
"
>
<LucideTrash class="size-4" />
</Button>
</div>
<div v-show="expandedAdvanced.has(index)" class="flex flex-col gap-3 rounded-md border p-3">
<KvInputField
:name="`notify_webhooks.${index}.headers`"
:label="t('page.shareOptions.notify.webhookHeaders')"
:config="{
key: {
placeholder: t('page.shareOptions.notify.webhookHeaderKey'),
enum: ['Content-Type', 'User-Agent', 'Authorization', 'Accept', 'Content-Length'],
},
value: {
placeholder: t('page.shareOptions.notify.webhookHeaderValue'),
component: [
[
(key: string) => key === 'Content-Type',
({ ...props }) =>
h(SelectField, { ...props, options: [{ value: 'text/plain' }, { value: 'application/json' }] }),
],
],
},
}"
/>
<TextareaField
:name="`notify_webhooks.${index}.body`"
:label="t('page.shareOptions.notify.webhookBody')"
:rows="4"
placeholder='{"key": "value"}'
/>
</div>
</div>
<div class="flex justify-start">
<Button
type="button"
size="sm"
@click="
setFieldValue('notify_webhooks', [
...((values.notify_webhooks as WebhookItem[]) || []),
{ url: '', method: 'POST', headers: [], body: '' },
])
"
>
<LucidePlus class="size-4" />
{{ t('common.add') }}
</Button>
</div>
</div>
</div>
</template>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import SwitchField from '../Field/SwitchField.vue'
import InputField from '../Field/InputField.vue' import InputField from '../Field/InputField.vue'
import SelectField from '../Field/SelectField.vue' import SelectField from '../Field/SelectField.vue'
import SwitchField from '../Field/SwitchField.vue'
import FormButton from '../Field/FormButton.vue' import FormButton from '../Field/FormButton.vue'
import NotifyConfigField from './NotifyConfigField.vue'
import type { TextShareHandleProps } from './types' import type { TextShareHandleProps } from './types'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
@@ -14,76 +15,70 @@ const props = defineProps<{
<template> <template>
<VeeForm v-slot="{ values, setFieldValue }" :initialValues="{ download_nums: 1, expire_time: 1440 }"> <VeeForm v-slot="{ values, setFieldValue }" :initialValues="{ download_nums: 1, expire_time: 1440 }">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3 max-h-[75vh]">
<h2 class="text-lg font-bold">{{ t('page.shareOptions.text.title') }}</h2> <h2 class="text-lg font-bold">{{ t('page.shareOptions.text.title') }}</h2>
<div class="flex flex-row items-center gap-2 text-sm"> <div class="flex flex-col gap-3 flex-1 overflow-y-auto">
<SelectField <div class="flex flex-row items-center gap-2 text-sm">
name="download_nums" <SelectField
:label="t('page.shareOptions.text.viewNums')" name="download_nums"
:options="[ :label="t('page.shareOptions.text.viewNums')"
{ label: t('page.shareOptions.text.viewOptions.xview', [1]), value: 1 }, :options="[
{ label: t('page.shareOptions.text.viewOptions.xview', [2]), value: 2 }, { label: t('page.shareOptions.text.viewOptions.xview', [1]), value: 1 },
{ label: t('page.shareOptions.text.viewOptions.xview', [3]), value: 3 }, { label: t('page.shareOptions.text.viewOptions.xview', [2]), value: 2 },
{ label: t('page.shareOptions.text.viewOptions.xview', [5]), value: 5 }, { label: t('page.shareOptions.text.viewOptions.xview', [3]), value: 3 },
{ label: t('page.shareOptions.text.viewOptions.xview', [10]), value: 10 }, { label: t('page.shareOptions.text.viewOptions.xview', [5]), value: 5 },
]" { label: t('page.shareOptions.text.viewOptions.xview', [10]), value: 10 },
/> ]"
{{ t('page.shareOptions.text.or') }}
<SelectField
name="expire_time"
:label="t('page.shareOptions.text.expireTime')"
:options="[
{ label: t('page.shareOptions.text.expireOptions.5min'), value: 5 },
{ label: t('page.shareOptions.text.expireOptions.1hour'), value: 60 },
{ label: t('page.shareOptions.text.expireOptions.1day'), value: 1440 },
{ label: t('page.shareOptions.text.expireOptions.3days'), value: 4320 },
]"
/>
{{ t('page.shareOptions.text.expireAfter') }}
</div>
<div class="flex flex-col gap-1">
<div class="flex flex-row gap-3 min-h-9">
<SwitchField
name="has_pickup_code"
:label="t('page.shareOptions.text.pickupCode')"
:rules="
(value: boolean) => {
if (!!value) {
setFieldValue('has_password', false)
}
return true
}
"
/> />
{{ t('page.shareOptions.text.or') }}
<SelectField
name="expire_time"
:label="t('page.shareOptions.text.expireTime')"
:options="[
{ label: t('page.shareOptions.text.expireOptions.5min'), value: 5 },
{ label: t('page.shareOptions.text.expireOptions.1hour'), value: 60 },
{ label: t('page.shareOptions.text.expireOptions.1day'), value: 1440 },
{ label: t('page.shareOptions.text.expireOptions.3days'), value: 4320 },
]"
/>
{{ t('page.shareOptions.text.expireAfter') }}
</div> </div>
<div class="flex flex-row gap-3 min-h-9"> <div class="flex flex-col gap-1">
<SwitchField <div class="flex flex-row gap-3 min-h-9">
name="has_password" <SwitchField
:label="t('page.shareOptions.text.passwordProtection')" name="has_pickup_code"
:rules=" :label="t('page.shareOptions.text.pickupCode')"
(value: boolean) => { :rules="
if (!!value) { (value: boolean) => {
setFieldValue('has_pickup_code', false) if (!!value) {
setFieldValue('has_password', false)
}
return true
} }
return true "
} />
" </div>
/> <div class="flex flex-row gap-3 min-h-9">
<InputField <SwitchField
v-if="!!values.has_password" name="has_password"
name="password" :label="t('page.shareOptions.text.passwordProtection')"
:placeholder="t('page.shareOptions.text.passwordPlaceholder')" :rules="
rules="required" (value: boolean) => {
/> if (!!value) {
</div> setFieldValue('has_pickup_code', false)
<div class="flex flex-row gap-3 min-h-9"> }
<SwitchField name="has_notify" :label="t('page.shareOptions.text.readNotify')" /> return true
<InputField }
v-if="!!values.has_notify" "
name="notify_email" />
:placeholder="t('page.shareOptions.text.emailPlaceholder')" <InputField
rules="required" v-if="!!values.has_password"
/> name="password"
:placeholder="t('page.shareOptions.text.passwordPlaceholder')"
rules="required"
/>
</div>
<NotifyConfigField :switchLabel="t('page.shareOptions.text.readNotify')" />
</div> </div>
</div> </div>
<FormButton <FormButton

View File

@@ -1,5 +1,5 @@
export type FileHandleKey = 'file-share' | 'file-image-compress' | 'file-image-convert' export type FileHandleKey = 'file-share' | 'file-image-compress' | 'file-image-convert'
export type FileShareHandleProps = { type: FileHandleKey; config: Record<string, any> } export type FileShareHandleProps = { type: FileHandleKey; config: Record<string, any> }
export type TextHandleKey = 'text-share' export type TextHandleKey = 'text-share' | 'text-translate'
export type TextShareHandleProps = { type: TextHandleKey; config: Record<string, any> } export type TextShareHandleProps = { type: TextHandleKey; config: Record<string, any> }

View File

@@ -2,8 +2,7 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import FilePreviewView from '@/components/FilePreviewView.vue' import FilePreviewView from '@/components/FilePreviewView.vue'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useClipboard, useShare } from '@vueuse/core' import { useShare } from '@vueuse/core'
import { toast } from 'vue-sonner'
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import useMyAppShare from '@/composables/useMyAppShare' import useMyAppShare from '@/composables/useMyAppShare'
import useMyAppConfig from '@/composables/useMyAppConfig' import useMyAppConfig from '@/composables/useMyAppConfig'
@@ -22,6 +21,7 @@ const { t } = useI18n()
const { createFileShare } = useMyAppShare() const { createFileShare } = useMyAppShare()
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['create-share', ...props?.data?.files?.map((item) => item.id)], queryKey: ['create-share', ...props?.data?.files?.map((item) => item.id)],
staleTime: Infinity,
queryFn: async () => { queryFn: async () => {
const { files, config } = props?.data || {} const { files, config } = props?.data || {}
const data = await createFileShare({ const data = await createFileShare({
@@ -49,7 +49,6 @@ const getShareUrl = (id: string) => {
return `${appConfig?.value?.site_url}/s/${id}` return `${appConfig?.value?.site_url}/s/${id}`
} }
const { copy } = useClipboard()
const { share, isSupported: isShareSupported } = useShare() const { share, isSupported: isShareSupported } = useShare()
const handleShare = async (id: string, fileName?: string) => { const handleShare = async (id: string, fileName?: string) => {
@@ -104,28 +103,20 @@ const handleShowQrCode = (id: string) => {
size="icon" size="icon"
@click.stop="handleShare(file?.id as string, file?.file_name)" @click.stop="handleShare(file?.id as string, file?.file_name)"
> >
<LucideShare /> <LucideShare class="size-1/2" />
</Button> </Button>
<Button <CopyButton
variant="outline"
:class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')" :class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')"
size="icon" :value="getShareUrl(file?.id as string)"
@click.stop=" @click.stop
() => { />
copy(getShareUrl(file?.id as string))
toast.success(t('page.result.file.copySuccess'))
}
"
>
<LucideCopy />
</Button>
<Button <Button
variant="outline" variant="outline"
:class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')" :class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')"
size="icon" size="icon"
@click.stop="handleShowQrCode(file?.id as string)" @click.stop="handleShowQrCode(file?.id as string)"
> >
<LucideQrCode /> <LucideQrCode class="size-1/2" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -147,19 +138,7 @@ const handleShowQrCode = (id: string) => {
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1" v-if="selectedFileShare?.pickup_code"> <div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1" v-if="selectedFileShare?.pickup_code">
<div class="flex flex-row justify-between w-full items-center"> <div class="flex flex-row justify-between w-full items-center">
<div class="text-xs font-semibold">{{ t('page.result.file.pickupCode') }}</div> <div class="text-xs font-semibold">{{ t('page.result.file.pickupCode') }}</div>
<Button <CopyButton class="bg-white/70 p-0 size-6" :value="selectedFileShare?.pickup_code as string" />
variant="outline"
class="bg-white/70 p-0 size-6"
size="icon"
@click="
() => {
copy(selectedFileShare?.pickup_code as string)
toast.success(t('page.result.file.copySuccess'))
}
"
>
<LucideCopy class="size-3" />
</Button>
</div> </div>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<div v-for="s in selectedFileShare?.pickup_code" class="text-2xl font-light"> <div v-for="s in selectedFileShare?.pickup_code" class="text-2xl font-light">
@@ -185,24 +164,12 @@ const handleShowQrCode = (id: string) => {
) )
" "
> >
<LucideShare /> <LucideShare class="size-1/2" />
</Button>
<Button
variant="outline"
class="bg-white/70"
size="icon"
@click="
() => {
copy(getShareUrl(selectedFileShare?.id as string))
toast.success(t('page.result.file.copySuccess'))
}
"
>
<LucideCopy />
</Button> </Button>
<CopyButton class="bg-white/70" :value="getShareUrl(selectedFileShare?.id as string)" />
<Button variant="outline" class="bg-white/70" size="icon" @click="handleShowQrCode(selectedFileShare?.id as string)"> <Button variant="outline" class="bg-white/70" size="icon" @click="handleShowQrCode(selectedFileShare?.id as string)">
<LucideQrCode /> <LucideQrCode class="size-1/2" />
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import FileShareResult from '@/components/Result/FileShareResult.vue' import FileShareResult from '@/components/Result/FileShareResult.vue'
import TextShareResult from '@/components/Result/TextShareResult.vue' import TextShareResult from '@/components/Result/TextShareResult.vue'
import TextTranslateResult from '@/components/Result/TextTranslateResult.vue'
import ImageCompressResult from '@/components/Result/ImageCompressResult.vue' import ImageCompressResult from '@/components/Result/ImageCompressResult.vue'
import ImageConvertResult from '@/components/Result/ImageConvertResult.vue' import ImageConvertResult from '@/components/Result/ImageConvertResult.vue'
import type { filehandleData, handleComponent, handleKey, texthandleData } from './types' import type { filehandleData, handleComponent, handleKey, texthandleData } from './types'
@@ -16,6 +17,7 @@ const emit = defineEmits<{
const handleList: { component: handleComponent; key: handleKey }[] = [ const handleList: { component: handleComponent; key: handleKey }[] = [
{ component: FileShareResult, key: 'file-share' }, { component: FileShareResult, key: 'file-share' },
{ component: TextShareResult, key: 'text-share' }, { component: TextShareResult, key: 'text-share' },
{ component: TextTranslateResult, key: 'text-translate' },
{ component: ImageCompressResult, key: 'file-image-compress' }, { component: ImageCompressResult, key: 'file-image-compress' },
{ component: ImageConvertResult, key: 'file-image-convert' }, { component: ImageConvertResult, key: 'file-image-convert' },
] ]

View File

@@ -1,8 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { useClipboard } from '@vueuse/core'
import { toast } from 'vue-sonner'
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import useMyAppShare from '@/composables/useMyAppShare' import useMyAppShare from '@/composables/useMyAppShare'
import useMyAppConfig from '@/composables/useMyAppConfig' import useMyAppConfig from '@/composables/useMyAppConfig'
@@ -37,7 +35,6 @@ const url = computed(() => {
return `${appConfig?.value?.site_url}/s/${id}` return `${appConfig?.value?.site_url}/s/${id}`
}) })
const { copy } = useClipboard()
const { t } = useI18n() const { t } = useI18n()
</script> </script>
@@ -60,19 +57,7 @@ const { t } = useI18n()
<div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1" v-if="data?.pickup_code"> <div class="rounded-xl flex flex-col bg-black/10 px-3 py-2 gap-1" v-if="data?.pickup_code">
<div class="flex flex-row justify-between w-full items-center"> <div class="flex flex-row justify-between w-full items-center">
<div class="text-xs font-semibold">{{ t('page.result.text.pickupCode') }}</div> <div class="text-xs font-semibold">{{ t('page.result.text.pickupCode') }}</div>
<Button <CopyButton class="bg-white/70 p-0 size-6" :value="data?.pickup_code as string" />
variant="outline"
class="bg-white/70 p-0 size-6"
size="icon"
@click="
() => {
copy(data?.pickup_code as string)
toast.success(t('page.result.text.copySuccess'))
}
"
>
<LucideCopy class="size-3" />
</Button>
</div> </div>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<div v-for="s in data?.pickup_code" class="text-2xl font-light"> <div v-for="s in data?.pickup_code" class="text-2xl font-light">
@@ -86,19 +71,7 @@ const { t } = useI18n()
<div class="text-sm font-semibold">{{ t('page.result.text.link') }}</div> <div class="text-sm font-semibold">{{ t('page.result.text.link') }}</div>
<div class="flex flex-row gap-2"> <div class="flex flex-row gap-2">
<Input v-model="url" class="bg-white/70" readonly /> <Input v-model="url" class="bg-white/70" readonly />
<Button <CopyButton class="bg-white/70" :value="url" />
variant="outline"
class="bg-white/70"
size="icon"
@click="
() => {
copy(url)
toast.success(t('page.result.text.copySuccess'))
}
"
>
<LucideCopy />
</Button>
<Button <Button
variant="outline" variant="outline"

View File

@@ -0,0 +1,120 @@
<script setup lang="ts">
import VeeForm from '@/components/VeeForm.vue'
import MarkdownInputField from '@/components/Field/MarkdownInputField.vue'
import SelectField from '@/components/Field/SelectField.vue'
import FormButton from '@/components/Field/FormButton.vue'
import MarkdownRender from '@/components/MarkdownRender.vue'
import { Button } from '@/components/ui/button'
import { useClipboard } from '@vueuse/core'
import { toast } from 'vue-sonner'
import type { handleTextComponentProps } from './types'
const props = defineProps<handleTextComponentProps>()
console.log(props?.data)
const { t } = useI18n()
const { copy } = useClipboard()
const translatedText = ref('')
const languageOptions = computed(() => [
{ label: t('page.result.textTranslate.language.auto'), value: 'auto' },
{ label: t('page.result.textTranslate.language.zh-CN'), value: 'zh-CN' },
{ label: t('page.result.textTranslate.language.en'), value: 'en' },
{ label: t('page.result.textTranslate.language.ja'), value: 'ja' },
{ label: t('page.result.textTranslate.language.ko'), value: 'ko' },
])
const providerOptions = computed(() => [
{ label: 'Google', value: 'google' },
{ label: 'Microsoft', value: 'microsoft' },
{ label: 'DeepSeek', value: 'deepseek' },
// { label: 'DeepLX', value: 'deeplx' },
])
const handleCopyText = async (text?: string) => {
if (!text) return
await copy(text)
toast.success('复制成功')
}
const handleRetranslate = async () => {
translatedText.value = ''
}
</script>
<template>
<BaseCard class="flex flex-col gap-4" :title="t('page.result.textTranslate.title')" :showBackButton="true">
<VeeForm v-slot="{ values, setFieldValue }" :initialValues="{ ...data?.config, input: data?.text }">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<div class="flex flex-row justify-between items-end">
<div class="flex flex-col gap-1">
<Label class="text-xs">{{ t('page.result.textTranslate.sourceLanguage') }}</Label>
<SelectField
name="source"
class="bg-white/70"
:placeholder="t('page.result.textTranslate.sourceLanguage')"
default-value="auto"
:options="languageOptions"
rules="required"
/>
</div>
<div class="flex flex-row gap-1">
<Button variant="outline" class="bg-white/70" size="icon" @click="handleCopyText(values.input)">
<LucideCopy class="size-4" />
</Button>
</div>
</div>
<MarkdownInputField name="input" rules="required" class="max-h-[30vh] min-h-40 overflow-y-auto max-w-full" />
</div>
<div class="flex flex-row justify-center items-center gap-2">
<Button class="px-10">
<LucideArrowUpDown />
</Button>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-between gap-3 items-end">
<div class="flex flex-row gap-3">
<div class="flex flex-row justify-between">
<div class="flex flex-col gap-1">
<Label class="text-xs">{{ t('page.result.textTranslate.targetLanguage') }}</Label>
<SelectField
name="target"
class="bg-white/70"
:placeholder="t('page.result.textTranslate.targetLanguage')"
:options="languageOptions"
rules="required"
/>
</div>
</div>
<div class="flex flex-col gap-1">
<Label class="text-xs">{{ t('page.result.textTranslate.provider') }}</Label>
<SelectField
name="provider"
class="bg-white/70"
:placeholder="t('page.result.textTranslate.provider')"
default-value="mtranslate"
:options="providerOptions"
rules="required"
/>
</div>
</div>
<Button variant="outline" class="bg-white/70" size="icon" @click="handleCopyText(translatedText)">
<LucideCopy class="size-4" />
</Button>
</div>
<div class="rounded-md bg-white/50 min-h-48 p-2">
<MarkdownRender :markdown="translatedText" class="prose prose-sm max-w-none min-h-40 max-h-[30vh]" />
</div>
</div>
<FormButton @click="handleRetranslate" class="w-full">
<LucideLanguages class="size-4" />
{{ t('page.result.textTranslate.retranslate') }}
</FormButton>
</div>
</VeeForm>
</BaseCard>
</template>

View File

@@ -4,7 +4,7 @@ import dayjs from 'dayjs'
import duration from 'dayjs/plugin/duration' import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { isBoolean } from 'lodash-es' import { isBoolean } from 'lodash-es'
import { LucideCheck, LucideX } from 'lucide-vue-next' import { LucideCheck, LucideX } from '@lucide/vue'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import showDrawer from '~/lib/showDrawer' import showDrawer from '~/lib/showDrawer'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
@@ -19,22 +19,24 @@ const props = defineProps<{
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { downloadFile, getShareToken } = useMyAppShare() const { downloadFile, getShareToken } = useMyAppShare()
const token = ref<string>()
const handleDownload = async () => { const handleDownload = async () => {
const { id } = props?.data || {} const { id } = props?.data || {}
try { try {
let token = null if (!token.value) {
if (props?.data?.has_password) { if (props?.data?.has_password) {
token = await showDrawer({ token.value = await showDrawer({
render: ({ ...rest }) => h(PasswallShareDrawer, { ...rest, share_id: id }), render: ({ ...rest }) => h(PasswallShareDrawer, { ...rest, share_id: id }),
}) })
} else { } else {
token = await getShareToken(id) token.value = await getShareToken(id)
}
if (!token.value) {
throw new Error(t('page.shareView.fileShare.getTokenFailed'))
}
} }
if (!token) { downloadFile(token.value)
throw new Error(t('page.shareView.fileShare.getTokenFailed'))
}
downloadFile(token)
} catch (error: any) { } catch (error: any) {
toast.error(error?.data?.message || error?.message || error) toast.error(error?.data?.message || error?.message || error)
} finally { } finally {

View File

@@ -4,13 +4,10 @@ import AsyncButton from '@/components/ui/button/AsyncButton.vue'
import duration from 'dayjs/plugin/duration' import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import { isBoolean } from 'lodash-es' import { isBoolean } from 'lodash-es'
import { LucideCheck, LucideX } from 'lucide-vue-next' import { LucideCheck, LucideX } from '@lucide/vue'
import { cx } from 'class-variance-authority' import { cx } from 'class-variance-authority'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import MarkdownRender from '@/components/MarkdownRender.vue' import MarkdownRender from '@/components/MarkdownRender.vue'
import { Button } from '@/components/ui/button'
import { LucideCopy } from 'lucide-vue-next'
import { useClipboard } from '@vueuse/core'
import showDrawer from '~/lib/showDrawer' import showDrawer from '~/lib/showDrawer'
import PasswallShareDrawer from '~/components/Drawer/PasswallShareDrawer.vue' import PasswallShareDrawer from '~/components/Drawer/PasswallShareDrawer.vue'
@@ -30,8 +27,6 @@ const expireSeconds = computed(() => {
const { remaining, start } = useCountdown(expireSeconds.value) const { remaining, start } = useCountdown(expireSeconds.value)
const { copy } = useClipboard()
onMounted(() => { onMounted(() => {
start() start()
}) })
@@ -74,19 +69,7 @@ const handlePreview = async () => {
<div :class="cx('flex flex-col max-h-full', !!previewText ? 'gap-3' : 'gap-16 items-center')"> <div :class="cx('flex flex-col max-h-full', !!previewText ? 'gap-3' : 'gap-16 items-center')">
<div :class="cx('flex flex-row w-full', !!previewText ? 'justify-between' : 'justify-center')"> <div :class="cx('flex flex-row w-full', !!previewText ? 'justify-between' : 'justify-center')">
<h1 class="text-xl">{{ t('page.shareView.textShare.title') }}</h1> <h1 class="text-xl">{{ t('page.shareView.textShare.title') }}</h1>
<Button <CopyButton v-if="!!previewText" :value="previewText as string" />
v-if="!!previewText"
variant="outline"
size="icon"
@click="
() => {
copy(previewText as string)
toast.success(t('page.result.text.copySuccess'))
}
"
>
<LucideCopy />
</Button>
</div> </div>
<template v-if="!previewText"> <template v-if="!previewText">
<div class="flex flex-col gap-2 md:flex-row w-full"> <div class="flex flex-col gap-2 md:flex-row w-full">

View File

@@ -4,6 +4,8 @@ import StarterKit from '@tiptap/starter-kit'
import { Markdown } from 'tiptap-markdown' import { Markdown } from 'tiptap-markdown'
import Placeholder from '@tiptap/extension-placeholder' import Placeholder from '@tiptap/extension-placeholder'
import { cx } from 'class-variance-authority' import { cx } from 'class-variance-authority'
import countWords from '@/lib/countWords'
const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
modelValue?: string modelValue?: string
@@ -15,6 +17,7 @@ const emit = defineEmits<{
}>() }>()
const editor = ref<Editor | undefined>(undefined) const editor = ref<Editor | undefined>(undefined)
onMounted(() => { onMounted(() => {
editor.value = new Editor({ editor.value = new Editor({
content: props.modelValue, content: props.modelValue,
@@ -51,11 +54,17 @@ onUnmounted(() => {
:editor="editor as any" :editor="editor as any"
:class=" :class="
cx( cx(
'prose prose-sm bg-white/50 rounded-md p-2 [&>*]:outline-none prose-p:my-1 prose-headings:my-2 prose-pre:mb-0 prose-blockquote:border-black/50 selection:bg-primary/20 max-w-full', 'prose prose-sm bg-white/50 rounded-md p-2 *:outline-none prose-p:my-1 prose-headings:my-2 prose-pre:mb-0 prose-blockquote:border-black/50 selection:bg-primary/20 max-w-full',
props.class props.class
) )
" "
> >
</editor-content> </editor-content>
<!-- <BubbleMenuView :editor="editor as any" /> --> <!-- <BubbleMenuView :editor="editor as any" /> -->
<div
v-if="modelValue?.length && modelValue?.length > 0"
class="absolute bottom-2 right-3 flex justify-end px-2 py-1 text-xs text-gray-400 select-none bg-white rounded-md"
>
{{ `${modelValue?.length ?? 0} ${t('common.length')} · ${countWords(modelValue ?? '')} ${t('common.words')}` }}
</div>
</template> </template>

View File

@@ -2,7 +2,7 @@
import type { AccordionTriggerProps } from 'reka-ui' import type { AccordionTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from '@vueuse/core'
import { ChevronDown } from 'lucide-vue-next' import { ChevronDown } from '@lucide/vue'
import { AccordionHeader, AccordionTrigger } from 'reka-ui' import { AccordionHeader, AccordionTrigger } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'

View File

@@ -12,6 +12,7 @@ const props = withDefaults(
nameKey?: string nameKey?: string
labelKey?: string labelKey?: string
labelFormatter?: (d: number | Date) => string labelFormatter?: (d: number | Date) => string
valueFormatter?: (value: unknown, key: string) => string
payload?: Record<string, any> payload?: Record<string, any>
config?: ChartConfig config?: ChartConfig
class?: HTMLAttributes['class'] class?: HTMLAttributes['class']
@@ -99,7 +100,7 @@ const tooltipLabel = computed(() => {
</span> </span>
</div> </div>
<span v-if="value" class="text-foreground font-mono font-medium tabular-nums"> <span v-if="value" class="text-foreground font-mono font-medium tabular-nums">
{{ value.toLocaleString() }} {{ props.valueFormatter ? props.valueFormatter(value, key) : value.toLocaleString() }}
</span> </span>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { ListboxRootEmits, ListboxRootProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ListboxRoot, useFilter, useForwardPropsEmits } from 'reka-ui'
import { reactive, ref, watch } from 'vue'
import { cn } from '@/lib/utils'
import { provideCommandContext } from '.'
const props = withDefaults(defineProps<ListboxRootProps & { class?: HTMLAttributes['class'] }>(), {
modelValue: '',
})
const emits = defineEmits<ListboxRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const allItems = ref<Map<string, string>>(new Map())
const allGroups = ref<Map<string, Set<string>>>(new Map())
const { contains } = useFilter({ sensitivity: 'base' })
const filterState = reactive({
search: '',
filtered: {
/** The count of all visible items. */
count: 0,
/** Map from visible item id to its search score. */
items: new Map() as Map<string, number>,
/** Set of groups with at least one visible item. */
groups: new Set() as Set<string>,
},
})
function filterItems() {
if (!filterState.search) {
filterState.filtered.count = allItems.value.size
// Do nothing, each item will know to show itself because search is empty
return
}
// Reset the groups
filterState.filtered.groups = new Set()
let itemCount = 0
// Check which items should be included
for (const [id, value] of allItems.value) {
const score = contains(value, filterState.search)
filterState.filtered.items.set(id, score ? 1 : 0)
if (score) itemCount++
}
// Check which groups have at least 1 item shown
for (const [groupId, group] of allGroups.value) {
for (const itemId of group) {
if (filterState.filtered.items.get(itemId)! > 0) {
filterState.filtered.groups.add(groupId)
break
}
}
}
filterState.filtered.count = itemCount
}
watch(
() => filterState.search,
() => {
filterItems()
}
)
provideCommandContext({
allItems,
allGroups,
filterState,
})
</script>
<template>
<ListboxRoot
data-slot="command"
v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md', props.class)"
>
<slot />
</ListboxRoot>
</template>

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { useForwardPropsEmits } from 'reka-ui'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import Command from './Command.vue'
const props = withDefaults(
defineProps<
DialogRootProps & {
title?: string
description?: string
}
>(),
{
title: 'Command Palette',
description: 'Search for a command to run...',
}
)
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<Dialog v-slot="slotProps" v-bind="forwarded">
<DialogContent class="overflow-hidden p-0">
<DialogHeader class="sr-only">
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription>{{ description }}</DialogDescription>
</DialogHeader>
<Command>
<slot v-bind="slotProps" />
</Command>
</DialogContent>
</Dialog>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Primitive } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
import { useCommand } from '.'
const props = defineProps<PrimitiveProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const { filterState } = useCommand()
const isRender = computed(() => !!filterState.search && filterState.filtered.count === 0)
</script>
<template>
<Primitive v-if="isRender" data-slot="command-empty" v-bind="delegatedProps" :class="cn('py-6 text-center text-sm', props.class)">
<slot />
</Primitive>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import type { ListboxGroupProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ListboxGroup, ListboxGroupLabel, useId } from 'reka-ui'
import { computed, onMounted, onUnmounted } from 'vue'
import { cn } from '@/lib/utils'
import { provideCommandGroupContext, useCommand } from '.'
const props = defineProps<
ListboxGroupProps & {
class?: HTMLAttributes['class']
heading?: string
}
>()
const delegatedProps = reactiveOmit(props, 'class')
const { allGroups, filterState } = useCommand()
const id = useId()
const isRender = computed(() => (!filterState.search ? true : filterState.filtered.groups.has(id)))
provideCommandGroupContext({ id })
onMounted(() => {
if (!allGroups.value.has(id)) allGroups.value.set(id, new Set())
})
onUnmounted(() => {
allGroups.value.delete(id)
})
</script>
<template>
<ListboxGroup
v-bind="delegatedProps"
:id="id"
data-slot="command-group"
:class="cn('text-foreground overflow-hidden p-1', props.class)"
:hidden="isRender ? undefined : true"
>
<ListboxGroupLabel v-if="heading" data-slot="command-group-heading" class="px-2 py-1.5 text-xs font-medium text-muted-foreground">
{{ heading }}
</ListboxGroupLabel>
<slot />
</ListboxGroup>
</template>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import type { ListboxFilterProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Search } from '@lucide/vue'
import { ListboxFilter, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { useCommand } from '.'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<
ListboxFilterProps & {
class?: HTMLAttributes['class']
}
>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
const { filterState } = useCommand()
</script>
<template>
<div data-slot="command-input-wrapper" class="flex h-9 items-center gap-2 border-b px-3">
<Search class="size-4 shrink-0 opacity-50" />
<ListboxFilter
v-bind="{ ...forwardedProps, ...$attrs }"
v-model="filterState.search"
data-slot="command-input"
auto-focus
:class="
cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
props.class
)
"
/>
</div>
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { ListboxItemEmits, ListboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit, useCurrentElement } from '@vueuse/core'
import { ListboxItem, useForwardPropsEmits, useId } from 'reka-ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { cn } from '@/lib/utils'
import { useCommand, useCommandGroup } from '.'
const props = defineProps<ListboxItemProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<ListboxItemEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
const id = useId()
const { filterState, allItems, allGroups } = useCommand()
const groupContext = useCommandGroup()
const isRender = computed(() => {
if (!filterState.search) {
return true
} else {
const filteredCurrentItem = filterState.filtered.items.get(id)
// If the filtered items is undefined means not in the all times map yet
// Do the first render to add into the map
if (filteredCurrentItem === undefined) {
return true
}
// Check with filter
return filteredCurrentItem > 0
}
})
const itemRef = ref()
const currentElement = useCurrentElement(itemRef)
onMounted(() => {
if (!(currentElement.value instanceof HTMLElement)) return
// textValue to perform filter
allItems.value.set(id, currentElement.value.textContent ?? props.value?.toString() ?? '')
const groupId = groupContext?.id
if (groupId) {
if (!allGroups.value.has(groupId)) {
allGroups.value.set(groupId, new Set([id]))
} else {
allGroups.value.get(groupId)?.add(id)
}
}
})
onUnmounted(() => {
allItems.value.delete(id)
})
</script>
<template>
<ListboxItem
v-if="isRender"
v-bind="forwarded"
:id="id"
ref="itemRef"
data-slot="command-item"
:class="
cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
props.class
)
"
@select="
() => {
filterState.search = ''
}
"
>
<slot />
</ListboxItem>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { ListboxContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ListboxContent, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<ListboxContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardProps(delegatedProps)
</script>
<template>
<ListboxContent
data-slot="command-list"
v-bind="forwarded"
:class="cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', props.class)"
>
<div role="presentation">
<slot />
</div>
</ListboxContent>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { SeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Separator } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<SeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<Separator data-slot="command-separator" v-bind="delegatedProps" :class="cn('bg-border -mx-1 h-px', props.class)">
<slot />
</Separator>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<span data-slot="command-shortcut" :class="cn('text-muted-foreground ml-auto text-xs tracking-widest', props.class)">
<slot />
</span>
</template>

View File

@@ -0,0 +1,25 @@
import type { Ref } from 'vue'
import { createContext } from 'reka-ui'
export { default as Command } from './Command.vue'
export { default as CommandDialog } from './CommandDialog.vue'
export { default as CommandEmpty } from './CommandEmpty.vue'
export { default as CommandGroup } from './CommandGroup.vue'
export { default as CommandInput } from './CommandInput.vue'
export { default as CommandItem } from './CommandItem.vue'
export { default as CommandList } from './CommandList.vue'
export { default as CommandSeparator } from './CommandSeparator.vue'
export { default as CommandShortcut } from './CommandShortcut.vue'
export const [useCommand, provideCommandContext] = createContext<{
allItems: Ref<Map<string, string>>
allGroups: Ref<Map<string, Set<string>>>
filterState: {
search: string
filtered: { count: number; items: Map<string, number>; groups: Set<string> }
}
}>('Command')
export const [useCommandGroup, provideCommandGroupContext] = createContext<{
id?: string
}>('CommandGroup')

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { DialogRootEmits, DialogRootProps } from 'reka-ui'
import { DialogRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<DialogRootProps>()
const emits = defineEmits<DialogRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DialogRoot v-slot="slotProps" data-slot="dialog" v-bind="forwarded">
<slot v-bind="slotProps" />
</DialogRoot>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogCloseProps } from 'reka-ui'
import { DialogClose } from 'reka-ui'
const props = defineProps<DialogCloseProps>()
</script>
<template>
<DialogClose data-slot="dialog-close" v-bind="props">
<slot />
</DialogClose>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from '@lucide/vue'
import { DialogClose, DialogContent, DialogPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import DialogOverlay from './DialogOverlay.vue'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes['class']; showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class
)
"
>
<slot />
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogDescriptionProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogDescription, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogDescription data-slot="dialog-description" v-bind="forwardedProps" :class="cn('text-muted-foreground text-sm', props.class)">
<slot />
</DialogDescription>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { DialogClose } from 'reka-ui'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
showCloseButton?: boolean
}>(),
{
showCloseButton: false,
}
)
</script>
<template>
<div data-slot="dialog-footer" :class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)">
<slot />
<DialogClose v-if="showCloseButton" as-child>
<Button variant="outline"> Close </Button>
</DialogClose>
</div>
</template>

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div data-slot="dialog-header" :class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)">
<slot />
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogOverlay } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="
cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
props.class
)
"
>
<slot />
</DialogOverlay>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { X } from '@lucide/vue'
import { DialogClose, DialogContent, DialogOverlay, DialogPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class
)
"
v-bind="{ ...$attrs, ...forwarded }"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent
const target = originalEvent.target as HTMLElement
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
event.preventDefault()
}
}
"
>
<slot />
<DialogClose class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary">
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { DialogTitleProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { DialogTitle, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<DialogTitle data-slot="dialog-title" v-bind="forwardedProps" :class="cn('text-lg leading-none font-semibold', props.class)">
<slot />
</DialogTitle>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { DialogTriggerProps } from 'reka-ui'
import { DialogTrigger } from 'reka-ui'
const props = defineProps<DialogTriggerProps>()
</script>
<template>
<DialogTrigger data-slot="dialog-trigger" v-bind="props">
<slot />
</DialogTrigger>
</template>

View File

@@ -0,0 +1,10 @@
export { default as Dialog } from './Dialog.vue'
export { default as DialogClose } from './DialogClose.vue'
export { default as DialogContent } from './DialogContent.vue'
export { default as DialogDescription } from './DialogDescription.vue'
export { default as DialogFooter } from './DialogFooter.vue'
export { default as DialogHeader } from './DialogHeader.vue'
export { default as DialogOverlay } from './DialogOverlay.vue'
export { default as DialogScrollContent } from './DialogScrollContent.vue'
export { default as DialogTitle } from './DialogTitle.vue'
export { default as DialogTrigger } from './DialogTrigger.vue'

View File

@@ -2,7 +2,7 @@
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui' import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from '@vueuse/core'
import { Check } from 'lucide-vue-next' import { Check } from '@lucide/vue'
import { DropdownMenuCheckboxItem, DropdownMenuItemIndicator, useForwardPropsEmits } from 'reka-ui' import { DropdownMenuCheckboxItem, DropdownMenuItemIndicator, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'

View File

@@ -2,7 +2,7 @@
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui' import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from '@vueuse/core'
import { Circle } from 'lucide-vue-next' import { Circle } from '@lucide/vue'
import { DropdownMenuItemIndicator, DropdownMenuRadioItem, useForwardPropsEmits } from 'reka-ui' import { DropdownMenuItemIndicator, DropdownMenuRadioItem, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'

View File

@@ -2,7 +2,7 @@
import type { DropdownMenuSubTriggerProps } from 'reka-ui' import type { DropdownMenuSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from '@vueuse/core'
import { ChevronRight } from 'lucide-vue-next' import { ChevronRight } from '@lucide/vue'
import { DropdownMenuSubTrigger, useForwardProps } from 'reka-ui' import { DropdownMenuSubTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'

View File

@@ -2,7 +2,7 @@
import type { MenubarCheckboxItemEmits, MenubarCheckboxItemProps } from 'reka-ui' import type { MenubarCheckboxItemEmits, MenubarCheckboxItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from '@vueuse/core'
import { Check } from 'lucide-vue-next' import { Check } from '@lucide/vue'
import { MenubarCheckboxItem, MenubarItemIndicator, useForwardPropsEmits } from 'reka-ui' import { MenubarCheckboxItem, MenubarItemIndicator, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'

View File

@@ -2,7 +2,7 @@
import type { MenubarRadioItemEmits, MenubarRadioItemProps } from 'reka-ui' import type { MenubarRadioItemEmits, MenubarRadioItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from '@vueuse/core'
import { Circle } from 'lucide-vue-next' import { Circle } from '@lucide/vue'
import { MenubarItemIndicator, MenubarRadioItem, useForwardPropsEmits } from 'reka-ui' import { MenubarItemIndicator, MenubarRadioItem, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'

View File

@@ -2,7 +2,7 @@
import type { MenubarSubTriggerProps } from 'reka-ui' import type { MenubarSubTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from '@vueuse/core'
import { ChevronRight } from 'lucide-vue-next' import { ChevronRight } from '@lucide/vue'
import { MenubarSubTrigger, useForwardProps } from 'reka-ui' import { MenubarSubTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Minus } from 'lucide-vue-next' import { Minus } from '@lucide/vue'
import { Primitive, type PrimitiveProps, useForwardProps } from 'reka-ui' import { Primitive, type PrimitiveProps, useForwardProps } from 'reka-ui'
const props = defineProps<PrimitiveProps>() const props = defineProps<PrimitiveProps>()
@@ -7,12 +7,9 @@ const forwardedProps = useForwardProps(props)
</script> </script>
<template> <template>
<Primitive <Primitive data-slot="pin-input-separator" v-bind="forwardedProps">
data-slot="pin-input-separator" <slot>
v-bind="forwardedProps" <Minus />
> </slot>
<slot> </Primitive>
<Minus />
</slot>
</Primitive>
</template> </template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
const props = defineProps<PopoverRootProps>()
const emits = defineEmits<PopoverRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<PopoverRoot v-slot="slotProps" data-slot="popover" v-bind="forwarded">
<slot v-bind="slotProps" />
</PopoverRoot>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { PopoverAnchorProps } from 'reka-ui'
import { PopoverAnchor } from 'reka-ui'
const props = defineProps<PopoverAnchorProps>()
</script>
<template>
<PopoverAnchor data-slot="popover-anchor" v-bind="props">
<slot />
</PopoverAnchor>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { PopoverContent, PopoverPortal, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(), {
align: 'center',
sideOffset: 4,
})
const emits = defineEmits<PopoverContentEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<PopoverPortal>
<PopoverContent
data-slot="popover-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
props.class
)
"
>
<slot />
</PopoverContent>
</PopoverPortal>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { PopoverTriggerProps } from 'reka-ui'
import { PopoverTrigger } from 'reka-ui'
const props = defineProps<PopoverTriggerProps>()
</script>
<template>
<PopoverTrigger data-slot="popover-trigger" v-bind="props">
<slot />
</PopoverTrigger>
</template>

View File

@@ -0,0 +1,4 @@
export { default as Popover } from './Popover.vue'
export { default as PopoverAnchor } from './PopoverAnchor.vue'
export { default as PopoverContent } from './PopoverContent.vue'
export { default as PopoverTrigger } from './PopoverTrigger.vue'

View File

@@ -9,10 +9,7 @@ const forwarded = useForwardPropsEmits(props, emits)
</script> </script>
<template> <template>
<SelectRoot <SelectRoot v-slot="slotProps" data-slot="select" v-bind="forwarded">
data-slot="select" <slot v-bind="slotProps" />
v-bind="forwarded" </SelectRoot>
>
<slot />
</SelectRoot>
</template> </template>

View File

@@ -1,55 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectContent, SelectPortal, SelectViewport, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import {
SelectContent,
type SelectContentEmits,
type SelectContentProps,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
import { SelectScrollDownButton, SelectScrollUpButton } from '.' import { SelectScrollDownButton, SelectScrollUpButton } from '.'
defineOptions({ defineOptions({
inheritAttrs: false, inheritAttrs: false,
}) })
const props = withDefaults( const props = withDefaults(defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(), {
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
{
position: 'popper', position: 'popper',
}, })
)
const emits = defineEmits<SelectContentEmits>() const emits = defineEmits<SelectContentEmits>()
const delegatedProps = computed(() => { const delegatedProps = reactiveOmit(props, 'class')
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits) const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script> </script>
<template> <template>
<SelectPortal> <SelectPortal>
<SelectContent <SelectContent
data-slot="select-content" data-slot="select-content"
v-bind="{ ...forwarded, ...$attrs }" v-bind="{ ...$attrs, ...forwarded }"
:class="cn( :class="
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md', cn(
position === 'popper' 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', position === 'popper' &&
props.class, 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
) props.class
" )
> "
<SelectScrollUpButton /> >
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')"> <SelectScrollUpButton />
<slot /> <SelectViewport
</SelectViewport> :class="
<SelectScrollDownButton /> cn(
</SelectContent> 'p-1',
</SelectPortal> position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1'
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template> </template>

View File

@@ -1,14 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { SelectGroup, type SelectGroupProps } from 'reka-ui' import type { SelectGroupProps } from 'reka-ui'
import { SelectGroup } from 'reka-ui'
const props = defineProps<SelectGroupProps>() const props = defineProps<SelectGroupProps>()
</script> </script>
<template> <template>
<SelectGroup <SelectGroup data-slot="select-group" v-bind="props">
data-slot="select-group" <slot />
v-bind="props" </SelectGroup>
>
<slot />
</SelectGroup>
</template> </template>

View File

@@ -1,45 +1,39 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectItemProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { Check } from '@lucide/vue'
import { SelectItem, SelectItemIndicator, SelectItemText, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Check } from 'lucide-vue-next'
import {
SelectItem,
SelectItemIndicator,
type SelectItemProps,
SelectItemText,
useForwardProps,
} from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>() const props = defineProps<SelectItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => { const delegatedProps = reactiveOmit(props, 'class')
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<SelectItem <SelectItem
data-slot="select-item" data-slot="select-item"
v-bind="forwardedProps" v-bind="forwardedProps"
:class=" :class="
cn( cn(
`focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2`, 'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class, props.class
) )
" "
> >
<span class="absolute right-2 flex size-3.5 items-center justify-center"> <span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator> <SelectItemIndicator>
<Check class="size-4" /> <slot name="indicator-icon">
</SelectItemIndicator> <Check class="size-4" />
</span> </slot>
</SelectItemIndicator>
</span>
<SelectItemText> <SelectItemText>
<slot /> <slot />
</SelectItemText> </SelectItemText>
</SelectItem> </SelectItem>
</template> </template>

View File

@@ -1,14 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { SelectItemText, type SelectItemTextProps } from 'reka-ui' import type { SelectItemTextProps } from 'reka-ui'
import { SelectItemText } from 'reka-ui'
const props = defineProps<SelectItemTextProps>() const props = defineProps<SelectItemTextProps>()
</script> </script>
<template> <template>
<SelectItemText <SelectItemText data-slot="select-item-text" v-bind="props">
data-slot="select-item-text" <slot />
v-bind="props" </SelectItemText>
>
<slot />
</SelectItemText>
</template> </template>

View File

@@ -1,16 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectLabelProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { SelectLabel } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { SelectLabel, type SelectLabelProps } from 'reka-ui'
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>() const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
</script> </script>
<template> <template>
<SelectLabel <SelectLabel data-slot="select-label" :class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)">
data-slot="select-label" <slot />
:class="cn('px-2 py-1.5 text-sm font-medium', props.class)" </SelectLabel>
>
<slot />
</SelectLabel>
</template> </template>

View File

@@ -1,28 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectScrollDownButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronDown } from '@lucide/vue'
import { SelectScrollDownButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ChevronDown } from 'lucide-vue-next'
import { SelectScrollDownButton, type SelectScrollDownButtonProps, useForwardProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>() const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => { const delegatedProps = reactiveOmit(props, 'class')
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<SelectScrollDownButton <SelectScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)" :class="cn('flex cursor-default items-center justify-center py-1', props.class)"
> >
<slot> <slot>
<ChevronDown class="size-4" /> <ChevronDown class="size-4" />
</slot> </slot>
</SelectScrollDownButton> </SelectScrollDownButton>
</template> </template>

View File

@@ -1,28 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectScrollUpButtonProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronUp } from '@lucide/vue'
import { SelectScrollUpButton, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ChevronUp } from 'lucide-vue-next'
import { SelectScrollUpButton, type SelectScrollUpButtonProps, useForwardProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>() const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => { const delegatedProps = reactiveOmit(props, 'class')
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<SelectScrollUpButton <SelectScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn('flex cursor-default items-center justify-center py-1', props.class)" :class="cn('flex cursor-default items-center justify-center py-1', props.class)"
> >
<slot> <slot>
<ChevronUp class="size-4" /> <ChevronUp class="size-4" />
</slot> </slot>
</SelectScrollUpButton> </SelectScrollUpButton>
</template> </template>

View File

@@ -1,21 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectSeparatorProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { SelectSeparator } from 'reka-ui'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { SelectSeparator, type SelectSeparatorProps } from 'reka-ui'
import { computed, type HTMLAttributes } from 'vue'
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>() const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => { const delegatedProps = reactiveOmit(props, 'class')
const { class: _, ...delegated } = props
return delegated
})
</script> </script>
<template> <template>
<SelectSeparator <SelectSeparator data-slot="select-separator" v-bind="delegatedProps" :class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)" />
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
/>
</template> </template>

View File

@@ -1,32 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import type { SelectTriggerProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue' import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import { reactiveOmit } from '@vueuse/core' import { reactiveOmit } from '@vueuse/core'
import { ChevronDown } from 'lucide-vue-next' import { ChevronDown } from '@lucide/vue'
import { SelectIcon, SelectTrigger, type SelectTriggerProps, useForwardProps } from 'reka-ui' import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults( const props = withDefaults(defineProps<SelectTriggerProps & { class?: HTMLAttributes['class']; size?: 'sm' | 'default' }>(), { size: 'default' })
defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'], size?: 'sm' | 'default' }>(),
{ size: 'default' },
)
const delegatedProps = reactiveOmit(props, 'class', 'size') const delegatedProps = reactiveOmit(props, 'class', 'size')
const forwardedProps = useForwardProps(delegatedProps) const forwardedProps = useForwardProps(delegatedProps)
</script> </script>
<template> <template>
<SelectTrigger <SelectTrigger
data-slot="select-trigger" data-slot="select-trigger"
:data-size="size" :data-size="size"
v-bind="forwardedProps" v-bind="forwardedProps"
:class="cn( :class="
`border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`, cn(
props.class, 'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
)" props.class
> )
<slot /> "
<SelectIcon as-child> >
<ChevronDown class="size-4 opacity-50" /> <slot />
</SelectIcon> <SelectIcon as-child>
</SelectTrigger> <ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template> </template>

View File

@@ -1,14 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { SelectValue, type SelectValueProps } from 'reka-ui' import type { SelectValueProps } from 'reka-ui'
import { SelectValue } from 'reka-ui'
const props = defineProps<SelectValueProps>() const props = defineProps<SelectValueProps>()
</script> </script>
<template> <template>
<SelectValue <SelectValue data-slot="select-value" v-bind="props">
data-slot="select-value" <slot />
v-bind="props" </SelectValue>
>
<slot />
</SelectValue>
</template> </template>

View File

@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { ToasterProps } from 'vue-sonner'
import 'vue-sonner/style.css'
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from '@lucide/vue'
import { Toaster as Sonner } from 'vue-sonner'
import { cn } from '@/lib/utils'
const props = defineProps<ToasterProps>()
</script>
<template>
<Sonner
:class="cn('toaster group', props.class)"
:style="{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
'--border-radius': 'var(--radius)',
}"
v-bind="props"
>
<template #success-icon>
<CircleCheckIcon class="size-4" />
</template>
<template #info-icon>
<InfoIcon class="size-4" />
</template>
<template #warning-icon>
<TriangleAlertIcon class="size-4" />
</template>
<template #error-icon>
<OctagonXIcon class="size-4" />
</template>
<template #loading-icon>
<div>
<Loader2Icon class="size-4 animate-spin" />
</div>
</template>
<template #close-icon>
<XIcon class="size-4" />
</template>
</Sonner>
</template>

View File

@@ -0,0 +1 @@
export { default as Toaster } from './Sonner.vue'

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { useVModel } from '@vueuse/core'
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
defaultValue?: string | number
modelValue?: string | number
}>()
const emits = defineEmits<{
(e: 'update:modelValue', payload: string | number): void
}>()
const modelValue = useVModel(props, 'modelValue', emits, {
passive: true,
defaultValue: props.defaultValue,
})
</script>
<template>
<textarea
v-model="modelValue"
data-slot="textarea"
:class="
cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
props.class
)
"
/>
</template>

View File

@@ -0,0 +1 @@
export { default as Textarea } from './Textarea.vue'

Some files were not shown because too many files have changed in this diff Show More