merge main

This commit is contained in:
jhqxxx
2026-05-09 16:42:26 +08:00
247 changed files with 41360 additions and 11666 deletions
+2
View File
@@ -0,0 +1,2 @@
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+4
View File
@@ -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/*
Generated
+27 -23
View File
@@ -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",
+2 -4
View File
@@ -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]
+66 -10
View File
@@ -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 |
+66 -10
View File
@@ -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
```
## 文档
| 文档 | 描述 |
+2
View File
@@ -0,0 +1,2 @@
[target.x86_64-pc-windows-msvc]
rustflags = ["-C", "target-feature=+crt-static"]
+12
View File
@@ -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 ,基本上是直接粘贴过来的,
这个项目只有两个
+25
View File
@@ -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": {}
}
+19
View File
@@ -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>
+8318
View File
File diff suppressed because it is too large Load Diff
+89
View File
@@ -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"
}
}
+6388
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
allowBuilds:
esbuild: true
msw: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokewidth="2" strokelinecap="round" strokelinejoin="round">
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"></path>
</svg><style>@media (prefers-color-scheme: light) { :root { filter: contrast(1) brightness(0.8); } }
</style></svg>

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

+1
View File
@@ -0,0 +1 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="948,463.5,24,24"><defs><clipPath id="clip-1"><rect x="948" y="463.5" width="24" height="24" id="clip-1" stroke="none"/></clipPath></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g><g id="Group 1"><g clip-path="url(#clip-1)" id="Group 1"><path d="M963,469.5v12c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3h-12c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3v-12c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3h12c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3" id="Path 1" stroke="#f2f2f2"/></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1013 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

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

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

+8702
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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 = "../../" }
+3
View File
@@ -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"
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Capability for the main window","local":true,"windows":["main"],"permissions":["core:default","opener:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+437
View File
@@ -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");
}
+6
View File
@@ -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()
}
+37
View File
@@ -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>
)
}
+16
View File
@@ -0,0 +1,16 @@
export { IconDiscord } from './icon-discord'
export { IconDocker } from './icon-docker'
export { IconFacebook } from './icon-facebook'
export { IconFigma } from './icon-figma'
export { IconGithub } from './icon-github'
export { IconGitlab } from './icon-gitlab'
export { IconGmail } from './icon-gmail'
export { IconMedium } from './icon-medium'
export { IconNotion } from './icon-notion'
export { IconSkype } from './icon-skype'
export { IconSlack } from './icon-slack'
export { IconStripe } from './icon-stripe'
export { IconTelegram } from './icon-telegram'
export { IconTrello } from './icon-trello'
export { IconWhatsapp } from './icon-whatsapp'
export { IconZoom } from './icon-zoom'
+41
View File
@@ -0,0 +1,41 @@
import { type SVGProps } from 'react'
export function ClerkFullLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
width={77}
height={24}
viewBox='0 0 77 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M35.148 16.738a4.198 4.198 0 01-3.06 1.283 3.53 3.53 0 01-2.604-1.034c-.619-.645-.975-1.566-.975-2.665 0-2.199 1.432-3.703 3.58-3.703a3.914 3.914 0 013.034 1.377l1.859-1.644c-1.211-1.47-3.176-2.229-5.042-2.229-3.652 0-6.24 2.517-6.24 6.22 0 1.831.643 3.374 1.728 4.463s2.631 1.728 4.415 1.728c2.317 0 4.166-.94 5.203-2.122l-1.898-1.674zM38.727 3.428h2.766V20.34h-2.766V3.428zM54.818 15.283c.046-.368.07-.74.076-1.11 0-3.507-2.296-6.047-5.847-6.047a5.738 5.738 0 00-4.215 1.725c-1.038 1.089-1.66 2.631-1.66 4.47 0 3.749 2.642 6.216 6.146 6.216 2.35 0 4.043-.951 5.058-2.242l-1.812-1.605-.09-.076a3.749 3.749 0 01-3.008 1.406c-1.778 0-3.061-1.037-3.427-2.737h8.779zm-8.733-2.22a3.365 3.365 0 01.737-1.449 3.082 3.082 0 012.368-.996c1.58 0 2.57.988 2.911 2.445h-6.016zM63.445 8.09v3.084a13.36 13.36 0 00-.838-.05c-2.094 0-3.282 1.505-3.282 3.479v5.736h-2.763V8.261h2.763v1.83h.025c.938-1.283 2.284-1.997 3.75-1.997l.345-.004zM69.887 15.281l-1.998 2.222v2.837h-2.764V3.428h2.764v10.374L72.822 8.3h3.283l-4.341 4.86 4.417 7.18h-3.11l-3.133-5.059h-.051z'
fill='#1F0256'
/>
<path
d='M19.116 3.16l-2.88 2.881a.571.571 0 01-.701.084 6.854 6.854 0 00-10.39 5.647 6.867 6.867 0 00.979 3.764.571.571 0 01-.084.699l-2.88 2.88a.57.57 0 01-.865-.063A11.994 11.994 0 0119.051 2.295a.57.57 0 01.065.866z'
fill='url(#paint0_linear_26568_214324)'
/>
<path
d='M19.113 20.829l-2.88-2.88a.571.571 0 00-.7-.085 6.854 6.854 0 01-7.081 0 .571.571 0 00-.7.084l-2.881 2.88a.57.57 0 00.062.877 11.994 11.994 0 0014.114 0 .571.571 0 00.066-.876zM11.997 15.422a3.427 3.427 0 100-6.854 3.427 3.427 0 000 6.854z'
fill='#1F0256'
/>
<defs>
<linearGradient
id='paint0_linear_26568_214324'
x1={16.4087}
y1={-1.75881}
x2={-7.88473}
y2={22.5365}
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#17CCFC' />
<stop offset={0.5} stopColor='#5D31FF' />
<stop offset={1} stopColor='#F35AFF' />
</linearGradient>
</defs>
</svg>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function ClerkLogo({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
id='clerk'
height='24'
width='24'
className={cn('[&>path]:fill-foreground', className)}
{...props}
>
<title>Clerk</title>
<path
d='m21.47 20.829 -2.881 -2.881a0.572 0.572 0 0 0 -0.7 -0.084 6.854 6.854 0 0 1 -7.081 0 0.576 0.576 0 0 0 -0.7 0.084l-2.881 2.881a0.576 0.576 0 0 0 -0.103 0.69 0.57 0.57 0 0 0 0.166 0.186 12 12 0 0 0 14.113 0 0.58 0.58 0 0 0 0.239 -0.423 0.576 0.576 0 0 0 -0.172 -0.453Zm0.002 -17.668 -2.88 2.88a0.569 0.569 0 0 1 -0.701 0.084A6.857 6.857 0 0 0 8.724 8.08a6.862 6.862 0 0 0 -1.222 3.692 6.86 6.86 0 0 0 0.978 3.764 0.573 0.573 0 0 1 -0.083 0.699l-2.881 2.88a0.567 0.567 0 0 1 -0.864 -0.063A11.993 11.993 0 0 1 6.771 2.7a11.99 11.99 0 0 1 14.637 -0.405 0.566 0.566 0 0 1 0.232 0.418 0.57 0.57 0 0 1 -0.168 0.448Zm-7.118 12.261a3.427 3.427 0 1 0 0 -6.854 3.427 3.427 0 0 0 0 6.854Z'
strokeWidth='1'
></path>
</svg>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
import { type Direction } from '@/context/direction-provider'
type IconDirProps = SVGProps<SVGSVGElement> & {
dir: Direction
}
export function IconDir({ dir, className, ...props }: IconDirProps) {
return (
<svg
data-name={`icon-dir-${dir}`}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
className={cn(dir === 'rtl' && 'rotate-y-180', className)}
{...props}
>
<path
d='M23.42.51h51.92c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
opacity={0.15}
/>
<path
fill='none'
opacity={0.72}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 14.88L17.78 14.88'
/>
<path
fill='none'
opacity={0.48}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 22.09L16.08 22.09'
/>
<path
fill='none'
opacity={0.55}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 18.38L14.93 18.38'
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
<path
fill='none'
opacity={0.8}
strokeWidth='2px'
d='M12.06 6.14L17.78 6.14'
/>
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
</g>
<path
fill='none'
opacity={0.62}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='3px'
d='M29.41 7.4L34.67 7.4'
/>
<rect
x={28.76}
y={11.21}
width={26.03}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.44}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={28.76}
y={17.01}
width={44.25}
height={13.48}
rx={0.64}
ry={0.64}
opacity={0.3}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={28.76}
y={33.57}
width={44.25}
height={4.67}
rx={0.64}
ry={0.64}
opacity={0.21}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={28.76}
y={41.32}
width={36.21}
height={4.67}
rx={0.64}
ry={0.64}
opacity={0.3}
strokeLinecap='round'
strokeMiterlimit={10}
/>
</svg>
)
}
@@ -0,0 +1,131 @@
import { type SVGProps } from 'react'
export function IconLayoutCompact(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-layout-compact'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<rect
x={5.84}
y={5.2}
width={4}
height={40}
rx={2}
ry={2}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
<path
fill='none'
opacity={0.66}
strokeWidth='2px'
d='M7.26 11.56L8.37 11.56'
/>
<path
fill='none'
opacity={0.51}
strokeWidth='2px'
d='M7.26 14.49L8.37 14.49'
/>
<path
fill='none'
opacity={0.52}
strokeWidth='2px'
d='M7.26 17.39L8.37 17.39'
/>
<circle cx={7.81} cy={7.25} r={1.16} fill='#fff' opacity={0.8} />
</g>
<path
fill='none'
opacity={0.75}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='3px'
d='M15.81 14.49L22.89 14.49'
/>
<rect
x={14.93}
y={18.39}
width={22.19}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.5}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={14.93}
y={5.89}
width={59.16}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.9}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={14.93}
y={24.22}
width={32.68}
height={19.95}
rx={2.11}
ry={2.11}
opacity={0.4}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<rect
x={59.05}
y={38.15}
width={2.01}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.32}
/>
<rect
x={54.78}
y={34.99}
width={2.01}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.44}
/>
<rect
x={63.17}
y={32.86}
width={2.01}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
<rect
x={67.54}
y={29.17}
width={2.01}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.66}
/>
</g>
<g opacity={0.5}>
<circle cx={62.16} cy={18.63} r={7.5} />
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
</g>
<g opacity={0.74}>
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
</g>
</svg>
)
}
@@ -0,0 +1,124 @@
import { type SVGProps } from 'react'
export function IconLayoutDefault(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='con-layout-default'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<path
d='M39.22 15.99h-8.16c-.79 0-1.43-.67-1.43-1.5s.64-1.5 1.43-1.5h8.16c.79 0 1.43.67 1.43 1.5s-.64 1.5-1.43 1.5z'
opacity={0.75}
/>
<rect
x={29.63}
y={18.39}
width={16.72}
height={2.73}
rx={1.36}
ry={1.36}
opacity={0.5}
/>
<path
d='M75.1 6.68v1.45c0 .63-.49 1.14-1.09 1.14H30.72c-.6 0-1.09-.51-1.09-1.14V6.68c0-.62.49-1.14 1.09-1.14h43.29c.6 0 1.09.52 1.09 1.14z'
opacity={0.9}
/>
<rect
x={29.63}
y={24.22}
width={21.8}
height={19.95}
rx={2.11}
ry={2.11}
opacity={0.4}
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<rect
x={61.06}
y={38.15}
width={2.01}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.32}
/>
<rect
x={56.78}
y={34.99}
width={2.01}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.44}
/>
<rect
x={65.17}
y={32.86}
width={2.01}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
<rect
x={69.55}
y={29.17}
width={2.01}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.66}
/>
</g>
<g opacity={0.5}>
<circle cx={63.17} cy={18.63} r={7.5} />
<path d='M63.17 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
</g>
<g opacity={0.74}>
<path d='M64.05 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
<path d='M67.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
</g>
<g strokeLinecap='round' strokeMiterlimit={10}>
<rect
x={5.84}
y={5.02}
width={19.14}
height={40}
rx={2}
ry={2}
opacity={0.8}
/>
<g stroke='#fff'>
<path
fill='none'
opacity={0.72}
strokeWidth='2px'
d='M9.02 17.39L21.25 17.39'
/>
<path
fill='none'
opacity={0.48}
strokeWidth='2px'
d='M9.02 24.6L19.54 24.6'
/>
<path
fill='none'
opacity={0.55}
strokeWidth='2px'
d='M9.02 20.88L18.4 20.88'
/>
<circle cx={10.98} cy={9.91} r={2.54} fill='#fff' opacity={0.8} />
<path
fill='none'
opacity={0.8}
strokeWidth='2px'
d='M15.53 8.65L21.25 8.65'
/>
<path fill='none' opacity={0.6} d='M15.32 11.3L20.38 11.3' />
</g>
</g>
</svg>
)
}
@@ -0,0 +1,100 @@
import { type SVGProps } from 'react'
export function IconLayoutFull(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-layout-full'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<path
fill='none'
opacity={0.75}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='3px'
d='M6.85 14.49L15.02 14.49'
/>
<rect
x={5.84}
y={18.39}
width={25.6}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.5}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={5.84}
y={5.89}
width={68.26}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.9}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={5.84}
y={24.22}
width={37.71}
height={19.95}
rx={2.11}
ry={2.11}
opacity={0.4}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<rect
x={59.05}
y={38.15}
width={2.01}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.32}
/>
<rect
x={54.78}
y={34.99}
width={2.01}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.44}
/>
<rect
x={63.17}
y={32.86}
width={2.01}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
<rect
x={67.54}
y={29.17}
width={2.01}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.66}
/>
</g>
<g opacity={0.5}>
<circle cx={62.16} cy={18.63} r={7.5} />
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
</g>
<g opacity={0.74}>
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
</g>
</svg>
)
}
@@ -0,0 +1,82 @@
import { type SVGProps } from 'react'
export function IconSidebarFloating(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-sidebar-floating'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<rect
x={5.89}
y={5.15}
width={19.74}
height={40}
rx={2}
ry={2}
opacity={0.8}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
<path
fill='none'
opacity={0.72}
strokeWidth='2px'
d='M9.81 18.36L22.04 18.36'
/>
<path
fill='none'
opacity={0.48}
strokeWidth='2px'
d='M9.81 25.57L20.33 25.57'
/>
<path
fill='none'
opacity={0.55}
strokeWidth='2px'
d='M9.81 21.85L19.18 21.85'
/>
<circle cx={11.76} cy={10.88} r={2.54} fill='#fff' opacity={0.8} />
<path
fill='none'
opacity={0.8}
strokeWidth='2px'
d='M16.31 9.62L22.04 9.62'
/>
<path fill='none' opacity={0.6} d='M16.1 12.27L21.16 12.27' />
</g>
<path
fill='none'
opacity={0.62}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='3px'
d='M30.59 9.62L35.85 9.62'
/>
<rect
x={29.94}
y={13.42}
width={26.03}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.44}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={29.94}
y={19.28}
width={43.11}
height={25.87}
rx={2}
ry={2}
opacity={0.3}
strokeLinecap='round'
strokeMiterlimit={10}
/>
</svg>
)
}
@@ -0,0 +1,58 @@
import { type SVGProps } from 'react'
export function IconSidebarInset(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-sidebar-inset'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<rect
x={23.39}
y={5.57}
width={50.22}
height={40}
rx={2}
ry={2}
opacity={0.2}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<path
fill='none'
opacity={0.72}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.08 17.05L17.31 17.05'
/>
<path
fill='none'
opacity={0.48}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.08 24.25L15.6 24.25'
/>
<path
fill='none'
opacity={0.55}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.08 20.54L14.46 20.54'
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<circle cx={7.04} cy={9.57} r={2.54} opacity={0.8} />
<path
fill='none'
opacity={0.8}
strokeWidth='2px'
d='M11.59 8.3L17.31 8.3'
/>
<path fill='none' opacity={0.6} d='M11.38 10.95L16.44 10.95' />
</g>
</svg>
)
}
@@ -0,0 +1,53 @@
import { type SVGProps } from 'react'
export function IconSidebarSidebar(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-sidebar-sidebar'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<path
d='M23.42.51h51.99c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
opacity={0.2}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<path
fill='none'
opacity={0.72}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 14.88L17.78 14.88'
/>
<path
fill='none'
opacity={0.48}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 22.09L16.08 22.09'
/>
<path
fill='none'
opacity={0.55}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 18.38L14.93 18.38'
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
<path
fill='none'
opacity={0.8}
strokeWidth='2px'
d='M12.06 6.14L17.78 6.14'
/>
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
</g>
</svg>
)
}
@@ -0,0 +1,79 @@
import { type SVGProps } from 'react'
export function IconThemeDark(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-theme-dark'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<g fill='#1d2b3f'>
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
</g>
<path
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
fill='#0d1628'
/>
<circle cx={6.7} cy={7.04} r={3.54} fill='#426187' />
<path
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
fill='#426187'
/>
<g fill='#2a62bc'>
<rect
x={33.36}
y={19.73}
width={2.75}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.32}
/>
<rect
x={29.64}
y={16.57}
width={2.75}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.44}
/>
<rect
x={37.16}
y={14.44}
width={2.75}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
<rect
x={41.19}
y={10.75}
width={2.75}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
</g>
<circle cx={62.74} cy={16.32} r={8} fill='#2f5491' opacity={0.5} />
<path
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
fill='#2f5491'
opacity={0.74}
/>
<rect
x={29.64}
y={27.75}
width={41.62}
height={18.62}
rx={1.69}
ry={1.69}
fill='#17273f'
/>
</svg>
)
}
@@ -0,0 +1,78 @@
import { type SVGProps } from 'react'
export function IconThemeLight(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-theme-light'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<g fill='#d9d9d9'>
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
</g>
<path
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
fill='#ecedef'
/>
<circle cx={6.7} cy={7.04} r={3.54} fill='#fff' />
<path
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
fill='#fff'
/>
<g fill='#c0c4c4'>
<rect
x={33.36}
y={19.73}
width={2.75}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.32}
/>
<rect
x={29.64}
y={16.57}
width={2.75}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.44}
/>
<rect
x={37.16}
y={14.44}
width={2.75}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
<rect
x={41.19}
y={10.75}
width={2.75}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
</g>
<circle cx={62.74} cy={16.32} r={8} fill='#fff' />
<g fill='#d9d9d9'>
<path d='M63.62 15.82L67 10.15c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
<path d='M67.14 10.88a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95s-1.67-2.21-2.86-2.92z' />
</g>
<rect
x={29.64}
y={27.75}
width={41.62}
height={18.62}
rx={1.69}
ry={1.69}
fill='#fff'
/>
</svg>
)
}
@@ -0,0 +1,116 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconThemeSystem({
className,
...props
}: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-theme-system'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
className={cn(
'overflow-hidden rounded-[6px]',
'fill-primary stroke-primary group-data-[state=unchecked]:fill-muted-foreground group-data-[state=unchecked]:stroke-muted-foreground',
className
)}
{...props}
>
<path opacity={0.2} d='M0 0.03H22.88V51.17H0z' />
<circle
cx={6.7}
cy={7.04}
r={3.54}
fill='#fff'
opacity={0.8}
stroke='#fff'
strokeLinecap='round'
strokeMiterlimit={10}
/>
<path
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1z'
fill='#fff'
stroke='none'
opacity={0.75}
/>
<path
d='M18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05z'
fill='#fff'
stroke='none'
opacity={0.72}
/>
<path
d='M15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91z'
fill='#fff'
stroke='none'
opacity={0.55}
/>
<path
d='M16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
fill='#fff'
stroke='none'
opacity={0.67}
/>
<rect
x={33.36}
y={19.73}
width={2.75}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.31}
stroke='none'
/>
<rect
x={29.64}
y={16.57}
width={2.75}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.4}
stroke='none'
/>
<rect
x={37.16}
y={14.44}
width={2.75}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.26}
stroke='none'
/>
<rect
x={41.19}
y={10.75}
width={2.75}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.37}
stroke='none'
/>
<g>
<circle cx={62.74} cy={16.32} r={8} opacity={0.25} />
<path
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
opacity={0.45}
/>
</g>
<rect
x={29.64}
y={27.75}
width={41.62}
height={18.62}
rx={1.69}
ry={1.69}
opacity={0.3}
stroke='none'
strokeLinecap='round'
strokeMiterlimit={10}
/>
</svg>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function Logo({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
id='shadcn-admin-logo'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
height='24'
width='24'
fill='none'
stroke='currentColor'
strokeWidth='2'
strokeLinecap='round'
strokeLinejoin='round'
className={cn('size-6', className)}
{...props}
>
<title>Shadcn-Admin</title>
<path d='M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3' />
</svg>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { Telescope } from 'lucide-react'
export function ComingSoon() {
return (
<div className='h-svh'>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
<Telescope size={72} />
<h1 className='text-4xl leading-tight font-bold'>Coming Soon!</h1>
<p className='text-center text-muted-foreground'>
This page has not been created yet. <br />
Stay tuned though!
</p>
</div>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
import React from 'react'
import { useNavigate } from '@tanstack/react-router'
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
import { useSearch } from '@/context/search-provider'
import { useTheme } from '@/context/theme-provider'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { sidebarData } from './layout/data/sidebar-data'
import { ScrollArea } from './ui/scroll-area'
export function CommandMenu() {
const navigate = useNavigate()
const { setTheme } = useTheme()
const { open, setOpen } = useSearch()
const runCommand = React.useCallback(
(command: () => unknown) => {
setOpen(false)
command()
},
[setOpen]
)
return (
<CommandDialog modal open={open} onOpenChange={setOpen}>
<CommandInput placeholder='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>
)
}
+362
View File
@@ -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>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { cn } from '@/lib/utils'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
type ConfirmDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: React.ReactNode
disabled?: boolean
desc: React.JSX.Element | string
cancelBtnText?: string
confirmText?: React.ReactNode
destructive?: boolean
isLoading?: boolean
className?: string
children?: React.ReactNode
} & (
| { form: string; handleConfirm?: undefined }
| { form?: undefined; handleConfirm: () => void }
)
export function ConfirmDialog(props: ConfirmDialogProps) {
const {
title,
desc,
children,
className,
confirmText,
cancelBtnText,
destructive,
isLoading,
disabled = false,
form,
handleConfirm,
...actions
} = props
return (
<AlertDialog {...actions}>
<AlertDialogContent className={cn(className && className)}>
<AlertDialogHeader className='text-start'>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>{desc}</div>
</AlertDialogDescription>
</AlertDialogHeader>
{children}
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>
{cancelBtnText ?? 'Cancel'}
</AlertDialogCancel>
<Button
type={form ? 'submit' : 'button'}
form={form}
onClick={handleConfirm}
variant={destructive ? 'destructive' : 'default'}
disabled={disabled || isLoading}
>
{confirmText ?? 'Continue'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,213 @@
import { useState, useEffect, useRef } from 'react'
import { type Table } from '@tanstack/react-table'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
type DataTableBulkActionsProps<TData> = {
table: Table<TData>
entityName: string
children: React.ReactNode
}
/**
* A modular toolbar for displaying bulk actions when table rows are selected.
*
* @template TData The type of data in the table.
* @param {object} props The component props.
* @param {Table<TData>} props.table The react-table instance.
* @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
* @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
* @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
*/
export function DataTableBulkActions<TData>({
table,
entityName,
children,
}: DataTableBulkActionsProps<TData>): React.ReactNode | null {
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedCount = selectedRows.length
const toolbarRef = useRef<HTMLDivElement>(null)
const [announcement, setAnnouncement] = useState('')
// Announce selection changes to screen readers
useEffect(() => {
if (selectedCount > 0) {
const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
// Use queueMicrotask to defer state update and avoid cascading renders
queueMicrotask(() => {
setAnnouncement(message)
})
// Clear announcement after a delay
const timer = setTimeout(() => setAnnouncement(''), 3000)
return () => clearTimeout(timer)
}
}, [selectedCount, entityName])
const handleClearSelection = () => {
table.resetRowSelection()
}
const handleKeyDown = (event: React.KeyboardEvent) => {
const buttons = toolbarRef.current?.querySelectorAll('button')
if (!buttons) return
const currentIndex = Array.from(buttons).findIndex(
(button) => button === document.activeElement
)
switch (event.key) {
case 'ArrowRight': {
event.preventDefault()
const nextIndex = (currentIndex + 1) % buttons.length
buttons[nextIndex]?.focus()
break
}
case 'ArrowLeft': {
event.preventDefault()
const prevIndex =
currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
buttons[prevIndex]?.focus()
break
}
case 'Home':
event.preventDefault()
buttons[0]?.focus()
break
case 'End':
event.preventDefault()
buttons[buttons.length - 1]?.focus()
break
case 'Escape': {
// Check if the Escape key came from a dropdown trigger or content
// We can't check dropdown state because Radix UI closes it before our handler runs
const target = event.target as HTMLElement
const activeElement = document.activeElement as HTMLElement
// Check if the event target or currently focused element is a dropdown trigger
const isFromDropdownTrigger =
target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
activeElement?.getAttribute('data-slot') ===
'dropdown-menu-trigger' ||
target?.closest('[data-slot="dropdown-menu-trigger"]') ||
activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
// Check if the focused element is inside dropdown content (which is portaled)
const isFromDropdownContent =
activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
target?.closest('[data-slot="dropdown-menu-content"]')
if (isFromDropdownTrigger || isFromDropdownContent) {
// Escape was meant for the dropdown - don't clear selection
return
}
// Escape was meant for the toolbar - clear selection
event.preventDefault()
handleClearSelection()
break
}
}
}
if (selectedCount === 0) {
return null
}
return (
<>
{/* Live region for screen reader announcements */}
<div
aria-live='polite'
aria-atomic='true'
className='sr-only'
role='status'
>
{announcement}
</div>
<div
ref={toolbarRef}
role='toolbar'
aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`}
aria-describedby='bulk-actions-description'
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cn(
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
'transition-all delay-100 duration-300 ease-out hover:scale-105',
'focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none'
)}
>
<div
className={cn(
'p-2 shadow-xl',
'rounded-xl border',
'bg-background/95 backdrop-blur-lg supports-backdrop-filter:bg-background/60',
'flex items-center gap-x-2'
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={handleClearSelection}
className='size-6 rounded-full'
aria-label='Clear selection'
title='Clear selection (Escape)'
>
<X />
<span className='sr-only'>Clear selection</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Clear selection (Escape)</p>
</TooltipContent>
</Tooltip>
<Separator
className='h-5'
orientation='vertical'
aria-hidden='true'
/>
<div
className='flex items-center gap-x-1 text-sm'
id='bulk-actions-description'
>
<Badge
variant='default'
className='min-w-8 rounded-lg'
aria-label={`${selectedCount} selected`}
>
{selectedCount}
</Badge>{' '}
<span className='hidden sm:inline'>
{entityName}
{selectedCount > 1 ? 's' : ''}
</span>{' '}
selected
</div>
<Separator
className='h-5'
orientation='vertical'
aria-hidden='true'
/>
{children}
</div>
</div>
</>
)
}
@@ -0,0 +1,74 @@
import {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
EyeNoneIcon,
} from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
type DataTableColumnHeaderProps<TData, TValue> =
React.HTMLAttributes<HTMLDivElement> & {
column: Column<TData, TValue>
title: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-8 data-[state=open]:bg-accent'
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ms-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ms-2 h-4 w-4' />
) : (
<CaretSortIcon className='ms-2 h-4 w-4' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='size-3.5 text-muted-foreground/70' />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='size-3.5 text-muted-foreground/70' />
Desc
</DropdownMenuItem>
{column.getCanHide() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className='size-3.5 text-muted-foreground/70' />
Hide
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -0,0 +1,146 @@
import * as React from 'react'
import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Separator } from '@/components/ui/separator'
type DataTableFacetedFilterProps<TData, TValue> = {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options,
}: DataTableFacetedFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
return (
<Popover>
<PopoverTrigger asChild>
<Button variant='outline' size='sm' className='h-8 border-dashed'>
<PlusCircledIcon className='size-4' />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation='vertical' className='mx-2 h-4' />
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal lg:hidden'
>
{selectedValues.size}
</Badge>
<div className='hidden space-x-1 lg:flex'>
{selectedValues.size > 2 ? (
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal'
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant='secondary'
key={option.value}
className='rounded-sm px-1 font-normal'
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className='w-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>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { format } from 'date-fns'
import { Calendar as CalendarIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
type DatePickerProps = {
selected: Date | undefined
onSelect: (date: Date | undefined) => void
placeholder?: string
}
export function DatePicker({
selected,
onSelect,
placeholder = 'Pick a date',
}: DatePickerProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
data-empty={!selected}
className='w-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,
},
],
},
],
},
],
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
type HeaderProps = React.HTMLAttributes<HTMLElement> & {
fixed?: boolean
ref?: React.Ref<HTMLElement>
}
export function Header({ className, fixed, children, ...props }: HeaderProps) {
const [offset, setOffset] = useState(0)
useEffect(() => {
const onScroll = () => {
setOffset(document.body.scrollTop || document.documentElement.scrollTop)
}
// Add scroll listener to the body
document.addEventListener('scroll', onScroll, { passive: true })
// Clean up the event listener on unmount
return () => document.removeEventListener('scroll', onScroll)
}, [])
return (
<header
className={cn(
'z-50 h-16',
fixed && 'header-fixed peer/header sticky top-0 w-[inherit]',
offset > 10 && fixed ? 'shadow' : 'shadow-none',
className
)}
{...props}
>
<div
className={cn(
'relative flex h-full items-center gap-3 p-4 sm:gap-4',
offset > 10 &&
fixed &&
'after:absolute after:inset-0 after:-z-10 after:bg-background/20 after:backdrop-blur-lg'
)}
>
<SidebarTrigger variant='outline' className='max-md:scale-125' />
<Separator orientation='vertical' className='h-6' />
{children}
</div>
</header>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { cn } from '@/lib/utils'
type MainProps = React.HTMLAttributes<HTMLElement> & {
fixed?: boolean
fluid?: boolean
ref?: React.Ref<HTMLElement>
}
export function Main({ fixed, className, fluid, ...props }: MainProps) {
return (
<main
data-layout={fixed ? 'fixed' : 'auto'}
className={cn(
'px-4 py-6',
// If layout is fixed, make the main container flex and grow
fixed && 'flex grow flex-col overflow-hidden',
// If layout is not fluid, set the max-width
!fluid &&
'@7xl/content:mx-auto @7xl/content:w-full @7xl/content:max-w-7xl',
className
)}
{...props}
/>
)
}
+185
View File
@@ -0,0 +1,185 @@
import { type ReactNode } from 'react'
import { Link, useLocation } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from '@/components/ui/sidebar'
import { Badge } from '../ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu'
import {
type NavCollapsible,
type NavItem,
type NavLink,
type NavGroup as NavGroupProps,
} from './types'
export function NavGroup({ title, items }: NavGroupProps) {
const { state, isMobile } = useSidebar()
const href = useLocation({ select: (location) => location.href })
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
const key = `${item.title}-${item.url}`
if (!item.items)
return <SidebarMenuLink key={key} item={item} href={href} />
if (state === 'collapsed' && !isMobile)
return (
<SidebarMenuCollapsedDropdown key={key} item={item} href={href} />
)
return <SidebarMenuCollapsible key={key} item={item} href={href} />
})}
</SidebarMenu>
</SidebarGroup>
)
}
function NavBadge({ children }: { children: ReactNode }) {
return <Badge className='rounded-full px-1 py-0 text-xs'>{children}</Badge>
}
function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
const { setOpenMobile } = useSidebar()
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={checkIsActive(href, item)}
tooltip={item.title}
>
<Link to={item.url} onClick={() => setOpenMobile(false)}>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
function SidebarMenuCollapsible({
item,
href,
}: {
item: NavCollapsible
href: string
}) {
const { setOpenMobile } = useSidebar()
return (
<Collapsible
asChild
defaultOpen={checkIsActive(href, item, true)}
className='group/collapsible'
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 rtl:rotate-180' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={checkIsActive(href, subItem)}
>
<Link to={subItem.url} onClick={() => setOpenMobile(false)}>
{subItem.icon && <subItem.icon />}
<span>{subItem.title}</span>
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
)
}
function SidebarMenuCollapsedDropdown({
item,
href,
}: {
item: NavCollapsible
href: string
}) {
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={checkIsActive(href, item)}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side='right' align='start' sideOffset={4}>
<DropdownMenuLabel>
{item.title} {item.badge ? `(${item.badge})` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items.map((sub) => (
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
<Link
to={sub.url}
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
>
{sub.icon && <sub.icon />}
<span className='max-w-52 text-wrap'>{sub.title}</span>
{sub.badge && (
<span className='ms-auto text-xs'>{sub.badge}</span>
)}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
}
function checkIsActive(href: string, item: NavItem, mainNav = false) {
return (
href === item.url || // /endpint?search=param
href.split('?')[0] === item.url || // endpoint
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
(mainNav &&
href.split('/')[1] !== '' &&
href.split('/')[1] === item?.url?.split('/')[1])
)
}
+124
View File
@@ -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>
)
}
+70
View File
@@ -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>
</>
)
}

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