Compare commits

...

10 Commits

Author SHA1 Message Date
shenjianZ 2fc223ef23 merge: integrate test environment deployment
# Conflicts:
#	backend/saveEmail.js
#	docker-compose.build.yml
#	docker-compose.full.yml
#	docker-compose.yml
#	frontend/src/i18n/index.js
#	frontend/src/views/Home.vue
2026-07-28 14:52:39 +08:00
shenjianZ bab4b85bed feat: add test environment deployment workflow 2026-07-28 14:48:46 +08:00
shenjianZ 099e05fb56 docs: add product usage and operations guides 2026-07-25 23:23:56 +08:00
shenjianZ 32e0969ca1 feat: rebuild email platform and operations console 2026-07-25 23:06:15 +08:00
shenjianZ c109abc6f5 feat: sample.png 2025-08-02 11:28:01 +08:00
shenjianZ d57037781e feat: Display received time and fix timestamp timezone conversion. 2025-08-02 11:26:41 +08:00
shenjianZ e936fbc140 feat(docker): 安装 tzdata 并设置 TZ=Asia/Shanghai 以让 Node.js 使用上海时区 2025-08-02 10:42:43 +08:00
shenjianZ a96aa6e073 feat(docker): 安装 tzdata 并设置 TZ=Asia/Shanghai 以让 Node.js 使用上海时区 2025-08-02 10:00:47 +08:00
shenjianZ 893fac1ff2 feat(docker): 安装 tzdata 并设置 TZ=Asia/Shanghai 以让 Node.js 使用上海时区 2025-08-02 09:56:38 +08:00
shenjianZ be59fa85de feat(backend): 实施连接与数据层速率限制
为了增强服务的稳定性和安全性,防止滥用行为,本次提交引入了两个核心的速率限制和验证机制:

1. **连接层验证 (`onConnect`)**
   - **IP 速率限制**:在 `connectionValidator.js` 中实现。限制单个 IP 地址每分钟最多 20 次连接,超过限制将临时封禁 5 分钟,以防御暴力连接攻击。
   - **反向 DNS (PTR) 检查**:要求所有公共网络连接必须具有有效的 PTR 记录,用于区分合法邮件服务器和僵尸网络。

2. **数据层验证 (`onData`)**
   - **发件人域速率限制**:在 `rateLimiter.js` 中实现。在邮件数据传输阶段,限制单个发件人域名每分钟最多发送 10 封邮件,超过限制的域名将被临时封禁 5 分钟,以防止单一来源的邮件泛滥。

