init
This commit is contained in:
20
.cnb.yml
Normal file
20
.cnb.yml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# .cnb.yml
|
||||||
|
include:
|
||||||
|
- https://cnb.cool/kevisual/cnb/-/blob/main/.cnb/template.yml
|
||||||
|
|
||||||
|
.common_env: &common_env
|
||||||
|
env:
|
||||||
|
USERNAME: root
|
||||||
|
imports:
|
||||||
|
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
|
||||||
|
|
||||||
|
$:
|
||||||
|
vscode:
|
||||||
|
- docker:
|
||||||
|
image: docker.cnb.cool/kevisual/dev-env:latest
|
||||||
|
services:
|
||||||
|
- vscode
|
||||||
|
- docker
|
||||||
|
env: !reference [.common_env, env]
|
||||||
|
imports: !reference [.common_env, imports]
|
||||||
|
stages: !reference [.dev_template, stages]
|
||||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
pack-dist
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
.turbo
|
||||||
|
|
||||||
|
.pnpm-store
|
||||||
|
|
||||||
|
.tanstack
|
||||||
|
.env*
|
||||||
|
|
||||||
|
!.env.example
|
||||||
|
.pnpm-lock.yaml
|
||||||
72
AGENTS.md
Normal file
72
AGENTS.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
本指南为在此仓库中工作的 AI 编码代理提供关键信息。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ui/ # shadcn/ui 组件(Base UI 基础组件)
|
||||||
|
├── lib/ # 工具函数(cn() 函数用于 className 合并)
|
||||||
|
├── modules/ # 应用模块(query client、basename)
|
||||||
|
├── pages/ # 页面组件(默认导出)
|
||||||
|
├── routes/ # TanStack Router 基于文件的路由
|
||||||
|
├── styles/ # 全局样式、主题 CSS
|
||||||
|
└── main.tsx # 应用入口
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 代码风格指南
|
||||||
|
|
||||||
|
### 模块目录结构
|
||||||
|
|
||||||
|
每个新模块(如 `page-app`)应遵循以下结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
pages/page-app/
|
||||||
|
├── components/ # 模块专属组件
|
||||||
|
├── hooks/ # 模块 React Query hooks(API 查询封装)
|
||||||
|
├── modules/ # 模块功能函数(UI 组件、工具函数等)
|
||||||
|
└── store/ # 模块状态管理(Zustand)
|
||||||
|
```
|
||||||
|
|
||||||
|
### hooks/ 文件夹说明
|
||||||
|
|
||||||
|
每个模块的 `hooks/` 文件夹用于封装与该模块相关的 React Query hooks:
|
||||||
|
|
||||||
|
- **use-api-query.ts**: 使用 `@tanstack/react-query` 的 `useQuery` 封装 API 调用
|
||||||
|
- 定义 `queryKeys` 常量用于缓存标识
|
||||||
|
- 封装 `useQuery` hooks 用于数据获取(GET 请求)
|
||||||
|
- 封装 `useMutation` hooks 用于数据修改(POST/PUT/DELETE 请求)
|
||||||
|
- 支持预取(prefetch)和无限滚动(infinite query)
|
||||||
|
- **index.ts**: 导出模块所有 hooks,便于统一导入使用
|
||||||
|
|
||||||
|
### 状态和数据获取
|
||||||
|
|
||||||
|
- **@tanstack/react-query** 用于数据获取、缓存和状态管理
|
||||||
|
- 在模块的 `hooks/` 文件夹中封装 API 调用
|
||||||
|
- QueryClient 实例位于 `src/modules/query.ts`
|
||||||
|
- 在 `src/routes/__root.tsx` 中通过 `QueryClientProvider` 提供
|
||||||
|
- **Zustand** 用于全局状态管理
|
||||||
|
- **@kevisual/query** 用于底层 API 请求封装
|
||||||
|
- **React Hook Form** 用于表单管理
|
||||||
|
|
||||||
|
## 核心依赖
|
||||||
|
|
||||||
|
- **@base-ui/react**: Headless UI 基础组件
|
||||||
|
- **@tanstack/react-query**: 数据获取、缓存和状态管理(配合 hooks/ 使用)
|
||||||
|
- **@tanstack/react-router**: 基于 TanStack Router 插件的文件路由
|
||||||
|
- **class-variance-authority**: 基于变体的样式系统
|
||||||
|
- **clsx + tailwind-merge**: 通过 `cn()` 提供 className 工具函数
|
||||||
|
- **lucide-react**: 图标库
|
||||||
|
- **react-hook-form**: 表单处理
|
||||||
|
- **sonner**: Toast 通知
|
||||||
|
- **zustand**: 状态管理
|
||||||
|
- **tailwindcss v4**: 使用 @tailwindcss/vite 插件进行样式处理
|
||||||
|
|
||||||
|
## 主题系统
|
||||||
|
|
||||||
|
- **主题配色**: 采用黑白配色方案,提供简洁优雅的视觉体验
|
||||||
|
- **主题模式**: 支持 light(浅色)和 dark(深色)模式切换
|
||||||
|
- **主题实现**: 使用 `next-themes` 进行主题管理
|
||||||
|
- **CSS 变量**: 主题相关的 CSS 变量定义在 `src/styles/theme.css` 中
|
||||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# vite-react-template
|
||||||
|
|
||||||
|
## download template
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ev sync clone -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template
|
||||||
|
```
|
||||||
|
|
||||||
|
## clone auth update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ev sync clone -l -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## code-graph
|
||||||
|
|
||||||
|
使用graphology分析代码依赖关系,生成关系。
|
||||||
|
|
||||||
|
使用sigmajs可视化关系图。
|
||||||
|
|
||||||
20
components.json
Normal file
20
components.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
index.html
Normal file
32
index.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/jpg" href="https://kevisual.xiongxiao.me/root/center/panda.jpg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Light Code</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
21
kevisual.json
Normal file
21
kevisual.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "kevisual",
|
||||||
|
"share": "public"
|
||||||
|
},
|
||||||
|
"registry": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template",
|
||||||
|
"clone": {
|
||||||
|
".": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syncd": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"registry": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sync": {}
|
||||||
|
}
|
||||||
68
package.json
Normal file
68
package.json
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "vite-react",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"basename": "/root/vite-react",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"ui": "bunx shadcn@latest add ",
|
||||||
|
"pub": "envision deploy ./dist -k vite-react -v 0.0.1 -y y -u"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.2.0",
|
||||||
|
"@kevisual/api": "^0.0.64",
|
||||||
|
"@kevisual/context": "^0.0.8",
|
||||||
|
"@kevisual/router": "0.1.1",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@tanstack/react-router": "^1.166.7",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"convex": "^1.32.0",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
|
"es-toolkit": "^1.45.1",
|
||||||
|
"fuse.js": "^7.1.0",
|
||||||
|
"graphology": "^0.26.0",
|
||||||
|
"graphology-types": "^0.24.8",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-hook-form": "^7.71.2",
|
||||||
|
"sigma": "^3.0.2",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@kevisual/ai": "0.0.28",
|
||||||
|
"@kevisual/kv-login": "^0.1.17",
|
||||||
|
"@kevisual/query": "0.0.53",
|
||||||
|
"@kevisual/types": "^0.0.12",
|
||||||
|
"@kevisual/vite-html-plugin": "^0.0.1",
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@tanstack/react-router-devtools": "^1.166.7",
|
||||||
|
"@tanstack/router-plugin": "^1.166.7",
|
||||||
|
"@types/node": "^25.4.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "v8.0.0-beta.18"
|
||||||
|
}
|
||||||
|
}
|
||||||
3320
pnpm-lock.yaml
generated
Normal file
3320
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
public/auth.json
Normal file
44
public/auth.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"name": "kevisual",
|
||||||
|
"share": "public"
|
||||||
|
},
|
||||||
|
"registry": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template",
|
||||||
|
"clone": {
|
||||||
|
".": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"syncd": [
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
|
"registry": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"auth": "ev sync clone -l -i https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json"
|
||||||
|
},
|
||||||
|
"sync": {
|
||||||
|
"AGENTS.md": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/AGENTS.md",
|
||||||
|
"vite.config.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/vite.config.ts",
|
||||||
|
"src/main.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/main.tsx",
|
||||||
|
"public/auth.json": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/public/auth.json",
|
||||||
|
"src/agents/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/agents/index.ts",
|
||||||
|
"src/modules/basename.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/modules/basename.ts",
|
||||||
|
"src/modules/query.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/modules/query.ts",
|
||||||
|
"src/routes/demo.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/demo.tsx",
|
||||||
|
"src/routes/index.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/index.tsx",
|
||||||
|
"src/routes/login.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/routes/login.tsx",
|
||||||
|
"src/styles/theme.css": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/styles/theme.css",
|
||||||
|
"src/pages/auth/index.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/index.tsx",
|
||||||
|
"src/pages/auth/page.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/page.tsx",
|
||||||
|
"src/pages/auth/store.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/store.ts",
|
||||||
|
"src/pages/demo/page.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/demo/page.tsx",
|
||||||
|
"src/pages/auth/hooks/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/hooks/index.ts",
|
||||||
|
"src/pages/auth/hooks/use-api-query.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/hooks/use-api-query.ts",
|
||||||
|
"src/pages/auth/modules/BaseHeader.tsx": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/auth/modules/BaseHeader.tsx",
|
||||||
|
"src/pages/demo/store/index.ts": "https://kevisual.cn/root/ai/kevisual/frontend/vite-react-template/src/pages/demo/store/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
public/demo.html
Normal file
39
public/demo.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="https://kevisual.xiongxiao.me/root/center/panda.jpg" />
|
||||||
|
<title>Demo</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" crossorigin href="./render.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { render } from './render.js';
|
||||||
|
console.log('render', render);
|
||||||
|
const opts = {
|
||||||
|
renderRoot: document.getElementById('root'),
|
||||||
|
}
|
||||||
|
render(opts);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
3
src/agents/app.ts
Normal file
3
src/agents/app.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { QueryRouterServer } from "@kevisual/router/browser"
|
||||||
|
import { useContextKey } from '@kevisual/context'
|
||||||
|
export const app = useContextKey('app', new QueryRouterServer())
|
||||||
1
src/agents/index.ts
Normal file
1
src/agents/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './app.ts'
|
||||||
48
src/components/ui/badge.tsx
Normal file
48
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive: "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20",
|
||||||
|
outline: "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost: "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ className, variant })),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
125
src/components/ui/breadcrumb.tsx
Normal file
125
src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground gap-1.5 text-sm flex flex-wrap items-center wrap-break-word",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("gap-1 inline-flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"a">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "a",
|
||||||
|
props: mergeProps<"a">(
|
||||||
|
{
|
||||||
|
className: cn("hover:text-foreground transition-colors", className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "breadcrumb-link",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("text-foreground font-normal", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<ChevronRightIcon />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"size-5 [&>svg]:size-4 flex items-center justify-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
51
src/components/ui/button.tsx
Normal file
51
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||||
|
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
94
src/components/ui/card.tsx
Normal file
94
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn("ring-foreground/10 bg-card text-card-foreground gap-4 overflow-hidden rounded-xl py-4 text-sm ring-1 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("bg-muted/50 rounded-b-xl border-t p-4 group-data-[size=sm]/card:p-3 flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
29
src/components/ui/checkbox.tsx
Normal file
29
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<CheckboxPrimitive.Root
|
||||||
|
data-slot="checkbox"
|
||||||
|
className={cn(
|
||||||
|
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<CheckboxPrimitive.Indicator
|
||||||
|
data-slot="checkbox-indicator"
|
||||||
|
className="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</CheckboxPrimitive.Indicator>
|
||||||
|
</CheckboxPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Checkbox }
|
||||||
188
src/components/ui/command.tsx
Normal file
188
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground rounded-xl! p-1 flex size-full flex-col overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl! top-1/3 translate-y-0 overflow-hidden p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||||
|
<InputGroup className="bg-input/30 border-input/30 h-8! rounded-lg! shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-72 scroll-py-1 outline-none overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn("text-foreground **:[[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-selected:bg-muted data-selected:text-foreground data-selected:*:[svg]:text-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! [&_svg:not([class*='size-'])]:size-4 group/command-item data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn("text-muted-foreground group-data-selected/command-item:text-foreground ml-auto text-xs tracking-widest", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
149
src/components/ui/dialog.tsx
Normal file
149
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-4 rounded-xl p-4 text-sm ring-1 duration-100 sm:max-w-sm fixed top-1/2 left-1/2 z-50 w-full -translate-x-1/2 -translate-y-1/2 outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("gap-2 flex flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted/50 -mx-4 -mb-4 rounded-b-xl border-t p-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-base leading-none font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
260
src/components/ui/dropdown-menu.tsx
Normal file
260
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn("text-muted-foreground px-1.5 py-1 text-xs font-medium data-inset:pl-7", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-lg p-1 shadow-lg ring-1 duration-100 w-auto", className)}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
149
src/components/ui/input-group.tsx
Normal file
149
src/components/ui/input-group.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"border-input dark:bg-input/30 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-disabled:bg-input/50 dark:has-disabled:bg-input/80 h-8 rounded-lg border transition-colors in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5 group/input-group relative flex w-full min-w-0 items-center outline-none has-[>textarea]:h-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"text-muted-foreground h-auto gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4 flex cursor-text items-center justify-center select-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start": "pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem] order-first",
|
||||||
|
"inline-end": "pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem] order-last",
|
||||||
|
"block-start":
|
||||||
|
"px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2 order-first w-full justify-start",
|
||||||
|
"block-end":
|
||||||
|
"px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2 order-last w-full justify-start",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"gap-2 text-sm shadow-none flex items-center",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: "",
|
||||||
|
"icon-xs": "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants> & {
|
||||||
|
type?: "button" | "submit" | "reset"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 flex items-center [&_svg]:pointer-events-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn("rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn("rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent flex-1 resize-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 h-8 rounded-lg border bg-transparent px-2.5 py-1 text-base transition-colors file:h-6 file:text-sm file:font-medium focus-visible:ring-3 aria-invalid:ring-3 md:text-sm file:text-foreground placeholder:text-muted-foreground w-full min-w-0 outline-none file:inline-flex file:border-0 file:bg-transparent disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
26
src/components/ui/kbd.tsx
Normal file
26
src/components/ui/kbd.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd"
|
||||||
|
className={cn(
|
||||||
|
"bg-muted text-muted-foreground in-data-[slot=tooltip-content]:bg-background/20 in-data-[slot=tooltip-content]:text-background dark:in-data-[slot=tooltip-content]:bg-background/10 h-5 w-fit min-w-5 gap-1 rounded-sm px-1 font-sans text-xs font-medium [&_svg:not([class*='size-'])]:size-3 pointer-events-none inline-flex items-center justify-center select-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<kbd
|
||||||
|
data-slot="kbd-group"
|
||||||
|
className={cn("gap-1 inline-flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Kbd, KbdGroup }
|
||||||
18
src/components/ui/label.tsx
Normal file
18
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"gap-2 text-sm leading-none font-medium group-data-[disabled=true]:opacity-50 peer-disabled:opacity-50 flex items-center select-none group-data-[disabled=true]:pointer-events-none peer-disabled:cursor-not-allowed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
265
src/components/ui/menubar.tsx
Normal file
265
src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<MenubarPrimitive
|
||||||
|
data-slot="menubar"
|
||||||
|
className={cn("bg-background h-8 gap-0.5 rounded-lg border p-[3px] flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
|
||||||
|
return <DropdownMenu data-slot="menubar-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuGroup>) {
|
||||||
|
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuPortal>) {
|
||||||
|
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
data-slot="menubar-trigger"
|
||||||
|
className={cn(
|
||||||
|
"hover:bg-muted aria-expanded:bg-muted rounded-sm px-1.5 py-[2px] text-sm font-medium flex items-center outline-hidden select-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarContent({
|
||||||
|
className,
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -4,
|
||||||
|
sideOffset = 8,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="menubar-content"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn("bg-popover text-popover-foreground data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuItem>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-slot="menubar-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn("focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-disabled:opacity-50 data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 group/menubar-item", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="menubar-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm data-inset:pl-7 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="left-1.5 size-4 [&_svg:not([class*='size-'])]:size-4 pointer-events-none absolute flex items-center justify-center">
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
|
||||||
|
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="menubar-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm data-disabled:opacity-50 data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="left-1.5 size-4 [&_svg:not([class*='size-'])]:size-4 pointer-events-none absolute flex items-center justify-center">
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuLabel> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuLabel
|
||||||
|
data-slot="menubar-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn("px-1.5 py-1 text-sm font-medium data-inset:pl-7", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSeparator
|
||||||
|
data-slot="menubar-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuShortcut
|
||||||
|
data-slot="menubar-shortcut"
|
||||||
|
className={cn("text-muted-foreground group-focus/menubar-item:text-accent-foreground text-xs tracking-widest ml-auto", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSub({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSub>) {
|
||||||
|
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSubTrigger
|
||||||
|
data-slot="menubar-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn("focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground gap-1.5 rounded-md px-1.5 py-1 text-sm data-inset:pl-7 [&_svg:not([class*='size-'])]:size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenubarSubContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuSubContent
|
||||||
|
data-slot="menubar-sub-content"
|
||||||
|
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-32 rounded-lg p-1 shadow-lg ring-1 duration-100", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Menubar,
|
||||||
|
MenubarPortal,
|
||||||
|
MenubarMenu,
|
||||||
|
MenubarTrigger,
|
||||||
|
MenubarContent,
|
||||||
|
MenubarGroup,
|
||||||
|
MenubarSeparator,
|
||||||
|
MenubarLabel,
|
||||||
|
MenubarItem,
|
||||||
|
MenubarShortcut,
|
||||||
|
MenubarCheckboxItem,
|
||||||
|
MenubarRadioGroup,
|
||||||
|
MenubarRadioItem,
|
||||||
|
MenubarSub,
|
||||||
|
MenubarSubTrigger,
|
||||||
|
MenubarSubContent,
|
||||||
|
}
|
||||||
88
src/components/ui/popover.tsx
Normal file
88
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
PopoverPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<PopoverPrimitive.Popup
|
||||||
|
data-slot="popover-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 w-72 origin-(--transform-origin) outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Positioner>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Title
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Description
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDescription,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
}
|
||||||
191
src/components/ui/select.tsx
Normal file
191
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-placeholder:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 gap-1.5 rounded-lg border bg-transparent py-2 pr-2 pl-2.5 text-sm transition-colors select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:gap-1.5 [&_svg:not([class*='size-'])]:size-4 flex w-fit items-center justify-between whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="text-muted-foreground size-4 pointer-events-none" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 min-w-36 rounded-lg shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 relative isolate z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto data-[align-trigger=true]:animate-none", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-1.5 py-1 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 relative flex w-full cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 gap-2 shrink-0 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border -mx-1 my-1 h-px pointer-events-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 top-0 w-full", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn("bg-popover z-10 flex cursor-default items-center justify-center py-1 [&_svg:not([class*='size-'])]:size-4 bottom-0 w-full", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
129
src/components/ui/sheet.tsx
Normal file
129
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Backdrop
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Popup.Props & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Popup
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn("bg-background data-open:animate-in data-closed:animate-out data-[side=right]:data-closed:slide-out-to-right-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=top]:data-closed:slide-out-to-top-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:fade-out-0 data-open:fade-in-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=bottom]:data-open:slide-in-from-bottom-10 fixed z-50 flex flex-col gap-4 bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Popup>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("gap-0.5 p-4 flex flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("gap-2 p-4 mt-auto flex flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground text-base font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
49
src/components/ui/sonner.tsx
Normal file
49
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes"
|
||||||
|
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||||
|
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
icons={{
|
||||||
|
success: (
|
||||||
|
<CircleCheckIcon className="size-4" />
|
||||||
|
),
|
||||||
|
info: (
|
||||||
|
<InfoIcon className="size-4" />
|
||||||
|
),
|
||||||
|
warning: (
|
||||||
|
<TriangleAlertIcon className="size-4" />
|
||||||
|
),
|
||||||
|
error: (
|
||||||
|
<OctagonXIcon className="size-4" />
|
||||||
|
),
|
||||||
|
loading: (
|
||||||
|
<Loader2Icon className="size-4 animate-spin" />
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
"--border-radius": "var(--radius)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "cn-toast",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Toaster }
|
||||||
80
src/components/ui/tabs.tsx
Normal file
80
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"gap-2 group/tabs flex data-horizontal:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"rounded-lg p-[3px] group-data-horizontal/tabs:h-8 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Tab
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg:not([class*='size-'])]:size-4 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center whitespace-nowrap transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
|
"data-active:bg-background dark:data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 data-active:text-foreground",
|
||||||
|
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Panel
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("text-sm flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"border-input dark:bg-input/30 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 disabled:bg-input/50 dark:disabled:bg-input/80 rounded-lg border bg-transparent px-2.5 py-2 text-base transition-colors focus-visible:ring-3 aria-invalid:ring-3 md:text-sm placeholder:text-muted-foreground flex field-sizing-content min-h-16 w-full outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
64
src/components/ui/tooltip.tsx
Normal file
64
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "top",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
className={cn(
|
||||||
|
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 rounded-md px-3 py-1.5 text-xs data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 bg-foreground text-background z-50 w-fit max-w-xs origin-(--transform-origin)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 bg-foreground fill-foreground z-50 data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
126
src/index.css
Normal file
126
src/index.css
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "./styles/theme.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
27
src/main.tsx
Normal file
27
src/main.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||||
|
import { routeTree } from './routeTree.gen'
|
||||||
|
import './index.css'
|
||||||
|
import { getDynamicBasename } from './modules/basename'
|
||||||
|
import './agents/index.ts';
|
||||||
|
// Set up a Router instance
|
||||||
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
basepath: getDynamicBasename(),
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
scrollRestoration: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Register things for typesafety
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root')!
|
||||||
|
|
||||||
|
if (!rootElement.innerHTML) {
|
||||||
|
const root = ReactDOM.createRoot(rootElement)
|
||||||
|
root.render(<RouterProvider router={router} />)
|
||||||
|
}
|
||||||
32
src/modules/basename.ts
Normal file
32
src/modules/basename.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
export const basename = BASE_NAME;
|
||||||
|
|
||||||
|
export const wrapBasename = (path: string) => {
|
||||||
|
const hasEnd = path.endsWith('/')
|
||||||
|
if (basename) {
|
||||||
|
return `${basename}${path}` + (hasEnd ? '' : '/');
|
||||||
|
} else {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态计算 basename,根据当前 URL 路径
|
||||||
|
export const getDynamicBasename = (): string => {
|
||||||
|
const path = window.location.pathname
|
||||||
|
const [user, key, id] = path.split('/').filter(Boolean)
|
||||||
|
if (key === 'v1' && id) {
|
||||||
|
return `/${user}/v1/${id}`
|
||||||
|
}
|
||||||
|
// 默认使用构建时的 basename
|
||||||
|
return basename
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openLink = (path: string, target: string = '_self') => {
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
window.open(path, target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL(path, window.location.origin);
|
||||||
|
url.pathname = wrapBasename(url.pathname);
|
||||||
|
window.open(url.toString(), target);
|
||||||
|
}
|
||||||
404
src/modules/project-api.ts
Normal file
404
src/modules/project-api.ts
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import { createQueryApi } from '@kevisual/query/api';
|
||||||
|
import { query } from '@/modules/query.ts';
|
||||||
|
const api = {
|
||||||
|
"project": {
|
||||||
|
/**
|
||||||
|
* 注册并启动项目监控:开始监听项目目录的文件变更,并将所有文件全量同步到 Meilisearch 搜索索引
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.filepath - {string} 项目根目录的绝对路径,必填
|
||||||
|
* @param data.repo - {string} 代码仓库标识,用于搜索结果展示和过滤,格式如 owner/repo,例如 kevisual/cnb,选填(默认自动从 git 配置读取)
|
||||||
|
* @param data.name - {string} 项目显示名称,用于搜索结果展示,选填(默认使用目录名)
|
||||||
|
*/
|
||||||
|
"add": {
|
||||||
|
"path": "project",
|
||||||
|
"key": "add",
|
||||||
|
"description": "注册并启动项目监控:开始监听项目目录的文件变更,并将所有文件全量同步到 Meilisearch 搜索索引",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"filepath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"description": "项目根目录的绝对路径,必填"
|
||||||
|
},
|
||||||
|
"repo": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "代码仓库标识,用于搜索结果展示和过滤,格式如 owner/repo,例如 kevisual/cnb,选填(默认自动从 git 配置读取)",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "项目显示名称,用于搜索结果展示,选填(默认使用目录名)",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/root/v1/cnb-dev",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 移除项目:停止文件变更监听,并清除该项目在 Meilisearch 索引中的所有数据
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.filepath - {string (minLength: 1)} 要移除的项目根目录绝对路径,必填
|
||||||
|
*/
|
||||||
|
"remove": {
|
||||||
|
"path": "project",
|
||||||
|
"key": "remove",
|
||||||
|
"description": "移除项目:停止文件变更监听,并清除该项目在 Meilisearch 索引中的所有数据",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"filepath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "要移除的项目根目录绝对路径,必填"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/root/v1/cnb-dev",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 暂停项目的文件变更监听,暂停后文件修改将不再实时同步;可通过 add 接口重新启动监听
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.filepath - {string (minLength: 1)} 要暂停监听的项目根目录绝对路径,必填
|
||||||
|
*/
|
||||||
|
"stop": {
|
||||||
|
"path": "project",
|
||||||
|
"key": "stop",
|
||||||
|
"description": "暂停项目的文件变更监听,暂停后文件修改将不再实时同步;可通过 add 接口重新启动监听",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"filepath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "要暂停监听的项目根目录绝对路径,必填"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/root/v1/cnb-dev",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 获取指定项目的详细信息,包括路径、仓库名称、项目名称及当前监听状态等
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.filepath - {string (minLength: 1)} 要查询的项目根目录绝对路径,必填
|
||||||
|
*/
|
||||||
|
"get": {
|
||||||
|
"path": "project",
|
||||||
|
"key": "get",
|
||||||
|
"description": "获取指定项目的详细信息,包括路径、仓库名称、项目名称及当前监听状态等",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"filepath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "要查询的项目根目录绝对路径,必填"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/root/v1/cnb-dev",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 列出所有已注册的项目及其当前运行状态(路径、仓库名称、监听是否活跃等)
|
||||||
|
*/
|
||||||
|
"list": {
|
||||||
|
"path": "project",
|
||||||
|
"key": "list",
|
||||||
|
"description": "列出所有已注册的项目及其当前运行状态(路径、仓库名称、监听是否活跃等)",
|
||||||
|
"metadata": {
|
||||||
|
"url": "/root/v1/cnb-dev",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 更新项目的自定义描述信息(name / title / tags / summary / description / link),用于丰富项目元数据以便更好地展示和检索
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.filepath - {string (minLength: 1)} 要更新的项目根目录绝对路径,必填
|
||||||
|
* @param data.name - {string} 项目显示名称,选填
|
||||||
|
* @param data.title - {string} 项目标题,用于展示,选填
|
||||||
|
* @param data.tags - {array} 项目标签列表,用于分类过滤,选填
|
||||||
|
* @param data.summary - {string} 项目摘要,简短描述项目用途,选填
|
||||||
|
* @param data.description - {string} 项目详细描述,选填
|
||||||
|
* @param data.link - {string} 项目关联的外部链接(如官网、文档等),选填
|
||||||
|
*/
|
||||||
|
"update": {
|
||||||
|
"path": "project",
|
||||||
|
"key": "update",
|
||||||
|
"description": "更新项目的自定义描述信息(name / title / tags / summary / description / link),用于丰富项目元数据以便更好地展示和检索",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"filepath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "要更新的项目根目录绝对路径,必填"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "项目显示名称,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "项目标题,用于展示,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "项目标签列表,用于分类过滤,选填",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "项目摘要,简短描述项目用途,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "项目详细描述,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "项目关联的外部链接(如官网、文档等),选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/root/v1/cnb-dev",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"project-search": {
|
||||||
|
/**
|
||||||
|
* 在已索引的项目文件中执行全文搜索,支持按仓库、目录、标签等字段过滤,以及自定义排序和数量限制
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.q - {string} 搜索关键词,选填;留空或不传则返回全部文件
|
||||||
|
* @param data.projectPath - {string} 按项目根目录路径过滤,仅返回该项目下的文件,选填
|
||||||
|
* @param data.repo - {string} 按代码仓库标识过滤(如 owner/repo),选填
|
||||||
|
* @param data.title - {string} 按人工标注的标题字段过滤,选填
|
||||||
|
* @param data.tags - {array} 按人工标注的标签列表过滤,选填
|
||||||
|
* @param data.summary - {string} 按人工标注的摘要字段过滤,选填
|
||||||
|
* @param data.description - {string} 按人工标注的描述字段过滤,选填
|
||||||
|
* @param data.link - {string} 按人工标注的外部链接字段过滤,选填
|
||||||
|
* @param data.sort - {array} 排序规则数组,格式为 ["字段:asc"] 或 ["字段:desc"],选填,当 q 为空时默认为 ["projectPath:asc"]
|
||||||
|
* @param data.limit - {number} 返回结果数量上限,选填,当 q 为空时默认为 1000
|
||||||
|
* @param data.getContent - {boolean} 是否返回文件内容,默认为 false;如果为 true,则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景
|
||||||
|
*/
|
||||||
|
"files": {
|
||||||
|
"path": "project-search",
|
||||||
|
"key": "files",
|
||||||
|
"description": "在已索引的项目文件中执行全文搜索,支持按仓库、目录、标签等字段过滤,以及自定义排序和数量限制",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"q": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "搜索关键词,选填;留空或不传则返回全部文件",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"projectPath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "按项目根目录路径过滤,仅返回该项目下的文件,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"repo": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "按代码仓库标识过滤(如 owner/repo),选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "按人工标注的标题字段过滤,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "按人工标注的标签列表过滤,选填",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "按人工标注的摘要字段过滤,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "按人工标注的描述字段过滤,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "按人工标注的外部链接字段过滤,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sort": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "排序规则数组,格式为 [\"字段:asc\"] 或 [\"字段:desc\"],选填,当 q 为空时默认为 [\"projectPath:asc\"]",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"limit": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "返回结果数量上限,选填,当 q 为空时默认为 1000",
|
||||||
|
"type": "number",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"getContent": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "是否返回文件内容,默认为 false;如果为 true,则在结果中包含 content 字段,内容以 base64 编码返回,适用于前端预览或下载场景",
|
||||||
|
"type": "boolean",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/root/v1/cnb-dev",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"project-file": {
|
||||||
|
/**
|
||||||
|
* 读取指定路径的项目文件内容,以 base64 格式返回,适用于前端预览或下载场景
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.filepath - {string (minLength: 1)} 要读取的文件绝对路径,必填
|
||||||
|
*/
|
||||||
|
"get": {
|
||||||
|
"path": "project-file",
|
||||||
|
"key": "get",
|
||||||
|
"description": "读取指定路径的项目文件内容,以 base64 格式返回,适用于前端预览或下载场景",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"filepath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "要读取的文件绝对路径,必填"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/root/v1/cnb-dev",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 更新指定文件的自定义描述信息(title / tags / summary / description / link),用于人工标注和补充文件元数据
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.filepath - {string (minLength: 1)} 要更新的文件绝对路径,必填
|
||||||
|
* @param data.title - {string} 文件标题,用于展示和搜索,选填
|
||||||
|
* @param data.tags - {array} 文件标签列表,用于分类过滤,选填
|
||||||
|
* @param data.summary - {string} 文件摘要,简短描述文件内容,选填
|
||||||
|
* @param data.description - {string} 文件详细描述,选填
|
||||||
|
* @param data.link - {string} 与文件关联的外部链接(如文档、Issue 等),选填
|
||||||
|
*/
|
||||||
|
"update": {
|
||||||
|
"path": "project-file",
|
||||||
|
"key": "update",
|
||||||
|
"description": "更新指定文件的自定义描述信息(title / tags / summary / description / link),用于人工标注和补充文件元数据",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"filepath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "要更新的文件绝对路径,必填"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "文件标题,用于展示和搜索,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "文件标签列表,用于分类过滤,选填",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "文件摘要,简短描述文件内容,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "文件详细描述,选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"description": "与文件关联的外部链接(如文档、Issue 等),选填",
|
||||||
|
"type": "string",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/root/v1/cnb-dev",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 从搜索索引中删除指定路径的文件记录,文件本身不受影响
|
||||||
|
*
|
||||||
|
* @param data - Request parameters
|
||||||
|
* @param data.filepath - {string (minLength: 1)} 要删除的文件绝对路径,必填
|
||||||
|
*/
|
||||||
|
"delete": {
|
||||||
|
"path": "project-file",
|
||||||
|
"key": "delete",
|
||||||
|
"description": "从搜索索引中删除指定路径的文件记录,文件本身不受影响",
|
||||||
|
"metadata": {
|
||||||
|
"args": {
|
||||||
|
"filepath": {
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "要删除的文件绝对路径,必填"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"url": "/root/v1/cnb-dev",
|
||||||
|
"source": "query-proxy-api"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
const queryApi = createQueryApi({ api, query });
|
||||||
|
|
||||||
|
export { queryApi };
|
||||||
18
src/modules/query.ts
Normal file
18
src/modules/query.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { Query, DataOpts } from '@kevisual/query';
|
||||||
|
import { QueryLoginBrowser } from '@kevisual/api/query-login'
|
||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
import { QueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export const query = useContextKey('query', new Query({
|
||||||
|
url: '/api/router',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const queryClient = useContextKey('queryClient', new Query({
|
||||||
|
url: '/client/router',
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const queryLogin = useContextKey('queryLogin', new QueryLoginBrowser({
|
||||||
|
query: query
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const stackQueryClient = useContextKey('stackQueryClient', new QueryClient());
|
||||||
1
src/pages/auth/hooks/index.ts
Normal file
1
src/pages/auth/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './use-api-query';
|
||||||
55
src/pages/auth/hooks/use-api-query.ts
Normal file
55
src/pages/auth/hooks/use-api-query.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { queryLogin } from '@/modules/query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { UserInfo } from '../store';
|
||||||
|
|
||||||
|
export const authQueryKeys = {
|
||||||
|
me: ['auth', 'me'] as const,
|
||||||
|
token: ['auth', 'token'] as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const useMe = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: authQueryKeys.me,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await queryLogin.getMe();
|
||||||
|
if (res.code === 200) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
throw new Error(res.message || 'Failed to fetch user info');
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSwitchOrg = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (username?: string) => {
|
||||||
|
const res = await queryLogin.switchUser(username || '');
|
||||||
|
if (res.code === 200) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
throw new Error(res.message || 'Switch failed');
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('切换成功');
|
||||||
|
queryClient.invalidateQueries({ queryKey: authQueryKeys.me });
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error(error.message || '请求失败');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetToken = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: authQueryKeys.token,
|
||||||
|
queryFn: () => queryLogin.getToken(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
};
|
||||||
57
src/pages/auth/index.tsx
Normal file
57
src/pages/auth/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
import { useLayoutStore } from "./store"
|
||||||
|
import { useShallow } from "zustand/shallow"
|
||||||
|
import { LogIn, LockKeyhole } from "lucide-react"
|
||||||
|
export { BaseHeader } from './modules/BaseHeader'
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children?: React.ReactNode,
|
||||||
|
mustLogin?: boolean,
|
||||||
|
}
|
||||||
|
export const AuthProvider = ({ children, mustLogin }: Props) => {
|
||||||
|
const store = useLayoutStore(useShallow(state => ({
|
||||||
|
init: state.init,
|
||||||
|
me: state.me,
|
||||||
|
openLinkList: state.openLinkList,
|
||||||
|
})));
|
||||||
|
useEffect(() => {
|
||||||
|
store.init()
|
||||||
|
}, [])
|
||||||
|
const location = useLocation()
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isOpen = useMemo(() => {
|
||||||
|
return store.openLinkList.some(item => location.pathname.startsWith(item))
|
||||||
|
}, [location.pathname])
|
||||||
|
const loginUrl = '/root/login/?redirect=' + encodeURIComponent(window.location.href);
|
||||||
|
if (mustLogin && !store.me && !isOpen) {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full min-h-screen flex items-center justify-center bg-background">
|
||||||
|
<div className="flex flex-col items-center gap-6 p-10 rounded-2xl border border-border bg-card shadow-lg max-w-sm w-full mx-4">
|
||||||
|
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-muted">
|
||||||
|
<LockKeyhole className="w-8 h-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-foreground">需要登录</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">请先登录以继续访问此页面</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center gap-2 w-full px-6 py-2.5 rounded-lg bg-foreground text-background text-sm font-medium transition-opacity hover:opacity-80 active:opacity-70"
|
||||||
|
onClick={() => {
|
||||||
|
// window.open(loginUrl, '_blank');
|
||||||
|
navigate({ to: '/login' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
立即登录
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
}
|
||||||
92
src/pages/auth/modules/BaseHeader.tsx
Normal file
92
src/pages/auth/modules/BaseHeader.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { Home, User, LogIn, LogOut } from 'lucide-react';
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useLayoutStore } from '../store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export const BaseHeader = (props: { main?: React.ComponentType | null }) => {
|
||||||
|
const store = useLayoutStore(useShallow(state => ({
|
||||||
|
me: state.me,
|
||||||
|
clearMe: state.clearMe,
|
||||||
|
links: state.links,
|
||||||
|
showBaseHeader: state.showBaseHeader,
|
||||||
|
})));
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const meInfo = useMemo(() => {
|
||||||
|
if (!store.me) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate({ to: '/login' })}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogIn className="w-4 h-4" />
|
||||||
|
<span>去登录</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{store.me.avatar && (
|
||||||
|
<img
|
||||||
|
src={store.me.avatar}
|
||||||
|
alt="Avatar"
|
||||||
|
className="w-8 h-8 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!store.me.avatar && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
|
||||||
|
<User className="w-4 h-4 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="font-medium text-gray-700">{store.me?.username}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => store.clearMe?.()}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-sm text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors cursor-pointer"
|
||||||
|
title="退出登录"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}, [store.me, store.clearMe])
|
||||||
|
if (!store.showBaseHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2 text-lg w-full h-12 items-center justify-between bg-gray-200">
|
||||||
|
<div className='px-2 flex items-center gap-1'>
|
||||||
|
{
|
||||||
|
store.links.map(link => (
|
||||||
|
<div key={link.key || link.title}
|
||||||
|
className="cursor-pointer flex items-center justify-center gap-1 p-2 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
if (!link.href) return;
|
||||||
|
if (link.href.startsWith('http') || link.isRoot) {
|
||||||
|
window.open(link.href, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate({
|
||||||
|
to: link.href
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{link.key === 'home' && <Home className="w-4 h-4" />}
|
||||||
|
{link.icon && <>{link.icon}</>}
|
||||||
|
{!link.icon && link.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div className='mr-4'>
|
||||||
|
{meInfo}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LayoutMain = () => {
|
||||||
|
return <BaseHeader />
|
||||||
|
}
|
||||||
81
src/pages/auth/page.tsx
Normal file
81
src/pages/auth/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
import '@kevisual/kv-login';
|
||||||
|
import { checkPluginLogin } from '@kevisual/kv-login'
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLayoutStore } from './store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const LoginComponent = ({ onLoginSuccess }: { onLoginSuccess: () => void }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
// 监听登录成功事件
|
||||||
|
const handleLoginSuccess = () => {
|
||||||
|
console.log('监听到登录成功事件,关闭弹窗');
|
||||||
|
onLoginSuccess();
|
||||||
|
};
|
||||||
|
const loginEmitter = useContextKey('login-emitter')
|
||||||
|
console.log('KvLogin Types:', loginEmitter);
|
||||||
|
|
||||||
|
loginEmitter.on('login-success', handleLoginSuccess);
|
||||||
|
|
||||||
|
// 清理监听器
|
||||||
|
return () => {
|
||||||
|
loginEmitter.off('login-success', handleLoginSuccess);
|
||||||
|
};
|
||||||
|
}, [onLoginSuccess]);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
return (<kv-login></kv-login>)
|
||||||
|
}
|
||||||
|
export const App = () => {
|
||||||
|
const store = useLayoutStore(useShallow((state) => ({
|
||||||
|
init: state.init,
|
||||||
|
loginPageConfig: state.loginPageConfig,
|
||||||
|
})));
|
||||||
|
useEffect(() => {
|
||||||
|
checkPluginLogin();
|
||||||
|
}, []);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handleLoginSuccess = async () => {
|
||||||
|
await store.init()
|
||||||
|
navigate({ to: '/' })
|
||||||
|
};
|
||||||
|
const { title, subtitle, footer } = store.loginPageConfig;
|
||||||
|
return (
|
||||||
|
<div className='w-full h-full relative overflow-hidden bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900'>
|
||||||
|
{/* 背景装饰 - 圆形光晕 */}
|
||||||
|
<div className='absolute top-1/4 -left-32 w-96 h-96 bg-purple-500/30 rounded-full blur-3xl'></div>
|
||||||
|
<div className='absolute bottom-1/4 -right-32 w-96 h-96 bg-blue-500/30 rounded-full blur-3xl'></div>
|
||||||
|
<div className='absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-indigo-500/20 rounded-full blur-3xl'></div>
|
||||||
|
|
||||||
|
{/* 背景装饰 - 网格图案 */}
|
||||||
|
<div className='absolute inset-0 opacity-[0.03] bg-[linear-gradient(rgba(255,255,255,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.1)_1px,transparent_1px)] bg-[size:50px_50px]'></div>
|
||||||
|
|
||||||
|
{/* 顶部装饰文字 */}
|
||||||
|
<div className='absolute top-10 left-0 right-0 text-center'>
|
||||||
|
<h1 className='text-4xl font-bold text-white/90 tracking-wider'>{title}</h1>
|
||||||
|
<p className='mt-2 text-white/50 text-sm tracking-widest'>{subtitle}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 登录卡片容器 */}
|
||||||
|
<div className='w-full h-full flex items-center justify-center p-8'>
|
||||||
|
<div className='relative'>
|
||||||
|
{/* 卡片外圈光效 */}
|
||||||
|
<div className='absolute -inset-1 bg-gradient-to-r from-purple-500 via-blue-500 to-indigo-500 rounded-2xl blur opacity-30'></div>
|
||||||
|
|
||||||
|
{/* 登录组件容器 */}
|
||||||
|
<div className='relative bg-slate-900/80 backdrop-blur-xl rounded-2xl border border-white/10 shadow-2xl overflow-hidden'>
|
||||||
|
<LoginComponent onLoginSuccess={handleLoginSuccess} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部装饰 */}
|
||||||
|
<div className='absolute bottom-6 left-0 right-0 text-center'>
|
||||||
|
<p className='text-white/30 text-xs'>{footer}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
134
src/pages/auth/store.ts
Normal file
134
src/pages/auth/store.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
|
||||||
|
import { queryLogin, stackQueryClient } from '@/modules/query';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { authQueryKeys } from './hooks';
|
||||||
|
export type UserInfo = {
|
||||||
|
id?: string;
|
||||||
|
username?: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
needChangePassword?: boolean;
|
||||||
|
description?: string | null;
|
||||||
|
type?: 'user' | 'org';
|
||||||
|
orgs?: string[];
|
||||||
|
avatar?: string;
|
||||||
|
};
|
||||||
|
export type LayoutStore = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
openUser: boolean;
|
||||||
|
setOpenUser: (openUser: boolean) => void;
|
||||||
|
me?: UserInfo;
|
||||||
|
setMe: (me: UserInfo) => void;
|
||||||
|
clearMe: () => void;
|
||||||
|
getMe: () => Promise<void>;
|
||||||
|
switchOrg: (username?: string) => Promise<void>;
|
||||||
|
isAdmin: boolean;
|
||||||
|
setIsAdmin: (isAdmin: boolean) => void
|
||||||
|
init: () => Promise<void>;
|
||||||
|
openLinkList: string[];
|
||||||
|
setOpenLinkList: (openLinkList: string[]) => void;
|
||||||
|
loginPageConfig: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
footer: string;
|
||||||
|
};
|
||||||
|
setLoginPageConfig: (config: Partial<LayoutStore['loginPageConfig']>) => void;
|
||||||
|
links: HeaderLink[];
|
||||||
|
setLinks: (links: HeaderLink[]) => void;
|
||||||
|
showBaseHeader: boolean;
|
||||||
|
setShowBaseHeader: (showBaseHeader: boolean) => void;
|
||||||
|
serverData: Record<string, any> | null;
|
||||||
|
setServerData: (data: Record<string, any>) => void;
|
||||||
|
initConvex: () => Promise<void>;
|
||||||
|
};
|
||||||
|
type HeaderLink = {
|
||||||
|
title?: string;
|
||||||
|
href: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
key?: string;
|
||||||
|
isRoot?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLayoutStore = create<LayoutStore>((set, get) => ({
|
||||||
|
open: false,
|
||||||
|
setOpen: (open) => set({ open }),
|
||||||
|
openUser: false,
|
||||||
|
setOpenUser: (openUser) => set({ openUser }),
|
||||||
|
me: undefined,
|
||||||
|
setMe: (me) => set({ me }),
|
||||||
|
clearMe: () => {
|
||||||
|
set({ me: undefined, isAdmin: false });
|
||||||
|
},
|
||||||
|
getMe: async () => {
|
||||||
|
const data = await stackQueryClient.fetchQuery({
|
||||||
|
queryKey: authQueryKeys.me,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await queryLogin.getMe();
|
||||||
|
if (res.code === 200) {
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
throw new Error(res.message || 'Failed to fetch user info');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
set({ me: data, isAdmin: data?.orgs?.includes?.('admin') || false });
|
||||||
|
},
|
||||||
|
switchOrg: async (username?: string) => {
|
||||||
|
const res = await queryLogin.switchUser(username || '');
|
||||||
|
if (res.code === 200) {
|
||||||
|
toast.success('切换成功');
|
||||||
|
stackQueryClient.invalidateQueries({ queryKey: authQueryKeys.me });
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isAdmin: false,
|
||||||
|
setIsAdmin: (isAdmin) => set({ isAdmin }),
|
||||||
|
init: async () => {
|
||||||
|
await queryLogin.init();
|
||||||
|
const token = await queryLogin.checkLocalToken();
|
||||||
|
if (token) {
|
||||||
|
set({ me: {} });
|
||||||
|
try {
|
||||||
|
// const data = await stackQueryClient.fetchQuery({
|
||||||
|
// queryKey: authQueryKeys.me,
|
||||||
|
// }) as UserInfo;
|
||||||
|
const userInfo = await queryLogin.checkLocalUser();
|
||||||
|
if (userInfo) {
|
||||||
|
set({ me: userInfo as UserInfo, isAdmin: userInfo.orgs?.includes?.('admin') || false });
|
||||||
|
} else {
|
||||||
|
set({ me: undefined, isAdmin: false });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
set({ me: undefined, isAdmin: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 获取服务端数据
|
||||||
|
// @ts-ignore
|
||||||
|
const sererData = window.__SERVER_DATA__;
|
||||||
|
if (sererData) {
|
||||||
|
set({ serverData: sererData });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
initConvex: async () => { },
|
||||||
|
openLinkList: ['/login'],
|
||||||
|
setOpenLinkList: (openLinkList) => set({ openLinkList }),
|
||||||
|
loginPageConfig: {
|
||||||
|
title: '可视化管理平台',
|
||||||
|
subtitle: '让工具和智能化触手可及',
|
||||||
|
footer: '欢迎使用可视化管理平台 · 连接您的工具',
|
||||||
|
},
|
||||||
|
setLoginPageConfig: (config) => set((state) => ({
|
||||||
|
loginPageConfig: { ...state.loginPageConfig, ...config },
|
||||||
|
})),
|
||||||
|
links: [{ title: '', href: '/', key: 'home' }],
|
||||||
|
setLinks: (links) => set({ links }),
|
||||||
|
showBaseHeader: true,
|
||||||
|
setShowBaseHeader: (showBaseHeader) => set({ showBaseHeader }),
|
||||||
|
serverData: null,
|
||||||
|
setServerData: (data) => set({ serverData: data }),
|
||||||
|
}));
|
||||||
35
src/pages/code-graph/modules/tree.ts
Normal file
35
src/pages/code-graph/modules/tree.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export type FileProjectData = {
|
||||||
|
/**
|
||||||
|
* meilisearch 文档 ID(文件绝对路径的 base64url 编码,无碰撞且唯一)
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* 文件路径的 md5 hash,用于快速索引/查找
|
||||||
|
*/
|
||||||
|
hash: string;
|
||||||
|
/**
|
||||||
|
* 文件对应相关信息
|
||||||
|
*
|
||||||
|
* 具体文件路径
|
||||||
|
* /workspace/projects/project-search/src/index.ts
|
||||||
|
*/
|
||||||
|
filepath: string;
|
||||||
|
content?: string;
|
||||||
|
lastModified: number;
|
||||||
|
size: number;
|
||||||
|
/**
|
||||||
|
* 项目路径,文件所在的项目路径,方便搜索结果展示和过滤
|
||||||
|
* /workspace/projects/project-search
|
||||||
|
*/
|
||||||
|
projectPath: string;
|
||||||
|
repo: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人工编辑的字段,方便搜索结果展示和过滤
|
||||||
|
*/
|
||||||
|
title?: string;
|
||||||
|
tags?: string[];
|
||||||
|
summary?: string;
|
||||||
|
description?: string;
|
||||||
|
link?: string;
|
||||||
|
};
|
||||||
0
src/pages/code-graph/page.tsx
Normal file
0
src/pages/code-graph/page.tsx
Normal file
8
src/pages/demo/page.tsx
Normal file
8
src/pages/demo/page.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { useDemoStore } from './store/index'
|
||||||
|
export const App = () => {
|
||||||
|
const demoStore = useDemoStore()
|
||||||
|
console.log('demo', demoStore.formData)
|
||||||
|
return <div>App</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
95
src/pages/demo/store/index.ts
Normal file
95
src/pages/demo/store/index.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { query } from '@/modules/query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface Data {
|
||||||
|
id: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
formData: Record<string, any>;
|
||||||
|
setFormData: (data: Record<string, any>) => void;
|
||||||
|
showEdit: boolean;
|
||||||
|
setShowEdit: (showEdit: boolean) => void;
|
||||||
|
loading: boolean;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
list: Data[];
|
||||||
|
getItem: (id: string) => Promise<any>;
|
||||||
|
getList: () => Promise<any>;
|
||||||
|
updateData: (data: Data) => Promise<void>;
|
||||||
|
deleteData: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDemoStore = create<State>((set, get) => {
|
||||||
|
return {
|
||||||
|
formData: {},
|
||||||
|
setFormData: (data) => set({ formData: data }),
|
||||||
|
showEdit: false,
|
||||||
|
setShowEdit: (showEdit) => set({ showEdit }),
|
||||||
|
loading: false,
|
||||||
|
setLoading: (loading) => set({ loading }),
|
||||||
|
list: [],
|
||||||
|
getItem: async (id) => {
|
||||||
|
const { setLoading } = get();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'demo',
|
||||||
|
key: 'item',
|
||||||
|
data: { id }
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
return res;
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getList: async () => {
|
||||||
|
const { setLoading } = get();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'demo',
|
||||||
|
key: 'list'
|
||||||
|
});
|
||||||
|
if (res.code === 200) {
|
||||||
|
const list = res.data?.list || []
|
||||||
|
set({ list });
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateData: async (data) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'demo',
|
||||||
|
key: 'update',
|
||||||
|
data
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
get().getList()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteData: async (id) => {
|
||||||
|
const res = await query.post({
|
||||||
|
path: 'demo',
|
||||||
|
key: 'delete',
|
||||||
|
data: { id }
|
||||||
|
})
|
||||||
|
if (res.code === 200) {
|
||||||
|
get().getList()
|
||||||
|
} else {
|
||||||
|
toast.error(res.message || '请求失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
5
src/pages/page.tsx
Normal file
5
src/pages/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
const Home = () => {
|
||||||
|
return <div>Home Page</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Home;
|
||||||
95
src/routeTree.gen.ts
Normal file
95
src/routeTree.gen.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as DemoRouteImport } from './routes/demo'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DemoRoute = DemoRouteImport.update({
|
||||||
|
id: '/demo',
|
||||||
|
path: '/demo',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/demo': typeof DemoRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/demo': typeof DemoRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/demo': typeof DemoRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths: '/' | '/demo' | '/login'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to: '/' | '/demo' | '/login'
|
||||||
|
id: '__root__' | '/' | '/demo' | '/login'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
DemoRoute: typeof DemoRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/demo': {
|
||||||
|
id: '/demo'
|
||||||
|
path: '/demo'
|
||||||
|
fullPath: '/demo'
|
||||||
|
preLoaderRoute: typeof DemoRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
DemoRoute: DemoRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
40
src/routes/__root.tsx
Normal file
40
src/routes/__root.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { LayoutMain } from '@/pages/auth/modules/BaseHeader';
|
||||||
|
import { Outlet, createRootRoute } from '@tanstack/react-router'
|
||||||
|
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
import { AuthProvider } from '@/pages/auth'
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
|
import { useLayoutStore } from '@/pages/auth/store';
|
||||||
|
import { useShallow } from 'zustand/shallow';
|
||||||
|
import { stackQueryClient } from '@/modules/query'
|
||||||
|
import { QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import clsx from 'clsx';
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
const store = useLayoutStore(useShallow(state => ({
|
||||||
|
showBaseHeader: state.showBaseHeader,
|
||||||
|
})));
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={stackQueryClient}>
|
||||||
|
<div className='h-full overflow-hidden'>
|
||||||
|
<LayoutMain />
|
||||||
|
<AuthProvider mustLogin={true}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<main className={clsx('overflow-auto scrollbar', {
|
||||||
|
'h-[calc(100%-3rem)]': store.showBaseHeader,
|
||||||
|
'h-full': !store.showBaseHeader,
|
||||||
|
})}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</TooltipProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
<TanStackRouterDevtools position="bottom-right" />
|
||||||
|
<Toaster />
|
||||||
|
</div>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
src/routes/demo.tsx
Normal file
9
src/routes/demo.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import App from '@/pages/demo/page'
|
||||||
|
export const Route = createFileRoute('/demo')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
9
src/routes/index.tsx
Normal file
9
src/routes/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import App from '@/pages/page'
|
||||||
|
export const Route = createFileRoute('/')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
9
src/routes/login.tsx
Normal file
9
src/routes/login.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import App from '@/pages/auth/page'
|
||||||
|
export const Route = createFileRoute('/login')({
|
||||||
|
component: RouteComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
98
src/styles/theme.css
Normal file
98
src/styles/theme.css
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* --color-primary: #ffc107;
|
||||||
|
--color-secondary: #ffa000;
|
||||||
|
--color-text-primary: #000000;
|
||||||
|
--color-text-secondary: #000000;
|
||||||
|
--color-success: #28a745; */
|
||||||
|
|
||||||
|
--color-scrollbar-thumb: #999999;
|
||||||
|
--color-scrollbar-track: rgba(0, 0, 0, 0.1);
|
||||||
|
--color-scrollbar-thumb-hover: #666666;
|
||||||
|
--scrollbar-color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* font-family */
|
||||||
|
@utility font-family-mon {
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility font-family-rob {
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility font-family-int {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility font-family-orb {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility font-family-din {
|
||||||
|
font-family: 'DIN', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility flex-row-center {
|
||||||
|
@apply flex flex-row items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility flex-col-center {
|
||||||
|
@apply flex flex-col items-center justify-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility scrollbar {
|
||||||
|
overflow: auto;
|
||||||
|
|
||||||
|
/* 整个滚动条 */
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background-color: var(--color-scrollbar-track);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条有滑块的轨道部分 */
|
||||||
|
&::-webkit-scrollbar-track-piece {
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条滑块(竖向:vertical 横向:horizontal) */
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--color-scrollbar-thumb);
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条滑块hover */
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--color-scrollbar-thumb-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 同时有垂直和水平滚动条时交汇的部分 */
|
||||||
|
&::-webkit-scrollbar-corner {
|
||||||
|
display: block;
|
||||||
|
/* 修复交汇时出现的白块 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
menu {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
20
src/vite-env.d.ts
vendored
Normal file
20
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
type SimpleObject = {
|
||||||
|
[key: string | number]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare let BASE_NAME: string;
|
||||||
|
interface ViteTypeOptions {
|
||||||
|
// 添加这行代码,你就可以将 ImportMetaEnv 的类型设为严格模式,
|
||||||
|
// 这样就不允许有未知的键值了。
|
||||||
|
// strictImportMetaEnv: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_APP_TITLE: string;
|
||||||
|
// 更多环境变量...
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "@kevisual/types/json/frontend.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "./",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
]
|
||||||
|
}
|
||||||
47
vite.config.ts
Normal file
47
vite.config.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import path from 'path';
|
||||||
|
import pkgs from './package.json';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { tanstackRouter } from '@tanstack/router-plugin/vite'
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
const basename = isDev ? '/' : pkgs?.basename || '/';
|
||||||
|
|
||||||
|
let target = process.env.VITE_API_URL || 'http://localhost:51515';
|
||||||
|
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
|
||||||
|
let proxy = {
|
||||||
|
'/root/': apiProxy,
|
||||||
|
'/api': apiProxy,
|
||||||
|
'/client': apiProxy,
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @see https://vitejs.dev/config/
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
// Please make sure that '@tanstack/router-plugin' is passed before '@vitejs/plugin-react'
|
||||||
|
tanstackRouter({
|
||||||
|
target: 'react',
|
||||||
|
autoCodeSplitting: true,
|
||||||
|
}),
|
||||||
|
react(),
|
||||||
|
tailwindcss()
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: basename,
|
||||||
|
define: {
|
||||||
|
BASE_NAME: JSON.stringify(basename),
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 7008,
|
||||||
|
host: '0.0.0.0',
|
||||||
|
allowedHosts: true,
|
||||||
|
proxy,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user