merge main
@@ -0,0 +1,2 @@
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
@@ -29,3 +29,7 @@ findings.md
|
||||
modify_test.md
|
||||
progress.md
|
||||
task_plan.md
|
||||
|
||||
aha-ui/node_modules
|
||||
aha-ui/src-tauri/target/*
|
||||
aha-ui/dist/*
|
||||
@@ -34,6 +34,7 @@ dependencies = [
|
||||
"clap",
|
||||
"derive_builder",
|
||||
"dirs",
|
||||
"esaxx-rs",
|
||||
"ffmpeg-next",
|
||||
"half",
|
||||
"hound",
|
||||
@@ -57,7 +58,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"url",
|
||||
"uuid",
|
||||
"zip",
|
||||
"zip 8.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -518,7 +519,7 @@ dependencies = [
|
||||
"safetensors 0.7.0",
|
||||
"thiserror 2.0.18",
|
||||
"yoke 0.8.1",
|
||||
"zip",
|
||||
"zip 7.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -795,9 +796,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
@@ -884,6 +885,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
<<<<<<< HEAD
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
@@ -908,6 +910,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
|
||||
|
||||
[[package]]
|
||||
=======
|
||||
>>>>>>> main
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
@@ -1257,6 +1261,8 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "esaxx-rs"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
@@ -1837,11 +1843,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2575,11 +2583,10 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.15.7"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69"
|
||||
checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae"
|
||||
dependencies = [
|
||||
"crc",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
@@ -5904,20 +5911,6 @@ name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
@@ -5957,6 +5950,18 @@ name = "zip"
|
||||
version = "7.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"typed-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dcab981e19633ebcf0b001ddd37dd802996098bc1864f90b7c5d970ce76c1d59"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bzip2",
|
||||
@@ -5964,8 +5969,7 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"deflate64",
|
||||
"flate2",
|
||||
"generic-array",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.2",
|
||||
"hmac",
|
||||
"indexmap",
|
||||
"lzma-rust2",
|
||||
|
||||
@@ -38,7 +38,7 @@ rayon = "1.10"
|
||||
realfft = "3.5.0"
|
||||
symphonia = { version = "0.5.5", features = ["mp3", "wav"] }
|
||||
serde_yaml = "0.9.34"
|
||||
zip = "7.2.0"
|
||||
zip = "8.5.1"
|
||||
half = "2.7.1"
|
||||
byteorder = "1.5.0"
|
||||
sentencepiece = "0.13.1"
|
||||
@@ -46,9 +46,8 @@ ahash = "0.8.12"
|
||||
derive_builder = "0.20.2"
|
||||
kaldi-native-fbank = "0.1.0"
|
||||
rand = "0.10.1"
|
||||
esaxx-rs = "0.1.10"
|
||||
|
||||
[patch.crates-io]
|
||||
esaxx-rs = { path = "vendor/esaxx-rs" }
|
||||
|
||||
[features]
|
||||
flash-attn = ["candle-flash-attn"]
|
||||
@@ -61,4 +60,3 @@ needless_range_loop = "allow"
|
||||
single_range_in_vec_init = "allow"
|
||||
manual_div_ceil = "allow"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -49,6 +49,12 @@ aha is a high-performance, cross-platform AI inference engine built with Rust an
|
||||
- **🧠 Attention Optimization** - Optional Flash Attention support for optimized long sequence processing
|
||||
|
||||
## Changelog
|
||||
### 2026-05-09
|
||||
- merge pr/eastgold15/46, add aha-ui
|
||||
|
||||
### 2026-04-25
|
||||
- VoxCPM update stream
|
||||
|
||||
### 2026-04-17
|
||||
- Qwen3ASR add vad data recognition
|
||||
|
||||
@@ -67,15 +73,6 @@ aha is a high-performance, cross-platform AI inference engine built with Rust an
|
||||
### 0.2.5 (2026-04-06)
|
||||
- add qwen3-embedding/qwen3-reranker/all-minilm-l6-v2
|
||||
|
||||
### 2026-04-03
|
||||
- CLI update: subcommand must be specified
|
||||
- ChatCompletionParameters add repeat_penalty and repeat_last_n
|
||||
- generate add penalty repeat
|
||||
|
||||
### 2026-04-02
|
||||
- refactor generate code
|
||||
- \<think\>...\</think\> The content of the thought chain is returned using the reasoning_content field.
|
||||
- chat response add time info
|
||||
|
||||
**[View full changelog](docs/changelog.md)** →
|
||||
|
||||
@@ -127,7 +124,6 @@ aha run -m all-minilm-l6-v2 -i "Rust embedding test" --weight-path D:\model_down
|
||||
|
||||
# Start service only (model already downloaded)
|
||||
aha serv -m Qwen/Qwen3-ASR-0.6B -p 10100
|
||||
|
||||
```
|
||||
|
||||
### Chat
|
||||
@@ -149,6 +145,66 @@ curl http://localhost:10100/v1/chat/completions \
|
||||
'
|
||||
```
|
||||
|
||||
### aha-ui
|
||||
```bash
|
||||
cd aha-ui
|
||||
```
|
||||
|
||||
#### use npm
|
||||
##### install npm
|
||||
refer to https://nodejs.org/en/download
|
||||
```bash
|
||||
# Download and install nvm:
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
|
||||
# in lieu of restarting the shell
|
||||
\. "$HOME/.nvm/nvm.sh"
|
||||
# Download and install Node.js:
|
||||
nvm install 24
|
||||
# Verify the Node.js version:
|
||||
node -v # Should print "v24.15.0".
|
||||
# Verify npm version:
|
||||
npm -v # Should print "11.12.1".
|
||||
```
|
||||
|
||||
##### npm run aha-ui
|
||||
```bash
|
||||
# Make sure in the aha-ui directory
|
||||
# and make sure that aha has been compiled
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
##### npm build & install & run
|
||||
```bash
|
||||
npm run tauri build
|
||||
# target in
|
||||
# -- aha-ui/src-tauri/target/release/bundle/deb/aha-ui_0.1.0_amd64.deb
|
||||
# -- aha-ui/src-tauri/target/release/bundle/rpm/aha-ui-0.1.0-1.x86_64.rpm
|
||||
# -- aha-ui/src-tauri/target/release/bundle/appimage/aha-ui_0.1.0_amd64.AppImage
|
||||
```
|
||||
|
||||
#### use pnpm
|
||||
##### install pnpm
|
||||
```bash
|
||||
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||
```
|
||||
|
||||
##### pnpm run aha-ui
|
||||
```bash
|
||||
# Make sure in the aha-ui directory
|
||||
# and make sure that aha has been compiled
|
||||
pnpm run tauri dev
|
||||
```
|
||||
|
||||
##### pnpm build & install & run
|
||||
```bash
|
||||
pnpm run tauri build
|
||||
# target in
|
||||
# -- aha-ui/src-tauri/target/release/bundle/deb/aha-ui_0.1.0_amd64.deb
|
||||
# -- aha-ui/src-tauri/target/release/bundle/rpm/aha-ui-0.1.0-1.x86_64.rpm
|
||||
# -- aha-ui/src-tauri/target/release/bundle/appimage/aha-ui_0.1.0_amd64.AppImage
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
| Document | Description |
|
||||
|
||||
@@ -47,6 +47,12 @@ aha 是一款基于 Rust 和 Candle 框架构建的高性能跨平台 AI 推理
|
||||
- **🧠 注意力优化** - 可选 Flash Attention 支持,优化长序列处理
|
||||
|
||||
## 更新日志
|
||||
### 2026-05-09
|
||||
- 合并 pr/eastgold15/46, 添加 aha-ui
|
||||
|
||||
### 2026-04-25
|
||||
- VoxCPM 更新流式生成
|
||||
|
||||
### 2026-04-17
|
||||
- Qwen3ASR 增加 vad 数据识别
|
||||
|
||||
@@ -65,16 +71,6 @@ aha 是一款基于 Rust 和 Candle 框架构建的高性能跨平台 AI 推理
|
||||
### 0.2.5 (2026-04-06)
|
||||
- 添加 qwen3-embedding/qwen3-reranker/all-minilm-l6-v2
|
||||
|
||||
### 2026-04-03
|
||||
- CLI 更新: 必须指定子命令
|
||||
- ChatCompletionParameters 新增 repeat_penalty 和 repeat_last_n 参数
|
||||
- 生成添加重复惩罚代码
|
||||
|
||||
### 2026-04-02
|
||||
- 重构生成代码
|
||||
- \<think\>...\</think\> 思维链内容使用reasoning_content字段返回。
|
||||
- 对话返回添加耗时信息
|
||||
|
||||
**[查看完整更新日志](docs/changelog.zh-CN.md)** →
|
||||
|
||||
|
||||
@@ -146,6 +142,66 @@ curl http://localhost:10100/v1/chat/completions \
|
||||
}'
|
||||
```
|
||||
|
||||
### aha-ui
|
||||
```bash
|
||||
cd aha-ui
|
||||
```
|
||||
|
||||
#### 使用 npm
|
||||
##### 安装 npm
|
||||
参考 https://nodejs.org/en/download
|
||||
```bash
|
||||
# Download and install nvm:
|
||||
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.4/install.sh | bash
|
||||
# in lieu of restarting the shell
|
||||
\. "$HOME/.nvm/nvm.sh"
|
||||
# Download and install Node.js:
|
||||
nvm install 24
|
||||
# Verify the Node.js version:
|
||||
node -v # Should print "v24.15.0".
|
||||
# Verify npm version:
|
||||
npm -v # Should print "11.12.1".
|
||||
```
|
||||
|
||||
##### npm 运行 aha-ui
|
||||
```bash
|
||||
# 确定在 aha-ui目录
|
||||
# 确定 aha 编译完成
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
##### npm 编译 & 安装 & 运行
|
||||
```bash
|
||||
npm run tauri build
|
||||
# target in
|
||||
# -- aha-ui/src-tauri/target/release/bundle/deb/aha-ui_0.1.0_amd64.deb
|
||||
# -- aha-ui/src-tauri/target/release/bundle/rpm/aha-ui-0.1.0-1.x86_64.rpm
|
||||
# -- aha-ui/src-tauri/target/release/bundle/appimage/aha-ui_0.1.0_amd64.AppImage
|
||||
```
|
||||
|
||||
#### 使用 pnpm
|
||||
##### 安装 pnpm
|
||||
```bash
|
||||
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||
```
|
||||
|
||||
##### pnpm 运行 aha-ui
|
||||
```bash
|
||||
# 确定在 aha-ui目录
|
||||
# 确定 aha 编译完成
|
||||
pnpm run tauri dev
|
||||
```
|
||||
|
||||
##### pnpm 编译 & 安装 & 运行
|
||||
```bash
|
||||
pnpm run tauri build
|
||||
# target in
|
||||
# -- aha-ui/src-tauri/target/release/bundle/deb/aha-ui_0.1.0_amd64.deb
|
||||
# -- aha-ui/src-tauri/target/release/bundle/rpm/aha-ui-0.1.0-1.x86_64.rpm
|
||||
# -- aha-ui/src-tauri/target/release/bundle/appimage/aha-ui_0.1.0_amd64.AppImage
|
||||
```
|
||||
|
||||
## 文档
|
||||
|
||||
| 文档 | 描述 |
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-C", "target-feature=+crt-static"]
|
||||
@@ -0,0 +1,12 @@
|
||||
# Tauri + React + Typescript
|
||||
|
||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
|
||||
|
||||
|
||||
ui 使用shadcn-admin ,基本上是直接粘贴过来的,
|
||||
这个项目只有两个
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AHA 模型启动器</title>
|
||||
<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"
|
||||
>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"name": "aha-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run --browser.headless",
|
||||
"test:watch": "vitest --browser.headless"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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.99.0",
|
||||
"@tanstack/react-router": "^1.168.22",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"axios": "^1.15.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"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": {
|
||||
"@biomejs/biome": "2.4.12",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.99.0",
|
||||
"@tanstack/react-query-devtools": "^5.99.0",
|
||||
"@tanstack/react-router-devtools": "^1.166.13",
|
||||
"@tanstack/router-plugin": "^1.167.22",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"knip": "^6.4.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"ultracite": "7.6.1",
|
||||
"vite": "^8.0.8",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
msw: true
|
||||
|
After Width: | Height: | Size: 494 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokewidth="2" strokelinecap="round" strokelinejoin="round">
|
||||
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"></path>
|
||||
</svg><style>@media (prefers-color-scheme: light) { :root { filter: contrast(1) brightness(0.8); } }
|
||||
</style></svg>
|
||||
|
After Width: | Height: | Size: 520 B |
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="948,463.5,24,24"><defs><clipPath id="clip-1"><rect x="948" y="463.5" width="24" height="24" id="clip-1" stroke="none"/></clipPath></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g><g id="Group 1"><g clip-path="url(#clip-1)" id="Group 1"><path d="M963,469.5v12c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3h-12c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3v-12c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3h12c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3" id="Path 1" stroke="#f2f2f2"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1013 B |
|
After Width: | Height: | Size: 273 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "aha-ui"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "aha_ui_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
aha = { path = "../../" }
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default"]}}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,437 @@
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
// ── State ────────────────────────────────────────────
|
||||
|
||||
struct AppState {
|
||||
server_process: Arc<Mutex<Option<Child>>>,
|
||||
server_logs: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
// ── Data Types ───────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ModelInfo {
|
||||
model_id: String,
|
||||
owner: String,
|
||||
model_type: String,
|
||||
downloaded: bool,
|
||||
size: Option<u64>,
|
||||
size_human: Option<String>,
|
||||
path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ModelDetail {
|
||||
model_id: String,
|
||||
owner: String,
|
||||
model_type: String,
|
||||
downloaded: bool,
|
||||
size: Option<u64>,
|
||||
size_human: Option<String>,
|
||||
path: Option<String>,
|
||||
is_gguf: bool,
|
||||
is_onnx: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct LaunchConfig {
|
||||
model_id: String,
|
||||
address: Option<String>,
|
||||
port: Option<u16>,
|
||||
weight_path: Option<String>,
|
||||
gguf_path: Option<String>,
|
||||
mmproj_path: Option<String>,
|
||||
save_dir: Option<String>,
|
||||
download_retries: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ServerStatusResponse {
|
||||
running: bool,
|
||||
pid: Option<u32>,
|
||||
logs: Vec<String>,
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn get_default_save_dir() -> Result<String, String> {
|
||||
aha::utils::get_default_save_dir().ok_or_else(|| "无法获取 home 目录".to_string())
|
||||
}
|
||||
|
||||
fn get_save_dir() -> Result<String, String> {
|
||||
aha::utils::get_default_save_dir().ok_or_else(|| "无法获取 home 目录".to_string())
|
||||
}
|
||||
|
||||
fn find_aha_binary() -> Result<PathBuf, String> {
|
||||
// 辅助函数:根据平台生成候选路径
|
||||
fn candidate_path(dir: &std::path::Path, name: &str) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
dir.join(name).with_extension("exe")
|
||||
} else {
|
||||
dir.join(name)
|
||||
}
|
||||
}
|
||||
let binary_name = "aha";
|
||||
|
||||
// 1. 检查 PATH 环境变量
|
||||
if let Some(paths) = std::env::var_os("PATH") {
|
||||
for dir in std::env::split_paths(&paths) {
|
||||
let candidate = candidate_path(&dir, binary_name);
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 在 Tauri 可执行文件附近查找(开发模式)
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe.parent() {
|
||||
// 同级目录
|
||||
let candidate = candidate_path(exe_dir, binary_name);
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
// Workspace 目录
|
||||
// aha debug|release workspace/target/
|
||||
// aha-ui debug|release workspace/aha-ui/src-tauri/target/
|
||||
let mut current = exe_dir;
|
||||
for _ in 0..5 {
|
||||
// 最多向上查找 5 层
|
||||
if let Some(parent) = current.parent() {
|
||||
let dir = if parent.file_name().and_then(|n| n.to_str()) == Some("target") {
|
||||
parent
|
||||
} else {
|
||||
&parent.join("target")
|
||||
};
|
||||
let release_dir = dir.join("release");
|
||||
let candidate = candidate_path(&release_dir, binary_name);
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
let debug_dir = dir.join("debug");
|
||||
let candidate = candidate_path(&debug_dir, binary_name);
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
current = parent;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 根据 CARGO_MANIFEST_DIR 推断(编译时)
|
||||
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
// manifest = aha-ui/src-tauri
|
||||
|
||||
if let Some(workspace_root) = manifest.parent().and_then(|p| p.parent()) {
|
||||
// workspace_root = aha
|
||||
let target_release = workspace_root.join("target").join("release");
|
||||
let release_bin = candidate_path(&target_release, binary_name);
|
||||
if release_bin.exists() {
|
||||
return Ok(release_bin);
|
||||
}
|
||||
let target_debug = workspace_root.join("target").join("debug");
|
||||
let debug_bin = candidate_path(&target_debug, binary_name);
|
||||
if debug_bin.exists() {
|
||||
return Ok(debug_bin);
|
||||
}
|
||||
}
|
||||
|
||||
Err("未找到 aha 可执行文件,请先编译: cd aha && cargo build".to_string())
|
||||
}
|
||||
|
||||
// ── Model Commands ───────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn list_models() -> Vec<ModelInfo> {
|
||||
use aha::models::common::model_mapping::WhichModel;
|
||||
use aha::utils::{bytes_to_human, dir_size, get_default_weight_path, is_model_downloaded};
|
||||
|
||||
let models = WhichModel::model_list();
|
||||
|
||||
models
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let model_id = m.as_string();
|
||||
let downloaded = is_model_downloaded(*m);
|
||||
let (size, size_human, path) = if downloaded {
|
||||
let p = get_default_weight_path(*m);
|
||||
let sz = dir_size(std::path::Path::new(&p)).ok();
|
||||
(sz, sz.map(bytes_to_human), Some(p))
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
ModelInfo {
|
||||
model_id,
|
||||
owner: m.model_owner(),
|
||||
model_type: m.model_type().to_string(),
|
||||
downloaded,
|
||||
size,
|
||||
size_human,
|
||||
path,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_model_detail(model_id: String) -> Result<ModelDetail, String> {
|
||||
use aha::models::common::model_mapping::WhichModel;
|
||||
use aha::utils::{bytes_to_human, dir_size, get_default_weight_path, is_model_downloaded};
|
||||
|
||||
// 根据 model_id 找到对应的 WhichModel
|
||||
let model = WhichModel::model_list()
|
||||
.into_iter()
|
||||
.find(|m| m.as_string() == model_id)
|
||||
.ok_or_else(|| format!("未知模型: {}", model_id))?;
|
||||
|
||||
let downloaded = is_model_downloaded(model);
|
||||
let (size, size_human, path) = if downloaded {
|
||||
let p = get_default_weight_path(model);
|
||||
let sz = dir_size(std::path::Path::new(&p)).ok();
|
||||
(sz, sz.map(bytes_to_human), Some(p))
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
Ok(ModelDetail {
|
||||
model_id: model.as_string(),
|
||||
owner: model.model_owner(),
|
||||
model_type: model.model_type().to_string(),
|
||||
downloaded,
|
||||
size,
|
||||
size_human,
|
||||
path,
|
||||
is_gguf: model.is_gguf(),
|
||||
is_onnx: model.is_onnx(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn download_model(model_id: String, save_dir: Option<String>) -> Result<(), String> {
|
||||
use aha::utils::download_model;
|
||||
|
||||
let save_dir = match save_dir {
|
||||
Some(dir) => dir,
|
||||
None => get_save_dir()?,
|
||||
};
|
||||
download_model(&model_id, &save_dir, 3)
|
||||
.await
|
||||
.map_err(|e| format!("下载失败: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_model(model_id: String) -> Result<(), String> {
|
||||
use aha::models::common::model_mapping::WhichModel;
|
||||
use aha::utils::get_default_weight_path;
|
||||
|
||||
let model = WhichModel::model_list()
|
||||
.into_iter()
|
||||
.find(|m| m.as_string() == model_id)
|
||||
.ok_or_else(|| format!("未知模型: {}", model_id))?;
|
||||
|
||||
let path = get_default_weight_path(model);
|
||||
let p = std::path::Path::new(&path);
|
||||
if p.exists() {
|
||||
std::fs::remove_dir_all(p).map_err(|e| format!("删除失败: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Server Commands ──────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
fn start_server(
|
||||
config: LaunchConfig,
|
||||
state: State<'_, AppState>,
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let mut guard = state.server_process.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
// 检查是否已经在运行
|
||||
if let Some(ref mut child) = *guard {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {} // 已退出
|
||||
Ok(None) => return Err("服务已经在运行中".to_string()),
|
||||
Err(e) => return Err(format!("检查服务状态失败: {}", e)),
|
||||
}
|
||||
}
|
||||
// 进程已退出,清理
|
||||
*guard = None;
|
||||
|
||||
let binary = find_aha_binary()?;
|
||||
let address = config.address.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
let port = config.port.unwrap_or(10100);
|
||||
|
||||
let mut cmd = Command::new(&binary);
|
||||
cmd.arg("cli")
|
||||
.arg("-m")
|
||||
.arg(&config.model_id)
|
||||
.arg("--address")
|
||||
.arg(&address)
|
||||
.arg("--port")
|
||||
.arg(port.to_string());
|
||||
|
||||
if let Some(wp) = &config.weight_path {
|
||||
cmd.arg("--weight-path").arg(wp);
|
||||
}
|
||||
if let Some(gp) = &config.gguf_path {
|
||||
cmd.arg("--gguf-path").arg(gp);
|
||||
}
|
||||
if let Some(mp) = &config.mmproj_path {
|
||||
cmd.arg("--mmproj-path").arg(mp);
|
||||
}
|
||||
if let Some(sd) = &config.save_dir {
|
||||
cmd.arg("--save-dir").arg(sd);
|
||||
}
|
||||
if let Some(dr) = config.download_retries {
|
||||
cmd.arg("--download-retries").arg(dr.to_string());
|
||||
}
|
||||
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().map_err(|e| format!("启动服务失败: {}", e))?;
|
||||
|
||||
let pid = child.id();
|
||||
let logs = state.server_logs.clone();
|
||||
let app_clone = app.clone();
|
||||
|
||||
// 启动前追加一条日志
|
||||
{
|
||||
let mut l = logs.lock().map_err(|e| e.to_string())?;
|
||||
l.push(format!(
|
||||
"[aha] 启动服务: {}:{} 模型: {} PID: {}",
|
||||
address, port, config.model_id, pid
|
||||
));
|
||||
}
|
||||
|
||||
// 后台读取 stdout
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
let logs = logs.clone();
|
||||
let app = app_clone.clone();
|
||||
std::thread::spawn(move || {
|
||||
let reader = std::io::BufReader::new(stdout);
|
||||
for line in reader.lines() {
|
||||
if let Ok(line) = line {
|
||||
let mut l = logs.lock().unwrap();
|
||||
l.push(line.clone());
|
||||
if l.len() > 2000 {
|
||||
let excess = l.len() - 2000;
|
||||
l.drain(0..excess);
|
||||
}
|
||||
drop(l);
|
||||
let _ = app.emit("server-log", &line);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 后台读取 stderr
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let logs = logs.clone();
|
||||
let app = app_clone.clone();
|
||||
std::thread::spawn(move || {
|
||||
let reader = std::io::BufReader::new(stderr);
|
||||
for line in reader.lines() {
|
||||
if let Ok(line) = line {
|
||||
let mut l = logs.lock().unwrap();
|
||||
l.push(line.clone());
|
||||
if l.len() > 2000 {
|
||||
let excess = l.len() - 2000;
|
||||
l.drain(0..excess);
|
||||
}
|
||||
drop(l);
|
||||
let _ = app.emit("server-log", &line);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
*guard = Some(child);
|
||||
|
||||
let _ = app.emit("server-started", pid);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_server(state: State<'_, AppState>, app: tauri::AppHandle) -> Result<(), String> {
|
||||
let mut guard = state.server_process.lock().map_err(|e| e.to_string())?;
|
||||
|
||||
if let Some(mut child) = guard.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let mut l = state.server_logs.lock().map_err(|e| e.to_string())?;
|
||||
l.push("[aha] 服务已停止".to_string());
|
||||
let _ = app.emit("server-stopped", ());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_server_status(state: State<'_, AppState>) -> ServerStatusResponse {
|
||||
let mut running = false;
|
||||
let pid = {
|
||||
let mut guard = state.server_process.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => false,
|
||||
Ok(None) => {
|
||||
running = true;
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
.then(|| guard.as_ref().map(|c| c.id()))
|
||||
.flatten()
|
||||
};
|
||||
|
||||
let logs = state.server_logs.lock().unwrap().clone();
|
||||
ServerStatusResponse { running, pid, logs }
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_logs(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let mut l = state.server_logs.lock().map_err(|e| e.to_string())?;
|
||||
l.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Entry Point ──────────────────────────────────────
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(AppState {
|
||||
server_process: Arc::new(Mutex::new(None)),
|
||||
server_logs: Arc::new(Mutex::new(Vec::new())),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
list_models,
|
||||
get_model_detail,
|
||||
download_model,
|
||||
delete_model,
|
||||
start_server,
|
||||
stop_server,
|
||||
get_server_status,
|
||||
clear_logs,
|
||||
get_default_save_dir,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
aha_ui_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "aha-ui",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.boer.aha-ui",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "AHA 模型启动器",
|
||||
"width": 1100,
|
||||
"height": 720,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconDiscord({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Discord</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M8 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0' />
|
||||
<path d='M14 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0' />
|
||||
<path d='M15.5 17c0 1 1.5 3 2 3c1.5 0 2.833 -1.667 3.5 -3c.667 -1.667 .5 -5.833 -1.5 -11.5c-1.457 -1.015 -3 -1.34 -4.5 -1.5l-.972 1.923a11.913 11.913 0 0 0 -4.053 0l-.975 -1.923c-1.5 .16 -3.043 .485 -4.5 1.5c-2 5.667 -2.167 9.833 -1.5 11.5c.667 1.333 2 3 3.5 3c.5 0 2 -2 2 -3' />
|
||||
<path d='M7 16.5c3.5 1 6.5 1 10 0' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconDocker({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Docker</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M22 12.54c-1.804 -.345 -2.701 -1.08 -3.523 -2.94c-.487 .696 -1.102 1.568 -.92 2.4c.028 .238 -.32 1 -.557 1h-14c0 5.208 3.164 7 6.196 7c4.124 .022 7.828 -1.376 9.854 -5c1.146 -.101 2.296 -1.505 2.95 -2.46z' />
|
||||
<path d='M5 10h3v3h-3z' />
|
||||
<path d='M8 10h3v3h-3z' />
|
||||
<path d='M11 10h3v3h-3z' />
|
||||
<path d='M8 7h3v3h-3z' />
|
||||
<path d='M11 7h3v3h-3z' />
|
||||
<path d='M11 4h3v3h-3z' />
|
||||
<path d='M4.571 18c1.5 0 2.047 -.074 2.958 -.78' />
|
||||
<path d='M10 16l0 .01' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconFacebook({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Facebook</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M7 10v4h3v7h4v-7h3l1 -4h-4v-2a1 1 0 0 1 1 -1h3v-4h-3a5 5 0 0 0 -5 5v2h-3' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconFigma({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Figma</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M15 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0' />
|
||||
<path d='M6 3m0 3a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v0a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3z' />
|
||||
<path d='M9 9a3 3 0 0 0 0 6h3m-3 0a3 3 0 1 0 3 3v-15' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconGithub({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconGitlab({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>GitLab</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M21 14l-9 7l-9 -7l3 -11l3 7h6l3 -7z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconGmail({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Gmail</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M16 20h3a1 1 0 0 0 1 -1v-14a1 1 0 0 0 -1 -1h-3v16z' />
|
||||
<path d='M5 20h3v-16h-3a1 1 0 0 0 -1 1v14a1 1 0 0 0 1 1z' />
|
||||
<path d='M16 4l-4 4l-4 -4' />
|
||||
<path d='M4 6.5l8 7.5l8 -7.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconMedium({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Medium</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' />
|
||||
<path d='M8 9h1l3 3l3 -3h1' />
|
||||
<path d='M8 15l2 0' />
|
||||
<path d='M14 15l2 0' />
|
||||
<path d='M9 9l0 6' />
|
||||
<path d='M15 9l0 6' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconNotion({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Notion</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M11 17.5v-6.5h.5l4 6h.5v-6.5' />
|
||||
<path d='M19.077 20.071l-11.53 .887a1 1 0 0 1 -.876 -.397l-2.471 -3.294a1 1 0 0 1 -.2 -.6v-10.741a1 1 0 0 1 .923 -.997l11.389 -.876a2 2 0 0 1 1.262 .33l1.535 1.023a2 2 0 0 1 .891 1.664v12.004a1 1 0 0 1 -.923 .997z' />
|
||||
<path d='M4.5 5.5l2.5 2.5' />
|
||||
<path d='M20 7l-13 1v12.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconSkype({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Skype</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M12 3a9 9 0 0 1 8.603 11.65a4.5 4.5 0 0 1 -5.953 5.953a9 9 0 0 1 -11.253 -11.253a4.5 4.5 0 0 1 5.953 -5.954a8.987 8.987 0 0 1 2.65 -.396z' />
|
||||
<path d='M8 14.5c.5 2 2.358 2.5 4 2.5c2.905 0 4 -1.187 4 -2.5c0 -1.503 -1.927 -2.5 -4 -2.5s-4 -1 -4 -2.5c0 -1.313 1.095 -2.5 4 -2.5c1.642 0 3.5 .5 4 2.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconSlack({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Slack</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M12 12v-6a2 2 0 0 1 4 0v6m0 -2a2 2 0 1 1 2 2h-6' />
|
||||
<path d='M12 12h6a2 2 0 0 1 0 4h-6m2 0a2 2 0 1 1 -2 2v-6' />
|
||||
<path d='M12 12v6a2 2 0 0 1 -4 0v-6m0 2a2 2 0 1 1 -2 -2h6' />
|
||||
<path d='M12 12h-6a2 2 0 0 1 0 -4h6m-2 0a2 2 0 1 1 2 -2v6' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconStripe({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Stripe</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M11.453 8.056c0 -.623 .518 -.979 1.442 -.979c1.69 0 3.41 .343 4.605 .923l.5 -4c-.948 -.449 -2.82 -1 -5.5 -1c-1.895 0 -3.373 .087 -4.5 1c-1.172 .956 -2 2.33 -2 4c0 3.03 1.958 4.906 5 6c1.961 .69 3 .743 3 1.5c0 .735 -.851 1.5 -2 1.5c-1.423 0 -3.963 -.609 -5.5 -1.5l-.5 4c1.321 .734 3.474 1.5 6 1.5c2 0 3.957 -.468 5.084 -1.36c1.263 -.979 1.916 -2.268 1.916 -4.14c0 -3.096 -1.915 -4.547 -5 -5.637c-1.646 -.605 -2.544 -1.07 -2.544 -1.807z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconTelegram({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Telegram</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M15 10l-4 4l6 6l4 -16l-18 7l4 2l2 6l3 -4' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconTrello({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Trello</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' />
|
||||
<path d='M7 7h3v10h-3z' />
|
||||
<path d='M14 7h3v6h-3z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconWhatsapp({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>WhatsApp</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M3 21l1.65 -3.8a9 9 0 1 1 3.4 2.9l-5.05 .9' />
|
||||
<path d='M9 10a.5 .5 0 0 0 1 0v-1a.5 .5 0 0 0 -1 0v1a5 5 0 0 0 5 5h1a.5 .5 0 0 0 0 -1h-1a.5 .5 0 0 0 0 1' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconZoom({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Zoom</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M17.011 9.385v5.128l3.989 3.487v-12z' />
|
||||
<path d='M3.887 6h10.08c1.468 0 3.033 1.203 3.033 2.803v8.196a.991 .991 0 0 1 -.975 1h-10.373c-1.667 0 -2.652 -1.5 -2.652 -3l.01 -8a.882 .882 0 0 1 .208 -.71a.841 .841 0 0 1 .67 -.287z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { IconDiscord } from './icon-discord'
|
||||
export { IconDocker } from './icon-docker'
|
||||
export { IconFacebook } from './icon-facebook'
|
||||
export { IconFigma } from './icon-figma'
|
||||
export { IconGithub } from './icon-github'
|
||||
export { IconGitlab } from './icon-gitlab'
|
||||
export { IconGmail } from './icon-gmail'
|
||||
export { IconMedium } from './icon-medium'
|
||||
export { IconNotion } from './icon-notion'
|
||||
export { IconSkype } from './icon-skype'
|
||||
export { IconSlack } from './icon-slack'
|
||||
export { IconStripe } from './icon-stripe'
|
||||
export { IconTelegram } from './icon-telegram'
|
||||
export { IconTrello } from './icon-trello'
|
||||
export { IconWhatsapp } from './icon-whatsapp'
|
||||
export { IconZoom } from './icon-zoom'
|
||||
@@ -0,0 +1,41 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function ClerkFullLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={77}
|
||||
height={24}
|
||||
viewBox='0 0 77 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M35.148 16.738a4.198 4.198 0 01-3.06 1.283 3.53 3.53 0 01-2.604-1.034c-.619-.645-.975-1.566-.975-2.665 0-2.199 1.432-3.703 3.58-3.703a3.914 3.914 0 013.034 1.377l1.859-1.644c-1.211-1.47-3.176-2.229-5.042-2.229-3.652 0-6.24 2.517-6.24 6.22 0 1.831.643 3.374 1.728 4.463s2.631 1.728 4.415 1.728c2.317 0 4.166-.94 5.203-2.122l-1.898-1.674zM38.727 3.428h2.766V20.34h-2.766V3.428zM54.818 15.283c.046-.368.07-.74.076-1.11 0-3.507-2.296-6.047-5.847-6.047a5.738 5.738 0 00-4.215 1.725c-1.038 1.089-1.66 2.631-1.66 4.47 0 3.749 2.642 6.216 6.146 6.216 2.35 0 4.043-.951 5.058-2.242l-1.812-1.605-.09-.076a3.749 3.749 0 01-3.008 1.406c-1.778 0-3.061-1.037-3.427-2.737h8.779zm-8.733-2.22a3.365 3.365 0 01.737-1.449 3.082 3.082 0 012.368-.996c1.58 0 2.57.988 2.911 2.445h-6.016zM63.445 8.09v3.084a13.36 13.36 0 00-.838-.05c-2.094 0-3.282 1.505-3.282 3.479v5.736h-2.763V8.261h2.763v1.83h.025c.938-1.283 2.284-1.997 3.75-1.997l.345-.004zM69.887 15.281l-1.998 2.222v2.837h-2.764V3.428h2.764v10.374L72.822 8.3h3.283l-4.341 4.86 4.417 7.18h-3.11l-3.133-5.059h-.051z'
|
||||
fill='#1F0256'
|
||||
/>
|
||||
<path
|
||||
d='M19.116 3.16l-2.88 2.881a.571.571 0 01-.701.084 6.854 6.854 0 00-10.39 5.647 6.867 6.867 0 00.979 3.764.571.571 0 01-.084.699l-2.88 2.88a.57.57 0 01-.865-.063A11.994 11.994 0 0119.051 2.295a.57.57 0 01.065.866z'
|
||||
fill='url(#paint0_linear_26568_214324)'
|
||||
/>
|
||||
<path
|
||||
d='M19.113 20.829l-2.88-2.88a.571.571 0 00-.7-.085 6.854 6.854 0 01-7.081 0 .571.571 0 00-.7.084l-2.881 2.88a.57.57 0 00.062.877 11.994 11.994 0 0014.114 0 .571.571 0 00.066-.876zM11.997 15.422a3.427 3.427 0 100-6.854 3.427 3.427 0 000 6.854z'
|
||||
fill='#1F0256'
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='paint0_linear_26568_214324'
|
||||
x1={16.4087}
|
||||
y1={-1.75881}
|
||||
x2={-7.88473}
|
||||
y2={22.5365}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#17CCFC' />
|
||||
<stop offset={0.5} stopColor='#5D31FF' />
|
||||
<stop offset={1} stopColor='#F35AFF' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function ClerkLogo({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
id='clerk'
|
||||
height='24'
|
||||
width='24'
|
||||
className={cn('[&>path]:fill-foreground', className)}
|
||||
{...props}
|
||||
>
|
||||
<title>Clerk</title>
|
||||
<path
|
||||
d='m21.47 20.829 -2.881 -2.881a0.572 0.572 0 0 0 -0.7 -0.084 6.854 6.854 0 0 1 -7.081 0 0.576 0.576 0 0 0 -0.7 0.084l-2.881 2.881a0.576 0.576 0 0 0 -0.103 0.69 0.57 0.57 0 0 0 0.166 0.186 12 12 0 0 0 14.113 0 0.58 0.58 0 0 0 0.239 -0.423 0.576 0.576 0 0 0 -0.172 -0.453Zm0.002 -17.668 -2.88 2.88a0.569 0.569 0 0 1 -0.701 0.084A6.857 6.857 0 0 0 8.724 8.08a6.862 6.862 0 0 0 -1.222 3.692 6.86 6.86 0 0 0 0.978 3.764 0.573 0.573 0 0 1 -0.083 0.699l-2.881 2.88a0.567 0.567 0 0 1 -0.864 -0.063A11.993 11.993 0 0 1 6.771 2.7a11.99 11.99 0 0 1 14.637 -0.405 0.566 0.566 0 0 1 0.232 0.418 0.57 0.57 0 0 1 -0.168 0.448Zm-7.118 12.261a3.427 3.427 0 1 0 0 -6.854 3.427 3.427 0 0 0 0 6.854Z'
|
||||
strokeWidth='1'
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type Direction } from '@/context/direction-provider'
|
||||
|
||||
type IconDirProps = SVGProps<SVGSVGElement> & {
|
||||
dir: Direction
|
||||
}
|
||||
|
||||
export function IconDir({ dir, className, ...props }: IconDirProps) {
|
||||
return (
|
||||
<svg
|
||||
data-name={`icon-dir-${dir}`}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
className={cn(dir === 'rtl' && 'rotate-y-180', className)}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M23.42.51h51.92c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
|
||||
opacity={0.15}
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 14.88L17.78 14.88'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 22.09L16.08 22.09'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 18.38L14.93 18.38'
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M12.06 6.14L17.78 6.14'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
|
||||
</g>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.62}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M29.41 7.4L34.67 7.4'
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={11.21}
|
||||
width={26.03}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.44}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={17.01}
|
||||
width={44.25}
|
||||
height={13.48}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.3}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={33.57}
|
||||
width={44.25}
|
||||
height={4.67}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.21}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={41.32}
|
||||
width={36.21}
|
||||
height={4.67}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.3}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconLayoutCompact(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-layout-compact'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={5.2}
|
||||
width={4}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.66}
|
||||
strokeWidth='2px'
|
||||
d='M7.26 11.56L8.37 11.56'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.51}
|
||||
strokeWidth='2px'
|
||||
d='M7.26 14.49L8.37 14.49'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.52}
|
||||
strokeWidth='2px'
|
||||
d='M7.26 17.39L8.37 17.39'
|
||||
/>
|
||||
<circle cx={7.81} cy={7.25} r={1.16} fill='#fff' opacity={0.8} />
|
||||
</g>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.75}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M15.81 14.49L22.89 14.49'
|
||||
/>
|
||||
<rect
|
||||
x={14.93}
|
||||
y={18.39}
|
||||
width={22.19}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.5}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={14.93}
|
||||
y={5.89}
|
||||
width={59.16}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.9}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={14.93}
|
||||
y={24.22}
|
||||
width={32.68}
|
||||
height={19.95}
|
||||
rx={2.11}
|
||||
ry={2.11}
|
||||
opacity={0.4}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={59.05}
|
||||
y={38.15}
|
||||
width={2.01}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={54.78}
|
||||
y={34.99}
|
||||
width={2.01}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={63.17}
|
||||
y={32.86}
|
||||
width={2.01}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={67.54}
|
||||
y={29.17}
|
||||
width={2.01}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.66}
|
||||
/>
|
||||
</g>
|
||||
<g opacity={0.5}>
|
||||
<circle cx={62.16} cy={18.63} r={7.5} />
|
||||
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||
</g>
|
||||
<g opacity={0.74}>
|
||||
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconLayoutDefault(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='con-layout-default'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M39.22 15.99h-8.16c-.79 0-1.43-.67-1.43-1.5s.64-1.5 1.43-1.5h8.16c.79 0 1.43.67 1.43 1.5s-.64 1.5-1.43 1.5z'
|
||||
opacity={0.75}
|
||||
/>
|
||||
<rect
|
||||
x={29.63}
|
||||
y={18.39}
|
||||
width={16.72}
|
||||
height={2.73}
|
||||
rx={1.36}
|
||||
ry={1.36}
|
||||
opacity={0.5}
|
||||
/>
|
||||
<path
|
||||
d='M75.1 6.68v1.45c0 .63-.49 1.14-1.09 1.14H30.72c-.6 0-1.09-.51-1.09-1.14V6.68c0-.62.49-1.14 1.09-1.14h43.29c.6 0 1.09.52 1.09 1.14z'
|
||||
opacity={0.9}
|
||||
/>
|
||||
<rect
|
||||
x={29.63}
|
||||
y={24.22}
|
||||
width={21.8}
|
||||
height={19.95}
|
||||
rx={2.11}
|
||||
ry={2.11}
|
||||
opacity={0.4}
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={61.06}
|
||||
y={38.15}
|
||||
width={2.01}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={56.78}
|
||||
y={34.99}
|
||||
width={2.01}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={65.17}
|
||||
y={32.86}
|
||||
width={2.01}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={69.55}
|
||||
y={29.17}
|
||||
width={2.01}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.66}
|
||||
/>
|
||||
</g>
|
||||
<g opacity={0.5}>
|
||||
<circle cx={63.17} cy={18.63} r={7.5} />
|
||||
<path d='M63.17 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||
</g>
|
||||
<g opacity={0.74}>
|
||||
<path d='M64.05 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M67.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||
</g>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={5.02}
|
||||
width={19.14}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.8}
|
||||
/>
|
||||
<g stroke='#fff'>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeWidth='2px'
|
||||
d='M9.02 17.39L21.25 17.39'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeWidth='2px'
|
||||
d='M9.02 24.6L19.54 24.6'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeWidth='2px'
|
||||
d='M9.02 20.88L18.4 20.88'
|
||||
/>
|
||||
<circle cx={10.98} cy={9.91} r={2.54} fill='#fff' opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M15.53 8.65L21.25 8.65'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M15.32 11.3L20.38 11.3' />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconLayoutFull(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-layout-full'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.75}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M6.85 14.49L15.02 14.49'
|
||||
/>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={18.39}
|
||||
width={25.6}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.5}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={5.89}
|
||||
width={68.26}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.9}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={24.22}
|
||||
width={37.71}
|
||||
height={19.95}
|
||||
rx={2.11}
|
||||
ry={2.11}
|
||||
opacity={0.4}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={59.05}
|
||||
y={38.15}
|
||||
width={2.01}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={54.78}
|
||||
y={34.99}
|
||||
width={2.01}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={63.17}
|
||||
y={32.86}
|
||||
width={2.01}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={67.54}
|
||||
y={29.17}
|
||||
width={2.01}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.66}
|
||||
/>
|
||||
</g>
|
||||
<g opacity={0.5}>
|
||||
<circle cx={62.16} cy={18.63} r={7.5} />
|
||||
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||
</g>
|
||||
<g opacity={0.74}>
|
||||
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconSidebarFloating(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-sidebar-floating'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
x={5.89}
|
||||
y={5.15}
|
||||
width={19.74}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.8}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeWidth='2px'
|
||||
d='M9.81 18.36L22.04 18.36'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeWidth='2px'
|
||||
d='M9.81 25.57L20.33 25.57'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeWidth='2px'
|
||||
d='M9.81 21.85L19.18 21.85'
|
||||
/>
|
||||
<circle cx={11.76} cy={10.88} r={2.54} fill='#fff' opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M16.31 9.62L22.04 9.62'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M16.1 12.27L21.16 12.27' />
|
||||
</g>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.62}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M30.59 9.62L35.85 9.62'
|
||||
/>
|
||||
<rect
|
||||
x={29.94}
|
||||
y={13.42}
|
||||
width={26.03}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.44}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={29.94}
|
||||
y={19.28}
|
||||
width={43.11}
|
||||
height={25.87}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.3}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconSidebarInset(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-sidebar-inset'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
x={23.39}
|
||||
y={5.57}
|
||||
width={50.22}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.2}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.08 17.05L17.31 17.05'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.08 24.25L15.6 24.25'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.08 20.54L14.46 20.54'
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<circle cx={7.04} cy={9.57} r={2.54} opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M11.59 8.3L17.31 8.3'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M11.38 10.95L16.44 10.95' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconSidebarSidebar(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-sidebar-sidebar'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M23.42.51h51.99c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
|
||||
opacity={0.2}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 14.88L17.78 14.88'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 22.09L16.08 22.09'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 18.38L14.93 18.38'
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M12.06 6.14L17.78 6.14'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconThemeDark(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-theme-dark'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<g fill='#1d2b3f'>
|
||||
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
|
||||
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
|
||||
</g>
|
||||
<path
|
||||
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
|
||||
fill='#0d1628'
|
||||
/>
|
||||
<circle cx={6.7} cy={7.04} r={3.54} fill='#426187' />
|
||||
<path
|
||||
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||
fill='#426187'
|
||||
/>
|
||||
<g fill='#2a62bc'>
|
||||
<rect
|
||||
x={33.36}
|
||||
y={19.73}
|
||||
width={2.75}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={16.57}
|
||||
width={2.75}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={37.16}
|
||||
y={14.44}
|
||||
width={2.75}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={41.19}
|
||||
y={10.75}
|
||||
width={2.75}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
</g>
|
||||
<circle cx={62.74} cy={16.32} r={8} fill='#2f5491' opacity={0.5} />
|
||||
<path
|
||||
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
|
||||
fill='#2f5491'
|
||||
opacity={0.74}
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={27.75}
|
||||
width={41.62}
|
||||
height={18.62}
|
||||
rx={1.69}
|
||||
ry={1.69}
|
||||
fill='#17273f'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconThemeLight(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-theme-light'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<g fill='#d9d9d9'>
|
||||
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
|
||||
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
|
||||
</g>
|
||||
<path
|
||||
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
|
||||
fill='#ecedef'
|
||||
/>
|
||||
<circle cx={6.7} cy={7.04} r={3.54} fill='#fff' />
|
||||
<path
|
||||
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||
fill='#fff'
|
||||
/>
|
||||
<g fill='#c0c4c4'>
|
||||
<rect
|
||||
x={33.36}
|
||||
y={19.73}
|
||||
width={2.75}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={16.57}
|
||||
width={2.75}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={37.16}
|
||||
y={14.44}
|
||||
width={2.75}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={41.19}
|
||||
y={10.75}
|
||||
width={2.75}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
</g>
|
||||
<circle cx={62.74} cy={16.32} r={8} fill='#fff' />
|
||||
<g fill='#d9d9d9'>
|
||||
<path d='M63.62 15.82L67 10.15c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M67.14 10.88a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95s-1.67-2.21-2.86-2.92z' />
|
||||
</g>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={27.75}
|
||||
width={41.62}
|
||||
height={18.62}
|
||||
rx={1.69}
|
||||
ry={1.69}
|
||||
fill='#fff'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconThemeSystem({
|
||||
className,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-theme-system'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
className={cn(
|
||||
'overflow-hidden rounded-[6px]',
|
||||
'fill-primary stroke-primary group-data-[state=unchecked]:fill-muted-foreground group-data-[state=unchecked]:stroke-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<path opacity={0.2} d='M0 0.03H22.88V51.17H0z' />
|
||||
<circle
|
||||
cx={6.7}
|
||||
cy={7.04}
|
||||
r={3.54}
|
||||
fill='#fff'
|
||||
opacity={0.8}
|
||||
stroke='#fff'
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<path
|
||||
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.75}
|
||||
/>
|
||||
<path
|
||||
d='M18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.72}
|
||||
/>
|
||||
<path
|
||||
d='M15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.55}
|
||||
/>
|
||||
<path
|
||||
d='M16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.67}
|
||||
/>
|
||||
<rect
|
||||
x={33.36}
|
||||
y={19.73}
|
||||
width={2.75}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.31}
|
||||
stroke='none'
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={16.57}
|
||||
width={2.75}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.4}
|
||||
stroke='none'
|
||||
/>
|
||||
<rect
|
||||
x={37.16}
|
||||
y={14.44}
|
||||
width={2.75}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.26}
|
||||
stroke='none'
|
||||
/>
|
||||
<rect
|
||||
x={41.19}
|
||||
y={10.75}
|
||||
width={2.75}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.37}
|
||||
stroke='none'
|
||||
/>
|
||||
<g>
|
||||
<circle cx={62.74} cy={16.32} r={8} opacity={0.25} />
|
||||
<path
|
||||
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
|
||||
opacity={0.45}
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={27.75}
|
||||
width={41.62}
|
||||
height={18.62}
|
||||
rx={1.69}
|
||||
ry={1.69}
|
||||
opacity={0.3}
|
||||
stroke='none'
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Logo({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
id='shadcn-admin-logo'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
height='24'
|
||||
width='24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={cn('size-6', className)}
|
||||
{...props}
|
||||
>
|
||||
<title>Shadcn-Admin</title>
|
||||
<path d='M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Telescope } from 'lucide-react'
|
||||
|
||||
export function ComingSoon() {
|
||||
return (
|
||||
<div className='h-svh'>
|
||||
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
|
||||
<Telescope size={72} />
|
||||
<h1 className='text-4xl leading-tight font-bold'>Coming Soon!</h1>
|
||||
<p className='text-center text-muted-foreground'>
|
||||
This page has not been created yet. <br />
|
||||
Stay tuned though!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import { sidebarData } from './layout/data/sidebar-data'
|
||||
import { ScrollArea } from './ui/scroll-area'
|
||||
|
||||
export function CommandMenu() {
|
||||
const navigate = useNavigate()
|
||||
const { setTheme } = useTheme()
|
||||
const { open, setOpen } = useSearch()
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
(command: () => unknown) => {
|
||||
setOpen(false)
|
||||
command()
|
||||
},
|
||||
[setOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<CommandDialog modal open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder='Type a command or search...' />
|
||||
<CommandList>
|
||||
<ScrollArea type='hover' className='h-72 pe-1'>
|
||||
<CommandEmpty>No results found.</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='Theme'>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
|
||||
<Sun /> <span>Light</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
|
||||
<Moon className='scale-90' />
|
||||
<span>Dark</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('system'))}>
|
||||
<Laptop />
|
||||
<span>System</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
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='Open theme settings'
|
||||
className='rounded-full'
|
||||
>
|
||||
<Settings aria-hidden='true' />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='flex flex-col'>
|
||||
<SheetHeader className='pb-0 text-start'>
|
||||
<SheetTitle>Theme Settings</SheetTitle>
|
||||
<SheetDescription>
|
||||
Adjust the appearance and layout to suit your preferences.
|
||||
</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='Reset all settings to default values'
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({
|
||||
title,
|
||||
showReset = false,
|
||||
onReset,
|
||||
resetAriaLabel,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
showReset?: boolean
|
||||
onReset?: () => void
|
||||
/** Shown on the small per-section reset (RotateCcw) for accessibility and tests. */
|
||||
resetAriaLabel?: string
|
||||
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
|
||||
type='button'
|
||||
size='icon'
|
||||
variant='secondary'
|
||||
className='size-4 rounded-full'
|
||||
onClick={onReset}
|
||||
aria-label={resetAriaLabel}
|
||||
>
|
||||
<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={`Select ${item.label.toLowerCase()}`}
|
||||
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} option preview`}
|
||||
>
|
||||
<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='Theme'
|
||||
showReset={theme !== defaultTheme}
|
||||
onReset={() => setTheme(defaultTheme)}
|
||||
resetAriaLabel='Reset theme preference to default'
|
||||
/>
|
||||
<Radio
|
||||
value={theme}
|
||||
onValueChange={setTheme}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label='Select theme preference'
|
||||
aria-describedby='theme-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'system',
|
||||
label: 'System',
|
||||
icon: IconThemeSystem,
|
||||
},
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Light',
|
||||
icon: IconThemeLight,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Dark',
|
||||
icon: IconThemeDark,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} isTheme />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='theme-description' className='sr-only'>
|
||||
Choose between system preference, light mode, or dark mode
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarConfig() {
|
||||
const { defaultVariant, variant, setVariant } = useLayout()
|
||||
return (
|
||||
<div className='max-md:hidden'>
|
||||
<SectionTitle
|
||||
title='Sidebar'
|
||||
showReset={defaultVariant !== variant}
|
||||
onReset={() => setVariant(defaultVariant)}
|
||||
resetAriaLabel='Reset sidebar style to default'
|
||||
/>
|
||||
<Radio
|
||||
value={variant}
|
||||
onValueChange={setVariant}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label='Select sidebar style'
|
||||
aria-describedby='sidebar-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'inset',
|
||||
label: 'Inset',
|
||||
icon: IconSidebarInset,
|
||||
},
|
||||
{
|
||||
value: 'floating',
|
||||
label: 'Floating',
|
||||
icon: IconSidebarFloating,
|
||||
},
|
||||
{
|
||||
value: 'sidebar',
|
||||
label: 'Sidebar',
|
||||
icon: IconSidebarSidebar,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='sidebar-description' className='sr-only'>
|
||||
Choose between inset, floating, or standard sidebar layout
|
||||
</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='Layout'
|
||||
showReset={radioState !== 'default'}
|
||||
onReset={() => {
|
||||
setOpen(true)
|
||||
setCollapsible(defaultCollapsible)
|
||||
}}
|
||||
resetAriaLabel='Reset layout options to default'
|
||||
/>
|
||||
<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='Select layout style'
|
||||
aria-describedby='layout-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default',
|
||||
icon: IconLayoutDefault,
|
||||
},
|
||||
{
|
||||
value: 'icon',
|
||||
label: 'Compact',
|
||||
icon: IconLayoutCompact,
|
||||
},
|
||||
{
|
||||
value: 'offcanvas',
|
||||
label: 'Full layout',
|
||||
icon: IconLayoutFull,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='layout-description' className='sr-only'>
|
||||
Choose between default expanded, compact icon-only, or full layout mode
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DirConfig() {
|
||||
const { defaultDir, dir, setDir } = useDirection()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title='Direction'
|
||||
showReset={defaultDir !== dir}
|
||||
onReset={() => setDir(defaultDir)}
|
||||
resetAriaLabel='Reset text direction to default'
|
||||
/>
|
||||
<Radio
|
||||
value={dir}
|
||||
onValueChange={setDir}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label='Select site direction'
|
||||
aria-describedby='direction-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'ltr',
|
||||
label: 'Left to Right',
|
||||
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||
<IconDir dir='ltr' {...props} />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'rtl',
|
||||
label: 'Right to Left',
|
||||
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||
<IconDir dir='rtl' {...props} />
|
||||
),
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='direction-description' className='sr-only'>
|
||||
Choose between left-to-right or right-to-left site direction
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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-50 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-4 items-center justify-center rounded-sm border border-primary',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn('h-4 w-4 text-background')} />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className='size-4 text-muted-foreground' />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => column?.setFilterValue(undefined)}
|
||||
className='justify-center text-center'
|
||||
>
|
||||
Clear filters
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { DataTablePagination } from './pagination'
|
||||
export { DataTableColumnHeader } from './column-header'
|
||||
export { DataTableToolbar } from './toolbar'
|
||||
export { DataTableBulkActions } from './bulk-actions'
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { cn, getPageNumbers } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
type DataTablePaginationProps<TData> = {
|
||||
table: Table<TData>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
className,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const currentPage = table.getState().pagination.pageIndex + 1
|
||||
const totalPages = table.getPageCount()
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between overflow-clip px-2',
|
||||
'@max-2xl/content:flex-col-reverse @max-2xl/content:gap-4',
|
||||
className
|
||||
)}
|
||||
style={{ overflowClipMargin: 1 }}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex w-25 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-17.5'>
|
||||
<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-25 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-37.5 lg:w-62.5'
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={table.getState().globalFilter ?? ''}
|
||||
onChange={(event) => table.setGlobalFilter(event.target.value)}
|
||||
className='h-8 w-37.5 lg:w-62.5'
|
||||
/>
|
||||
)}
|
||||
<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-37.5'>
|
||||
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className='capitalize'
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { format } from 'date-fns'
|
||||
import { Calendar as CalendarIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
type DatePickerProps = {
|
||||
selected: Date | undefined
|
||||
onSelect: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
placeholder = 'Pick a date',
|
||||
}: DatePickerProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
data-empty={!selected}
|
||||
className='w-60 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,37 @@
|
||||
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'
|
||||
import { TeamSwitcher } from './team-switcher'
|
||||
|
||||
export function AppSidebar() {
|
||||
const { collapsible, variant } = useLayout()
|
||||
return (
|
||||
<Sidebar collapsible={collapsible} variant={variant}>
|
||||
<SidebarHeader>
|
||||
<TeamSwitcher teams={sidebarData.teams} />
|
||||
|
||||
{/* Replace <TeamSwitch /> with the following <AppTitle />
|
||||
/* if you want to use the normal app title instead of TeamSwitch dropdown */}
|
||||
{/* <AppTitle /> */}
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{sidebarData.navGroups.map((props) => (
|
||||
<NavGroup key={props.title} {...props} />
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={sidebarData.user} />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
export function AppTitle() {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='gap-0 py-0 hover:bg-transparent active:bg-transparent'
|
||||
asChild
|
||||
>
|
||||
<div>
|
||||
<Link
|
||||
to='/'
|
||||
onClick={() => setOpenMobile(false)}
|
||||
className='grid flex-1 text-start text-sm leading-tight'
|
||||
>
|
||||
<span className='truncate font-bold'>Shadcn-Admin</span>
|
||||
<span className='truncate text-xs'>Vite + ShadcnUI</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'>Toggle Sidebar</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,91 @@
|
||||
import {
|
||||
Package,
|
||||
Play,
|
||||
BookOpen,
|
||||
Settings,
|
||||
UserCog,
|
||||
Wrench,
|
||||
Palette,
|
||||
Bell,
|
||||
Monitor,
|
||||
|
||||
} from 'lucide-react'
|
||||
import { type SidebarData } from '../types'
|
||||
|
||||
export const sidebarData: SidebarData = {
|
||||
user: {
|
||||
name: 'AHA User',
|
||||
email: 'user@aha.app',
|
||||
avatar: '/avatars/shadcn.jpg',
|
||||
},
|
||||
teams: [
|
||||
{
|
||||
name: 'AHA Launcher',
|
||||
logo: Play,
|
||||
plan: '模型推理服务启动器',
|
||||
},
|
||||
],
|
||||
navGroups: [
|
||||
{
|
||||
title: '导航',
|
||||
items: [
|
||||
{
|
||||
title: '模型列表',
|
||||
url: '/',
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: '启动服务',
|
||||
url: '/launch',
|
||||
icon: Play,
|
||||
},
|
||||
{
|
||||
title: '使用指南',
|
||||
url: '/usage',
|
||||
icon: BookOpen,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '设置',
|
||||
items: [
|
||||
{
|
||||
title: 'Settings',
|
||||
icon: Settings,
|
||||
items: [
|
||||
{
|
||||
title: 'Model',
|
||||
url: '/settings/model',
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: 'Profile',
|
||||
url: '/settings',
|
||||
icon: UserCog,
|
||||
},
|
||||
{
|
||||
title: 'Account',
|
||||
url: '/settings/account',
|
||||
icon: Wrench,
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
url: '/settings/appearance',
|
||||
icon: Palette,
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
url: '/settings/notifications',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
url: '/settings/display',
|
||||
icon: Monitor,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
|
||||
type HeaderProps = React.HTMLAttributes<HTMLElement> & {
|
||||
fixed?: boolean
|
||||
ref?: React.Ref<HTMLElement>
|
||||
}
|
||||
|
||||
export function Header({ className, fixed, children, ...props }: HeaderProps) {
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
setOffset(document.body.scrollTop || document.documentElement.scrollTop)
|
||||
}
|
||||
|
||||
// Add scroll listener to the body
|
||||
document.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
// Clean up the event listener on unmount
|
||||
return () => document.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'z-50 h-16',
|
||||
fixed && 'header-fixed peer/header sticky top-0 w-[inherit]',
|
||||
offset > 10 && fixed ? 'shadow' : 'shadow-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full items-center gap-3 p-4 sm:gap-4',
|
||||
offset > 10 &&
|
||||
fixed &&
|
||||
'after:absolute after:inset-0 after:-z-10 after:bg-background/20 after:backdrop-blur-lg'
|
||||
)}
|
||||
>
|
||||
<SidebarTrigger variant='outline' className='max-md:scale-125' />
|
||||
<Separator orientation='vertical' className='h-6' />
|
||||
{children}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type MainProps = React.HTMLAttributes<HTMLElement> & {
|
||||
fixed?: boolean
|
||||
fluid?: boolean
|
||||
ref?: React.Ref<HTMLElement>
|
||||
}
|
||||
|
||||
export function Main({ fixed, className, fluid, ...props }: MainProps) {
|
||||
return (
|
||||
<main
|
||||
data-layout={fixed ? 'fixed' : 'auto'}
|
||||
className={cn(
|
||||
'px-4 py-6',
|
||||
|
||||
// If layout is fixed, make the main container flex and grow
|
||||
fixed && 'flex grow flex-col overflow-hidden',
|
||||
|
||||
// If layout is not fluid, set the max-width
|
||||
!fluid &&
|
||||
'@7xl/content:mx-auto @7xl/content:w-full @7xl/content:max-w-7xl',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Badge } from '../ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu'
|
||||
import {
|
||||
type NavCollapsible,
|
||||
type NavItem,
|
||||
type NavLink,
|
||||
type NavGroup as NavGroupProps,
|
||||
} from './types'
|
||||
|
||||
export function NavGroup({ title, items }: NavGroupProps) {
|
||||
const { state, isMobile } = useSidebar()
|
||||
const href = useLocation({ select: (location) => location.href })
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{title}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const key = `${item.title}-${item.url}`
|
||||
|
||||
if (!item.items)
|
||||
return <SidebarMenuLink key={key} item={item} href={href} />
|
||||
|
||||
if (state === 'collapsed' && !isMobile)
|
||||
return (
|
||||
<SidebarMenuCollapsedDropdown key={key} item={item} href={href} />
|
||||
)
|
||||
|
||||
return <SidebarMenuCollapsible key={key} item={item} href={href} />
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function NavBadge({ children }: { children: ReactNode }) {
|
||||
return <Badge className='rounded-full px-1 py-0 text-xs'>{children}</Badge>
|
||||
}
|
||||
|
||||
function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={checkIsActive(href, item)}
|
||||
tooltip={item.title}
|
||||
>
|
||||
<Link to={item.url} onClick={() => setOpenMobile(false)}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuCollapsible({
|
||||
item,
|
||||
href,
|
||||
}: {
|
||||
item: NavCollapsible
|
||||
href: string
|
||||
}) {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
return (
|
||||
<Collapsible
|
||||
asChild
|
||||
defaultOpen={checkIsActive(href, item, true)}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 rtl:rotate-180' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='CollapsibleContent'>
|
||||
<SidebarMenuSub>
|
||||
{item.items.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={checkIsActive(href, subItem)}
|
||||
>
|
||||
<Link to={subItem.url} onClick={() => setOpenMobile(false)}>
|
||||
{subItem.icon && <subItem.icon />}
|
||||
<span>{subItem.title}</span>
|
||||
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuCollapsedDropdown({
|
||||
item,
|
||||
href,
|
||||
}: {
|
||||
item: NavCollapsible
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={checkIsActive(href, item)}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side='right' align='start' sideOffset={4}>
|
||||
<DropdownMenuLabel>
|
||||
{item.title} {item.badge ? `(${item.badge})` : ''}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{item.items.map((sub) => (
|
||||
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
|
||||
<Link
|
||||
to={sub.url}
|
||||
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
|
||||
>
|
||||
{sub.icon && <sub.icon />}
|
||||
<span className='max-w-52 text-wrap'>{sub.title}</span>
|
||||
{sub.badge && (
|
||||
<span className='ms-auto text-xs'>{sub.badge}</span>
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function checkIsActive(href: string, item: NavItem, mainNav = false) {
|
||||
return (
|
||||
href === item.url || // /endpint?search=param
|
||||
href.split('?')[0] === item.url || // endpoint
|
||||
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
|
||||
(mainNav &&
|
||||
href.split('/')[1] !== '' &&
|
||||
href.split('/')[1] === item?.url?.split('/')[1])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import useDialogState from '@/hooks/use-dialog-state'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
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'>SN</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'>SN</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 />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/settings/account'>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/settings'>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/settings/notifications'>
|
||||
<Bell />
|
||||
Notifications
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant='destructive'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<LogOut />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
<SignOutDialog open={!!open} onOpenChange={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronsUpDown, Plus } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
type TeamSwitcherProps = {
|
||||
teams: {
|
||||
name: string
|
||||
logo: React.ElementType
|
||||
plan: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export function TeamSwitcher({ teams }: TeamSwitcherProps) {
|
||||
const { isMobile } = useSidebar()
|
||||
const [activeTeam, setActiveTeam] = React.useState(teams[0])
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
<div className='flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground'>
|
||||
<activeTeam.logo className='size-4' />
|
||||
</div>
|
||||
<div className='grid flex-1 text-start text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>
|
||||
{activeTeam.name}
|
||||
</span>
|
||||
<span className='truncate text-xs'>{activeTeam.plan}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className='ms-auto' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||
align='start'
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className='text-xs text-muted-foreground'>
|
||||
Teams
|
||||
</DropdownMenuLabel>
|
||||
{teams.map((team, index) => (
|
||||
<DropdownMenuItem
|
||||
key={team.name}
|
||||
onClick={() => setActiveTeam(team)}
|
||||
className='gap-2 p-2'
|
||||
>
|
||||
<div className='flex size-6 items-center justify-center rounded-sm border'>
|
||||
<team.logo className='size-4 shrink-0' />
|
||||
</div>
|
||||
{team.name}
|
||||
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className='gap-2 p-2'>
|
||||
<div className='flex size-6 items-center justify-center rounded-md border bg-background'>
|
||||
<Plus className='size-4' />
|
||||
</div>
|
||||
<div className='font-medium text-muted-foreground'>Add team</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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 (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
className={cn('md:size-7 lg:hidden', className)}
|
||||
>
|
||||
<Menu />
|
||||
<span className='sr-only'>Toggle navigation menu</span>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||