**主要变更:**
- 新增 `backend/connectionValidator.js`:处理连接时的 IP 速率限制和 PTR 验证。
- 新增 `backend/rateLimiter.js`:在 `onData` 阶段对发件人域名进行速率限制。
- 在 `backend/app.js` 的 `SMTPServer` 配置中集成了 `onConnect` 验证钩子。
- 在 `backend/saveEmail.js` 中调用速率限制器,并在超限时拒绝邮件。
- 更新日志模块 (`logger.js`, `db.js`),输出更清晰,并记录安全相关事件。
- 新增 `backend/SECURITY_POLICIES.md` 文档,详细说明所有安全策略和可配置参数。
2025-08-01 22:59:40 +08:00
534 changed files with 65095 additions and 1593 deletions
+1
View File
@@ -0,0 +1 @@
compose.self.env
+67
View File
@@ -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
+2
View File
@@ -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
+41 -4
View File
@@ -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__*` 环境变量;代码内默认值只用于首次初始化或管理员恢复默认。
+124 -80
View File
@@ -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 加密和管理员接口。
![sample.png](./sample.png)
## 技术架构 ## 本地开发
* **前端 (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
cd frontend
npm install
npm run build
```
3. 将构建生成的 `dist` 目录(位于 `frontend/dist`**完整上传**到服务器的 `/data/email-unlimit/frontend` 目录下。
### 步骤 4: 修改 Nginx 配置 (服务器)
编辑项目根目录下的 `nginx.full.conf` 文件,仅需修改以下三项:
```nginx
server_name mail.example.com; # 改为您自己的域名
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: 启动服务 (服务器) 用户端和管理端均使用 pnpm
在服务器的项目根目录 (`/data/email-unlimit`) 下执行以下命令:
```bash ```powershell
# -f 指定使用 docker-compose.full.yml 配置文件,-d 表示后台运行 cd frontend
docker compose -f docker-compose.full.yml up -d pnpm install
pnpm dev
cd admin
pnpm install
pnpm dev
``` ```
服务启动后,您可以通过 `docker compose -f docker-compose.full.yml logs -f` 查看实时日志。
## 如何使用 两个前端在开发模式下都通过 Vite 代理访问 `http://127.0.0.1:3000`。需要连接其他 API 时,可分别设置 `VITE_API_PROXY_TARGET`;生产构建连接独立 API 时使用:
1. **访问您的网站**: 在浏览器中打开 `https://mail.example.com` (替换为您的域名)。 ```env
2. **发送测试邮件**: 使用任何邮箱客户端,向 `anything@example.com` (例如 `test@example.com`) 发送一封邮件。 VITE_API_BASE_URL=http://localhost:3000
3. **查看邮件**: 在网站首页的输入框中输入您刚刚使用的收件人地址 (`test@example.com`),点击查询,即可看到收到的邮件。 ```
## Docker 部署
测试服务器使用宿主机 Nginx 提供静态文件和 HTTPS、使用阿里云 ACR 拉取 Rust 后端镜像时,请使用 [deploy/README.md](deploy/README.md) 中的独立部署方案。该方案适用于 `test-mail.shenjianl.cn``admin-mail.shenjianl.cn`,并与本地构建、上传前端静态文件的流程对应。
开发/内网部署:
先修改 `compose.env` 中所有 `CHANGE_ME` 配置,然后运行:
```powershell
docker compose up -d --build
```
用户端地址为 `http://localhost:8080`,管理端地址为 `http://localhost:8081`API 地址为 `http://localhost:3000`SMTP 监听宿主机 `25` 端口。
HTTPS 完整部署:
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)。
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
.git
+1
View File
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:3000
+26
View File
@@ -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?
+18
View File
@@ -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
+50
View File
@@ -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/(.*)$",
"^[./]"
]
}
+14
View File
@@ -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
+21
View File
@@ -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.
+24
View File
@@ -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` 的账号可以进入受保护页面。
+21
View File
@@ -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"
}
+59
View File
@@ -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',
},
}
)
+64
View File
@@ -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>
+11
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
+35
View File
@@ -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;
}
+84
View File
@@ -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"
}
}
+5844
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
'@clerk/shared': true
'@swc/core': true
esbuild: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

+4
View File
@@ -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

+1
View File
@@ -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>
)
}
+16
View File
@@ -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'
+41
View File
@@ -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>
)
}
+23
View File
@@ -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>
)
}
+110
View File
@@ -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>
)
}
+24
View File
@@ -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>
)
}
+8
View File
@@ -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>
)
}
+16
View File
@@ -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>
)
}
+91
View File
@@ -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>
)
}
+354
View File
@@ -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>
)
}
+72
View File
@@ -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>
)
}
+4
View File
@@ -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>
)
}
+51
View File
@@ -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>
)
}
+64
View File
@@ -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 },
],
},
],
}
+50
View File
@@ -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>
)
}
+27
View File
@@ -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}
/>
)
}
+185
View File
@@ -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])
)
}
+87
View File
@@ -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>
)
}
+67
View File
@@ -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>
</>
)
}
+44
View File
@@ -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 }
+44
View File
@@ -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>
)
}
+84
View File
@@ -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}
/>
)
}
+42
View File
@@ -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>
)
}
+53
View File
@@ -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} />
</>
)
}
+37
View File
@@ -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>
)
}
+62
View File
@@ -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>
)
}
+43
View File
@@ -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'
/>
)
}
+10
View File
@@ -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>
)
}
+58
View File
@@ -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