Compare commits
10 Commits
f785e7defc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fc223ef23 | |||
| bab4b85bed | |||
| 099e05fb56 | |||
| 32e0969ca1 | |||
| c109abc6f5 | |||
| d57037781e | |||
| e936fbc140 | |||
| a96aa6e073 | |||
| 893fac1ff2 | |||
| be59fa85de |
@@ -0,0 +1 @@
|
|||||||
|
compose.self.env
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: Build and Push Backend to Aliyun ACR
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Image version tag, for example v0.1.0-test.1"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ secrets.ALIYUN_REGISTRY }}
|
||||||
|
NAMESPACE: ${{ secrets.ALIYUN_NAME_SPACE }}
|
||||||
|
ALIYUN_REGISTRY_USER: ${{ secrets.ALIYUN_REGISTRY_USER }}
|
||||||
|
ALIYUN_REGISTRY_PASSWORD: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-backend:
|
||||||
|
name: Build and push Rust backend
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Aliyun ACR
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "$ALIYUN_REGISTRY_PASSWORD" | docker login \
|
||||||
|
--username "$ALIYUN_REGISTRY_USER" \
|
||||||
|
--password-stdin "$REGISTRY"
|
||||||
|
|
||||||
|
- name: Prepare image tags
|
||||||
|
id: prep
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${{ inputs.version || github.ref_name }}"
|
||||||
|
IMAGE="${REGISTRY}/${NAMESPACE}/email-unlimit-server"
|
||||||
|
SHORT_SHA="${GITHUB_SHA::7}"
|
||||||
|
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Build and push backend
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
docker buildx build \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--provenance=false \
|
||||||
|
--cache-from=type=gha,scope=email-unlimit-server \
|
||||||
|
--cache-to=type=gha,mode=max,scope=email-unlimit-server \
|
||||||
|
-t "${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.version }}" \
|
||||||
|
-t "${{ steps.prep.outputs.image }}:sha-${{ steps.prep.outputs.short_sha }}" \
|
||||||
|
-t "${{ steps.prep.outputs.image }}:latest" \
|
||||||
|
--push \
|
||||||
|
-f server/Dockerfile \
|
||||||
|
server
|
||||||
@@ -7,3 +7,5 @@ plan.md
|
|||||||
info.md
|
info.md
|
||||||
# docker-compose.self.yml
|
# docker-compose.self.yml
|
||||||
compose.self.env
|
compose.self.env
|
||||||
|
deploy/server/backend/.env
|
||||||
|
node_modules
|
||||||
|
|||||||
@@ -1,7 +1,44 @@
|
|||||||
1.在终端执行命令时使用适合Windows的命令,不要使用linux的命令
|
### 项目概述
|
||||||
|
|
||||||
2.始终使用中文回答
|
Email Unlimited 是一个前后端分离的临时邮箱服务:
|
||||||
|
|
||||||
3.对于项目代码的中打印语句代码(println语句、console语句等等)和注释需要保留,不要随意改动他们
|
- `server/`:Rust + Axum + SeaORM,提供 HTTP API、SMTP 收信、WebSocket 推送和后台管理接口。
|
||||||
|
- `frontend/`:Vue 3 + Pinia + Vite 用户端,提供认证、邮箱、收件箱和积分功能。
|
||||||
|
- `admin/`:React + TypeScript + Vite + TanStack Query + shadcn/ui 管理后台。
|
||||||
|
- 数据服务:MySQL / PostgreSQL / SQLite 与 Redis。
|
||||||
|
- 部署:根目录 Docker Compose 文件统一构建并运行 Rust 服务、Vue 用户端、React 管理端、数据库、Redis 与 Nginx。
|
||||||
|
|
||||||
4.对于前端的npm等命令你输出到屏幕即可(给出命令即可),我会在其他终端自己执行
|
### 构建与运行
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd server
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd admin
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose -f docker-compose.full.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发约定
|
||||||
|
|
||||||
|
1. 在终端执行命令时使用适合 Windows PowerShell 的命令。
|
||||||
|
2. 始终使用中文回答。
|
||||||
|
3. 保留已有打印语句和有业务含义的注释,不做无关改动。
|
||||||
|
4. 前端统一使用 pnpm,不使用 npm。
|
||||||
|
5. 嵌套环境变量使用双下划线,例如 `AUTH__JWT_SECRET`、`DATABASE__HOST`。
|
||||||
|
6. 积分规则由数据库和管理后台维护,不添加 `CREDIT__*` 环境变量;代码内默认值只用于首次初始化或管理员恢复默认。
|
||||||
|
|||||||
@@ -1,95 +1,139 @@
|
|||||||
# Email Unlimit - 轻量级、可自托管的临时邮件解决方案
|
# Email Unlimited
|
||||||
|
|
||||||
本项目是一个轻量级的、可自托管的临时邮件解决方案。它允许您使用自己的域名接收邮件,并通过一个简洁的网页界面来查看这些邮件。
|
Email Unlimited 是一个可自托管的临时邮箱服务,包含 Rust API/SMTP 服务、Vue 用户端和 React 管理后台。
|
||||||
|
|
||||||
与 `Mailu` 等复杂的邮件套件不同,本项目采用了一个极简的 Node.js 服务来直接接收和处理邮件,部署和维护都非常简单。
|
## 技术栈
|
||||||
|
|
||||||
**在线体验:** <a href="https://mail.shenjianl.cn" target="_blank">https://mail.shenjianl.cn</a>
|
- 后端:Rust、Axum、SeaORM、Tokio
|
||||||
|
- 用户端:Vue 3、Pinia、Vue Router、Element Plus
|
||||||
|
- 管理端:React 19、TypeScript、Vite、TanStack Query、shadcn/ui
|
||||||
|
- 数据:MySQL / PostgreSQL / SQLite、Redis
|
||||||
|
- 部署:Docker Compose、Nginx
|
||||||
|
|
||||||
**网站截图**:
|
服务直接监听 SMTP 端口接收邮件,支持临时邮箱、WebSocket 推送、积分与每日额度、黑名单、灰名单、隔离区、正文 AES-GCM 加密和管理员接口。
|
||||||

