mirror of
https://github.com/keven1024/015.git
synced 2026-05-26 07:08:02 +00:00
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:
@@ -13,6 +13,7 @@ jobs:
|
||||
node-version: '24'
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: latest
|
||||
cache: true
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,7 +22,7 @@ logs
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
config.yaml
|
||||
/config.yaml
|
||||
|
||||
# Serwist
|
||||
/front/public/sw*
|
||||
@@ -31,3 +31,6 @@ config.yaml
|
||||
# backend
|
||||
**/uploads/**
|
||||
**/tmp/**
|
||||
|
||||
# worker
|
||||
pkg/mail/out/
|
||||
|
||||
23
Dockerfile
23
Dockerfile
@@ -1,25 +1,23 @@
|
||||
FROM node:22-alpine AS front-base
|
||||
WORKDIR /app
|
||||
|
||||
# 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
|
||||
WORKDIR /app
|
||||
COPY --from=front-deps /app/dist/ .
|
||||
RUN corepack enable pnpm && pnpm build
|
||||
RUN apk add --no-cache gcompat
|
||||
ENV CI=true
|
||||
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
|
||||
# Workspace and module manifests for cache
|
||||
COPY go.work go.work.sum ./
|
||||
COPY backend/ ./backend/
|
||||
COPY worker/ ./worker/
|
||||
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 && \
|
||||
go mod download
|
||||
# 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
|
||||
ARG VERSION
|
||||
ARG BUILD_TIME
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache curl openssl
|
||||
ENV NODE_ENV production
|
||||
|
||||
@@ -37,7 +34,7 @@ RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nuxtjs
|
||||
|
||||
# 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 015.sh /app/015.sh
|
||||
|
||||
|
||||
46
README-zh.md
46
README-zh.md
@@ -42,15 +42,15 @@
|
||||
## 📸 截图预览
|
||||
|
||||
| 选择文件上传页面 | 输入文本上传页面 |
|
||||
|---------------------------------------|-----------------------------------------------|
|
||||
| ------------------------- | ------------------------- |
|
||||
|  |  |
|
||||
|
||||
| 多选文件上传 | 文件上传进度热力图 |
|
||||
|------------------------------------------------|---------------------------------------------------|
|
||||
| ------------------------- | ------------------------- |
|
||||
|  |  |
|
||||
|
||||
| 文件上传进度条 | 文件上传成功页面 |
|
||||
|------------------------------------------------|-------------------------------------------------|
|
||||
| ------------------------- | ------------------------- |
|
||||
|  |  |
|
||||
|
||||
## 🚀 快速开始
|
||||
@@ -63,29 +63,13 @@
|
||||
|
||||
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. 启动
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. 访问 `http://localhost:8080`
|
||||
|
||||
## 🏗️ 技术架构
|
||||
|
||||
@@ -130,15 +114,15 @@ docker compose up -d
|
||||
015/
|
||||
├── front/ # 前端应用 (Vue 3 + Nuxt 3)
|
||||
│ ├── components/ # Vue 组件
|
||||
│ │ ├── pages/ # 页面路由
|
||||
│ │ ├── composables/ # 组合式函数
|
||||
│ │ ├── i18n/ # 国际化文件
|
||||
│ │ └── assets/ # 静态资源
|
||||
│ └── middleware/ # 中间件
|
||||
│ ├── pages/ # 页面路由
|
||||
│ ├── composables/ # 组合式函数
|
||||
│ ├── i18n/ # 国际化文件
|
||||
│ ├── assets/ # 静态资源
|
||||
│ ├── plugins/ # Nuxt 插件
|
||||
│ └── server/ # 服务端路由
|
||||
├── backend/ # 后端服务 (Go + Echo)
|
||||
│ ├── internal/ # 内部包
|
||||
│ │ ├── controllers/ # 控制器
|
||||
│ │ ├── models/ # 数据模型
|
||||
│ │ ├── services/ # 业务逻辑
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ └── middleware/ # 中间件
|
||||
@@ -147,7 +131,7 @@ docker compose up -d
|
||||
│ │ ├── tasks/ # 任务处理器
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ └── middleware/ # 中间件
|
||||
└── tmp/ # 临时文件
|
||||
└── pkg/ # 公共包
|
||||
```
|
||||
|
||||
## 🔧 开发指南
|
||||
@@ -186,19 +170,19 @@ cd worker && go build -o worker .
|
||||
- 前端计算哈希和秒传
|
||||
- 并发切片上传 (使用 Web Worker)
|
||||
- 文件上传/文本上传和分享
|
||||
- 支持多文件上传
|
||||
- 上传统计页面
|
||||
- 多语言支持
|
||||
- 最大上传限制
|
||||
- 后端队列系统和 Worker 处理文件
|
||||
- 断点续传 (后端计算已上传部分并返回)
|
||||
- 图片格式转换和压缩
|
||||
|
||||
### 计划功能 🚧
|
||||
|
||||
- 断点续传 (后端计算已上传部分并返回)
|
||||
- 图片格式转换和压缩
|
||||
- 图片 OCR 复制
|
||||
- 文档转 Markdown
|
||||
- 文本翻译/总结
|
||||
- 支持上传多文件
|
||||
|
||||
## 🤝 贡献指南
|
||||
|
||||
|
||||
58
README.md
58
README.md
@@ -42,17 +42,34 @@ English | [中文](README-zh.md)
|
||||
## 📸 Screenshots
|
||||
|
||||
| File Selection Upload Page | Text Input Upload Page |
|
||||
|---------------------------------------|-----------------------------------------------|
|
||||
| -------------------------- | ------------------------- |
|
||||
|  |  |
|
||||
|
||||
| Multiple File Upload | Upload Progress Heatmap |
|
||||
|------------------------------------------------|---------------------------------------------------|
|
||||
| -------------------------- | ------------------------- |
|
||||
|  |  |
|
||||
|
||||
| Upload Progress Bar | Upload Success Page |
|
||||
|------------------------------------------------|-------------------------------------------------|
|
||||
| -------------------------- | ------------------------- |
|
||||
|  |  |
|
||||
|
||||
## 🚀 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
|
||||
|
||||
### Frontend Tech Stack
|
||||
@@ -90,36 +107,21 @@ English | [中文](README-zh.md)
|
||||
- **Redis Cache** - Share information and file metadata caching
|
||||
- **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
|
||||
|
||||
```
|
||||
015/
|
||||
├── front/ # Frontend application (Vue 3 + Nuxt 3)
|
||||
│ ├── components/ # Vue components
|
||||
│ │ ├── pages/ # Page routes
|
||||
│ │ ├── composables/ # Composable functions
|
||||
│ │ ├── i18n/ # Internationalization files
|
||||
│ │ └── assets/ # Static assets
|
||||
│ └── middleware/ # Middleware
|
||||
│ ├── pages/ # Page routes
|
||||
│ ├── composables/ # Composable functions
|
||||
│ ├── i18n/ # Internationalization files
|
||||
│ ├── assets/ # Static assets
|
||||
│ ├── plugins/ # Nuxt plugins
|
||||
│ └── server/ # Server-side routes
|
||||
├── backend/ # Backend service (Go + Echo)
|
||||
│ ├── internal/ # Internal packages
|
||||
│ │ ├── controllers/ # Controllers
|
||||
│ │ ├── models/ # Data models
|
||||
│ │ ├── services/ # Business logic
|
||||
│ │ └── utils/ # Utility functions
|
||||
│ └── middleware/ # Middleware
|
||||
@@ -128,7 +130,7 @@ docker compose up -d
|
||||
│ │ ├── tasks/ # Task processors
|
||||
│ │ └── utils/ # Utility functions
|
||||
│ └── middleware/ # Middleware
|
||||
└── tmp/ # Temporary files
|
||||
└── pkg/ # Shared packages
|
||||
```
|
||||
|
||||
## 🔧 Development Guide
|
||||
@@ -167,19 +169,19 @@ cd worker && go build -o worker .
|
||||
- Frontend hash calculation and instant transfer
|
||||
- Concurrent chunked upload (using Web Worker)
|
||||
- File upload/text upload and sharing
|
||||
- Multiple file upload support
|
||||
- Upload statistics page
|
||||
- Multi-language support
|
||||
- Maximum upload limits
|
||||
- Backend queue system and Worker file processing
|
||||
- Resume upload (backend calculates uploaded parts and returns)
|
||||
- Image format conversion and compression
|
||||
|
||||
### Planned Features 🚧
|
||||
|
||||
- Resume upload (backend calculates uploaded parts and returns)
|
||||
- Image format conversion and compression
|
||||
- Image OCR copy
|
||||
- Document to Markdown conversion
|
||||
- Text translation/summarization
|
||||
- Support for multiple file uploads
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
||||
@@ -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/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
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/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
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/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/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
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/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/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/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
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/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/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
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/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/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
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/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
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/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -10,12 +10,30 @@ import (
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func GetConfig(c *echo.Context) error {
|
||||
featureConfig := u.GetEnvMap("features")
|
||||
features := lo.FilterMap(lo.Entries(featureConfig), func(e lo.Entry[string, any], _ int) (string, bool) {
|
||||
var defaultEnabledFeatures = []string{
|
||||
"file-share",
|
||||
"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)
|
||||
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{
|
||||
"site_title": u.GetEnvMap("site.title"),
|
||||
@@ -23,8 +41,14 @@ func GetConfig(c *echo.Context) error {
|
||||
"site_url": u.GetEnv("site.url"),
|
||||
"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_enable_bg": cast.ToBool(u.GetEnvWithDefault("site.enable_bg", "true")),
|
||||
"version": u.GetEnvWithDefault("VERSION", "dev"),
|
||||
"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,
|
||||
// },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ package controllers
|
||||
|
||||
import (
|
||||
"backend/internal/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"pkg/models"
|
||||
u "pkg/utils"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/samber/lo"
|
||||
"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) {
|
||||
return []byte(u.GetEnv("share.download_secret")), nil
|
||||
})
|
||||
if err != nil {
|
||||
return utils.HTTPErrorHandler(c, err)
|
||||
if err != nil || !t.Valid {
|
||||
return utils.HTTPErrorHandler(c, lo.Ternary(err != nil, err, ErrInvalidRequest))
|
||||
}
|
||||
if !t.Valid {
|
||||
return utils.HTTPErrorHandler(c, ErrInvalidRequest)
|
||||
shareInfo, err := models.GetRedisShareInfo(claims.ShareId)
|
||||
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 {
|
||||
fileInfo, _ := models.GetRedisFileInfo(shareInfo.Data)
|
||||
uploadPath, err := u.GetUploadDirPath()
|
||||
@@ -81,7 +84,11 @@ func VaildateShare(c *echo.Context) error {
|
||||
return utils.HTTPErrorHandler(c, ErrInvalidSharePassword)
|
||||
}
|
||||
}
|
||||
// 如果下载次数为0,则设置为-1 防止空值问题
|
||||
return u.WithLocker(context.Background(), "015:shareInfoMap:"+r.ShareId, 0, func(ctx context.Context) error {
|
||||
shareInfo, err := models.GetRedisShareInfo(r.ShareId)
|
||||
if err != nil || shareInfo == nil {
|
||||
return utils.HTTPErrorHandler(c, lo.Ternary(err != nil, err, ErrShareNotFound))
|
||||
}
|
||||
if shareInfo.ViewNum < 1 {
|
||||
return utils.HTTPErrorHandler(c, ErrInsufficientDownloadQuota)
|
||||
}
|
||||
@@ -111,13 +118,9 @@ func VaildateShare(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
// download_nums 必须放在创建token的时候减掉,不然多线程下载会导致多次减掉
|
||||
latestViewNum := shareInfo.ViewNum - 1
|
||||
// 如果下载次数为0,则设置为-1 防止空值问题
|
||||
if latestViewNum < 1 {
|
||||
latestViewNum = -1
|
||||
}
|
||||
err = models.SetRedisShareInfo(r.ShareId, models.RedisShareInfo{
|
||||
ViewNum: latestViewNum,
|
||||
_, err = models.SetRedisShareInfo(r.ShareId, func(shareInfo *models.RedisShareInfo) *models.RedisShareInfo {
|
||||
shareInfo.ViewNum -= 1
|
||||
return shareInfo
|
||||
})
|
||||
if err != nil {
|
||||
return utils.HTTPErrorHandler(c, err)
|
||||
@@ -125,7 +128,7 @@ func VaildateShare(c *echo.Context) error {
|
||||
|
||||
// 统计分享数
|
||||
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
|
||||
return stat
|
||||
})
|
||||
@@ -133,6 +136,16 @@ func VaildateShare(c *echo.Context) error {
|
||||
return utils.HTTPErrorHandler(c, err)
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -141,4 +154,5 @@ func VaildateShare(c *echo.Context) error {
|
||||
return utils.HTTPSuccessHandler(c, map[string]any{
|
||||
"token": downloadToken,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ var (
|
||||
ErrInvalidFileSliceIndex = errors.New("InvalidFileSliceIndex") // 文件切片索引错误
|
||||
ErrInvalidFileSliceSize = errors.New("InvalidFileSliceSize") // 文件切片大小错误
|
||||
ErrIncompleteFileSlices = errors.New("IncompleteFileSlices") // 文件切片不完整
|
||||
ErrFileMD5Mismatch = errors.New("FileMD5Mismatch") // 文件MD5不一致
|
||||
ErrFileHashMismatch = errors.New("FileHashMismatch") // 文件Hash不一致
|
||||
|
||||
// 分享相关
|
||||
ErrShareFileNotFound = errors.New("ShareFileNotFound") // 分享文件不存在
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func CreateUploadTask(c *echo.Context) error {
|
||||
@@ -78,36 +77,33 @@ func CreateUploadTask(c *echo.Context) error {
|
||||
for r.FileSize/ChunkSize > 1000 {
|
||||
ChunkSize *= 2
|
||||
}
|
||||
uploadTaskExpire := cast.ToInt64(u.GetEnvWithDefault("upload.remove_expire", "2")) * 3600
|
||||
newFileInfo := models.RedisFileInfo{
|
||||
FileType: models.FileTypeInit,
|
||||
FileInfo: models.FileInfo{
|
||||
redisFileInfo, err := models.SetRedisFileInfo(fileId, func(fileInfo *models.RedisFileInfo) *models.RedisFileInfo {
|
||||
fileInfo.FileType = models.FileTypeInit
|
||||
fileInfo.FileInfo = models.FileInfo{
|
||||
FileSize: r.FileSize,
|
||||
MimeType: r.MimeType,
|
||||
FileHash: r.FileHash,
|
||||
ChunkSize: ChunkSize,
|
||||
},
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Expire: uploadTaskExpire,
|
||||
}
|
||||
err = models.SetRedisFileInfo(fileId, newFileInfo)
|
||||
return fileInfo
|
||||
})
|
||||
if err != nil {
|
||||
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 {
|
||||
return utils.HTTPErrorHandler(c, err)
|
||||
}
|
||||
|
||||
return utils.HTTPSuccessHandler(c, map[string]any{
|
||||
"size": newFileInfo.FileSize,
|
||||
"mime_type": newFileInfo.MimeType,
|
||||
"hash": newFileInfo.FileHash,
|
||||
"type": newFileInfo.FileType,
|
||||
"expire": newFileInfo.Expire,
|
||||
"size": redisFileInfo.FileSize,
|
||||
"mime_type": redisFileInfo.MimeType,
|
||||
"hash": redisFileInfo.FileHash,
|
||||
"type": redisFileInfo.FileType,
|
||||
"expire": redisFileInfo.Expire,
|
||||
"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)
|
||||
}
|
||||
|
||||
// 计算文件MD5
|
||||
// 计算文件SHA1
|
||||
file, err := os.Open(mergeFilePath)
|
||||
if err != nil {
|
||||
return utils.HTTPErrorHandler(c, err)
|
||||
}
|
||||
defer file.Close() //nolint:errcheck
|
||||
|
||||
file_hash, err := u.GetFileMd5(file)
|
||||
file_hash, err := u.GetFileSHA1(file)
|
||||
if err != nil || file_hash != fileInfo.FileHash {
|
||||
defer os.Remove(mergeFilePath) //nolint:errcheck
|
||||
if err == nil {
|
||||
return utils.HTTPErrorHandler(c, ErrFileMD5Mismatch)
|
||||
return utils.HTTPErrorHandler(c, ErrFileHashMismatch)
|
||||
}
|
||||
return utils.HTTPErrorHandler(c, err)
|
||||
}
|
||||
|
||||
// 更新文件信息
|
||||
err = models.SetRedisFileInfo(r.FileId, models.RedisFileInfo{
|
||||
FileType: models.FileTypeUpload,
|
||||
fileInfo, err = models.SetRedisFileInfo(r.FileId, func(fileInfo *models.RedisFileInfo) *models.RedisFileInfo {
|
||||
fileInfo.FileType = models.FileTypeUpload
|
||||
return fileInfo
|
||||
})
|
||||
if err != nil {
|
||||
return utils.HTTPErrorHandler(c, err)
|
||||
}
|
||||
// 统计
|
||||
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.FileNum += 1
|
||||
return stat
|
||||
|
||||
@@ -28,7 +28,10 @@ type ShareConfig struct {
|
||||
HasPassword bool `json:"has_password"`
|
||||
Password string `json:"password"`
|
||||
HasNotify bool `json:"has_notify"`
|
||||
NotifyEmail []string `json:"notify_email"`
|
||||
NotifyTypes []string `json:"notify_types"`
|
||||
NotifyEmails []string `json:"notify_emails"`
|
||||
NotifyWebhooks []models.NotifyWebhook `json:"notify_webhooks"`
|
||||
Locale string `json:"locale"`
|
||||
HasPickupCode bool `json:"has_pickup_code"`
|
||||
}
|
||||
|
||||
@@ -73,16 +76,41 @@ func CreateShareInfo(c *echo.Context) error {
|
||||
password = hash
|
||||
}
|
||||
|
||||
err = models.SetRedisShareInfo(id, models.RedisShareInfo{
|
||||
Data: r.Data,
|
||||
Type: r.Type,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Owner: owner,
|
||||
ViewNum: r.Config.ViewNum,
|
||||
Password: password,
|
||||
// NotifyEmail: r.Config.NotifyEmail,
|
||||
FileName: r.FileName,
|
||||
ExpireAt: ExpireTime.Unix(),
|
||||
var notifyEmails []string
|
||||
var notifyWebhooks []models.NotifyWebhook
|
||||
if r.Config.HasNotify {
|
||||
hasEmail, hasWebhook := false, false
|
||||
for _, nt := range r.Config.NotifyTypes {
|
||||
switch nt {
|
||||
case "email":
|
||||
hasEmail = true
|
||||
case "webhook":
|
||||
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 {
|
||||
return utils.HTTPErrorHandler(c, err)
|
||||
@@ -128,7 +156,7 @@ func CreateShareInfo(c *echo.Context) error {
|
||||
|
||||
// 统计分享数
|
||||
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
|
||||
return stat
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
var handleTaskMap = map[string]func(c *echo.Context) ([]byte, error){
|
||||
"image:compress": task.HandleImageCompress,
|
||||
"image:convert": task.HandleImageConvert,
|
||||
"text:translate": task.HandleTextTranslate,
|
||||
}
|
||||
|
||||
func CreateTask(c *echo.Context) error {
|
||||
|
||||
45
backend/internal/controllers/task/text.go
Normal file
45
backend/internal/controllers/task/text.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -71,18 +71,13 @@ func MergeFileSlices(fileId string, uploadPath string) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("打开切片文件失败: %v", err)
|
||||
}
|
||||
defer sf.Close() //nolint:errcheck
|
||||
for {
|
||||
n, err := sf.Read(buffer)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取切片文件失败: %v", err)
|
||||
}
|
||||
if _, err := destFile.Write(buffer[:n]); err != nil {
|
||||
return "", fmt.Errorf("写入合并文件失败: %v", err)
|
||||
|
||||
if _, err := io.CopyBuffer(destFile, sf, buffer); err != nil {
|
||||
sf.Close() //nolint:errcheck
|
||||
return "", fmt.Errorf("合并切片文件失败: %v", err)
|
||||
}
|
||||
if err := sf.Close(); err != nil {
|
||||
return "", fmt.Errorf("关闭切片文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
return mergeFilePath, nil
|
||||
|
||||
@@ -6,40 +6,36 @@ import (
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type Option interface {
|
||||
applyTo(*HTTPBaseResponse)
|
||||
}
|
||||
|
||||
type HTTPBaseResponse struct {
|
||||
code int
|
||||
message string
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
type HTTPBaseResponseProps func(props *HTTPBaseResponse) error
|
||||
type HTTPBaseResponseProps func(props *HTTPBaseResponse)
|
||||
|
||||
type WithCode int
|
||||
|
||||
func (o WithCode) applyTo(props *HTTPBaseResponse) {
|
||||
props.code = int(o)
|
||||
func WithCode(data int) HTTPBaseResponseProps {
|
||||
return func(props *HTTPBaseResponse) {
|
||||
props.code = data
|
||||
}
|
||||
}
|
||||
|
||||
type WithMessage string
|
||||
|
||||
func (o WithMessage) applyTo(props *HTTPBaseResponse) {
|
||||
props.message = string(o)
|
||||
func WithMessage(data string) HTTPBaseResponseProps {
|
||||
return func(props *HTTPBaseResponse) {
|
||||
props.message = data
|
||||
}
|
||||
}
|
||||
|
||||
type WithData map[string]any
|
||||
|
||||
func (o WithData) applyTo(props *HTTPBaseResponse) {
|
||||
props.data = o
|
||||
func WithData(data map[string]any) HTTPBaseResponseProps {
|
||||
return func(props *HTTPBaseResponse) {
|
||||
props.data = data
|
||||
}
|
||||
}
|
||||
|
||||
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{}}
|
||||
for _, option := range options {
|
||||
option.applyTo(&props)
|
||||
option(&props)
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
return HTTPBaseHandler(c, WithMessage(err.Error()), WithCode(http.StatusBadRequest))
|
||||
options = append([]HTTPBaseResponseProps{WithMessage(err.Error()), WithCode(http.StatusBadRequest)}, options...)
|
||||
return HTTPBaseHandler(c, options...)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,15 @@ func main() {
|
||||
}
|
||||
defer logger.Sync() //nolint:errcheck
|
||||
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()
|
||||
for _, middleware := range middlewares {
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import getFileSize from '~/lib/getFileSize'
|
||||
import SparkMD5 from 'spark-md5'
|
||||
import useMyAppConfig from '@/composables/useMyAppConfig'
|
||||
import { useFeatureMeta } from '@/composables/useFeatureMeta'
|
||||
import Progress from '~/components/ui/progress/Progress.vue'
|
||||
import renderI18n from '~/lib/renderI18n'
|
||||
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 appConfig = useMyAppConfig()
|
||||
@@ -34,9 +37,12 @@ const { data, isLoading } = useQuery({
|
||||
})
|
||||
const { t } = useI18n()
|
||||
|
||||
const genUserAvatar = (email: string) => {
|
||||
return `https://www.gravatar.com/avatar/${SparkMD5.hash(email)}?d=retro`
|
||||
}
|
||||
const { state: userAvatar } = useAsyncState(async () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -88,7 +94,7 @@ const genUserAvatar = (email: string) => {
|
||||
"
|
||||
>
|
||||
<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">
|
||||
{{ data?.name?.charAt(0)?.toUpperCase() }}
|
||||
</AvatarFallback>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cx } from 'class-variance-authority'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import dayjs from 'dayjs'
|
||||
import { filesize } from 'filesize'
|
||||
import { times } from 'lodash-es'
|
||||
import type { ChartConfig } from '@/components/ui/chart'
|
||||
import { VisArea, VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
|
||||
@@ -209,8 +210,15 @@ const currentChartData = computed((): AreaChartConfig => {
|
||||
:key="currentChartTab"
|
||||
:template="
|
||||
componentToString(currentChartData.config, ChartTooltipContent, {
|
||||
class: 'w-[14rem]',
|
||||
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)
|
||||
},
|
||||
})
|
||||
"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
showBackButton?: boolean
|
||||
@@ -21,7 +22,7 @@ const router = useRouter()
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideHome />
|
||||
<LucideHome class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<slot />
|
||||
|
||||
32
front/components/CopyButton.vue
Normal file
32
front/components/CopyButton.vue
Normal 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>
|
||||
@@ -1,23 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { cx } from 'class-variance-authority'
|
||||
import type { Locale } from '@intlify/core-base'
|
||||
|
||||
const props = defineProps<{
|
||||
hide: () => void
|
||||
}>()
|
||||
|
||||
const { availableLocales, setLocale, locale: currentLocale, t } = useI18n()
|
||||
const { locales, setLocale, locale: currentLocale, t } = useI18n()
|
||||
|
||||
const localeMap = {
|
||||
'zh-CN': '简体中文',
|
||||
en: 'English',
|
||||
// 'ja': '日本語',
|
||||
// 'ko': '한국어',
|
||||
// 'fr': 'Français',
|
||||
// 'de': 'Deutsch',
|
||||
}
|
||||
|
||||
const switchLocale = async (locale: string) => {
|
||||
await setLocale(locale as keyof typeof localeMap)
|
||||
const switchLocale = async (locale: Locale) => {
|
||||
await setLocale(locale)
|
||||
props.hide()
|
||||
}
|
||||
</script>
|
||||
@@ -26,12 +18,12 @@ const switchLocale = async (locale: string) => {
|
||||
<div class="flex flex-col gap-1 py-2">
|
||||
<div class="text-xl font-bold mb-3">{{ t('i18n.switchLocale') }}</div>
|
||||
<div
|
||||
v-for="locale in availableLocales"
|
||||
:key="locale"
|
||||
:class="cx('rounded-md hover:bg-black/10 p-2 cursor-pointer', currentLocale === locale && 'bg-black/10 font-bold')"
|
||||
@click="() => switchLocale(locale)"
|
||||
v-for="locale in locales"
|
||||
:key="locale.code"
|
||||
:class="cx('rounded-md hover:bg-black/10 p-2 cursor-pointer', currentLocale === locale.code && 'bg-black/10 font-bold')"
|
||||
@click="() => switchLocale(locale.code)"
|
||||
>
|
||||
{{ localeMap?.[locale as keyof typeof localeMap] }}
|
||||
{{ locale.name }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -20,6 +20,15 @@ const actionHandlers: Partial<Record<FeatureKey, ActionHandler>> = {
|
||||
'text-share': {
|
||||
onClick: () => showDrawer({ render: ({ hide }) => h(TextShareHandle, { ...props, hide }) }),
|
||||
},
|
||||
// 'text-translate': {
|
||||
// onClick: () =>
|
||||
// props.onTextHandle({
|
||||
// type: 'text-translate',
|
||||
// config: {
|
||||
// source: 'auto',
|
||||
// },
|
||||
// }),
|
||||
// },
|
||||
// 'text-image-generate': {
|
||||
// label: '生成配图', icon: LucideImage, className: 'bg-red-300',
|
||||
// onClick: () => { console.log('复制链接') }
|
||||
|
||||
@@ -3,7 +3,7 @@ import FileUpload from '@/components/FileUpload.vue'
|
||||
import { cx } from 'class-variance-authority'
|
||||
import type { RuleExpression } from 'vee-validate'
|
||||
import Button from '../ui/button/Button.vue'
|
||||
import { PlusIcon } from 'lucide-vue-next'
|
||||
import { PlusIcon } from '@lucide/vue'
|
||||
import { isEmpty } from 'lodash-es'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -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">
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { RuleExpression } from 'vee-validate'
|
||||
|
||||
const props = defineProps<{
|
||||
name: string
|
||||
label?: string
|
||||
rules?: RuleExpression<string>
|
||||
}>()
|
||||
|
||||
const { value, errorMessage } = useField<string>(props.name, props.rules)
|
||||
</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>
|
||||
|
||||
37
front/components/Field/InputGroupField.vue
Normal file
37
front/components/Field/InputGroupField.vue
Normal 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>
|
||||
102
front/components/Field/KvInputGroupField.vue
Normal file
102
front/components/Field/KvInputGroupField.vue
Normal 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>
|
||||
@@ -1,13 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { RuleExpression } from 'vee-validate'
|
||||
type SelectValue = string | number
|
||||
const props = defineProps<{
|
||||
@@ -19,13 +11,14 @@ const props = defineProps<{
|
||||
label?: string
|
||||
value: SelectValue
|
||||
}[]
|
||||
class?: string
|
||||
}>()
|
||||
const { value } = useField<SelectValue>(props.name, props?.rules)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Select v-model="value">
|
||||
<SelectTrigger>
|
||||
<SelectTrigger :class="class">
|
||||
<SelectValue :placeholder="placeholder" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
25
front/components/Field/TextareaField.vue
Normal file
25
front/components/Field/TextareaField.vue
Normal 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>
|
||||
@@ -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>
|
||||
41
front/components/FileIcon/File.vue
Normal file
41
front/components/FileIcon/File.vue
Normal 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>
|
||||
29
front/components/FileIcon/Image.vue
Normal file
29
front/components/FileIcon/Image.vue
Normal 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>
|
||||
61
front/components/FileIcon/Index.vue
Normal file
61
front/components/FileIcon/Index.vue
Normal 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>
|
||||
27
front/components/FileIcon/Video.vue
Normal file
27
front/components/FileIcon/Video.vue
Normal 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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import getFileSize from '~/lib/getFileSize'
|
||||
import type { filePreview } from './FileIcon.vue'
|
||||
import type { filePreview } from './FileIcon/Index.vue'
|
||||
const props = defineProps<{
|
||||
value: File | filePreview
|
||||
}>()
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
||||
import { createVNode } from 'vue'
|
||||
import type { VNode } from 'vue'
|
||||
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 currentDrawer = computed(() => store.drawer?.[store.drawer?.length - 1])
|
||||
|
||||
const render = computed(() => currentDrawer?.value?.render)
|
||||
const hide = computed(() => currentDrawer?.value?.onClose)
|
||||
const Children = () =>
|
||||
render.value
|
||||
? createVNode(render.value, {
|
||||
hide: hide?.value,
|
||||
})
|
||||
: null
|
||||
const Children = ({ drawer }: { drawer: DrawerItem }) => {
|
||||
if (!drawer.render) {
|
||||
return null
|
||||
}
|
||||
return isFunction(drawer.render) ? drawer.render({ hide: drawer.onClose }) : drawer.render
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
:open="!!store.drawer?.[store.drawer?.length - 1]"
|
||||
v-for="item in store.drawer"
|
||||
:key="item.key"
|
||||
:open="item.visible"
|
||||
@update:open="
|
||||
(open) => {
|
||||
if (!open && store?.drawer?.length && hide) {
|
||||
hide()
|
||||
if (!open) {
|
||||
item.onClose()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<DrawerContent>
|
||||
<div class="mx-auto min-w-lg max-w-[80vw] pb-10 px-3">
|
||||
<Children />
|
||||
<Children :drawer="item" />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<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 getFileSize from '~/lib/getFileSize'
|
||||
import { cx } from 'class-variance-authority'
|
||||
import asyncWorker from '@/lib/asyncWorker'
|
||||
import calcFileHashWorker from '@/lib/calcFileHashWorker?worker'
|
||||
import { detectSupportedEngines } from '@/lib/calcFileHash'
|
||||
import { clamp, get, isEmpty, isNumber, sample, shuffle, throttle, times } from 'lodash-es'
|
||||
import { nanoid } from 'nanoid'
|
||||
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 uploadfile = uploadfiles.value.find((item) => item.fileId === fileId)
|
||||
if (!uploadfile?.file) return
|
||||
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 || {}
|
||||
uploadfile.hash = hash
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ const handleTextShare = ({ type, config }: { type: string; config: any }) => {
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideX />
|
||||
<LucideX class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row gap-3">
|
||||
|
||||
@@ -14,7 +14,7 @@ const renderHtml = computed(() => {
|
||||
<div
|
||||
:class="
|
||||
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
|
||||
)
|
||||
"
|
||||
|
||||
@@ -22,9 +22,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cx } from 'class-variance-authority'
|
||||
import { LucideClipboardType, LucidePaperclip } from '#components'
|
||||
import { motion } from 'motion-v'
|
||||
import { LucideGlobe } from 'lucide-vue-next'
|
||||
import { LucideGlobe, LucideClipboardType, LucidePaperclip } from '@lucide/vue'
|
||||
import showDrawer from '@/lib/showDrawer'
|
||||
import I18nSwitchDrawer from './Drawer/I18nSwitchDrawer.vue'
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import SwitchField from '../Field/SwitchField.vue'
|
||||
import InputField from '../Field/InputField.vue'
|
||||
import SelectField from '../Field/SelectField.vue'
|
||||
import SwitchField from '../Field/SwitchField.vue'
|
||||
import FormButton from '../Field/FormButton.vue'
|
||||
import NotifyConfigField from './NotifyConfigField.vue'
|
||||
import type { FileShareHandleProps } from './types'
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
@@ -14,8 +15,9 @@ const props = defineProps<{
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<div class="flex flex-col gap-3 flex-1 overflow-y-auto">
|
||||
<div class="flex flex-row items-center gap-2 text-sm">
|
||||
<SelectField
|
||||
name="download_nums"
|
||||
@@ -76,14 +78,7 @@ const props = defineProps<{
|
||||
rules="required"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row gap-3 min-h-9">
|
||||
<SwitchField name="has_notify" :label="t('page.shareOptions.file.downloadNotify')" />
|
||||
<InputField
|
||||
v-if="!!values.has_notify"
|
||||
name="notify_email"
|
||||
:placeholder="t('page.shareOptions.file.emailPlaceholder')"
|
||||
rules="required"
|
||||
/>
|
||||
<NotifyConfigField :switchLabel="t('page.shareOptions.file.downloadNotify')" />
|
||||
</div>
|
||||
</div>
|
||||
<FormButton
|
||||
|
||||
178
front/components/Preprocessing/NotifyConfigField.vue
Normal file
178
front/components/Preprocessing/NotifyConfigField.vue
Normal 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>
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import SwitchField from '../Field/SwitchField.vue'
|
||||
import InputField from '../Field/InputField.vue'
|
||||
import SelectField from '../Field/SelectField.vue'
|
||||
import SwitchField from '../Field/SwitchField.vue'
|
||||
import FormButton from '../Field/FormButton.vue'
|
||||
import NotifyConfigField from './NotifyConfigField.vue'
|
||||
import type { TextShareHandleProps } from './types'
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
@@ -14,8 +15,9 @@ const props = defineProps<{
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<div class="flex flex-col gap-3 flex-1 overflow-y-auto">
|
||||
<div class="flex flex-row items-center gap-2 text-sm">
|
||||
<SelectField
|
||||
name="download_nums"
|
||||
@@ -76,14 +78,7 @@ const props = defineProps<{
|
||||
rules="required"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row gap-3 min-h-9">
|
||||
<SwitchField name="has_notify" :label="t('page.shareOptions.text.readNotify')" />
|
||||
<InputField
|
||||
v-if="!!values.has_notify"
|
||||
name="notify_email"
|
||||
:placeholder="t('page.shareOptions.text.emailPlaceholder')"
|
||||
rules="required"
|
||||
/>
|
||||
<NotifyConfigField :switchLabel="t('page.shareOptions.text.readNotify')" />
|
||||
</div>
|
||||
</div>
|
||||
<FormButton
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type FileHandleKey = 'file-share' | 'file-image-compress' | 'file-image-convert'
|
||||
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> }
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import FilePreviewView from '@/components/FilePreviewView.vue'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useClipboard, useShare } from '@vueuse/core'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useShare } from '@vueuse/core'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import useMyAppShare from '@/composables/useMyAppShare'
|
||||
import useMyAppConfig from '@/composables/useMyAppConfig'
|
||||
@@ -22,6 +21,7 @@ const { t } = useI18n()
|
||||
const { createFileShare } = useMyAppShare()
|
||||
const { data } = useQuery({
|
||||
queryKey: ['create-share', ...props?.data?.files?.map((item) => item.id)],
|
||||
staleTime: Infinity,
|
||||
queryFn: async () => {
|
||||
const { files, config } = props?.data || {}
|
||||
const data = await createFileShare({
|
||||
@@ -49,7 +49,6 @@ const getShareUrl = (id: string) => {
|
||||
return `${appConfig?.value?.site_url}/s/${id}`
|
||||
}
|
||||
|
||||
const { copy } = useClipboard()
|
||||
const { share, isSupported: isShareSupported } = useShare()
|
||||
|
||||
const handleShare = async (id: string, fileName?: string) => {
|
||||
@@ -104,28 +103,20 @@ const handleShowQrCode = (id: string) => {
|
||||
size="icon"
|
||||
@click.stop="handleShare(file?.id as string, file?.file_name)"
|
||||
>
|
||||
<LucideShare />
|
||||
<LucideShare class="size-1/2" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
<CopyButton
|
||||
:class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')"
|
||||
size="icon"
|
||||
@click.stop="
|
||||
() => {
|
||||
copy(getShareUrl(file?.id as string))
|
||||
toast.success(t('page.result.file.copySuccess'))
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideCopy />
|
||||
</Button>
|
||||
:value="getShareUrl(file?.id as string)"
|
||||
@click.stop
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
:class="cx('bg-white/70', selectedFile === file?.id && '!bg-white/30 border-none hover:text-white/80')"
|
||||
size="icon"
|
||||
@click.stop="handleShowQrCode(file?.id as string)"
|
||||
>
|
||||
<LucideQrCode />
|
||||
<LucideQrCode class="size-1/2" />
|
||||
</Button>
|
||||
</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="flex flex-row justify-between w-full items-center">
|
||||
<div class="text-xs font-semibold">{{ t('page.result.file.pickupCode') }}</div>
|
||||
<Button
|
||||
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>
|
||||
<CopyButton class="bg-white/70 p-0 size-6" :value="selectedFileShare?.pickup_code as string" />
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<div v-for="s in selectedFileShare?.pickup_code" class="text-2xl font-light">
|
||||
@@ -185,24 +164,12 @@ const handleShowQrCode = (id: string) => {
|
||||
)
|
||||
"
|
||||
>
|
||||
<LucideShare />
|
||||
</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 />
|
||||
<LucideShare class="size-1/2" />
|
||||
</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)">
|
||||
<LucideQrCode />
|
||||
<LucideQrCode class="size-1/2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import FileShareResult from '@/components/Result/FileShareResult.vue'
|
||||
import TextShareResult from '@/components/Result/TextShareResult.vue'
|
||||
import TextTranslateResult from '@/components/Result/TextTranslateResult.vue'
|
||||
import ImageCompressResult from '@/components/Result/ImageCompressResult.vue'
|
||||
import ImageConvertResult from '@/components/Result/ImageConvertResult.vue'
|
||||
import type { filehandleData, handleComponent, handleKey, texthandleData } from './types'
|
||||
@@ -16,6 +17,7 @@ const emit = defineEmits<{
|
||||
const handleList: { component: handleComponent; key: handleKey }[] = [
|
||||
{ component: FileShareResult, key: 'file-share' },
|
||||
{ component: TextShareResult, key: 'text-share' },
|
||||
{ component: TextTranslateResult, key: 'text-translate' },
|
||||
{ component: ImageCompressResult, key: 'file-image-compress' },
|
||||
{ component: ImageConvertResult, key: 'file-image-convert' },
|
||||
]
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import useMyAppShare from '@/composables/useMyAppShare'
|
||||
import useMyAppConfig from '@/composables/useMyAppConfig'
|
||||
@@ -37,7 +35,6 @@ const url = computed(() => {
|
||||
return `${appConfig?.value?.site_url}/s/${id}`
|
||||
})
|
||||
|
||||
const { copy } = useClipboard()
|
||||
const { t } = useI18n()
|
||||
</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="flex flex-row justify-between w-full items-center">
|
||||
<div class="text-xs font-semibold">{{ t('page.result.text.pickupCode') }}</div>
|
||||
<Button
|
||||
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>
|
||||
<CopyButton class="bg-white/70 p-0 size-6" :value="data?.pickup_code as string" />
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<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="flex flex-row gap-2">
|
||||
<Input v-model="url" class="bg-white/70" readonly />
|
||||
<Button
|
||||
variant="outline"
|
||||
class="bg-white/70"
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
copy(url)
|
||||
toast.success(t('page.result.text.copySuccess'))
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideCopy />
|
||||
</Button>
|
||||
<CopyButton class="bg-white/70" :value="url" />
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
120
front/components/Result/TextTranslateResult.vue
Normal file
120
front/components/Result/TextTranslateResult.vue
Normal 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>
|
||||
@@ -4,7 +4,7 @@ import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
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 showDrawer from '~/lib/showDrawer'
|
||||
import { toast } from 'vue-sonner'
|
||||
@@ -19,22 +19,24 @@ const props = defineProps<{
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { downloadFile, getShareToken } = useMyAppShare()
|
||||
const token = ref<string>()
|
||||
|
||||
const handleDownload = async () => {
|
||||
const { id } = props?.data || {}
|
||||
try {
|
||||
let token = null
|
||||
if (!token.value) {
|
||||
if (props?.data?.has_password) {
|
||||
token = await showDrawer({
|
||||
token.value = await showDrawer({
|
||||
render: ({ ...rest }) => h(PasswallShareDrawer, { ...rest, share_id: id }),
|
||||
})
|
||||
} else {
|
||||
token = await getShareToken(id)
|
||||
token.value = await getShareToken(id)
|
||||
}
|
||||
if (!token) {
|
||||
if (!token.value) {
|
||||
throw new Error(t('page.shareView.fileShare.getTokenFailed'))
|
||||
}
|
||||
downloadFile(token)
|
||||
}
|
||||
downloadFile(token.value)
|
||||
} catch (error: any) {
|
||||
toast.error(error?.data?.message || error?.message || error)
|
||||
} finally {
|
||||
|
||||
@@ -4,13 +4,10 @@ import AsyncButton from '@/components/ui/button/AsyncButton.vue'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
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 { toast } from 'vue-sonner'
|
||||
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 PasswallShareDrawer from '~/components/Drawer/PasswallShareDrawer.vue'
|
||||
|
||||
@@ -30,8 +27,6 @@ const expireSeconds = computed(() => {
|
||||
|
||||
const { remaining, start } = useCountdown(expireSeconds.value)
|
||||
|
||||
const { copy } = useClipboard()
|
||||
|
||||
onMounted(() => {
|
||||
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-row w-full', !!previewText ? 'justify-between' : 'justify-center')">
|
||||
<h1 class="text-xl">{{ t('page.shareView.textShare.title') }}</h1>
|
||||
<Button
|
||||
v-if="!!previewText"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@click="
|
||||
() => {
|
||||
copy(previewText as string)
|
||||
toast.success(t('page.result.text.copySuccess'))
|
||||
}
|
||||
"
|
||||
>
|
||||
<LucideCopy />
|
||||
</Button>
|
||||
<CopyButton v-if="!!previewText" :value="previewText as string" />
|
||||
</div>
|
||||
<template v-if="!previewText">
|
||||
<div class="flex flex-col gap-2 md:flex-row w-full">
|
||||
|
||||
@@ -4,6 +4,8 @@ import StarterKit from '@tiptap/starter-kit'
|
||||
import { Markdown } from 'tiptap-markdown'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { cx } from 'class-variance-authority'
|
||||
import countWords from '@/lib/countWords'
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: string
|
||||
@@ -15,6 +17,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const editor = ref<Editor | undefined>(undefined)
|
||||
|
||||
onMounted(() => {
|
||||
editor.value = new Editor({
|
||||
content: props.modelValue,
|
||||
@@ -51,11 +54,17 @@ onUnmounted(() => {
|
||||
:editor="editor as any"
|
||||
:class="
|
||||
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
|
||||
)
|
||||
"
|
||||
>
|
||||
</editor-content>
|
||||
<!-- <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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { AccordionTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { AccordionHeader, AccordionTrigger } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const props = withDefaults(
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
labelFormatter?: (d: number | Date) => string
|
||||
valueFormatter?: (value: unknown, key: string) => string
|
||||
payload?: Record<string, any>
|
||||
config?: ChartConfig
|
||||
class?: HTMLAttributes['class']
|
||||
@@ -99,7 +100,7 @@ const tooltipLabel = computed(() => {
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="value" class="text-foreground font-mono font-medium tabular-nums">
|
||||
{{ value.toLocaleString() }}
|
||||
{{ props.valueFormatter ? props.valueFormatter(value, key) : value.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
89
front/components/ui/command/Command.vue
Normal file
89
front/components/ui/command/Command.vue
Normal 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>
|
||||
36
front/components/ui/command/CommandDialog.vue
Normal file
36
front/components/ui/command/CommandDialog.vue
Normal 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>
|
||||
22
front/components/ui/command/CommandEmpty.vue
Normal file
22
front/components/ui/command/CommandEmpty.vue
Normal 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>
|
||||
46
front/components/ui/command/CommandGroup.vue
Normal file
46
front/components/ui/command/CommandGroup.vue
Normal 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>
|
||||
43
front/components/ui/command/CommandInput.vue
Normal file
43
front/components/ui/command/CommandInput.vue
Normal 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>
|
||||
80
front/components/ui/command/CommandItem.vue
Normal file
80
front/components/ui/command/CommandItem.vue
Normal 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>
|
||||
25
front/components/ui/command/CommandList.vue
Normal file
25
front/components/ui/command/CommandList.vue
Normal 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>
|
||||
17
front/components/ui/command/CommandSeparator.vue
Normal file
17
front/components/ui/command/CommandSeparator.vue
Normal 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>
|
||||
14
front/components/ui/command/CommandShortcut.vue
Normal file
14
front/components/ui/command/CommandShortcut.vue
Normal 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>
|
||||
25
front/components/ui/command/index.ts
Normal file
25
front/components/ui/command/index.ts
Normal 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')
|
||||
15
front/components/ui/dialog/Dialog.vue
Normal file
15
front/components/ui/dialog/Dialog.vue
Normal 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>
|
||||
12
front/components/ui/dialog/DialogClose.vue
Normal file
12
front/components/ui/dialog/DialogClose.vue
Normal 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>
|
||||
49
front/components/ui/dialog/DialogContent.vue
Normal file
49
front/components/ui/dialog/DialogContent.vue
Normal 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>
|
||||
19
front/components/ui/dialog/DialogDescription.vue
Normal file
19
front/components/ui/dialog/DialogDescription.vue
Normal 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>
|
||||
25
front/components/ui/dialog/DialogFooter.vue
Normal file
25
front/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||
14
front/components/ui/dialog/DialogHeader.vue
Normal file
14
front/components/ui/dialog/DialogHeader.vue
Normal 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>
|
||||
26
front/components/ui/dialog/DialogOverlay.vue
Normal file
26
front/components/ui/dialog/DialogOverlay.vue
Normal 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>
|
||||
53
front/components/ui/dialog/DialogScrollContent.vue
Normal file
53
front/components/ui/dialog/DialogScrollContent.vue
Normal 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>
|
||||
19
front/components/ui/dialog/DialogTitle.vue
Normal file
19
front/components/ui/dialog/DialogTitle.vue
Normal 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>
|
||||
12
front/components/ui/dialog/DialogTrigger.vue
Normal file
12
front/components/ui/dialog/DialogTrigger.vue
Normal 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>
|
||||
10
front/components/ui/dialog/index.ts
Normal file
10
front/components/ui/dialog/index.ts
Normal 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'
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { DropdownMenuCheckboxItemEmits, DropdownMenuCheckboxItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { DropdownMenuCheckboxItem, DropdownMenuItemIndicator, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { DropdownMenuRadioItemEmits, DropdownMenuRadioItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Circle } from 'lucide-vue-next'
|
||||
import { Circle } from '@lucide/vue'
|
||||
import { DropdownMenuItemIndicator, DropdownMenuRadioItem, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { DropdownMenuSubTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import { DropdownMenuSubTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { MenubarCheckboxItemEmits, MenubarCheckboxItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Check } from 'lucide-vue-next'
|
||||
import { Check } from '@lucide/vue'
|
||||
import { MenubarCheckboxItem, MenubarItemIndicator, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { MenubarRadioItemEmits, MenubarRadioItemProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { Circle } from 'lucide-vue-next'
|
||||
import { Circle } from '@lucide/vue'
|
||||
import { MenubarItemIndicator, MenubarRadioItem, useForwardPropsEmits } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { MenubarSubTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronRight } from 'lucide-vue-next'
|
||||
import { ChevronRight } from '@lucide/vue'
|
||||
import { MenubarSubTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Minus } from 'lucide-vue-next'
|
||||
import { Minus } from '@lucide/vue'
|
||||
import { Primitive, type PrimitiveProps, useForwardProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<PrimitiveProps>()
|
||||
@@ -7,10 +7,7 @@ const forwardedProps = useForwardProps(props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="pin-input-separator"
|
||||
v-bind="forwardedProps"
|
||||
>
|
||||
<Primitive data-slot="pin-input-separator" v-bind="forwardedProps">
|
||||
<slot>
|
||||
<Minus />
|
||||
</slot>
|
||||
|
||||
15
front/components/ui/popover/Popover.vue
Normal file
15
front/components/ui/popover/Popover.vue
Normal 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>
|
||||
12
front/components/ui/popover/PopoverAnchor.vue
Normal file
12
front/components/ui/popover/PopoverAnchor.vue
Normal 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>
|
||||
38
front/components/ui/popover/PopoverContent.vue
Normal file
38
front/components/ui/popover/PopoverContent.vue
Normal 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>
|
||||
12
front/components/ui/popover/PopoverTrigger.vue
Normal file
12
front/components/ui/popover/PopoverTrigger.vue
Normal 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>
|
||||
4
front/components/ui/popover/index.ts
Normal file
4
front/components/ui/popover/index.ts
Normal 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'
|
||||
@@ -9,10 +9,7 @@ const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot
|
||||
data-slot="select"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot />
|
||||
<SelectRoot v-slot="slotProps" data-slot="select" v-bind="forwarded">
|
||||
<slot v-bind="slotProps" />
|
||||
</SelectRoot>
|
||||
</template>
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
<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 {
|
||||
SelectContent,
|
||||
type SelectContentEmits,
|
||||
type SelectContentProps,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from '.'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
const props = withDefaults(defineProps<SelectContentProps & { class?: HTMLAttributes['class'] }>(), {
|
||||
position: 'popper',
|
||||
},
|
||||
)
|
||||
})
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
@@ -36,17 +24,25 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
data-slot="select-content"
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="cn(
|
||||
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 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper'
|
||||
&& '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,
|
||||
position === 'popper' &&
|
||||
'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')">
|
||||
<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'
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<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>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup
|
||||
data-slot="select-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<SelectGroup data-slot="select-group" v-bind="props">
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
<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 { 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 delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
@@ -27,14 +19,16 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
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`,
|
||||
props.class,
|
||||
'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
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<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>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItemText
|
||||
data-slot="select-item-text"
|
||||
v-bind="props"
|
||||
>
|
||||
<SelectItemText data-slot="select-item-text" v-bind="props">
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { SelectLabel } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SelectLabel, type SelectLabelProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel
|
||||
data-slot="select-label"
|
||||
:class="cn('px-2 py-1.5 text-sm font-medium', props.class)"
|
||||
>
|
||||
<SelectLabel data-slot="select-label" :class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)">
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<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 { 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 delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<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 { 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 delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
<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 { SelectSeparator, type SelectSeparatorProps } from 'reka-ui'
|
||||
import { computed, type HTMLAttributes } from 'vue'
|
||||
|
||||
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
|
||||
return delegated
|
||||
})
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator
|
||||
data-slot="select-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
<SelectSeparator data-slot="select-separator" v-bind="delegatedProps" :class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)" />
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectTriggerProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { SelectIcon, SelectTrigger, type SelectTriggerProps, useForwardProps } from 'reka-ui'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectTriggerProps & { class?: HTMLAttributes['class'], size?: 'sm' | 'default' }>(),
|
||||
{ size: 'default' },
|
||||
)
|
||||
const props = withDefaults(defineProps<SelectTriggerProps & { class?: HTMLAttributes['class']; size?: 'sm' | 'default' }>(), { size: 'default' })
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class', 'size')
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
@@ -19,10 +17,12 @@ const forwardedProps = useForwardProps(delegatedProps)
|
||||
data-slot="select-trigger"
|
||||
:data-size="size"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
`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,
|
||||
)"
|
||||
:class="
|
||||
cn(
|
||||
'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>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<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>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectValue
|
||||
data-slot="select-value"
|
||||
v-bind="props"
|
||||
>
|
||||
<SelectValue data-slot="select-value" v-bind="props">
|
||||
<slot />
|
||||
</SelectValue>
|
||||
</template>
|
||||
|
||||
43
front/components/ui/sonner/Sonner.vue
Normal file
43
front/components/ui/sonner/Sonner.vue
Normal 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>
|
||||
1
front/components/ui/sonner/index.ts
Normal file
1
front/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from './Sonner.vue'
|
||||
33
front/components/ui/textarea/Textarea.vue
Normal file
33
front/components/ui/textarea/Textarea.vue
Normal 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>
|
||||
1
front/components/ui/textarea/index.ts
Normal file
1
front/components/ui/textarea/index.ts
Normal 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
Reference in New Issue
Block a user