|
|
||||||
|
|
||||||
## 技术架构
|
## 本地开发
|
||||||
|
|
||||||
* **前端 (Frontend)**: 使用 Vue.js 构建的单页面应用,负责展示收到的邮件列表。
|
后端默认使用 `server/config/development.toml`:
|
||||||
* **后端 (Backend)**:
|
|
||||||
* 使用 Node.js 和 Express 搭建的 API 服务器。
|
|
||||||
* 内置一个轻量级的 SMTP 服务器 (`smtp-server`),用于直接接收邮件,无需外部邮件服务。
|
|
||||||
* 通过 WebSocket 实现新邮件的实时推送。
|
|
||||||
* **数据库 (Database)**: 使用 MySQL 存储邮件信息,确保数据的安全性和持久性。
|
|
||||||
* **部署 (Deployment)**: 使用 Docker Compose 统一管理后端服务、数据库和 Nginx,实现一键部署。
|
|
||||||
|
|
||||||
## 前置条件
|
```powershell
|
||||||
|
cd server
|
||||||
在开始之前,请确保您已满足以下条件:
|
cargo run
|
||||||
|
|
||||||
### 1. 硬件与域名
|
|
||||||
- 一台拥有公网 IP 的云服务器。
|
|
||||||
- 一个您自己的域名(建议已完成备案)。
|
|
||||||
|
|
||||||
### 2. 软件环境
|
|
||||||
- 服务器上已安装 [Docker 和 Docker Compose](https://docs.docker.com/get-docker/)。
|
|
||||||
- 本地开发环境已安装 [Node.js](https://nodejs.org/) (仅用于构建前端)。
|
|
||||||
|
|
||||||
### 3. 域名 DNS 配置
|
|
||||||
要让邮件能正确发送到您的服务器,您必须配置域名的 `A` 记录和 `MX` 记录。假设您的域名是 `example.com`,您希望用于收信的子域名是 `mail.example.com`。
|
|
||||||
|
|
||||||
| 类型 | 主机记录/名称 | 记录值/指向 | 备注 |
|
|
||||||
| :--- | :--- | :--- | :--- |
|
|
||||||
| A | mail | `您的服务器公网 IP` | 将 `mail` 子域名指向您的服务器 |
|
|
||||||
| MX | @ | `mail.example.com` | 优先级设置为 10 |
|
|
||||||
|
|
||||||
> **注意**: DNS 记录生效可能需要几分钟到几小时。
|
|
||||||
|
|
||||||
### 4. 申请 SSL/TLS 证书
|
|
||||||
为了启用 HTTPS,您需要为您的域名 `mail.example.com` 申请 SSL 证书。您可以从 Let's Encrypt、阿里云、腾讯云等证书颁发机构免费获取。
|
|
||||||
|
|
||||||
## 部署步骤
|
|
||||||
|
|
||||||
本部署方案使用 Docker Compose 统一管理所有服务(Nginx, Backend, MySQL),实现一键启动,无需在宿主机上安装 Nginx 或其他依赖。
|
|
||||||
|
|
||||||
### 步骤 1: 获取并准备项目
|
|
||||||
将项目代码下载并解压到您的服务器,例如 `/data/email-unlimit` 目录。
|
|
||||||
|
|
||||||
### 步骤 2: 上传 SSL 证书
|
|
||||||
将您申请到的 SSL 证书文件(通常是 `.pem`/.`crt` 和 `.key` 文件)上传到项目的 `certs` 目录下 (`/data/email-unlimit/certs`)。
|
|
||||||
|
|
||||||
### 步骤 3: 构建前端静态文件 (本地环境)
|
|
||||||
1. 在您的**本地开发机**上,进入 `frontend` 目录,修改 `.env` 环境变量文件:
|
|
||||||
```env
|
|
||||||
VITE_APP_DOMAIN=example.com # 改为你的主域名
|
|
||||||
```
|
```
|
||||||
2. 安装依赖并构建:
|
|
||||||
```bash
|
用户端和管理端均使用 pnpm:
|
||||||
|
|
||||||
|
```powershell
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
pnpm install
|
||||||
npm run build
|
pnpm dev
|
||||||
|
|
||||||
|
cd admin
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
```
|
```
|
||||||
3. 将构建生成的 `dist` 目录(位于 `frontend/dist`)**完整上传**到服务器的 `/data/email-unlimit/frontend` 目录下。
|
|
||||||
|
|
||||||
### 步骤 4: 修改 Nginx 配置 (服务器)
|
两个前端在开发模式下都通过 Vite 代理访问 `http://127.0.0.1:3000`。需要连接其他 API 时,可分别设置 `VITE_API_PROXY_TARGET`;生产构建连接独立 API 时使用:
|
||||||
编辑项目根目录下的 `nginx.full.conf` 文件,仅需修改以下三项:
|
|
||||||
```nginx
|
```env
|
||||||
server_name mail.example.com; # 改为您自己的域名
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
ssl_certificate /etc/nginx/certs/your_certificate.pem; # 改为您自己的证书文件名
|
|
||||||
ssl_certificate_key /etc/nginx/certs/your_certificate.key; # 改为您自己的密钥文件名
|
|
||||||
```
|
```
|
||||||
> **注意**: 配置文件中的证书路径 `/etc/nginx/certs/` 是容器内的路径,它会映射到宿主机的 `/data/email-unlimit/certs` 目录,请确保文件名正确。
|
|
||||||
|
|
||||||
### 步骤 5: 启动服务 (服务器)
|
## Docker 部署
|
||||||
在服务器的项目根目录 (`/data/email-unlimit`) 下执行以下命令:
|
|
||||||
```bash
|
测试服务器使用宿主机 Nginx 提供静态文件和 HTTPS、使用阿里云 ACR 拉取 Rust 后端镜像时,请使用 [deploy/README.md](deploy/README.md) 中的独立部署方案。该方案适用于 `test-mail.shenjianl.cn` 与 `admin-mail.shenjianl.cn`,并与本地构建、上传前端静态文件的流程对应。
|
||||||
# -f 指定使用 docker-compose.full.yml 配置文件,-d 表示后台运行
|
|
||||||
docker compose -f docker-compose.full.yml up -d
|
开发/内网部署:
|
||||||
|
|
||||||
|
先修改 `compose.env` 中所有 `CHANGE_ME` 配置,然后运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
服务启动后,您可以通过 `docker compose -f docker-compose.full.yml logs -f` 查看实时日志。
|
|
||||||
|
|
||||||
## 如何使用
|
用户端地址为 `http://localhost:8080`,管理端地址为 `http://localhost:8081`,API 地址为 `http://localhost:3000`,SMTP 监听宿主机 `25` 端口。
|
||||||
|
|
||||||
1. **访问您的网站**: 在浏览器中打开 `https://mail.example.com` (替换为您的域名)。
|
HTTPS 完整部署:
|
||||||
2. **发送测试邮件**: 使用任何邮箱客户端,向 `anything@example.com` (例如 `test@example.com`) 发送一封邮件。
|
|
||||||
3. **查看邮件**: 在网站首页的输入框中输入您刚刚使用的收件人地址 (`test@example.com`),点击查询,即可看到收到的邮件。
|
1. 修改 `compose.full.env` 中所有 `CHANGE_ME` 配置。
|
||||||
|
2. 修改 `nginx.full.conf` 中的域名与证书文件名。
|
||||||
|
3. 将证书放入 `certs/`。
|
||||||
|
4. 启动服务。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f docker-compose.full.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
使用外部数据库时,复制 `compose.self.env.example` 为 `compose.self.env`,填写数据库连接后运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f docker-compose.self.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
生产环境会拒绝默认 JWT 密钥、通配 CORS,以及缺失或占位的 32 字节正文加密密钥。
|
||||||
|
|
||||||
|
## 首个管理员
|
||||||
|
|
||||||
|
新部署时,将 `AUTH__BOOTSTRAP_ADMIN_EMAIL` 设置为首个管理员邮箱,并为 `AUTH__INVITE_CODE` 设置至少 16 字符的随机值。服务启动后,用这两个值注册:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$body = @{
|
||||||
|
email = "admin@example.com"
|
||||||
|
password = "replace-with-a-strong-password"
|
||||||
|
invite_code = "replace-with-the-configured-invite-code"
|
||||||
|
} | ConvertTo-Json
|
||||||
|
|
||||||
|
Invoke-RestMethod -Method Post `
|
||||||
|
-Uri "http://localhost:3000/auth/register" `
|
||||||
|
-ContentType "application/json" `
|
||||||
|
-Body $body
|
||||||
|
```
|
||||||
|
|
||||||
|
注册成功后即可登录管理端。已有管理员的部署可将 `AUTH__BOOTSTRAP_ADMIN_EMAIL` 留空。
|
||||||
|
|
||||||
|
普通用户通过用户端注册和登录。用户端支持创建/吊销邮箱、收信与 WebSocket 推送、附件下载、邮件删除、积分余额、今日额度与积分流水。每个邮箱使用独立 token;浏览器首次打开已有邮箱时会自动轮换并保存新 token。
|
||||||
|
|
||||||
|
## 积分规则
|
||||||
|
|
||||||
|
积分规则保存在数据库中,不使用 `CREDIT__*` 环境变量。首次启动会写入内置默认值,之后可在管理端的“积分规则”页面修改,保存后从下一次业务操作立即生效并跨容器重启保留。
|
||||||
|
|
||||||
|
| 规则 | 默认值 |
|
||||||
|
| ---- | ------ |
|
||||||
|
| 注册奖励 | 30 积分 |
|
||||||
|
| 每日签到 | 3 积分 |
|
||||||
|
| 奖励余额上限 | 90 积分 |
|
||||||
|
| 创建邮箱 | 2 积分/个 |
|
||||||
|
| 接收邮件 | 1 积分/封 |
|
||||||
|
| 每日邮箱上限 | 3 个 |
|
||||||
|
| 每日收信上限 | 20 封 |
|
||||||
|
|
||||||
|
所有邮箱创建和正常邮件接收从第一次开始扣分,不存在免费次数。删除邮件、吊销邮箱或自然过期不会返还积分和当日次数。隔离邮件在管理员放行时才占用次数并扣分。积分不可购买、转账、兑换或提现。
|
||||||
|
|
||||||
|
## DNS
|
||||||
|
|
||||||
|
用于接收邮件的域名至少需要:
|
||||||
|
|
||||||
|
| 类型 | 名称 | 值 |
|
||||||
|
| ---- | ------ | ------------------ |
|
||||||
|
| A | `mail` | 服务器公网 IP |
|
||||||
|
| MX | `@` | `mail.example.com` |
|
||||||
|
|
||||||
|
公网 SMTP 还需要开放 TCP 25,并根据实际域名调整 `server/config/production.toml` 的 `smtp.hostname` 与 `smtp.local_domains`。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd server
|
||||||
|
cargo test
|
||||||
|
cargo check
|
||||||
|
|
||||||
|
cd ..\frontend
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
cd ..\admin
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
本项目采用 MIT 许可证。详情请参阅 [LICENSE](LICENSE) 文件。
|
MIT,详见 [LICENSE](LICENSE)。
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.git
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Ignore everything
|
||||||
|
/*
|
||||||
|
|
||||||
|
# Except these files & folders
|
||||||
|
!/src
|
||||||
|
!index.html
|
||||||
|
!package.json
|
||||||
|
!tailwind.config.js
|
||||||
|
!tsconfig.json
|
||||||
|
!tsconfig.node.json
|
||||||
|
!vite.config.ts
|
||||||
|
!.prettierrc
|
||||||
|
!README.md
|
||||||
|
!eslint.config.js
|
||||||
|
!postcss.config.js
|
||||||
|
|
||||||
|
# Ignore auto generated routeTree.gen.ts
|
||||||
|
/src/routeTree.gen.ts
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"arrowParens": "always",
|
||||||
|
"semi": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"printWidth": 80,
|
||||||
|
"singleQuote": true,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"plugins": [
|
||||||
|
"@trivago/prettier-plugin-sort-imports",
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
],
|
||||||
|
"tailwindStylesheet": "./src/styles/index.css",
|
||||||
|
"importOrder": [
|
||||||
|
"^path$",
|
||||||
|
"^vite$",
|
||||||
|
"^@vitejs/(.*)$",
|
||||||
|
"^react$",
|
||||||
|
"^react-dom/client$",
|
||||||
|
"^react/(.*)$",
|
||||||
|
"^globals$",
|
||||||
|
"^zod$",
|
||||||
|
"^axios$",
|
||||||
|
"^date-fns$",
|
||||||
|
"^react-hook-form$",
|
||||||
|
"^use-intl$",
|
||||||
|
"^@radix-ui/(.*)$",
|
||||||
|
"^@hookform/resolvers/zod$",
|
||||||
|
"^@tanstack/react-query$",
|
||||||
|
"^@tanstack/react-router$",
|
||||||
|
"^@tanstack/react-table$",
|
||||||
|
"<THIRD_PARTY_MODULES>",
|
||||||
|
"^@/assets/(.*)",
|
||||||
|
"^@/api/(.*)$",
|
||||||
|
"^@/stores/(.*)$",
|
||||||
|
"^@/lib/(.*)$",
|
||||||
|
"^@/utils/(.*)$",
|
||||||
|
"^@/constants/(.*)$",
|
||||||
|
"^@/context/(.*)$",
|
||||||
|
"^@/hooks/(.*)$",
|
||||||
|
"^@/components/layouts/(.*)$",
|
||||||
|
"^@/components/ui/(.*)$",
|
||||||
|
"^@/components/errors/(.*)$",
|
||||||
|
"^@/components/(.*)$",
|
||||||
|
"^@/features/(.*)$",
|
||||||
|
"^[./]"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Sat Naing
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Email Unlimited Admin
|
||||||
|
|
||||||
|
Email Unlimited 的 React 管理后台,使用 Vite、TypeScript、TanStack Router、TanStack Query、Tailwind CSS 和 shadcn/ui。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
后端不在同源地址时,创建 `.env.local`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 生产构建
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
管理端使用真实 `/auth/login`、`/auth/refresh`、`/admin/stats` 与 `/admin/users` 接口。只有状态为 `active` 且角色为 `admin` 的账号可以进入受保护页面。
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/styles/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import globals from 'globals'
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import pluginQuery from '@tanstack/eslint-plugin-query'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig } from 'eslint/config'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
{ ignores: ['dist', 'src/components/ui'] },
|
||||||
|
{
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
...tseslint.configs.recommended,
|
||||||
|
...pluginQuery.configs['flat/recommended'],
|
||||||
|
],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
'no-console': 'error',
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
args: 'all',
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
caughtErrors: 'all',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
destructuredArrayIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
ignoreRestSiblings: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Enforce type-only imports for TypeScript types
|
||||||
|
'@typescript-eslint/consistent-type-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
prefer: 'type-imports',
|
||||||
|
fixStyle: 'inline-type-imports',
|
||||||
|
disallowTypeAnnotations: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Prevent duplicate imports from the same module
|
||||||
|
'no-duplicate-imports': 'error',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="/images/favicon.svg"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/svg+xml"
|
||||||
|
href="/images/favicon_light.svg"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
href="/images/favicon.png"
|
||||||
|
media="(prefers-color-scheme: light)"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
href="/images/favicon_light.png"
|
||||||
|
media="(prefers-color-scheme: dark)"
|
||||||
|
/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<!-- Primary Meta Tags -->
|
||||||
|
<title>无限邮箱管理后台</title>
|
||||||
|
<meta name="title" content="无限邮箱管理后台" />
|
||||||
|
<meta name="description" content="无限邮箱运维治理与管理后台。" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="无限邮箱管理后台" />
|
||||||
|
<meta property="og:description" content="无限邮箱运维治理与管理后台。" />
|
||||||
|
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:title" content="无限邮箱管理后台" />
|
||||||
|
<meta
|
||||||
|
property="twitter:description"
|
||||||
|
content="无限邮箱运维治理与管理后台。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- font family -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Manrope:wght@200..800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<meta name="theme-color" content="#fff" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { KnipConfig } from 'knip'
|
||||||
|
|
||||||
|
const config: KnipConfig = {
|
||||||
|
ignore: [
|
||||||
|
'src/components/ui/**',
|
||||||
|
'src/components/layout/app-title.tsx',
|
||||||
|
'src/tanstack-table.d.ts',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[[redirects]]
|
||||||
|
from = "/*"
|
||||||
|
to = "/index.html"
|
||||||
|
status = 200
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location ~ ^/(auth|api|admin)(/|$) {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /health {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /info {
|
||||||
|
proxy_pass http://backend:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"name": "email-unlimited-admin",
|
||||||
|
"private": false,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"knip": "knip"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@clerk/clerk-react": "^5.61.3",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-direction": "^1.1.1",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@tanstack/react-query": "^5.95.2",
|
||||||
|
"@tanstack/react-router": "^1.168.4",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"axios": "^1.13.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.4.12",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-day-picker": "9.14.0",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-hook-form": "^7.72.0",
|
||||||
|
"react-top-loading-bar": "^3.0.2",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@faker-js/faker": "^10.4.0",
|
||||||
|
"@tanstack/eslint-plugin-query": "^5.95.2",
|
||||||
|
"@tanstack/react-query-devtools": "^5.95.2",
|
||||||
|
"@tanstack/react-router-devtools": "^1.166.11",
|
||||||
|
"@tanstack/router-plugin": "^1.167.5",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
|
"@types/node": "^25.5.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||||
|
"eslint": "^10.1.0",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"knip": "^6.0.6",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"typescript-eslint": "^8.57.2",
|
||||||
|
"vite": "^8.0.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5844
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
allowBuilds:
|
||||||
|
'@clerk/shared': true
|
||||||
|
'@swc/core': true
|
||||||
|
esbuild: true
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 494 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokewidth="2" strokelinecap="round" strokelinejoin="round">
|
||||||
|
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"></path>
|
||||||
|
</svg><style>@media (prefers-color-scheme: light) { :root { filter: contrast(1) brightness(0.8); } }
|
||||||
|
</style></svg>
|
||||||
|
After Width: | Height: | Size: 520 B |
Binary file not shown.
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="948,463.5,24,24"><defs><clipPath id="clip-1"><rect x="948" y="463.5" width="24" height="24" id="clip-1" stroke="none"/></clipPath></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g><g id="Group 1"><g clip-path="url(#clip-1)" id="Group 1"><path d="M963,469.5v12c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3h-12c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3v-12c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3h12c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3" id="Path 1" stroke="#f2f2f2"/></g></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 1013 B |
Binary file not shown.
|
After Width: | Height: | Size: 273 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconDiscord({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Discord</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M8 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0' />
|
||||||
|
<path d='M14 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0' />
|
||||||
|
<path d='M15.5 17c0 1 1.5 3 2 3c1.5 0 2.833 -1.667 3.5 -3c.667 -1.667 .5 -5.833 -1.5 -11.5c-1.457 -1.015 -3 -1.34 -4.5 -1.5l-.972 1.923a11.913 11.913 0 0 0 -4.053 0l-.975 -1.923c-1.5 .16 -3.043 .485 -4.5 1.5c-2 5.667 -2.167 9.833 -1.5 11.5c.667 1.333 2 3 3.5 3c.5 0 2 -2 2 -3' />
|
||||||
|
<path d='M7 16.5c3.5 1 6.5 1 10 0' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconDocker({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Docker</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M22 12.54c-1.804 -.345 -2.701 -1.08 -3.523 -2.94c-.487 .696 -1.102 1.568 -.92 2.4c.028 .238 -.32 1 -.557 1h-14c0 5.208 3.164 7 6.196 7c4.124 .022 7.828 -1.376 9.854 -5c1.146 -.101 2.296 -1.505 2.95 -2.46z' />
|
||||||
|
<path d='M5 10h3v3h-3z' />
|
||||||
|
<path d='M8 10h3v3h-3z' />
|
||||||
|
<path d='M11 10h3v3h-3z' />
|
||||||
|
<path d='M8 7h3v3h-3z' />
|
||||||
|
<path d='M11 7h3v3h-3z' />
|
||||||
|
<path d='M11 4h3v3h-3z' />
|
||||||
|
<path d='M4.571 18c1.5 0 2.047 -.074 2.958 -.78' />
|
||||||
|
<path d='M10 16l0 .01' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconFacebook({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Facebook</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M7 10v4h3v7h4v-7h3l1 -4h-4v-2a1 1 0 0 1 1 -1h3v-4h-3a5 5 0 0 0 -5 5v2h-3' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconFigma({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Figma</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M15 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0' />
|
||||||
|
<path d='M6 3m0 3a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v0a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3z' />
|
||||||
|
<path d='M9 9a3 3 0 0 0 0 6h3m-3 0a3 3 0 1 0 3 3v-15' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconGithub({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>GitHub</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconGitlab({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>GitLab</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M21 14l-9 7l-9 -7l3 -11l3 7h6l3 -7z' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconGmail({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Gmail</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M16 20h3a1 1 0 0 0 1 -1v-14a1 1 0 0 0 -1 -1h-3v16z' />
|
||||||
|
<path d='M5 20h3v-16h-3a1 1 0 0 0 -1 1v14a1 1 0 0 0 1 1z' />
|
||||||
|
<path d='M16 4l-4 4l-4 -4' />
|
||||||
|
<path d='M4 6.5l8 7.5l8 -7.5' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconMedium({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Medium</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' />
|
||||||
|
<path d='M8 9h1l3 3l3 -3h1' />
|
||||||
|
<path d='M8 15l2 0' />
|
||||||
|
<path d='M14 15l2 0' />
|
||||||
|
<path d='M9 9l0 6' />
|
||||||
|
<path d='M15 9l0 6' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconNotion({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Notion</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M11 17.5v-6.5h.5l4 6h.5v-6.5' />
|
||||||
|
<path d='M19.077 20.071l-11.53 .887a1 1 0 0 1 -.876 -.397l-2.471 -3.294a1 1 0 0 1 -.2 -.6v-10.741a1 1 0 0 1 .923 -.997l11.389 -.876a2 2 0 0 1 1.262 .33l1.535 1.023a2 2 0 0 1 .891 1.664v12.004a1 1 0 0 1 -.923 .997z' />
|
||||||
|
<path d='M4.5 5.5l2.5 2.5' />
|
||||||
|
<path d='M20 7l-13 1v12.5' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconSkype({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Skype</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M12 3a9 9 0 0 1 8.603 11.65a4.5 4.5 0 0 1 -5.953 5.953a9 9 0 0 1 -11.253 -11.253a4.5 4.5 0 0 1 5.953 -5.954a8.987 8.987 0 0 1 2.65 -.396z' />
|
||||||
|
<path d='M8 14.5c.5 2 2.358 2.5 4 2.5c2.905 0 4 -1.187 4 -2.5c0 -1.503 -1.927 -2.5 -4 -2.5s-4 -1 -4 -2.5c0 -1.313 1.095 -2.5 4 -2.5c1.642 0 3.5 .5 4 2.5' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconSlack({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Slack</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M12 12v-6a2 2 0 0 1 4 0v6m0 -2a2 2 0 1 1 2 2h-6' />
|
||||||
|
<path d='M12 12h6a2 2 0 0 1 0 4h-6m2 0a2 2 0 1 1 -2 2v-6' />
|
||||||
|
<path d='M12 12v6a2 2 0 0 1 -4 0v-6m0 2a2 2 0 1 1 -2 -2h6' />
|
||||||
|
<path d='M12 12h-6a2 2 0 0 1 0 -4h6m-2 0a2 2 0 1 1 2 -2v6' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconStripe({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Stripe</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M11.453 8.056c0 -.623 .518 -.979 1.442 -.979c1.69 0 3.41 .343 4.605 .923l.5 -4c-.948 -.449 -2.82 -1 -5.5 -1c-1.895 0 -3.373 .087 -4.5 1c-1.172 .956 -2 2.33 -2 4c0 3.03 1.958 4.906 5 6c1.961 .69 3 .743 3 1.5c0 .735 -.851 1.5 -2 1.5c-1.423 0 -3.963 -.609 -5.5 -1.5l-.5 4c1.321 .734 3.474 1.5 6 1.5c2 0 3.957 -.468 5.084 -1.36c1.263 -.979 1.916 -2.268 1.916 -4.14c0 -3.096 -1.915 -4.547 -5 -5.637c-1.646 -.605 -2.544 -1.07 -2.544 -1.807z' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconTelegram({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Telegram</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M15 10l-4 4l6 6l4 -16l-18 7l4 2l2 6l3 -4' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconTrello({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Trello</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' />
|
||||||
|
<path d='M7 7h3v10h-3z' />
|
||||||
|
<path d='M14 7h3v6h-3z' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconWhatsapp({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>WhatsApp</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M3 21l1.65 -3.8a9 9 0 1 1 3.4 2.9l-5.05 .9' />
|
||||||
|
<path d='M9 10a.5 .5 0 0 0 1 0v-1a.5 .5 0 0 0 -1 0v1a5 5 0 0 0 5 5h1a.5 .5 0 0 0 0 -1h-1a.5 .5 0 0 0 0 1' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconZoom({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
width='24'
|
||||||
|
height='24'
|
||||||
|
className={cn('[&>path]:stroke-current', className)}
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Zoom</title>
|
||||||
|
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||||
|
<path d='M17.011 9.385v5.128l3.989 3.487v-12z' />
|
||||||
|
<path d='M3.887 6h10.08c1.468 0 3.033 1.203 3.033 2.803v8.196a.991 .991 0 0 1 -.975 1h-10.373c-1.667 0 -2.652 -1.5 -2.652 -3l.01 -8a.882 .882 0 0 1 .208 -.71a.841 .841 0 0 1 .67 -.287z' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export { IconDiscord } from './icon-discord'
|
||||||
|
export { IconDocker } from './icon-docker'
|
||||||
|
export { IconFacebook } from './icon-facebook'
|
||||||
|
export { IconFigma } from './icon-figma'
|
||||||
|
export { IconGithub } from './icon-github'
|
||||||
|
export { IconGitlab } from './icon-gitlab'
|
||||||
|
export { IconGmail } from './icon-gmail'
|
||||||
|
export { IconMedium } from './icon-medium'
|
||||||
|
export { IconNotion } from './icon-notion'
|
||||||
|
export { IconSkype } from './icon-skype'
|
||||||
|
export { IconSlack } from './icon-slack'
|
||||||
|
export { IconStripe } from './icon-stripe'
|
||||||
|
export { IconTelegram } from './icon-telegram'
|
||||||
|
export { IconTrello } from './icon-trello'
|
||||||
|
export { IconWhatsapp } from './icon-whatsapp'
|
||||||
|
export { IconZoom } from './icon-zoom'
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
|
||||||
|
export function ClerkFullLogo(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={77}
|
||||||
|
height={24}
|
||||||
|
viewBox='0 0 77 24'
|
||||||
|
fill='none'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d='M35.148 16.738a4.198 4.198 0 01-3.06 1.283 3.53 3.53 0 01-2.604-1.034c-.619-.645-.975-1.566-.975-2.665 0-2.199 1.432-3.703 3.58-3.703a3.914 3.914 0 013.034 1.377l1.859-1.644c-1.211-1.47-3.176-2.229-5.042-2.229-3.652 0-6.24 2.517-6.24 6.22 0 1.831.643 3.374 1.728 4.463s2.631 1.728 4.415 1.728c2.317 0 4.166-.94 5.203-2.122l-1.898-1.674zM38.727 3.428h2.766V20.34h-2.766V3.428zM54.818 15.283c.046-.368.07-.74.076-1.11 0-3.507-2.296-6.047-5.847-6.047a5.738 5.738 0 00-4.215 1.725c-1.038 1.089-1.66 2.631-1.66 4.47 0 3.749 2.642 6.216 6.146 6.216 2.35 0 4.043-.951 5.058-2.242l-1.812-1.605-.09-.076a3.749 3.749 0 01-3.008 1.406c-1.778 0-3.061-1.037-3.427-2.737h8.779zm-8.733-2.22a3.365 3.365 0 01.737-1.449 3.082 3.082 0 012.368-.996c1.58 0 2.57.988 2.911 2.445h-6.016zM63.445 8.09v3.084a13.36 13.36 0 00-.838-.05c-2.094 0-3.282 1.505-3.282 3.479v5.736h-2.763V8.261h2.763v1.83h.025c.938-1.283 2.284-1.997 3.75-1.997l.345-.004zM69.887 15.281l-1.998 2.222v2.837h-2.764V3.428h2.764v10.374L72.822 8.3h3.283l-4.341 4.86 4.417 7.18h-3.11l-3.133-5.059h-.051z'
|
||||||
|
fill='#1F0256'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d='M19.116 3.16l-2.88 2.881a.571.571 0 01-.701.084 6.854 6.854 0 00-10.39 5.647 6.867 6.867 0 00.979 3.764.571.571 0 01-.084.699l-2.88 2.88a.57.57 0 01-.865-.063A11.994 11.994 0 0119.051 2.295a.57.57 0 01.065.866z'
|
||||||
|
fill='url(#paint0_linear_26568_214324)'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d='M19.113 20.829l-2.88-2.88a.571.571 0 00-.7-.085 6.854 6.854 0 01-7.081 0 .571.571 0 00-.7.084l-2.881 2.88a.57.57 0 00.062.877 11.994 11.994 0 0014.114 0 .571.571 0 00.066-.876zM11.997 15.422a3.427 3.427 0 100-6.854 3.427 3.427 0 000 6.854z'
|
||||||
|
fill='#1F0256'
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id='paint0_linear_26568_214324'
|
||||||
|
x1={16.4087}
|
||||||
|
y1={-1.75881}
|
||||||
|
x2={-7.88473}
|
||||||
|
y2={22.5365}
|
||||||
|
gradientUnits='userSpaceOnUse'
|
||||||
|
>
|
||||||
|
<stop stopColor='#17CCFC' />
|
||||||
|
<stop offset={0.5} stopColor='#5D31FF' />
|
||||||
|
<stop offset={1} stopColor='#F35AFF' />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function ClerkLogo({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
role='img'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
id='clerk'
|
||||||
|
height='24'
|
||||||
|
width='24'
|
||||||
|
className={cn('[&>path]:fill-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Clerk</title>
|
||||||
|
<path
|
||||||
|
d='m21.47 20.829 -2.881 -2.881a0.572 0.572 0 0 0 -0.7 -0.084 6.854 6.854 0 0 1 -7.081 0 0.576 0.576 0 0 0 -0.7 0.084l-2.881 2.881a0.576 0.576 0 0 0 -0.103 0.69 0.57 0.57 0 0 0 0.166 0.186 12 12 0 0 0 14.113 0 0.58 0.58 0 0 0 0.239 -0.423 0.576 0.576 0 0 0 -0.172 -0.453Zm0.002 -17.668 -2.88 2.88a0.569 0.569 0 0 1 -0.701 0.084A6.857 6.857 0 0 0 8.724 8.08a6.862 6.862 0 0 0 -1.222 3.692 6.86 6.86 0 0 0 0.978 3.764 0.573 0.573 0 0 1 -0.083 0.699l-2.881 2.88a0.567 0.567 0 0 1 -0.864 -0.063A11.993 11.993 0 0 1 6.771 2.7a11.99 11.99 0 0 1 14.637 -0.405 0.566 0.566 0 0 1 0.232 0.418 0.57 0.57 0 0 1 -0.168 0.448Zm-7.118 12.261a3.427 3.427 0 1 0 0 -6.854 3.427 3.427 0 0 0 0 6.854Z'
|
||||||
|
strokeWidth='1'
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { type Direction } from '@/context/direction-provider'
|
||||||
|
|
||||||
|
type IconDirProps = SVGProps<SVGSVGElement> & {
|
||||||
|
dir: Direction
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconDir({ dir, className, ...props }: IconDirProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-name={`icon-dir-${dir}`}
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 79.86 51.14'
|
||||||
|
className={cn(dir === 'rtl' && 'rotate-y-180', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d='M23.42.51h51.92c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
|
||||||
|
opacity={0.15}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.72}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M5.56 14.88L17.78 14.88'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.48}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M5.56 22.09L16.08 22.09'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.55}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M5.56 18.38L14.93 18.38'
|
||||||
|
/>
|
||||||
|
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||||
|
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.8}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M12.06 6.14L17.78 6.14'
|
||||||
|
/>
|
||||||
|
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.62}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='3px'
|
||||||
|
d='M29.41 7.4L34.67 7.4'
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={28.76}
|
||||||
|
y={11.21}
|
||||||
|
width={26.03}
|
||||||
|
height={2.73}
|
||||||
|
rx={0.64}
|
||||||
|
ry={0.64}
|
||||||
|
opacity={0.44}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={28.76}
|
||||||
|
y={17.01}
|
||||||
|
width={44.25}
|
||||||
|
height={13.48}
|
||||||
|
rx={0.64}
|
||||||
|
ry={0.64}
|
||||||
|
opacity={0.3}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={28.76}
|
||||||
|
y={33.57}
|
||||||
|
width={44.25}
|
||||||
|
height={4.67}
|
||||||
|
rx={0.64}
|
||||||
|
ry={0.64}
|
||||||
|
opacity={0.21}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={28.76}
|
||||||
|
y={41.32}
|
||||||
|
width={36.21}
|
||||||
|
height={4.67}
|
||||||
|
rx={0.64}
|
||||||
|
ry={0.64}
|
||||||
|
opacity={0.3}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
|
||||||
|
export function IconLayoutCompact(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-name='icon-layout-compact'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 79.86 51.14'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={5.84}
|
||||||
|
y={5.2}
|
||||||
|
width={4}
|
||||||
|
height={40}
|
||||||
|
rx={2}
|
||||||
|
ry={2}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.66}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M7.26 11.56L8.37 11.56'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.51}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M7.26 14.49L8.37 14.49'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.52}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M7.26 17.39L8.37 17.39'
|
||||||
|
/>
|
||||||
|
<circle cx={7.81} cy={7.25} r={1.16} fill='#fff' opacity={0.8} />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.75}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='3px'
|
||||||
|
d='M15.81 14.49L22.89 14.49'
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={14.93}
|
||||||
|
y={18.39}
|
||||||
|
width={22.19}
|
||||||
|
height={2.73}
|
||||||
|
rx={0.64}
|
||||||
|
ry={0.64}
|
||||||
|
opacity={0.5}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={14.93}
|
||||||
|
y={5.89}
|
||||||
|
width={59.16}
|
||||||
|
height={2.73}
|
||||||
|
rx={0.64}
|
||||||
|
ry={0.64}
|
||||||
|
opacity={0.9}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={14.93}
|
||||||
|
y={24.22}
|
||||||
|
width={32.68}
|
||||||
|
height={19.95}
|
||||||
|
rx={2.11}
|
||||||
|
ry={2.11}
|
||||||
|
opacity={0.4}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||||
|
<rect
|
||||||
|
x={59.05}
|
||||||
|
y={38.15}
|
||||||
|
width={2.01}
|
||||||
|
height={3.42}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.32}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={54.78}
|
||||||
|
y={34.99}
|
||||||
|
width={2.01}
|
||||||
|
height={6.58}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.44}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={63.17}
|
||||||
|
y={32.86}
|
||||||
|
width={2.01}
|
||||||
|
height={8.7}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.53}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={67.54}
|
||||||
|
y={29.17}
|
||||||
|
width={2.01}
|
||||||
|
height={12.4}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.66}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g opacity={0.5}>
|
||||||
|
<circle cx={62.16} cy={18.63} r={7.5} />
|
||||||
|
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||||
|
</g>
|
||||||
|
<g opacity={0.74}>
|
||||||
|
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||||
|
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
|
||||||
|
export function IconLayoutDefault(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-name='con-layout-default'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 79.86 51.14'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d='M39.22 15.99h-8.16c-.79 0-1.43-.67-1.43-1.5s.64-1.5 1.43-1.5h8.16c.79 0 1.43.67 1.43 1.5s-.64 1.5-1.43 1.5z'
|
||||||
|
opacity={0.75}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={29.63}
|
||||||
|
y={18.39}
|
||||||
|
width={16.72}
|
||||||
|
height={2.73}
|
||||||
|
rx={1.36}
|
||||||
|
ry={1.36}
|
||||||
|
opacity={0.5}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d='M75.1 6.68v1.45c0 .63-.49 1.14-1.09 1.14H30.72c-.6 0-1.09-.51-1.09-1.14V6.68c0-.62.49-1.14 1.09-1.14h43.29c.6 0 1.09.52 1.09 1.14z'
|
||||||
|
opacity={0.9}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={29.63}
|
||||||
|
y={24.22}
|
||||||
|
width={21.8}
|
||||||
|
height={19.95}
|
||||||
|
rx={2.11}
|
||||||
|
ry={2.11}
|
||||||
|
opacity={0.4}
|
||||||
|
/>
|
||||||
|
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||||
|
<rect
|
||||||
|
x={61.06}
|
||||||
|
y={38.15}
|
||||||
|
width={2.01}
|
||||||
|
height={3.42}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.32}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={56.78}
|
||||||
|
y={34.99}
|
||||||
|
width={2.01}
|
||||||
|
height={6.58}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.44}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={65.17}
|
||||||
|
y={32.86}
|
||||||
|
width={2.01}
|
||||||
|
height={8.7}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.53}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={69.55}
|
||||||
|
y={29.17}
|
||||||
|
width={2.01}
|
||||||
|
height={12.4}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.66}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g opacity={0.5}>
|
||||||
|
<circle cx={63.17} cy={18.63} r={7.5} />
|
||||||
|
<path d='M63.17 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||||
|
</g>
|
||||||
|
<g opacity={0.74}>
|
||||||
|
<path d='M64.05 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||||
|
<path d='M67.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||||
|
</g>
|
||||||
|
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||||
|
<rect
|
||||||
|
x={5.84}
|
||||||
|
y={5.02}
|
||||||
|
width={19.14}
|
||||||
|
height={40}
|
||||||
|
rx={2}
|
||||||
|
ry={2}
|
||||||
|
opacity={0.8}
|
||||||
|
/>
|
||||||
|
<g stroke='#fff'>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.72}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M9.02 17.39L21.25 17.39'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.48}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M9.02 24.6L19.54 24.6'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.55}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M9.02 20.88L18.4 20.88'
|
||||||
|
/>
|
||||||
|
<circle cx={10.98} cy={9.91} r={2.54} fill='#fff' opacity={0.8} />
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.8}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M15.53 8.65L21.25 8.65'
|
||||||
|
/>
|
||||||
|
<path fill='none' opacity={0.6} d='M15.32 11.3L20.38 11.3' />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
|
||||||
|
export function IconLayoutFull(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-name='icon-layout-full'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 79.86 51.14'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.75}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='3px'
|
||||||
|
d='M6.85 14.49L15.02 14.49'
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={5.84}
|
||||||
|
y={18.39}
|
||||||
|
width={25.6}
|
||||||
|
height={2.73}
|
||||||
|
rx={0.64}
|
||||||
|
ry={0.64}
|
||||||
|
opacity={0.5}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={5.84}
|
||||||
|
y={5.89}
|
||||||
|
width={68.26}
|
||||||
|
height={2.73}
|
||||||
|
rx={0.64}
|
||||||
|
ry={0.64}
|
||||||
|
opacity={0.9}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={5.84}
|
||||||
|
y={24.22}
|
||||||
|
width={37.71}
|
||||||
|
height={19.95}
|
||||||
|
rx={2.11}
|
||||||
|
ry={2.11}
|
||||||
|
opacity={0.4}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||||
|
<rect
|
||||||
|
x={59.05}
|
||||||
|
y={38.15}
|
||||||
|
width={2.01}
|
||||||
|
height={3.42}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.32}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={54.78}
|
||||||
|
y={34.99}
|
||||||
|
width={2.01}
|
||||||
|
height={6.58}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.44}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={63.17}
|
||||||
|
y={32.86}
|
||||||
|
width={2.01}
|
||||||
|
height={8.7}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.53}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={67.54}
|
||||||
|
y={29.17}
|
||||||
|
width={2.01}
|
||||||
|
height={12.4}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.66}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<g opacity={0.5}>
|
||||||
|
<circle cx={62.16} cy={18.63} r={7.5} />
|
||||||
|
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||||
|
</g>
|
||||||
|
<g opacity={0.74}>
|
||||||
|
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||||
|
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
|
||||||
|
export function IconSidebarFloating(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-name='icon-sidebar-floating'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 79.86 51.14'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={5.89}
|
||||||
|
y={5.15}
|
||||||
|
width={19.74}
|
||||||
|
height={40}
|
||||||
|
rx={2}
|
||||||
|
ry={2}
|
||||||
|
opacity={0.8}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.72}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M9.81 18.36L22.04 18.36'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.48}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M9.81 25.57L20.33 25.57'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.55}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M9.81 21.85L19.18 21.85'
|
||||||
|
/>
|
||||||
|
<circle cx={11.76} cy={10.88} r={2.54} fill='#fff' opacity={0.8} />
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.8}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M16.31 9.62L22.04 9.62'
|
||||||
|
/>
|
||||||
|
<path fill='none' opacity={0.6} d='M16.1 12.27L21.16 12.27' />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.62}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='3px'
|
||||||
|
d='M30.59 9.62L35.85 9.62'
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={29.94}
|
||||||
|
y={13.42}
|
||||||
|
width={26.03}
|
||||||
|
height={2.73}
|
||||||
|
rx={0.64}
|
||||||
|
ry={0.64}
|
||||||
|
opacity={0.44}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={29.94}
|
||||||
|
y={19.28}
|
||||||
|
width={43.11}
|
||||||
|
height={25.87}
|
||||||
|
rx={2}
|
||||||
|
ry={2}
|
||||||
|
opacity={0.3}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
|
||||||
|
export function IconSidebarInset(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-name='icon-sidebar-inset'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 79.86 51.14'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x={23.39}
|
||||||
|
y={5.57}
|
||||||
|
width={50.22}
|
||||||
|
height={40}
|
||||||
|
rx={2}
|
||||||
|
ry={2}
|
||||||
|
opacity={0.2}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.72}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M5.08 17.05L17.31 17.05'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.48}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M5.08 24.25L15.6 24.25'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.55}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M5.08 20.54L14.46 20.54'
|
||||||
|
/>
|
||||||
|
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||||
|
<circle cx={7.04} cy={9.57} r={2.54} opacity={0.8} />
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.8}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M11.59 8.3L17.31 8.3'
|
||||||
|
/>
|
||||||
|
<path fill='none' opacity={0.6} d='M11.38 10.95L16.44 10.95' />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
|
||||||
|
export function IconSidebarSidebar(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-name='icon-sidebar-sidebar'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 79.86 51.14'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d='M23.42.51h51.99c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
|
||||||
|
opacity={0.2}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.72}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M5.56 14.88L17.78 14.88'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.48}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M5.56 22.09L16.08 22.09'
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.55}
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M5.56 18.38L14.93 18.38'
|
||||||
|
/>
|
||||||
|
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||||
|
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
|
||||||
|
<path
|
||||||
|
fill='none'
|
||||||
|
opacity={0.8}
|
||||||
|
strokeWidth='2px'
|
||||||
|
d='M12.06 6.14L17.78 6.14'
|
||||||
|
/>
|
||||||
|
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
|
||||||
|
export function IconThemeDark(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-name='icon-theme-dark'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 79.86 51.14'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<g fill='#1d2b3f'>
|
||||||
|
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
|
||||||
|
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
|
||||||
|
fill='#0d1628'
|
||||||
|
/>
|
||||||
|
<circle cx={6.7} cy={7.04} r={3.54} fill='#426187' />
|
||||||
|
<path
|
||||||
|
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||||
|
fill='#426187'
|
||||||
|
/>
|
||||||
|
<g fill='#2a62bc'>
|
||||||
|
<rect
|
||||||
|
x={33.36}
|
||||||
|
y={19.73}
|
||||||
|
width={2.75}
|
||||||
|
height={3.42}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.32}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={29.64}
|
||||||
|
y={16.57}
|
||||||
|
width={2.75}
|
||||||
|
height={6.58}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.44}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={37.16}
|
||||||
|
y={14.44}
|
||||||
|
width={2.75}
|
||||||
|
height={8.7}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.53}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={41.19}
|
||||||
|
y={10.75}
|
||||||
|
width={2.75}
|
||||||
|
height={12.4}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.53}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<circle cx={62.74} cy={16.32} r={8} fill='#2f5491' opacity={0.5} />
|
||||||
|
<path
|
||||||
|
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
|
||||||
|
fill='#2f5491'
|
||||||
|
opacity={0.74}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={29.64}
|
||||||
|
y={27.75}
|
||||||
|
width={41.62}
|
||||||
|
height={18.62}
|
||||||
|
rx={1.69}
|
||||||
|
ry={1.69}
|
||||||
|
fill='#17273f'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
|
||||||
|
export function IconThemeLight(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-name='icon-theme-light'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 79.86 51.14'
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<g fill='#d9d9d9'>
|
||||||
|
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
|
||||||
|
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
|
||||||
|
</g>
|
||||||
|
<path
|
||||||
|
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
|
||||||
|
fill='#ecedef'
|
||||||
|
/>
|
||||||
|
<circle cx={6.7} cy={7.04} r={3.54} fill='#fff' />
|
||||||
|
<path
|
||||||
|
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||||
|
fill='#fff'
|
||||||
|
/>
|
||||||
|
<g fill='#c0c4c4'>
|
||||||
|
<rect
|
||||||
|
x={33.36}
|
||||||
|
y={19.73}
|
||||||
|
width={2.75}
|
||||||
|
height={3.42}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.32}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={29.64}
|
||||||
|
y={16.57}
|
||||||
|
width={2.75}
|
||||||
|
height={6.58}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.44}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={37.16}
|
||||||
|
y={14.44}
|
||||||
|
width={2.75}
|
||||||
|
height={8.7}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.53}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={41.19}
|
||||||
|
y={10.75}
|
||||||
|
width={2.75}
|
||||||
|
height={12.4}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.53}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<circle cx={62.74} cy={16.32} r={8} fill='#fff' />
|
||||||
|
<g fill='#d9d9d9'>
|
||||||
|
<path d='M63.62 15.82L67 10.15c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||||
|
<path d='M67.14 10.88a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95s-1.67-2.21-2.86-2.92z' />
|
||||||
|
</g>
|
||||||
|
<rect
|
||||||
|
x={29.64}
|
||||||
|
y={27.75}
|
||||||
|
width={41.62}
|
||||||
|
height={18.62}
|
||||||
|
rx={1.69}
|
||||||
|
ry={1.69}
|
||||||
|
fill='#fff'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function IconThemeSystem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-name='icon-theme-system'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 79.86 51.14'
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden rounded-[6px]',
|
||||||
|
'fill-primary stroke-primary group-data-[state=unchecked]:fill-muted-foreground group-data-[state=unchecked]:stroke-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path opacity={0.2} d='M0 0.03H22.88V51.17H0z' />
|
||||||
|
<circle
|
||||||
|
cx={6.7}
|
||||||
|
cy={7.04}
|
||||||
|
r={3.54}
|
||||||
|
fill='#fff'
|
||||||
|
opacity={0.8}
|
||||||
|
stroke='#fff'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1z'
|
||||||
|
fill='#fff'
|
||||||
|
stroke='none'
|
||||||
|
opacity={0.75}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d='M18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05z'
|
||||||
|
fill='#fff'
|
||||||
|
stroke='none'
|
||||||
|
opacity={0.72}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d='M15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91z'
|
||||||
|
fill='#fff'
|
||||||
|
stroke='none'
|
||||||
|
opacity={0.55}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d='M16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||||
|
fill='#fff'
|
||||||
|
stroke='none'
|
||||||
|
opacity={0.67}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={33.36}
|
||||||
|
y={19.73}
|
||||||
|
width={2.75}
|
||||||
|
height={3.42}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.31}
|
||||||
|
stroke='none'
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={29.64}
|
||||||
|
y={16.57}
|
||||||
|
width={2.75}
|
||||||
|
height={6.58}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.4}
|
||||||
|
stroke='none'
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={37.16}
|
||||||
|
y={14.44}
|
||||||
|
width={2.75}
|
||||||
|
height={8.7}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.26}
|
||||||
|
stroke='none'
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={41.19}
|
||||||
|
y={10.75}
|
||||||
|
width={2.75}
|
||||||
|
height={12.4}
|
||||||
|
rx={0.33}
|
||||||
|
ry={0.33}
|
||||||
|
opacity={0.37}
|
||||||
|
stroke='none'
|
||||||
|
/>
|
||||||
|
<g>
|
||||||
|
<circle cx={62.74} cy={16.32} r={8} opacity={0.25} />
|
||||||
|
<path
|
||||||
|
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
|
||||||
|
opacity={0.45}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<rect
|
||||||
|
x={29.64}
|
||||||
|
y={27.75}
|
||||||
|
width={41.62}
|
||||||
|
height={18.62}
|
||||||
|
rx={1.69}
|
||||||
|
ry={1.69}
|
||||||
|
opacity={0.3}
|
||||||
|
stroke='none'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeMiterlimit={10}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function Logo({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
id='shadcn-admin-logo'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
height='24'
|
||||||
|
width='24'
|
||||||
|
fill='none'
|
||||||
|
stroke='currentColor'
|
||||||
|
strokeWidth='2'
|
||||||
|
strokeLinecap='round'
|
||||||
|
strokeLinejoin='round'
|
||||||
|
className={cn('size-6', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<title>Shadcn-Admin</title>
|
||||||
|
<path d='M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3' />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Info,
|
||||||
|
Loader2,
|
||||||
|
RotateCcw,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { type StatusTone } from './status-badge'
|
||||||
|
|
||||||
|
type AdminActionDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: React.ReactNode
|
||||||
|
description: React.ReactNode
|
||||||
|
confirmLabel: React.ReactNode
|
||||||
|
tone?: StatusTone
|
||||||
|
isLoading?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
cancelLabel?: React.ReactNode
|
||||||
|
onConfirm: () => void
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const toneMeta: Record<
|
||||||
|
StatusTone,
|
||||||
|
{
|
||||||
|
icon: React.ElementType
|
||||||
|
className: string
|
||||||
|
buttonVariant: 'default' | 'destructive'
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
success: {
|
||||||
|
icon: CheckCircle2,
|
||||||
|
className: 'bg-success/10 text-success-foreground ring-success/20',
|
||||||
|
buttonVariant: 'default',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
className: 'bg-warning/10 text-warning-foreground ring-warning/20',
|
||||||
|
buttonVariant: 'default',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
icon: Info,
|
||||||
|
className: 'bg-info/10 text-info-foreground ring-info/20',
|
||||||
|
buttonVariant: 'default',
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
icon: AlertTriangle,
|
||||||
|
className: 'bg-danger/10 text-danger-foreground ring-danger/20',
|
||||||
|
buttonVariant: 'destructive',
|
||||||
|
},
|
||||||
|
neutral: {
|
||||||
|
icon: RotateCcw,
|
||||||
|
className: 'bg-neutral/10 text-neutral-foreground ring-neutral/20',
|
||||||
|
buttonVariant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminActionDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
tone = 'info',
|
||||||
|
isLoading,
|
||||||
|
disabled,
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
}: AdminActionDialogProps) {
|
||||||
|
const meta = toneMeta[tone]
|
||||||
|
const Icon = meta.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent className='overflow-hidden p-0 sm:max-w-[460px]'>
|
||||||
|
<div className='border-b bg-muted/30 px-6 py-5'>
|
||||||
|
<AlertDialogHeader className='text-start'>
|
||||||
|
<div className='flex items-start gap-4'>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-lg ring-1',
|
||||||
|
meta.className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className='size-5' />
|
||||||
|
</div>
|
||||||
|
<div className='min-w-0 space-y-2'>
|
||||||
|
<AlertDialogTitle className='text-base leading-6'>
|
||||||
|
{title}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div className='text-sm leading-6 text-muted-foreground'>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
</div>
|
||||||
|
{children && <div className='px-6 py-4'>{children}</div>}
|
||||||
|
<AlertDialogFooter className='border-t bg-background px-6 py-4'>
|
||||||
|
<AlertDialogCancel disabled={isLoading}>
|
||||||
|
{cancelLabel}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant={meta.buttonVariant}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className='size-4 animate-spin' />}
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { ComponentType } from 'react'
|
||||||
|
import { Loader2, MoreHorizontal } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
|
||||||
|
export type AdminRowAction = {
|
||||||
|
label: string
|
||||||
|
icon?: ComponentType<{ className?: string }>
|
||||||
|
onSelect?: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
destructive?: boolean
|
||||||
|
separatorBefore?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminRowActionsMenuProps = {
|
||||||
|
label: string
|
||||||
|
actions: AdminRowAction[]
|
||||||
|
loading?: boolean
|
||||||
|
align?: 'start' | 'center' | 'end'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminRowActionsMenu({
|
||||||
|
label,
|
||||||
|
actions,
|
||||||
|
loading = false,
|
||||||
|
align = 'end',
|
||||||
|
}: AdminRowActionsMenuProps) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
className='h-8 w-8 p-0 data-[state=open]:bg-muted'
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<span className='sr-only'>{label}</span>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className='size-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className='size-4' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align={align} className='admin-menu-content'>
|
||||||
|
<DropdownMenuLabel>{label}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{actions.map((action, index) => {
|
||||||
|
const Icon = action.icon
|
||||||
|
return (
|
||||||
|
<div key={`${action.label}-${index}`}>
|
||||||
|
{action.separatorBefore ? <DropdownMenuSeparator /> : null}
|
||||||
|
<DropdownMenuItem
|
||||||
|
disabled={action.disabled}
|
||||||
|
variant={action.destructive ? 'destructive' : 'default'}
|
||||||
|
onSelect={action.onSelect}
|
||||||
|
>
|
||||||
|
{Icon ? <Icon className='size-4' /> : null}
|
||||||
|
{action.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { AdminActionDialog, type StatusTone } from '@/components/admin'
|
||||||
|
|
||||||
|
export type BatchActionId = 'hide' | 'delete' | 'restore' | string
|
||||||
|
|
||||||
|
export interface BatchActionConfig {
|
||||||
|
id: BatchActionId
|
||||||
|
label: string
|
||||||
|
/** When true, the confirmation requires a note before submit. */
|
||||||
|
destructive?: boolean
|
||||||
|
tone?: StatusTone
|
||||||
|
confirmTitle: string
|
||||||
|
confirmDescription: string
|
||||||
|
confirmLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchOperationsToolbarProps {
|
||||||
|
selectedCount: number
|
||||||
|
totalCount?: number
|
||||||
|
onSelectAll?: () => void
|
||||||
|
onClearSelection?: () => void
|
||||||
|
actions: BatchActionConfig[]
|
||||||
|
onAction: (action: BatchActionConfig, remark: string) => void
|
||||||
|
isLoading?: boolean
|
||||||
|
pendingAction?: BatchActionId | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatchOperationsToolbar({
|
||||||
|
selectedCount,
|
||||||
|
totalCount,
|
||||||
|
onSelectAll,
|
||||||
|
onClearSelection,
|
||||||
|
actions,
|
||||||
|
onAction,
|
||||||
|
isLoading = false,
|
||||||
|
pendingAction = null,
|
||||||
|
}: BatchOperationsToolbarProps) {
|
||||||
|
const [pending, setPending] = React.useState<BatchActionConfig | null>(null)
|
||||||
|
const [remark, setRemark] = React.useState('')
|
||||||
|
|
||||||
|
if (selectedCount === 0) return null
|
||||||
|
|
||||||
|
function handleConfirm() {
|
||||||
|
if (!pending) return
|
||||||
|
if (pending.destructive && !remark.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onAction(pending, remark.trim())
|
||||||
|
setPending(null)
|
||||||
|
setRemark('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-wrap items-center gap-3 rounded-md border border-primary/30 bg-primary/5 px-3 py-2'>
|
||||||
|
<Badge variant='default' className='gap-1'>
|
||||||
|
Selected {selectedCount}
|
||||||
|
{typeof totalCount === 'number' ? ` / ${totalCount}` : ''}
|
||||||
|
</Badge>
|
||||||
|
{onSelectAll && (
|
||||||
|
<Button variant='ghost' size='sm' onClick={onSelectAll}>
|
||||||
|
Select current page
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onClearSelection && (
|
||||||
|
<Button variant='ghost' size='sm' onClick={onClearSelection}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className='ml-auto flex flex-wrap items-center gap-2'>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<Button
|
||||||
|
key={action.id}
|
||||||
|
size='sm'
|
||||||
|
variant={action.destructive ? 'destructive' : 'outline'}
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={() => {
|
||||||
|
setRemark('')
|
||||||
|
setPending(action)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminActionDialog
|
||||||
|
open={pending !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setPending(null)
|
||||||
|
setRemark('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tone={pending?.tone ?? (pending?.destructive ? 'danger' : 'info')}
|
||||||
|
title={pending?.confirmTitle ?? 'Confirm bulk action'}
|
||||||
|
description={pending?.confirmDescription ?? ''}
|
||||||
|
confirmLabel={pending?.confirmLabel ?? 'Confirm'}
|
||||||
|
isLoading={isLoading && pendingAction === pending?.id}
|
||||||
|
onConfirm={handleConfirm}
|
||||||
|
>
|
||||||
|
{pending?.destructive ? (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-sm font-medium'>Note (required)</label>
|
||||||
|
<textarea
|
||||||
|
className='min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none'
|
||||||
|
placeholder='Add a short reason for this action'
|
||||||
|
value={remark}
|
||||||
|
onChange={(e) => setRemark(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<label className='text-sm font-medium'>Note (optional)</label>
|
||||||
|
<textarea
|
||||||
|
className='min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none'
|
||||||
|
placeholder='Add a short note'
|
||||||
|
value={remark}
|
||||||
|
onChange={(e) => setRemark(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AdminActionDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
|
import { StatusBadge, type StatusTone } from './status-badge'
|
||||||
|
|
||||||
|
export type DetailField = {
|
||||||
|
label: React.ReactNode
|
||||||
|
value: React.ReactNode
|
||||||
|
wide?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DetailSection = {
|
||||||
|
title: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
fields?: DetailField[]
|
||||||
|
content?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DetailAction = {
|
||||||
|
label: React.ReactNode
|
||||||
|
icon?: React.ElementType
|
||||||
|
variant?: React.ComponentProps<typeof Button>['variant']
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type EntityDetailSheetProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
status?: {
|
||||||
|
label: React.ReactNode
|
||||||
|
tone?: StatusTone
|
||||||
|
}
|
||||||
|
meta?: React.ReactNode
|
||||||
|
sections: DetailSection[]
|
||||||
|
actions?: DetailAction[]
|
||||||
|
isLoading?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderValue(value: React.ReactNode) {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return <span className='text-muted-foreground'>-</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityDetailSheet({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
status,
|
||||||
|
meta,
|
||||||
|
sections,
|
||||||
|
actions,
|
||||||
|
isLoading,
|
||||||
|
className,
|
||||||
|
}: EntityDetailSheetProps) {
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
className={cn(
|
||||||
|
'w-[calc(100vw-1rem)] max-w-none gap-0 overflow-hidden p-0 sm:w-[min(92vw,960px)] sm:max-w-none lg:w-[min(86vw,1040px)]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SheetHeader className='border-b bg-muted/30 px-6 py-5'>
|
||||||
|
<div className='flex items-start justify-between gap-4 pe-8'>
|
||||||
|
<div className='min-w-0 space-y-2'>
|
||||||
|
<SheetTitle className='truncate text-xl'>{title}</SheetTitle>
|
||||||
|
{description && (
|
||||||
|
<SheetDescription className='line-clamp-2 leading-6 [overflow-wrap:anywhere] break-all'>
|
||||||
|
{description}
|
||||||
|
</SheetDescription>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status && <StatusBadge label={status.label} tone={status.tone} />}
|
||||||
|
</div>
|
||||||
|
{meta && (
|
||||||
|
<div className='text-xs [overflow-wrap:anywhere] break-all text-muted-foreground'>
|
||||||
|
{meta}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<ScrollArea className='min-h-0 min-w-0 flex-1 overflow-x-hidden'>
|
||||||
|
<div className='min-w-0 space-y-5 px-6 py-5'>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className='flex h-48 items-center justify-center gap-2 text-sm text-muted-foreground'>
|
||||||
|
<Loader2 className='size-4 animate-spin' />
|
||||||
|
Loading details...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sections.map((section, index) => (
|
||||||
|
<section
|
||||||
|
key={index}
|
||||||
|
className='min-w-0 overflow-hidden rounded-lg border bg-card/60 p-4 shadow-xs'
|
||||||
|
>
|
||||||
|
<div className='mb-3 min-w-0 space-y-1'>
|
||||||
|
<h3 className='text-sm font-semibold'>{section.title}</h3>
|
||||||
|
{section.description && (
|
||||||
|
<p className='text-xs leading-5 [overflow-wrap:anywhere] break-all text-muted-foreground'>
|
||||||
|
{section.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{section.fields && (
|
||||||
|
<dl className='grid min-w-0 gap-3 sm:grid-cols-2'>
|
||||||
|
{section.fields.map((field, fieldIndex) => (
|
||||||
|
<div
|
||||||
|
key={fieldIndex}
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 overflow-hidden rounded-md bg-muted/35 px-3 py-2.5',
|
||||||
|
field.wide && 'sm:col-span-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<dt className='text-xs text-muted-foreground'>
|
||||||
|
{field.label}
|
||||||
|
</dt>
|
||||||
|
<dd className='mt-1 min-w-0 text-sm leading-6 font-medium [overflow-wrap:anywhere] break-all whitespace-pre-wrap'>
|
||||||
|
{renderValue(field.value)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
{section.content && (
|
||||||
|
<div className='min-w-0 overflow-x-auto'>
|
||||||
|
{section.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{actions && actions.length > 0 && (
|
||||||
|
<SheetFooter className='border-t bg-background px-6 py-4 sm:flex-row sm:justify-end'>
|
||||||
|
{actions.map((action, index) => {
|
||||||
|
const Icon = action.icon
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
type='button'
|
||||||
|
variant={action.variant ?? 'outline'}
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={action.onClick}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className='size-4' />}
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</SheetFooter>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export * from './admin-action-dialog'
|
||||||
|
export * from './admin-row-actions-menu'
|
||||||
|
export * from './batch-operations-toolbar'
|
||||||
|
export * from './entity-detail-sheet'
|
||||||
|
export * from './readable-field'
|
||||||
|
export * from './readable-field-utils'
|
||||||
|
export * from './selectable-list'
|
||||||
|
export * from './status-badge'
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export function formatEmpty(value?: string | number | null) {
|
||||||
|
if (value === null || value === undefined || value === '') return '-'
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatEnumLabel(value?: string | number | null) {
|
||||||
|
if (value === null || value === undefined || value === '') return '-'
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
.trim()
|
||||||
|
.split(/[_\s.-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBoolean(value?: boolean | null) {
|
||||||
|
if (value === null || value === undefined) return '-'
|
||||||
|
return value ? 'Yes' : 'No'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(
|
||||||
|
value?: string | number | Date | null,
|
||||||
|
locale = 'en-US'
|
||||||
|
) {
|
||||||
|
if (value === null || value === undefined || value === '') return '-'
|
||||||
|
|
||||||
|
const date = value instanceof Date ? value : new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value)
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { ExternalLink, FileImage, FileVideo, UserRound } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
type EntityRefProps = {
|
||||||
|
id?: string | number | null
|
||||||
|
name?: string | null
|
||||||
|
kind?: string
|
||||||
|
avatar?: string | null
|
||||||
|
compact?: boolean
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EntityRef({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
kind = 'User',
|
||||||
|
avatar,
|
||||||
|
compact = false,
|
||||||
|
className,
|
||||||
|
}: EntityRefProps) {
|
||||||
|
const hasId = id !== null && id !== undefined && id !== ''
|
||||||
|
if (!name && !hasId) return <span className='text-muted-foreground'>-</span>
|
||||||
|
|
||||||
|
const title = name || `${kind} #${id}`
|
||||||
|
const idLabel = hasId ? `${kind} ID: ${id}` : undefined
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('inline-flex min-w-0 flex-col', className)}
|
||||||
|
title={idLabel}
|
||||||
|
>
|
||||||
|
<span className='max-w-[180px] truncate font-medium'>{title}</span>
|
||||||
|
{name && idLabel ? (
|
||||||
|
<span className='text-xs text-muted-foreground'>#{id}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('inline-flex min-w-0 items-center gap-2', className)}>
|
||||||
|
<Avatar className='size-8 border'>
|
||||||
|
{avatar ? <AvatarImage src={avatar} alt={title} /> : null}
|
||||||
|
<AvatarFallback>
|
||||||
|
<UserRound className='size-4' />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className='min-w-0'>
|
||||||
|
<span className='block max-w-[180px] truncate font-medium'>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
{name && idLabel ? (
|
||||||
|
<span className='block text-xs text-muted-foreground'>{idLabel}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourcePreviewProps = {
|
||||||
|
value?: string | null
|
||||||
|
type?: string | null
|
||||||
|
resolveResourceUrl?: (value?: string | null) => string | null | undefined
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourcePreview({
|
||||||
|
value,
|
||||||
|
type,
|
||||||
|
resolveResourceUrl,
|
||||||
|
className,
|
||||||
|
}: ResourcePreviewProps) {
|
||||||
|
const url = resolveResourceUrl ? resolveResourceUrl(value) : value
|
||||||
|
const normalizedType = String(type || '').toLowerCase()
|
||||||
|
const isVideo = normalizedType.includes('video')
|
||||||
|
const typeLabel = isVideo ? 'Video' : 'Image'
|
||||||
|
const fileName = value?.split(/[\\/]/).pop() || 'No resource URL'
|
||||||
|
|
||||||
|
if (!url) return <span className='text-muted-foreground'>-</span>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
className={cn('group inline-flex min-w-0 items-center gap-2', className)}
|
||||||
|
title={value || undefined}
|
||||||
|
>
|
||||||
|
<span className='relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted'>
|
||||||
|
{isVideo ? (
|
||||||
|
<FileVideo className='size-5 text-muted-foreground' />
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=''
|
||||||
|
className='size-full object-cover'
|
||||||
|
loading='lazy'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className='min-w-0'>
|
||||||
|
<span className='flex items-center gap-1.5'>
|
||||||
|
<Badge variant='outline' className='h-5 px-1.5 text-[11px]'>
|
||||||
|
{isVideo ? (
|
||||||
|
<FileVideo className='mr-1 size-3' />
|
||||||
|
) : (
|
||||||
|
<FileImage className='mr-1 size-3' />
|
||||||
|
)}
|
||||||
|
{typeLabel}
|
||||||
|
</Badge>
|
||||||
|
<ExternalLink className='size-3 text-muted-foreground group-hover:text-foreground' />
|
||||||
|
</span>
|
||||||
|
<span className='mt-0.5 block max-w-[220px] truncate text-xs text-muted-foreground'>
|
||||||
|
{fileName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
|
||||||
|
type SelectableId = string | number
|
||||||
|
|
||||||
|
type SelectableListProps<TItem> = {
|
||||||
|
items: TItem[]
|
||||||
|
selectedIds: SelectableId[]
|
||||||
|
onToggle: (id: SelectableId) => void
|
||||||
|
getId: (item: TItem) => SelectableId
|
||||||
|
getTitle: (item: TItem) => React.ReactNode
|
||||||
|
getSubtitle?: (item: TItem) => React.ReactNode
|
||||||
|
getSearchText?: (item: TItem) => string
|
||||||
|
searchPlaceholder?: string
|
||||||
|
emptyText?: string
|
||||||
|
className?: string
|
||||||
|
maxHeightClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyNode(value: React.ReactNode) {
|
||||||
|
if (typeof value === 'string' || typeof value === 'number') {
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectableList<TItem>({
|
||||||
|
items,
|
||||||
|
selectedIds,
|
||||||
|
onToggle,
|
||||||
|
getId,
|
||||||
|
getTitle,
|
||||||
|
getSubtitle,
|
||||||
|
getSearchText,
|
||||||
|
searchPlaceholder = 'Search...',
|
||||||
|
emptyText = 'No options available',
|
||||||
|
className,
|
||||||
|
maxHeightClassName = 'h-[360px]',
|
||||||
|
}: SelectableListProps<TItem>) {
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||||
|
if (!normalizedKeyword) return items
|
||||||
|
|
||||||
|
return items.filter((item) => {
|
||||||
|
const text =
|
||||||
|
getSearchText?.(item) ??
|
||||||
|
[getTitle(item), getSubtitle?.(item)]
|
||||||
|
.map((value) => stringifyNode(value))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
return text.toLowerCase().includes(normalizedKeyword)
|
||||||
|
})
|
||||||
|
}, [getSearchText, getSubtitle, getTitle, items, keyword])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-3', className)}>
|
||||||
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
|
<div className='relative sm:max-w-xs sm:flex-1'>
|
||||||
|
<Search className='absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground' />
|
||||||
|
<Input
|
||||||
|
value={keyword}
|
||||||
|
onChange={(event) => setKeyword(event.target.value)}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
className='pl-9'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='text-sm text-muted-foreground'>
|
||||||
|
Selected {selectedIds.length} / {items.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea
|
||||||
|
className={cn('rounded-lg border bg-muted/20', maxHeightClassName)}
|
||||||
|
>
|
||||||
|
<div className='space-y-2 p-2'>
|
||||||
|
{filteredItems.length === 0 ? (
|
||||||
|
<div className='flex h-28 items-center justify-center text-sm text-muted-foreground'>
|
||||||
|
{emptyText}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredItems.map((item) => {
|
||||||
|
const id = getId(item)
|
||||||
|
const checked = selectedSet.has(id)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={String(id)}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-pointer items-start gap-3 rounded-md border bg-background p-3 text-sm transition-colors hover:bg-accent/60',
|
||||||
|
checked && 'border-primary/35 bg-primary/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={() => onToggle(id)}
|
||||||
|
className='mt-0.5'
|
||||||
|
/>
|
||||||
|
<div className='min-w-0 flex-1'>
|
||||||
|
<div className='leading-5 font-medium break-words'>
|
||||||
|
{getTitle(item)}
|
||||||
|
</div>
|
||||||
|
{getSubtitle && (
|
||||||
|
<div className='mt-1 text-xs leading-5 break-words text-muted-foreground'>
|
||||||
|
{getSubtitle(item)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
export type StatusTone = 'success' | 'warning' | 'info' | 'danger' | 'neutral'
|
||||||
|
|
||||||
|
const toneClassName: Record<StatusTone, string> = {
|
||||||
|
success:
|
||||||
|
'border-success/25 bg-success/10 text-success-foreground dark:border-success/35 dark:bg-success/15',
|
||||||
|
warning:
|
||||||
|
'border-warning/25 bg-warning/10 text-warning-foreground dark:border-warning/35 dark:bg-warning/15',
|
||||||
|
info: 'border-info/25 bg-info/10 text-info-foreground dark:border-info/35 dark:bg-info/15',
|
||||||
|
danger:
|
||||||
|
'border-danger/25 bg-danger/10 text-danger-foreground dark:border-danger/35 dark:bg-danger/15',
|
||||||
|
neutral:
|
||||||
|
'border-neutral/25 bg-neutral/10 text-neutral-foreground dark:border-neutral/35 dark:bg-neutral/15',
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusBadgeProps = {
|
||||||
|
label: React.ReactNode
|
||||||
|
tone?: StatusTone
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({
|
||||||
|
label,
|
||||||
|
tone = 'neutral',
|
||||||
|
className,
|
||||||
|
}: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant='outline'
|
||||||
|
className={cn(
|
||||||
|
'gap-1.5 border font-medium',
|
||||||
|
toneClassName[tone],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className='size-1.5 rounded-full bg-current' />
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Telescope } from 'lucide-react'
|
||||||
|
|
||||||
|
export function ComingSoon() {
|
||||||
|
return (
|
||||||
|
<div className='h-svh'>
|
||||||
|
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
|
||||||
|
<Telescope size={72} />
|
||||||
|
<h1 className='text-4xl leading-tight font-bold'>Coming Soon!</h1>
|
||||||
|
<p className='text-center text-muted-foreground'>
|
||||||
|
This page has not been created yet. <br />
|
||||||
|
Stay tuned though!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
|
||||||
|
import { useSearch } from '@/context/search-provider'
|
||||||
|
import { useTheme } from '@/context/theme-provider'
|
||||||
|
import {
|
||||||
|
CommandDialog,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from '@/components/ui/command'
|
||||||
|
import { sidebarData } from './layout/data/sidebar-data'
|
||||||
|
import { ScrollArea } from './ui/scroll-area'
|
||||||
|
|
||||||
|
export function CommandMenu() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { setTheme } = useTheme()
|
||||||
|
const { open, setOpen } = useSearch()
|
||||||
|
|
||||||
|
const runCommand = React.useCallback(
|
||||||
|
(command: () => unknown) => {
|
||||||
|
setOpen(false)
|
||||||
|
command()
|
||||||
|
},
|
||||||
|
[setOpen]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandDialog modal open={open} onOpenChange={setOpen}>
|
||||||
|
<CommandInput placeholder='输入命令或搜索页面...' />
|
||||||
|
<CommandList>
|
||||||
|
<ScrollArea type='hover' className='h-72 pe-1'>
|
||||||
|
<CommandEmpty>没有匹配结果</CommandEmpty>
|
||||||
|
{sidebarData.navGroups.map((group) => (
|
||||||
|
<CommandGroup key={group.title} heading={group.title}>
|
||||||
|
{group.items.map((navItem, i) => {
|
||||||
|
if (navItem.url)
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={`${navItem.url}-${i}`}
|
||||||
|
value={navItem.title}
|
||||||
|
onSelect={() => {
|
||||||
|
runCommand(() => navigate({ to: navItem.url }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='flex size-4 items-center justify-center'>
|
||||||
|
<ArrowRight className='size-2 text-muted-foreground/80' />
|
||||||
|
</div>
|
||||||
|
{navItem.title}
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
return navItem.items?.map((subItem, i) => (
|
||||||
|
<CommandItem
|
||||||
|
key={`${navItem.title}-${subItem.url}-${i}`}
|
||||||
|
value={`${navItem.title}-${subItem.url}`}
|
||||||
|
onSelect={() => {
|
||||||
|
runCommand(() => navigate({ to: subItem.url }))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='flex size-4 items-center justify-center'>
|
||||||
|
<ArrowRight className='size-2 text-muted-foreground/80' />
|
||||||
|
</div>
|
||||||
|
{navItem.title} <ChevronRight /> {subItem.title}
|
||||||
|
</CommandItem>
|
||||||
|
))
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
))}
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup heading='主题'>
|
||||||
|
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
|
||||||
|
<Sun /> <span>浅色</span>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
|
||||||
|
<Moon className='scale-90' />
|
||||||
|
<span>深色</span>
|
||||||
|
</CommandItem>
|
||||||
|
<CommandItem onSelect={() => runCommand(() => setTheme('system'))}>
|
||||||
|
<Laptop />
|
||||||
|
<span>跟随系统</span>
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</ScrollArea>
|
||||||
|
</CommandList>
|
||||||
|
</CommandDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { type SVGProps } from 'react'
|
||||||
|
import { Root as Radio, Item } from '@radix-ui/react-radio-group'
|
||||||
|
import { CircleCheck, RotateCcw, Settings } from 'lucide-react'
|
||||||
|
import { IconDir } from '@/assets/custom/icon-dir'
|
||||||
|
import { IconLayoutCompact } from '@/assets/custom/icon-layout-compact'
|
||||||
|
import { IconLayoutDefault } from '@/assets/custom/icon-layout-default'
|
||||||
|
import { IconLayoutFull } from '@/assets/custom/icon-layout-full'
|
||||||
|
import { IconSidebarFloating } from '@/assets/custom/icon-sidebar-floating'
|
||||||
|
import { IconSidebarInset } from '@/assets/custom/icon-sidebar-inset'
|
||||||
|
import { IconSidebarSidebar } from '@/assets/custom/icon-sidebar-sidebar'
|
||||||
|
import { IconThemeDark } from '@/assets/custom/icon-theme-dark'
|
||||||
|
import { IconThemeLight } from '@/assets/custom/icon-theme-light'
|
||||||
|
import { IconThemeSystem } from '@/assets/custom/icon-theme-system'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useDirection } from '@/context/direction-provider'
|
||||||
|
import { type Collapsible, useLayout } from '@/context/layout-provider'
|
||||||
|
import { useTheme } from '@/context/theme-provider'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
|
import { useSidebar } from './ui/sidebar'
|
||||||
|
|
||||||
|
export function ConfigDrawer() {
|
||||||
|
const { setOpen } = useSidebar()
|
||||||
|
const { resetDir } = useDirection()
|
||||||
|
const { resetTheme } = useTheme()
|
||||||
|
const { resetLayout } = useLayout()
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setOpen(true)
|
||||||
|
resetDir()
|
||||||
|
resetTheme()
|
||||||
|
resetLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet>
|
||||||
|
<SheetTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size='icon'
|
||||||
|
variant='ghost'
|
||||||
|
aria-label='打开界面设置'
|
||||||
|
aria-describedby='config-drawer-description'
|
||||||
|
className='rounded-full'
|
||||||
|
>
|
||||||
|
<Settings aria-hidden='true' />
|
||||||
|
</Button>
|
||||||
|
</SheetTrigger>
|
||||||
|
<SheetContent className='flex flex-col'>
|
||||||
|
<SheetHeader className='pb-0 text-start'>
|
||||||
|
<SheetTitle>界面设置</SheetTitle>
|
||||||
|
<SheetDescription id='config-drawer-description'>
|
||||||
|
调整主题、侧边栏和页面布局。
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className='space-y-6 overflow-y-auto px-4'>
|
||||||
|
<ThemeConfig />
|
||||||
|
<SidebarConfig />
|
||||||
|
<LayoutConfig />
|
||||||
|
<DirConfig />
|
||||||
|
</div>
|
||||||
|
<SheetFooter className='gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
onClick={handleReset}
|
||||||
|
aria-label='恢复全部默认设置'
|
||||||
|
>
|
||||||
|
恢复默认
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionTitle({
|
||||||
|
title,
|
||||||
|
showReset = false,
|
||||||
|
onReset,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
showReset?: boolean
|
||||||
|
onReset?: () => void
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'mb-2 flex items-center gap-2 text-sm font-semibold text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
{showReset && onReset && (
|
||||||
|
<Button
|
||||||
|
size='icon'
|
||||||
|
variant='secondary'
|
||||||
|
className='size-4 rounded-full'
|
||||||
|
onClick={onReset}
|
||||||
|
>
|
||||||
|
<RotateCcw className='size-3' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
item,
|
||||||
|
isTheme = false,
|
||||||
|
}: {
|
||||||
|
item: {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
icon: (props: SVGProps<SVGSVGElement>) => React.ReactElement
|
||||||
|
}
|
||||||
|
isTheme?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Item
|
||||||
|
value={item.value}
|
||||||
|
className={cn('group outline-none', 'transition duration-200 ease-in')}
|
||||||
|
aria-label={`选择${item.label}`}
|
||||||
|
aria-describedby={`${item.value}-description`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative rounded-[6px] ring-[1px] ring-border',
|
||||||
|
'group-data-[state=checked]:shadow-2xl group-data-[state=checked]:ring-primary',
|
||||||
|
'group-focus-visible:ring-2'
|
||||||
|
)}
|
||||||
|
role='img'
|
||||||
|
aria-hidden='false'
|
||||||
|
aria-label={`${item.label}选项预览`}
|
||||||
|
>
|
||||||
|
<CircleCheck
|
||||||
|
className={cn(
|
||||||
|
'size-6 fill-primary stroke-white',
|
||||||
|
'group-data-[state=unchecked]:hidden',
|
||||||
|
'absolute top-0 right-0 translate-x-1/2 -translate-y-1/2'
|
||||||
|
)}
|
||||||
|
aria-hidden='true'
|
||||||
|
/>
|
||||||
|
<item.icon
|
||||||
|
className={cn(
|
||||||
|
!isTheme &&
|
||||||
|
'fill-primary stroke-primary group-data-[state=unchecked]:fill-muted-foreground group-data-[state=unchecked]:stroke-muted-foreground'
|
||||||
|
)}
|
||||||
|
aria-hidden='true'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className='mt-1 text-xs'
|
||||||
|
id={`${item.value}-description`}
|
||||||
|
aria-live='polite'
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThemeConfig() {
|
||||||
|
const { defaultTheme, theme, setTheme } = useTheme()
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionTitle
|
||||||
|
title='主题'
|
||||||
|
showReset={theme !== defaultTheme}
|
||||||
|
onReset={() => setTheme(defaultTheme)}
|
||||||
|
/>
|
||||||
|
<Radio
|
||||||
|
value={theme}
|
||||||
|
onValueChange={setTheme}
|
||||||
|
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||||
|
aria-label='选择主题'
|
||||||
|
aria-describedby='theme-description'
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
value: 'system',
|
||||||
|
label: '跟随系统',
|
||||||
|
icon: IconThemeSystem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'light',
|
||||||
|
label: '浅色',
|
||||||
|
icon: IconThemeLight,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dark',
|
||||||
|
label: '深色',
|
||||||
|
icon: IconThemeDark,
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<RadioGroupItem key={item.value} item={item} isTheme />
|
||||||
|
))}
|
||||||
|
</Radio>
|
||||||
|
<div id='theme-description' className='sr-only'>
|
||||||
|
可选择跟随系统、浅色或深色主题
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarConfig() {
|
||||||
|
const { defaultVariant, variant, setVariant } = useLayout()
|
||||||
|
return (
|
||||||
|
<div className='max-md:hidden'>
|
||||||
|
<SectionTitle
|
||||||
|
title='侧边栏样式'
|
||||||
|
showReset={defaultVariant !== variant}
|
||||||
|
onReset={() => setVariant(defaultVariant)}
|
||||||
|
/>
|
||||||
|
<Radio
|
||||||
|
value={variant}
|
||||||
|
onValueChange={setVariant}
|
||||||
|
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||||
|
aria-label='选择侧边栏样式'
|
||||||
|
aria-describedby='sidebar-description'
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
value: 'inset',
|
||||||
|
label: '内嵌',
|
||||||
|
icon: IconSidebarInset,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'floating',
|
||||||
|
label: '悬浮',
|
||||||
|
icon: IconSidebarFloating,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'sidebar',
|
||||||
|
label: '标准',
|
||||||
|
icon: IconSidebarSidebar,
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<RadioGroupItem key={item.value} item={item} />
|
||||||
|
))}
|
||||||
|
</Radio>
|
||||||
|
<div id='sidebar-description' className='sr-only'>
|
||||||
|
可选择内嵌、悬浮或标准侧边栏
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LayoutConfig() {
|
||||||
|
const { open, setOpen } = useSidebar()
|
||||||
|
const { defaultCollapsible, collapsible, setCollapsible } = useLayout()
|
||||||
|
|
||||||
|
const radioState = open ? 'default' : collapsible
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='max-md:hidden'>
|
||||||
|
<SectionTitle
|
||||||
|
title='展开方式'
|
||||||
|
showReset={radioState !== 'default'}
|
||||||
|
onReset={() => {
|
||||||
|
setOpen(true)
|
||||||
|
setCollapsible(defaultCollapsible)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Radio
|
||||||
|
value={radioState}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === 'default') {
|
||||||
|
setOpen(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
setCollapsible(v as Collapsible)
|
||||||
|
}}
|
||||||
|
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||||
|
aria-label='选择展开方式'
|
||||||
|
aria-describedby='layout-description'
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
value: 'default',
|
||||||
|
label: '默认',
|
||||||
|
icon: IconLayoutDefault,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'icon',
|
||||||
|
label: '紧凑',
|
||||||
|
icon: IconLayoutCompact,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'offcanvas',
|
||||||
|
label: '完全收起',
|
||||||
|
icon: IconLayoutFull,
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<RadioGroupItem key={item.value} item={item} />
|
||||||
|
))}
|
||||||
|
</Radio>
|
||||||
|
<div id='layout-description' className='sr-only'>
|
||||||
|
可选择默认展开、仅显示图标或完全收起
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DirConfig() {
|
||||||
|
const { defaultDir, dir, setDir } = useDirection()
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SectionTitle
|
||||||
|
title='文字方向'
|
||||||
|
showReset={defaultDir !== dir}
|
||||||
|
onReset={() => setDir(defaultDir)}
|
||||||
|
/>
|
||||||
|
<Radio
|
||||||
|
value={dir}
|
||||||
|
onValueChange={setDir}
|
||||||
|
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||||
|
aria-label='选择文字方向'
|
||||||
|
aria-describedby='direction-description'
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
value: 'ltr',
|
||||||
|
label: '从左到右',
|
||||||
|
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||||
|
<IconDir dir='ltr' {...props} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'rtl',
|
||||||
|
label: '从右到左',
|
||||||
|
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||||
|
<IconDir dir='rtl' {...props} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
].map((item) => (
|
||||||
|
<RadioGroupItem key={item.value} item={item} />
|
||||||
|
))}
|
||||||
|
</Radio>
|
||||||
|
<div id='direction-description' className='sr-only'>
|
||||||
|
可选择从左到右或从右到左
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
|
||||||
|
interface ConfirmActionDialogProps {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
confirmLabel?: string
|
||||||
|
onConfirm: () => void
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmActionDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
onConfirm,
|
||||||
|
onOpenChange,
|
||||||
|
}: ConfirmActionDialogProps) {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={onConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
type ConfirmDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: React.ReactNode
|
||||||
|
disabled?: boolean
|
||||||
|
desc: React.JSX.Element | string
|
||||||
|
cancelBtnText?: string
|
||||||
|
confirmText?: React.ReactNode
|
||||||
|
destructive?: boolean
|
||||||
|
isLoading?: boolean
|
||||||
|
className?: string
|
||||||
|
children?: React.ReactNode
|
||||||
|
} & (
|
||||||
|
| { form: string; handleConfirm?: undefined }
|
||||||
|
| { form?: undefined; handleConfirm: () => void }
|
||||||
|
)
|
||||||
|
|
||||||
|
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
desc,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
confirmText,
|
||||||
|
cancelBtnText,
|
||||||
|
destructive,
|
||||||
|
isLoading,
|
||||||
|
disabled = false,
|
||||||
|
form,
|
||||||
|
handleConfirm,
|
||||||
|
...actions
|
||||||
|
} = props
|
||||||
|
return (
|
||||||
|
<AlertDialog {...actions}>
|
||||||
|
<AlertDialogContent className={cn(className && className)}>
|
||||||
|
<AlertDialogHeader className='text-start'>
|
||||||
|
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription asChild>
|
||||||
|
<div>{desc}</div>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
{children}
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={isLoading}>
|
||||||
|
{cancelBtnText ?? 'Cancel'}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<Button
|
||||||
|
type={form ? 'submit' : 'button'}
|
||||||
|
form={form}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
variant={destructive ? 'destructive' : 'default'}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
>
|
||||||
|
{confirmText ?? 'Continue'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { type Table } from '@tanstack/react-table'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
type DataTableBulkActionsProps<TData> = {
|
||||||
|
table: Table<TData>
|
||||||
|
entityName: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A modular toolbar for displaying bulk actions when table rows are selected.
|
||||||
|
*
|
||||||
|
* @template TData The type of data in the table.
|
||||||
|
* @param {object} props The component props.
|
||||||
|
* @param {Table<TData>} props.table The react-table instance.
|
||||||
|
* @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
|
||||||
|
* @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
|
||||||
|
* @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
|
||||||
|
*/
|
||||||
|
export function DataTableBulkActions<TData>({
|
||||||
|
table,
|
||||||
|
entityName,
|
||||||
|
children,
|
||||||
|
}: DataTableBulkActionsProps<TData>): React.ReactNode | null {
|
||||||
|
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||||
|
const selectedCount = selectedRows.length
|
||||||
|
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [announcement, setAnnouncement] = useState('')
|
||||||
|
|
||||||
|
// Announce selection changes to screen readers
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCount > 0) {
|
||||||
|
const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
|
||||||
|
|
||||||
|
// Use queueMicrotask to defer state update and avoid cascading renders
|
||||||
|
queueMicrotask(() => {
|
||||||
|
setAnnouncement(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear announcement after a delay
|
||||||
|
const timer = setTimeout(() => setAnnouncement(''), 3000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [selectedCount, entityName])
|
||||||
|
|
||||||
|
const handleClearSelection = () => {
|
||||||
|
table.resetRowSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
const buttons = toolbarRef.current?.querySelectorAll('button')
|
||||||
|
if (!buttons) return
|
||||||
|
|
||||||
|
const currentIndex = Array.from(buttons).findIndex(
|
||||||
|
(button) => button === document.activeElement
|
||||||
|
)
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowRight': {
|
||||||
|
event.preventDefault()
|
||||||
|
const nextIndex = (currentIndex + 1) % buttons.length
|
||||||
|
buttons[nextIndex]?.focus()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'ArrowLeft': {
|
||||||
|
event.preventDefault()
|
||||||
|
const prevIndex =
|
||||||
|
currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
|
||||||
|
buttons[prevIndex]?.focus()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'Home':
|
||||||
|
event.preventDefault()
|
||||||
|
buttons[0]?.focus()
|
||||||
|
break
|
||||||
|
case 'End':
|
||||||
|
event.preventDefault()
|
||||||
|
buttons[buttons.length - 1]?.focus()
|
||||||
|
break
|
||||||
|
case 'Escape': {
|
||||||
|
// Check if the Escape key came from a dropdown trigger or content
|
||||||
|
// We can't check dropdown state because Radix UI closes it before our handler runs
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
const activeElement = document.activeElement as HTMLElement
|
||||||
|
|
||||||
|
// Check if the event target or currently focused element is a dropdown trigger
|
||||||
|
const isFromDropdownTrigger =
|
||||||
|
target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
|
||||||
|
activeElement?.getAttribute('data-slot') ===
|
||||||
|
'dropdown-menu-trigger' ||
|
||||||
|
target?.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||||
|
activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
|
||||||
|
|
||||||
|
// Check if the focused element is inside dropdown content (which is portaled)
|
||||||
|
const isFromDropdownContent =
|
||||||
|
activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
|
||||||
|
target?.closest('[data-slot="dropdown-menu-content"]')
|
||||||
|
|
||||||
|
if (isFromDropdownTrigger || isFromDropdownContent) {
|
||||||
|
// Escape was meant for the dropdown - don't clear selection
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape was meant for the toolbar - clear selection
|
||||||
|
event.preventDefault()
|
||||||
|
handleClearSelection()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Live region for screen reader announcements */}
|
||||||
|
<div
|
||||||
|
aria-live='polite'
|
||||||
|
aria-atomic='true'
|
||||||
|
className='sr-only'
|
||||||
|
role='status'
|
||||||
|
>
|
||||||
|
{announcement}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={toolbarRef}
|
||||||
|
role='toolbar'
|
||||||
|
aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`}
|
||||||
|
aria-describedby='bulk-actions-description'
|
||||||
|
tabIndex={-1}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className={cn(
|
||||||
|
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
|
||||||
|
'transition-all delay-100 duration-300 ease-out hover:scale-105',
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-2 shadow-xl',
|
||||||
|
'rounded-xl border',
|
||||||
|
'bg-background/95 backdrop-blur-lg supports-backdrop-filter:bg-background/60',
|
||||||
|
'flex items-center gap-x-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='icon'
|
||||||
|
onClick={handleClearSelection}
|
||||||
|
className='size-6 rounded-full'
|
||||||
|
aria-label='Clear selection'
|
||||||
|
title='Clear selection (Escape)'
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
<span className='sr-only'>Clear selection</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Clear selection (Escape)</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
className='h-5'
|
||||||
|
orientation='vertical'
|
||||||
|
aria-hidden='true'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className='flex items-center gap-x-1 text-sm'
|
||||||
|
id='bulk-actions-description'
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant='default'
|
||||||
|
className='min-w-8 rounded-lg'
|
||||||
|
aria-label={`${selectedCount} selected`}
|
||||||
|
>
|
||||||
|
{selectedCount}
|
||||||
|
</Badge>{' '}
|
||||||
|
<span className='hidden sm:inline'>
|
||||||
|
{entityName}
|
||||||
|
{selectedCount > 1 ? 's' : ''}
|
||||||
|
</span>{' '}
|
||||||
|
selected
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator
|
||||||
|
className='h-5'
|
||||||
|
orientation='vertical'
|
||||||
|
aria-hidden='true'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
CaretSortIcon,
|
||||||
|
EyeNoneIcon,
|
||||||
|
} from '@radix-ui/react-icons'
|
||||||
|
import { type Column } from '@tanstack/react-table'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
|
||||||
|
type DataTableColumnHeaderProps<TData, TValue> =
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
column: Column<TData, TValue>
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableColumnHeader<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||||
|
if (!column.getCanSort()) {
|
||||||
|
return <div className={cn(className)}>{title}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center space-x-2', className)}>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
className='h-8 data-[state=open]:bg-accent'
|
||||||
|
>
|
||||||
|
<span>{title}</span>
|
||||||
|
{column.getIsSorted() === 'desc' ? (
|
||||||
|
<ArrowDownIcon className='ms-2 h-4 w-4' />
|
||||||
|
) : column.getIsSorted() === 'asc' ? (
|
||||||
|
<ArrowUpIcon className='ms-2 h-4 w-4' />
|
||||||
|
) : (
|
||||||
|
<CaretSortIcon className='ms-2 h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align='start'>
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||||
|
<ArrowUpIcon className='size-3.5 text-muted-foreground/70' />
|
||||||
|
Asc
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||||
|
<ArrowDownIcon className='size-3.5 text-muted-foreground/70' />
|
||||||
|
Desc
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{column.getCanHide() && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||||
|
<EyeNoneIcon className='size-3.5 text-muted-foreground/70' />
|
||||||
|
Hide
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'
|
||||||
|
import { type Column } from '@tanstack/react-table'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
|
} from '@/components/ui/command'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
|
||||||
|
type DataTableFacetedFilterProps<TData, TValue> = {
|
||||||
|
column?: Column<TData, TValue>
|
||||||
|
title?: string
|
||||||
|
options: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableFacetedFilter<TData, TValue>({
|
||||||
|
column,
|
||||||
|
title,
|
||||||
|
options,
|
||||||
|
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||||
|
const facets = column?.getFacetedUniqueValues()
|
||||||
|
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant='outline' size='sm' className='h-8 border-dashed'>
|
||||||
|
<PlusCircledIcon className='size-4' />
|
||||||
|
{title}
|
||||||
|
{selectedValues?.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Separator orientation='vertical' className='mx-2 h-4' />
|
||||||
|
<Badge
|
||||||
|
variant='secondary'
|
||||||
|
className='rounded-sm px-1 font-normal lg:hidden'
|
||||||
|
>
|
||||||
|
{selectedValues.size}
|
||||||
|
</Badge>
|
||||||
|
<div className='hidden space-x-1 lg:flex'>
|
||||||
|
{selectedValues.size > 2 ? (
|
||||||
|
<Badge
|
||||||
|
variant='secondary'
|
||||||
|
className='rounded-sm px-1 font-normal'
|
||||||
|
>
|
||||||
|
{selectedValues.size} selected
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
options
|
||||||
|
.filter((option) => selectedValues.has(option.value))
|
||||||
|
.map((option) => (
|
||||||
|
<Badge
|
||||||
|
variant='secondary'
|
||||||
|
key={option.value}
|
||||||
|
className='rounded-sm px-1 font-normal'
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className='w-[200px] p-0' align='start'>
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder={title} />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = selectedValues.has(option.value)
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
onSelect={() => {
|
||||||
|
if (isSelected) {
|
||||||
|
selectedValues.delete(option.value)
|
||||||
|
} else {
|
||||||
|
selectedValues.add(option.value)
|
||||||
|
}
|
||||||
|
const filterValues = Array.from(selectedValues)
|
||||||
|
column?.setFilterValue(
|
||||||
|
filterValues.length ? filterValues : undefined
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex size-4 items-center justify-center rounded-sm border border-primary',
|
||||||
|
isSelected
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'opacity-50 [&_svg]:invisible'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon className={cn('h-4 w-4 text-background')} />
|
||||||
|
</div>
|
||||||
|
{option.icon && (
|
||||||
|
<option.icon className='size-4 text-muted-foreground' />
|
||||||
|
)}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{facets?.get(option.value) && (
|
||||||
|
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
|
||||||
|
{facets.get(option.value)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CommandItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
{selectedValues.size > 0 && (
|
||||||
|
<>
|
||||||
|
<CommandSeparator />
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={() => column?.setFilterValue(undefined)}
|
||||||
|
className='justify-center text-center'
|
||||||
|
>
|
||||||
|
Clear filters
|
||||||
|
</CommandItem>
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { DataTablePagination } from './pagination'
|
||||||
|
export { DataTableColumnHeader } from './column-header'
|
||||||
|
export { DataTableToolbar } from './toolbar'
|
||||||
|
export { DataTableBulkActions } from './bulk-actions'
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
DoubleArrowLeftIcon,
|
||||||
|
DoubleArrowRightIcon,
|
||||||
|
} from '@radix-ui/react-icons'
|
||||||
|
import { type Table } from '@tanstack/react-table'
|
||||||
|
import { cn, getPageNumbers } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
|
type DataTablePaginationProps<TData> = {
|
||||||
|
table: Table<TData>
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTablePagination<TData>({
|
||||||
|
table,
|
||||||
|
className,
|
||||||
|
}: DataTablePaginationProps<TData>) {
|
||||||
|
const currentPage = table.getState().pagination.pageIndex + 1
|
||||||
|
const totalPages = table.getPageCount()
|
||||||
|
const pageNumbers = getPageNumbers(currentPage, totalPages)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between overflow-clip px-2',
|
||||||
|
'@max-2xl/content:flex-col-reverse @max-2xl/content:gap-4',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{ overflowClipMargin: 1 }}
|
||||||
|
>
|
||||||
|
<div className='flex w-full items-center justify-between'>
|
||||||
|
<div className='flex w-[100px] items-center justify-center text-sm font-medium @2xl/content:hidden'>
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2 @max-2xl/content:flex-row-reverse'>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className='h-8 w-[70px]'>
|
||||||
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side='top'>
|
||||||
|
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center sm:space-x-6 lg:space-x-8'>
|
||||||
|
<div className='flex w-[100px] items-center justify-center text-sm font-medium @max-3xl/content:hidden'>
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
className='size-8 p-0 @max-md/content:hidden'
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className='sr-only'>Go to first page</span>
|
||||||
|
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
className='size-8 p-0'
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className='sr-only'>Go to previous page</span>
|
||||||
|
<ChevronLeftIcon className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Page number buttons */}
|
||||||
|
{pageNumbers.map((pageNumber, index) => (
|
||||||
|
<div key={`${pageNumber}-${index}`} className='flex items-center'>
|
||||||
|
{pageNumber === '...' ? (
|
||||||
|
<span className='px-1 text-sm text-muted-foreground'>...</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant={currentPage === pageNumber ? 'default' : 'outline'}
|
||||||
|
className='h-8 min-w-8 px-2'
|
||||||
|
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
|
||||||
|
>
|
||||||
|
<span className='sr-only'>Go to page {pageNumber}</span>
|
||||||
|
{pageNumber}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
className='size-8 p-0'
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className='sr-only'>Go to next page</span>
|
||||||
|
<ChevronRightIcon className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
className='size-8 p-0 @max-md/content:hidden'
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className='sr-only'>Go to last page</span>
|
||||||
|
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { Cross2Icon } from '@radix-ui/react-icons'
|
||||||
|
import { type Table } from '@tanstack/react-table'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { DataTableFacetedFilter } from './faceted-filter'
|
||||||
|
import { DataTableViewOptions } from './view-options'
|
||||||
|
|
||||||
|
type DataTableToolbarProps<TData> = {
|
||||||
|
table: Table<TData>
|
||||||
|
searchPlaceholder?: string
|
||||||
|
searchKey?: string
|
||||||
|
filters?: {
|
||||||
|
columnId: string
|
||||||
|
title: string
|
||||||
|
options: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
|
}[]
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableToolbar<TData>({
|
||||||
|
table,
|
||||||
|
searchPlaceholder = 'Filter...',
|
||||||
|
searchKey,
|
||||||
|
filters = [],
|
||||||
|
}: DataTableToolbarProps<TData>) {
|
||||||
|
const isFiltered =
|
||||||
|
table.getState().columnFilters.length > 0 || table.getState().globalFilter
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
|
||||||
|
{searchKey ? (
|
||||||
|
<Input
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={
|
||||||
|
(table.getColumn(searchKey)?.getFilterValue() as string) ?? ''
|
||||||
|
}
|
||||||
|
onChange={(event) =>
|
||||||
|
table.getColumn(searchKey)?.setFilterValue(event.target.value)
|
||||||
|
}
|
||||||
|
className='h-8 w-[150px] lg:w-[250px]'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
value={table.getState().globalFilter ?? ''}
|
||||||
|
onChange={(event) => table.setGlobalFilter(event.target.value)}
|
||||||
|
className='h-8 w-[150px] lg:w-[250px]'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className='flex gap-x-2'>
|
||||||
|
{filters.map((filter) => {
|
||||||
|
const column = table.getColumn(filter.columnId)
|
||||||
|
if (!column) return null
|
||||||
|
return (
|
||||||
|
<DataTableFacetedFilter
|
||||||
|
key={filter.columnId}
|
||||||
|
column={column}
|
||||||
|
title={filter.title}
|
||||||
|
options={filter.options}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{isFiltered && (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
onClick={() => {
|
||||||
|
table.resetColumnFilters()
|
||||||
|
table.setGlobalFilter('')
|
||||||
|
}}
|
||||||
|
className='h-8 px-2 lg:px-3'
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
<Cross2Icon className='ms-2 h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DataTableViewOptions table={table} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
|
||||||
|
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
|
||||||
|
import { type Table } from '@tanstack/react-table'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
|
||||||
|
type DataTableViewOptionsProps<TData> = {
|
||||||
|
table: Table<TData>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableViewOptions<TData>({
|
||||||
|
table,
|
||||||
|
}: DataTableViewOptionsProps<TData>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
className='ms-auto hidden h-8 lg:flex'
|
||||||
|
>
|
||||||
|
<MixerHorizontalIcon className='size-4' />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align='end' className='w-[150px]'>
|
||||||
|
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter(
|
||||||
|
(column) =>
|
||||||
|
typeof column.accessorFn !== 'undefined' && column.getCanHide()
|
||||||
|
)
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
className='capitalize'
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { format } from 'date-fns'
|
||||||
|
import { Calendar as CalendarIcon } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Calendar } from '@/components/ui/calendar'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
|
||||||
|
type DatePickerProps = {
|
||||||
|
selected: Date | undefined
|
||||||
|
onSelect: (date: Date | undefined) => void
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DatePicker({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
placeholder = 'Pick a date',
|
||||||
|
}: DatePickerProps) {
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
data-empty={!selected}
|
||||||
|
className='w-[240px] justify-start text-start font-normal data-[empty=true]:text-muted-foreground'
|
||||||
|
>
|
||||||
|
{selected ? (
|
||||||
|
format(selected, 'MMM d, yyyy')
|
||||||
|
) : (
|
||||||
|
<span>{placeholder}</span>
|
||||||
|
)}
|
||||||
|
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className='w-auto p-0'>
|
||||||
|
<Calendar
|
||||||
|
mode='single'
|
||||||
|
captionLayout='dropdown'
|
||||||
|
selected={selected}
|
||||||
|
onSelect={onSelect}
|
||||||
|
disabled={(date: Date) =>
|
||||||
|
date > new Date() || date < new Date('1900-01-01')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
|
import { useLayout } from '@/context/layout-provider'
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarRail,
|
||||||
|
} from '@/components/ui/sidebar'
|
||||||
|
import { AppTitle } from './app-title'
|
||||||
|
import { sidebarData } from './data/sidebar-data'
|
||||||
|
import { NavGroup } from './nav-group'
|
||||||
|
import { NavUser } from './nav-user'
|
||||||
|
|
||||||
|
export function AppSidebar() {
|
||||||
|
const { collapsible, variant } = useLayout()
|
||||||
|
const user = useAuthStore((state) => state.auth.user)
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible={collapsible} variant={variant}>
|
||||||
|
<SidebarHeader>
|
||||||
|
<AppTitle />
|
||||||
|
</SidebarHeader>
|
||||||
|
<SidebarContent>
|
||||||
|
{sidebarData.navGroups.map((props) => (
|
||||||
|
<NavGroup key={props.title} {...props} />
|
||||||
|
))}
|
||||||
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<NavUser
|
||||||
|
user={{
|
||||||
|
name: user?.role === 'admin' ? '管理员' : '普通用户',
|
||||||
|
email: user?.email ?? '',
|
||||||
|
avatar: '',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SidebarFooter>
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { Menu, X } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from '@/components/ui/sidebar'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
|
||||||
|
export function AppTitle() {
|
||||||
|
const { setOpenMobile } = useSidebar()
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size='lg'
|
||||||
|
className='gap-0 py-0 hover:bg-transparent active:bg-transparent'
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
to='/'
|
||||||
|
onClick={() => setOpenMobile(false)}
|
||||||
|
className='grid flex-1 text-start text-sm leading-tight'
|
||||||
|
>
|
||||||
|
<span className='truncate font-bold'>无限邮箱</span>
|
||||||
|
<span className='truncate text-xs'>管理后台</span>
|
||||||
|
</Link>
|
||||||
|
<ToggleSidebar />
|
||||||
|
</div>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleSidebar({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar='trigger'
|
||||||
|
data-slot='sidebar-trigger'
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className={cn('aspect-square size-8 max-md:scale-125', className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className='md:hidden' />
|
||||||
|
<Menu className='max-md:hidden' />
|
||||||
|
<span className='sr-only'>切换侧边栏</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Outlet } from '@tanstack/react-router'
|
||||||
|
import { getCookie } from '@/lib/cookies'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { LayoutProvider } from '@/context/layout-provider'
|
||||||
|
import { SearchProvider } from '@/context/search-provider'
|
||||||
|
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
||||||
|
import { AppSidebar } from '@/components/layout/app-sidebar'
|
||||||
|
import { SkipToMain } from '@/components/skip-to-main'
|
||||||
|
|
||||||
|
type AuthenticatedLayoutProps = {
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) {
|
||||||
|
const defaultOpen = getCookie('sidebar_state') !== 'false'
|
||||||
|
return (
|
||||||
|
<SearchProvider>
|
||||||
|
<LayoutProvider>
|
||||||
|
<SidebarProvider defaultOpen={defaultOpen}>
|
||||||
|
<SkipToMain />
|
||||||
|
<AppSidebar />
|
||||||
|
<SidebarInset
|
||||||
|
className={cn(
|
||||||
|
// Set content container, so we can use container queries
|
||||||
|
'@container/content',
|
||||||
|
|
||||||
|
// If layout is fixed, set the height
|
||||||
|
// to 100svh to prevent overflow
|
||||||
|
'has-data-[layout=fixed]:h-svh',
|
||||||
|
|
||||||
|
// If layout is fixed and sidebar is inset,
|
||||||
|
// set the height to 100svh - spacing (total margins) to prevent overflow
|
||||||
|
'peer-data-[variant=inset]:has-data-[layout=fixed]:h-[calc(100svh-(var(--spacing)*4))]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children ?? <Outlet />}
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</LayoutProvider>
|
||||||
|
</SearchProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Ban,
|
||||||
|
Coins,
|
||||||
|
FileClock,
|
||||||
|
History,
|
||||||
|
Inbox,
|
||||||
|
Mail,
|
||||||
|
MailCheck,
|
||||||
|
ScrollText,
|
||||||
|
ShieldAlert,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { type SidebarData } from '../types'
|
||||||
|
|
||||||
|
export const sidebarData: SidebarData = {
|
||||||
|
user: {
|
||||||
|
name: '管理员',
|
||||||
|
email: '',
|
||||||
|
avatar: '',
|
||||||
|
},
|
||||||
|
teams: [],
|
||||||
|
navGroups: [
|
||||||
|
{
|
||||||
|
title: '监控中心',
|
||||||
|
items: [{ title: '运行概览', url: '/', icon: Activity }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账号与资源',
|
||||||
|
items: [
|
||||||
|
{ title: '用户管理', url: '/users', icon: Users },
|
||||||
|
{ title: '邮箱管理', url: '/mailboxes', icon: Inbox },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '邮件治理',
|
||||||
|
items: [
|
||||||
|
{ title: '全部邮件', url: '/emails', icon: Mail },
|
||||||
|
{ title: '隔离队列', url: '/quarantine', icon: ShieldAlert },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '风险控制',
|
||||||
|
items: [
|
||||||
|
{ title: '黑名单', url: '/blacklists', icon: Ban },
|
||||||
|
{ title: 'SMTP 审计', url: '/smtp-audit', icon: ScrollText },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '积分运营',
|
||||||
|
items: [
|
||||||
|
{ title: '积分规则', url: '/credits', icon: Coins },
|
||||||
|
{ title: '积分流水', url: '/credit-transactions', icon: History },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '运维审计',
|
||||||
|
items: [
|
||||||
|
{ title: '管理员操作', url: '/audit-logs', icon: FileClock },
|
||||||
|
{ title: '外发邮件日志', url: '/email-logs', icon: MailCheck },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||||
|
|
||||||
|
type HeaderProps = React.HTMLAttributes<HTMLElement> & {
|
||||||
|
fixed?: boolean
|
||||||
|
ref?: React.Ref<HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ className, fixed, children, ...props }: HeaderProps) {
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
setOffset(document.body.scrollTop || document.documentElement.scrollTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add scroll listener to the body
|
||||||
|
document.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
|
||||||
|
// Clean up the event listener on unmount
|
||||||
|
return () => document.removeEventListener('scroll', onScroll)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
className={cn(
|
||||||
|
'z-50 h-16',
|
||||||
|
fixed && 'header-fixed peer/header sticky top-0 w-[inherit]',
|
||||||
|
offset > 10 && fixed ? 'shadow' : 'shadow-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative flex h-full items-center gap-3 p-4 sm:gap-4',
|
||||||
|
offset > 10 &&
|
||||||
|
fixed &&
|
||||||
|
'after:absolute after:inset-0 after:-z-10 after:bg-background/20 after:backdrop-blur-lg'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarTrigger variant='outline' className='max-md:scale-125' />
|
||||||
|
<Separator orientation='vertical' className='h-6' />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
type MainProps = React.HTMLAttributes<HTMLElement> & {
|
||||||
|
fixed?: boolean
|
||||||
|
fluid?: boolean
|
||||||
|
ref?: React.Ref<HTMLElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Main({ fixed, className, fluid, ...props }: MainProps) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-layout={fixed ? 'fixed' : 'auto'}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-6',
|
||||||
|
|
||||||
|
// If layout is fixed, make the main container flex and grow
|
||||||
|
fixed && 'flex grow flex-col overflow-hidden',
|
||||||
|
|
||||||
|
// If layout is not fluid, set the max-width
|
||||||
|
!fluid &&
|
||||||
|
'@7xl/content:mx-auto @7xl/content:w-full @7xl/content:max-w-7xl',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
import { Link, useLocation } from '@tanstack/react-router'
|
||||||
|
import { ChevronRight } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from '@/components/ui/collapsible'
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
useSidebar,
|
||||||
|
} from '@/components/ui/sidebar'
|
||||||
|
import { Badge } from '../ui/badge'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '../ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
type NavCollapsible,
|
||||||
|
type NavItem,
|
||||||
|
type NavLink,
|
||||||
|
type NavGroup as NavGroupProps,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export function NavGroup({ title, items }: NavGroupProps) {
|
||||||
|
const { state, isMobile } = useSidebar()
|
||||||
|
const href = useLocation({ select: (location) => location.href })
|
||||||
|
return (
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>{title}</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
{items.map((item) => {
|
||||||
|
const key = `${item.title}-${item.url}`
|
||||||
|
|
||||||
|
if (!item.items)
|
||||||
|
return <SidebarMenuLink key={key} item={item} href={href} />
|
||||||
|
|
||||||
|
if (state === 'collapsed' && !isMobile)
|
||||||
|
return (
|
||||||
|
<SidebarMenuCollapsedDropdown key={key} item={item} href={href} />
|
||||||
|
)
|
||||||
|
|
||||||
|
return <SidebarMenuCollapsible key={key} item={item} href={href} />
|
||||||
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavBadge({ children }: { children: ReactNode }) {
|
||||||
|
return <Badge className='rounded-full px-1 py-0 text-xs'>{children}</Badge>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
|
||||||
|
const { setOpenMobile } = useSidebar()
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
asChild
|
||||||
|
isActive={checkIsActive(href, item)}
|
||||||
|
tooltip={item.title}
|
||||||
|
>
|
||||||
|
<Link to={item.url} onClick={() => setOpenMobile(false)}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuCollapsible({
|
||||||
|
item,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
item: NavCollapsible
|
||||||
|
href: string
|
||||||
|
}) {
|
||||||
|
const { setOpenMobile } = useSidebar()
|
||||||
|
return (
|
||||||
|
<Collapsible
|
||||||
|
asChild
|
||||||
|
defaultOpen={checkIsActive(href, item, true)}
|
||||||
|
className='group/collapsible'
|
||||||
|
>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarMenuButton tooltip={item.title}>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||||
|
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 rtl:rotate-180' />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent className='CollapsibleContent'>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{item.items.map((subItem) => (
|
||||||
|
<SidebarMenuSubItem key={subItem.title}>
|
||||||
|
<SidebarMenuSubButton
|
||||||
|
asChild
|
||||||
|
isActive={checkIsActive(href, subItem)}
|
||||||
|
>
|
||||||
|
<Link to={subItem.url} onClick={() => setOpenMobile(false)}>
|
||||||
|
{subItem.icon && <subItem.icon />}
|
||||||
|
<span>{subItem.title}</span>
|
||||||
|
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuCollapsedDropdown({
|
||||||
|
item,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
item: NavCollapsible
|
||||||
|
href: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
tooltip={item.title}
|
||||||
|
isActive={checkIsActive(href, item)}
|
||||||
|
>
|
||||||
|
{item.icon && <item.icon />}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||||
|
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side='right' align='start' sideOffset={4}>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
{item.title} {item.badge ? `(${item.badge})` : ''}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{item.items.map((sub) => (
|
||||||
|
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
|
||||||
|
<Link
|
||||||
|
to={sub.url}
|
||||||
|
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
|
||||||
|
>
|
||||||
|
{sub.icon && <sub.icon />}
|
||||||
|
<span className='max-w-52 text-wrap'>{sub.title}</span>
|
||||||
|
{sub.badge && (
|
||||||
|
<span className='ms-auto text-xs'>{sub.badge}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkIsActive(href: string, item: NavItem, mainNav = false) {
|
||||||
|
return (
|
||||||
|
href === item.url || // /endpint?search=param
|
||||||
|
href.split('?')[0] === item.url || // endpoint
|
||||||
|
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
|
||||||
|
(mainNav &&
|
||||||
|
href.split('/')[1] !== '' &&
|
||||||
|
href.split('/')[1] === item?.url?.split('/')[1])
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { ChevronsUpDown, LogOut } from 'lucide-react'
|
||||||
|
import useDialogState from '@/hooks/use-dialog-state'
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from '@/components/ui/sidebar'
|
||||||
|
import { SignOutDialog } from '@/components/sign-out-dialog'
|
||||||
|
|
||||||
|
type NavUserProps = {
|
||||||
|
user: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavUser({ user }: NavUserProps) {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
const [open, setOpen] = useDialogState()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size='lg'
|
||||||
|
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||||
|
>
|
||||||
|
<Avatar className='h-8 w-8 rounded-lg'>
|
||||||
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
|
<AvatarFallback className='rounded-lg'>管</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className='grid flex-1 text-start text-sm leading-tight'>
|
||||||
|
<span className='truncate font-semibold'>{user.name}</span>
|
||||||
|
<span className='truncate text-xs'>{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className='ms-auto size-4' />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||||
|
side={isMobile ? 'bottom' : 'right'}
|
||||||
|
align='end'
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className='p-0 font-normal'>
|
||||||
|
<div className='flex items-center gap-2 px-1 py-1.5 text-start text-sm'>
|
||||||
|
<Avatar className='h-8 w-8 rounded-lg'>
|
||||||
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
|
<AvatarFallback className='rounded-lg'>管</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className='grid flex-1 text-start text-sm leading-tight'>
|
||||||
|
<span className='truncate font-semibold'>{user.name}</span>
|
||||||
|
<span className='truncate text-xs'>{user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant='destructive'
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<LogOut />
|
||||||
|
退出登录
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
|
||||||
|
<SignOutDialog open={!!open} onOpenChange={setOpen} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { ChevronsUpDown, Plus } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from '@/components/ui/sidebar'
|
||||||
|
|
||||||
|
type TeamSwitcherProps = {
|
||||||
|
teams: {
|
||||||
|
name: string
|
||||||
|
logo: React.ElementType
|
||||||
|
plan: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamSwitcher({ teams }: TeamSwitcherProps) {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
const [activeTeam, setActiveTeam] = React.useState(teams[0])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size='lg'
|
||||||
|
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||||
|
>
|
||||||
|
<div className='flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground'>
|
||||||
|
<activeTeam.logo className='size-4' />
|
||||||
|
</div>
|
||||||
|
<div className='grid flex-1 text-start text-sm leading-tight'>
|
||||||
|
<span className='truncate font-semibold'>
|
||||||
|
{activeTeam.name}
|
||||||
|
</span>
|
||||||
|
<span className='truncate text-xs'>{activeTeam.plan}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className='ms-auto' />
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||||
|
align='start'
|
||||||
|
side={isMobile ? 'bottom' : 'right'}
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className='text-xs text-muted-foreground'>
|
||||||
|
Teams
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{teams.map((team, index) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={team.name}
|
||||||
|
onClick={() => setActiveTeam(team)}
|
||||||
|
className='gap-2 p-2'
|
||||||
|
>
|
||||||
|
<div className='flex size-6 items-center justify-center rounded-sm border'>
|
||||||
|
<team.logo className='size-4 shrink-0' />
|
||||||
|
</div>
|
||||||
|
{team.name}
|
||||||
|
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className='gap-2 p-2'>
|
||||||
|
<div className='flex size-6 items-center justify-center rounded-md border bg-background'>
|
||||||
|
<Plus className='size-4' />
|
||||||
|
</div>
|
||||||
|
<div className='font-medium text-muted-foreground'>Add team</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { Menu } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
|
||||||
|
type TopNavProps = React.HTMLAttributes<HTMLElement> & {
|
||||||
|
links: {
|
||||||
|
title: string
|
||||||
|
href: string
|
||||||
|
isActive: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopNav({ className, links, ...props }: TopNavProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='lg:hidden'>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button size='icon' variant='outline' className='md:size-7'>
|
||||||
|
<Menu />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side='bottom' align='start'>
|
||||||
|
{links.map(({ title, href, isActive, disabled }) => (
|
||||||
|
<DropdownMenuItem key={`${title}-${href}`} asChild>
|
||||||
|
<Link
|
||||||
|
to={href}
|
||||||
|
className={!isActive ? 'text-muted-foreground' : ''}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
className={cn(
|
||||||
|
'hidden items-center space-x-4 lg:flex lg:space-x-4 xl:space-x-6',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{links.map(({ title, href, isActive, disabled }) => (
|
||||||
|
<Link
|
||||||
|
key={`${title}-${href}`}
|
||||||
|
to={href}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`text-sm font-medium transition-colors hover:text-primary ${isActive ? '' : 'text-muted-foreground'}`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { type LinkProps } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Team = {
|
||||||
|
name: string
|
||||||
|
logo: React.ElementType
|
||||||
|
plan: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseNavItem = {
|
||||||
|
title: string
|
||||||
|
badge?: string
|
||||||
|
icon?: React.ElementType
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavLink = BaseNavItem & {
|
||||||
|
url: LinkProps['to'] | (string & {})
|
||||||
|
items?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavCollapsible = BaseNavItem & {
|
||||||
|
items: (BaseNavItem & { url: LinkProps['to'] | (string & {}) })[]
|
||||||
|
url?: never
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavItem = NavCollapsible | NavLink
|
||||||
|
|
||||||
|
type NavGroup = {
|
||||||
|
title: string
|
||||||
|
items: NavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SidebarData = {
|
||||||
|
user: User
|
||||||
|
teams: Team[]
|
||||||
|
navGroups: NavGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SidebarData, NavGroup, NavItem, NavCollapsible, NavLink }
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { type Root, type Content, type Trigger } from '@radix-ui/react-popover'
|
||||||
|
import { CircleQuestionMark } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
|
||||||
|
type LearnMoreProps = React.ComponentProps<typeof Root> & {
|
||||||
|
contentProps?: React.ComponentProps<typeof Content>
|
||||||
|
triggerProps?: React.ComponentProps<typeof Trigger>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LearnMore({
|
||||||
|
children,
|
||||||
|
contentProps,
|
||||||
|
triggerProps,
|
||||||
|
...props
|
||||||
|
}: LearnMoreProps) {
|
||||||
|
return (
|
||||||
|
<Popover {...props}>
|
||||||
|
<PopoverTrigger
|
||||||
|
asChild
|
||||||
|
{...triggerProps}
|
||||||
|
className={cn('size-5 rounded-full', triggerProps?.className)}
|
||||||
|
>
|
||||||
|
<Button variant='outline' size='icon'>
|
||||||
|
<span className='sr-only'>Learn more</span>
|
||||||
|
<CircleQuestionMark className='size-4 [&>circle]:hidden' />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side='top'
|
||||||
|
align='start'
|
||||||
|
{...contentProps}
|
||||||
|
className={cn('text-sm text-muted-foreground', contentProps?.className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/popover'
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip'
|
||||||
|
|
||||||
|
type LongTextProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
contentClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LongText({
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
contentClassName = '',
|
||||||
|
}: LongTextProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
const [isOverflown, setIsOverflown] = useState(false)
|
||||||
|
|
||||||
|
// Use ref callback to check overflow when element is mounted
|
||||||
|
const refCallback = (node: HTMLDivElement | null) => {
|
||||||
|
ref.current = node
|
||||||
|
if (node && checkOverflow(node)) {
|
||||||
|
queueMicrotask(() => setIsOverflown(true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOverflown)
|
||||||
|
return (
|
||||||
|
<div ref={refCallback} className={cn('truncate', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='hidden sm:block'>
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div ref={refCallback} className={cn('truncate', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className={contentClassName}>{children}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<div className='sm:hidden'>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<div ref={refCallback} className={cn('truncate', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className={cn('w-fit', contentClassName)}>
|
||||||
|
<p>{children}</p>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkOverflow = (textContainer: HTMLDivElement | null) => {
|
||||||
|
if (textContainer) {
|
||||||
|
return (
|
||||||
|
textContainer.offsetHeight < textContainer.scrollHeight ||
|
||||||
|
textContainer.offsetWidth < textContainer.scrollWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useRouterState } from '@tanstack/react-router'
|
||||||
|
import LoadingBar, { type LoadingBarRef } from 'react-top-loading-bar'
|
||||||
|
|
||||||
|
export function NavigationProgress() {
|
||||||
|
const ref = useRef<LoadingBarRef>(null)
|
||||||
|
const state = useRouterState()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status === 'pending') {
|
||||||
|
ref.current?.continuousStart()
|
||||||
|
} else {
|
||||||
|
ref.current?.complete()
|
||||||
|
}
|
||||||
|
}, [state.status])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadingBar
|
||||||
|
color='var(--muted-foreground)'
|
||||||
|
ref={ref}
|
||||||
|
shadow={true}
|
||||||
|
height={2}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Eye, EyeOff } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from './ui/button'
|
||||||
|
|
||||||
|
type PasswordInputProps = Omit<
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
'type'
|
||||||
|
> & {
|
||||||
|
ref?: React.Ref<HTMLInputElement>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PasswordInput({
|
||||||
|
className,
|
||||||
|
disabled,
|
||||||
|
ref,
|
||||||
|
...props
|
||||||
|
}: PasswordInputProps) {
|
||||||
|
const [showPassword, setShowPassword] = React.useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative rounded-md', className)}>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
size='icon'
|
||||||
|
variant='ghost'
|
||||||
|
disabled={disabled}
|
||||||
|
className='absolute end-1 top-1/2 h-6 w-6 -translate-y-1/2 rounded-md text-muted-foreground'
|
||||||
|
onClick={() => setShowPassword((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
|
import useDialogState from '@/hooks/use-dialog-state'
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { SignOutDialog } from '@/components/sign-out-dialog'
|
||||||
|
|
||||||
|
export function ProfileDropdown() {
|
||||||
|
const [open, setOpen] = useDialogState()
|
||||||
|
const user = useAuthStore((state) => state.auth.user)
|
||||||
|
const initials = user?.email.slice(0, 2).toUpperCase() || 'AD'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
|
||||||
|
<Avatar className='h-8 w-8'>
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className='w-56' align='end' forceMount>
|
||||||
|
<DropdownMenuLabel className='font-normal'>
|
||||||
|
<div className='flex flex-col gap-1.5'>
|
||||||
|
<p className='text-sm leading-none font-medium'>管理员</p>
|
||||||
|
<p className='text-xs leading-none text-muted-foreground'>
|
||||||
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant='destructive' onClick={() => setOpen(true)}>
|
||||||
|
退出登录
|
||||||
|
<DropdownMenuShortcut className='text-current'>
|
||||||
|
⇧⌘Q
|
||||||
|
</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<SignOutDialog open={!!open} onOpenChange={setOpen} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { SearchIcon } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useSearch } from '@/context/search-provider'
|
||||||
|
import { Button } from './ui/button'
|
||||||
|
|
||||||
|
type SearchProps = {
|
||||||
|
className?: string
|
||||||
|
type?: React.HTMLInputTypeAttribute
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Search({
|
||||||
|
className = '',
|
||||||
|
placeholder = 'Search',
|
||||||
|
}: SearchProps) {
|
||||||
|
const { setOpen } = useSearch()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
className={cn(
|
||||||
|
'group relative h-8 w-full flex-1 justify-start rounded-md bg-muted/25 text-sm font-normal text-muted-foreground shadow-none hover:bg-accent sm:w-40 sm:pe-12 md:flex-none lg:w-52 xl:w-64',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
<SearchIcon
|
||||||
|
aria-hidden='true'
|
||||||
|
className='absolute start-1.5 top-1/2 -translate-y-1/2'
|
||||||
|
size={16}
|
||||||
|
/>
|
||||||
|
<span className='ms-4'>{placeholder}</span>
|
||||||
|
<kbd className='pointer-events-none absolute end-[0.3rem] top-[0.3rem] hidden h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 select-none group-hover:bg-accent sm:flex'>
|
||||||
|
<span className='text-xs'>⌘</span>K
|
||||||
|
</kbd>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Loader } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { FormControl } from '@/components/ui/form'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
|
type SelectDropdownProps = {
|
||||||
|
onValueChange?: (value: string) => void
|
||||||
|
defaultValue: string | undefined
|
||||||
|
placeholder?: string
|
||||||
|
isPending?: boolean
|
||||||
|
items: { label: string; value: string }[] | undefined
|
||||||
|
disabled?: boolean
|
||||||
|
className?: string
|
||||||
|
isControlled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectDropdown({
|
||||||
|
defaultValue,
|
||||||
|
onValueChange,
|
||||||
|
isPending,
|
||||||
|
items,
|
||||||
|
placeholder,
|
||||||
|
disabled,
|
||||||
|
className = '',
|
||||||
|
isControlled = false,
|
||||||
|
}: SelectDropdownProps) {
|
||||||
|
const defaultState = isControlled
|
||||||
|
? { value: defaultValue, onValueChange }
|
||||||
|
: { defaultValue, onValueChange }
|
||||||
|
return (
|
||||||
|
<Select {...defaultState}>
|
||||||
|
<FormControl>
|
||||||
|
<SelectTrigger disabled={disabled} className={cn(className)}>
|
||||||
|
<SelectValue placeholder={placeholder ?? 'Select'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
</FormControl>
|
||||||
|
<SelectContent>
|
||||||
|
{isPending ? (
|
||||||
|
<SelectItem disabled value='loading' className='h-14'>
|
||||||
|
<div className='flex items-center justify-center gap-2'>
|
||||||
|
<Loader className='h-5 w-5 animate-spin' />
|
||||||
|
{' '}
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
items?.map(({ label, value }) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth-store'
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
|
||||||
|
interface SignOutDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { auth } = useAuthStore()
|
||||||
|
|
||||||
|
const handleSignOut = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/auth/logout')
|
||||||
|
} finally {
|
||||||
|
auth.reset()
|
||||||
|
}
|
||||||
|
// Preserve current location for redirect after sign-in
|
||||||
|
const currentPath = location.href
|
||||||
|
navigate({
|
||||||
|
to: '/sign-in',
|
||||||
|
search: { redirect: currentPath },
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
title='退出登录'
|
||||||
|
desc='确定要退出管理后台吗?再次访问时需要重新登录。'
|
||||||
|
confirmText='确认退出'
|
||||||
|
destructive
|
||||||
|
handleConfirm={handleSignOut}
|
||||||
|
className='sm:max-w-sm'
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function SkipToMain() {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className={`fixed start-44 z-999 -translate-y-52 bg-primary px-4 py-2 text-sm font-medium whitespace-nowrap text-primary-foreground opacity-95 shadow-sm transition hover:bg-primary/90 focus:translate-y-3 focus:transform focus-visible:ring-1 focus-visible:ring-ring`}
|
||||||
|
href='#content'
|
||||||
|
>
|
||||||
|
Skip to Main
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { Check, Moon, Sun } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useTheme } from '@/context/theme-provider'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
|
||||||
|
export function ThemeSwitch() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
/* Update theme-color meta tag
|
||||||
|
* when theme is updated */
|
||||||
|
useEffect(() => {
|
||||||
|
const themeColor = theme === 'dark' ? '#020817' : '#fff'
|
||||||
|
const metaThemeColor = document.querySelector("meta[name='theme-color']")
|
||||||
|
if (metaThemeColor) metaThemeColor.setAttribute('content', themeColor)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant='ghost' size='icon' className='scale-95 rounded-full'>
|
||||||
|
<Sun className='size-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90' />
|
||||||
|
<Moon className='absolute size-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0' />
|
||||||
|
<span className='sr-only'>切换主题</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align='end'>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||||
|
浅色{' '}
|
||||||
|
<Check
|
||||||
|
size={14}
|
||||||
|
className={cn('ms-auto', theme !== 'light' && 'hidden')}
|
||||||
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||||
|
深色
|
||||||
|
<Check
|
||||||
|
size={14}
|
||||||
|
className={cn('ms-auto', theme !== 'dark' && 'hidden')}
|
||||||
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||||
|
跟随系统
|
||||||
|
<Check
|
||||||
|
size={14}
|
||||||
|
className={cn('ms-auto', theme !== 'system' && 'hidden')}
|
||||||
|
/>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user