generated from kevisual/vite-react-template
Compare commits
12 Commits
300da513c1
...
1884e87421
| Author | SHA1 | Date | |
|---|---|---|---|
| 1884e87421 | |||
| 3d66eee666 | |||
| f876a65c6b | |||
| 36edf12fd0 | |||
| 1f5aa69aed | |||
| b90020cde0 | |||
| a2629fec7b | |||
| 0ced574b8b | |||
| 4a9bbf1911 | |||
| f117302a98 | |||
| abd9860a90 | |||
| 234dabcfb4 |
2
.cnb.yml
2
.cnb.yml
@@ -4,7 +4,7 @@ include:
|
||||
|
||||
.common_env: &common_env
|
||||
env:
|
||||
TO_REPO: template/vite-react-template
|
||||
TO_REPO: kevisual/cnb-center
|
||||
TO_URL: git.xiongxiao.me
|
||||
imports:
|
||||
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
|
||||
|
||||
48
AGENTS.md
Normal file
48
AGENTS.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 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/ # 模块专属组件
|
||||
├── store/ # 模块状态管理
|
||||
└── module/ # 模块功能函数
|
||||
```
|
||||
|
||||
### 状态和数据获取
|
||||
|
||||
- **Zustand** 用于全局状态管理
|
||||
- **@kevisual/query** 用于数据获取(QueryClient 实例位于 `src/modules/query.ts`)
|
||||
- **React Hook Form** 用于表单管理
|
||||
|
||||
## 核心依赖
|
||||
|
||||
- **@base-ui/react**: Headless UI 基础组件
|
||||
- **@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 插件进行样式处理
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="https://kevisual.xiongxiao.me/root/center/panda.png" />
|
||||
<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>Vite + React + TS</title>
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
<title>CNB Center</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
|
||||
46
package.json
46
package.json
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "vite-react",
|
||||
"name": "@kevisual/cnb-center",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.2",
|
||||
"type": "module",
|
||||
"basename": "/",
|
||||
"basename": "/root/cnb-center",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"ui": "pnpm dlx shadcn@latest add ",
|
||||
"pub": "envision deploy ./dist -k vite-react -v 0.0.1 -y y -u"
|
||||
"pub": "envision deploy ./dist -k cnb-center -v 0.0.2 -y y -u"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
@@ -17,39 +17,51 @@
|
||||
"author": "abearxiong <xiongxiao@xiongxiao.me>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.44",
|
||||
"@ai-sdk/openai": "^3.0.29",
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@kevisual/cnb": "^0.0.26",
|
||||
"@kevisual/cnb-ai": "^0.0.2",
|
||||
"@kevisual/context": "^0.0.6",
|
||||
"@kevisual/router": "0.0.70",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-router": "^1.158.0",
|
||||
"@tanstack/react-router": "^1.160.0",
|
||||
"ai": "^6.0.86",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"es-toolkit": "^1.44.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"sonner": "^2.0.7",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/query": "0.0.39",
|
||||
"@kevisual/query": "0.0.40",
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-router-devtools": "^1.158.0",
|
||||
"@tanstack/router-plugin": "^1.158.0",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/react": "^19.2.10",
|
||||
"@tanstack/react-router-devtools": "^1.160.0",
|
||||
"@tanstack/router-plugin": "^1.160.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"dotenv": "^17.3.1",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^8.0.0-beta.13"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2"
|
||||
"packageManager": "pnpm@10.29.3"
|
||||
}
|
||||
1425
pnpm-lock.yaml
generated
1425
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
30
src/agents/app.ts
Normal file
30
src/agents/app.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { QueryRouterServer } from '@kevisual/router/browser'
|
||||
|
||||
import { useContextKey } from '@kevisual/context'
|
||||
import { useConfigStore } from '@/app/config/store'
|
||||
import { CNB } from '@kevisual/cnb'
|
||||
export const app = useContextKey('app', new QueryRouterServer())
|
||||
|
||||
export const cnb: CNB = useContextKey('cnb', () => {
|
||||
const state = useConfigStore.getState()
|
||||
const config = state.config || {}
|
||||
const cors: any = {}
|
||||
if (config.ENABLE_CORS) {
|
||||
cors.baseUrl = config.CNB_CORS_URL || 'https://cors.kevisual.cn'
|
||||
}
|
||||
console.log('state', state)
|
||||
// if(state.config.)
|
||||
return new CNB({
|
||||
token: config.CNB_API_KEY,
|
||||
cookie: config.CNB_COOKIE,
|
||||
cors
|
||||
})
|
||||
})
|
||||
//
|
||||
|
||||
// import '@kevisual/cnb-ai'
|
||||
|
||||
const url = 'https://kevisual.cn/root/cnb-ai/dist/app.js'
|
||||
setTimeout(() => {
|
||||
import(/* @vite-ignore */url)
|
||||
}, 2000)
|
||||
43
src/app/ai/page.tsx
Normal file
43
src/app/ai/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { CNB, Issue } from '@kevisual/cnb'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { useConfigStore } from '../config/store'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { generateText } from 'ai';
|
||||
const init2 = async () => {
|
||||
const cnb = new CNB({
|
||||
token: 'cIDfLOOIr1Trt15cdnwfndupEZG',
|
||||
cookie: 'CNBSESSION=1770014410.1935321989751226368.7f386c282d80efb5256180ef94c2865e20a8be72e2927a5f8eb1eb72142de39f;csrfkey=2028873452',
|
||||
cors: {
|
||||
baseUrl: 'https://cors.kevisual.cn'
|
||||
}
|
||||
})
|
||||
// const res = await cnb.issue.getList('kevisual/kevisual')
|
||||
// console.log('res', res)
|
||||
const token = await cnb.user.getCurrentUser()
|
||||
console.log('token', token)
|
||||
}
|
||||
|
||||
const initAi = async () => {
|
||||
const state = useConfigStore.getState()
|
||||
const config = state.config
|
||||
const cors = state.config.CNB_CORS_URL
|
||||
const base = cors + '/' + config.AI_BASE_URL.replace('https://', '')
|
||||
const cnb = createOpenAICompatible({
|
||||
baseURL: base,
|
||||
name: 'custom-cnb',
|
||||
apiKey: config.AI_API_KEY,
|
||||
});
|
||||
const model = config.AI_MODEL;
|
||||
// const model = 'hunyuan';
|
||||
const { text } = await generateText({
|
||||
model: cnb(model),
|
||||
prompt: '你好',
|
||||
});
|
||||
console.log('text', text)
|
||||
}
|
||||
export const Home = () => {
|
||||
useLayoutEffect(() => { initAi() }, [])
|
||||
return <div>Home Page</div>
|
||||
}
|
||||
|
||||
export default Home;
|
||||
131
src/app/config/page.tsx
Normal file
131
src/app/config/page.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useConfigStore } from './store';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { configSchema } from './store/schema';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const ConfigPage = () => {
|
||||
const { config, setConfig, resetConfig } = useConfigStore();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const result = configSchema.safeParse(config);
|
||||
if (result.success) {
|
||||
toast.success('配置已保存')
|
||||
setTimeout(() => {
|
||||
location.reload()
|
||||
}, 400)
|
||||
} else {
|
||||
console.error('验证错误:', result.error.format());
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof typeof config, value: string | boolean) => {
|
||||
setConfig({ [field]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto max-w-2xl py-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>CNB 配置</CardTitle>
|
||||
<CardDescription>
|
||||
配置您的 CNB API 设置。这些设置会保存在浏览器的本地存储中。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="api-key">API 密钥</Label>
|
||||
<Input
|
||||
id="api-key"
|
||||
type="text"
|
||||
value={config.CNB_API_KEY}
|
||||
onChange={(e) => handleChange('CNB_API_KEY', e.target.value)}
|
||||
placeholder="请输入您的 CNB API 密钥"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cookie">Cookie</Label>
|
||||
<Input
|
||||
id="cookie"
|
||||
type="text"
|
||||
value={config.CNB_COOKIE}
|
||||
onChange={(e) => handleChange('CNB_COOKIE', e.target.value)}
|
||||
placeholder="请输入您的 CNB Cookie"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cors-url">跨域地址</Label>
|
||||
<Input
|
||||
id="cors-url"
|
||||
type="url"
|
||||
value={config.CNB_CORS_URL}
|
||||
onChange={(e) => handleChange('CNB_CORS_URL', e.target.value)}
|
||||
placeholder="https://cors.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-cors"
|
||||
checked={config.ENABLE_CORS}
|
||||
onCheckedChange={(checked) => handleChange('ENABLE_CORS', checked === true)}
|
||||
/>
|
||||
<Label htmlFor="enable-cors" className="cursor-pointer">
|
||||
启用跨域
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-base-url">AI 基础地址</Label>
|
||||
<Input
|
||||
id="ai-base-url"
|
||||
type="url"
|
||||
value={config.AI_BASE_URL}
|
||||
onChange={(e) => handleChange('AI_BASE_URL', e.target.value)}
|
||||
placeholder="请输入 AI 基础地址"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-model">AI 模型</Label>
|
||||
<Input
|
||||
id="ai-model"
|
||||
type="text"
|
||||
value={config.AI_MODEL}
|
||||
onChange={(e) => handleChange('AI_MODEL', e.target.value)}
|
||||
placeholder="请输入 AI 模型名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ai-api-key">AI 密钥</Label>
|
||||
<Input
|
||||
id="ai-api-key"
|
||||
type="password"
|
||||
value={config.AI_API_KEY}
|
||||
onChange={(e) => handleChange('AI_API_KEY', e.target.value)}
|
||||
placeholder="请输入您的 AI API 密钥"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit">保存配置</Button>
|
||||
<Button type="button" variant="outline" onClick={resetConfig}>
|
||||
重置为默认值
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigPage;
|
||||
51
src/app/config/store/index.ts
Normal file
51
src/app/config/store/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import type { Config, defaultConfig } from './schema';
|
||||
|
||||
type ConfigState = {
|
||||
config: Config;
|
||||
setConfig: (config: Partial<Config>) => void;
|
||||
resetConfig: () => void;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'cnb-config';
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
CNB_API_KEY: '',
|
||||
CNB_COOKIE: '',
|
||||
CNB_CORS_URL: 'https://cors.kevisual.cn',
|
||||
ENABLE_CORS: true,
|
||||
AI_BASE_URL: 'https://api.cnb.cool/kevisual/cnb-ai/-/ai/',
|
||||
AI_MODEL: 'CNB-Models',
|
||||
AI_API_KEY: ''
|
||||
}
|
||||
const loadInitialConfig = (): Config => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return DEFAULT_CONFIG;
|
||||
};
|
||||
|
||||
export const useConfigStore = create<ConfigState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
config: loadInitialConfig(),
|
||||
setConfig: (newConfig) =>
|
||||
set((state) => ({
|
||||
config: { ...state.config, ...newConfig },
|
||||
})),
|
||||
resetConfig: () =>
|
||||
set({
|
||||
config: DEFAULT_CONFIG,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: STORAGE_KEY,
|
||||
}
|
||||
)
|
||||
);
|
||||
23
src/app/config/store/schema.ts
Normal file
23
src/app/config/store/schema.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const configSchema = z.object({
|
||||
CNB_API_KEY: z.string().min(1, 'API Key is required'),
|
||||
CNB_COOKIE: z.string().min(1, 'Cookie is required'),
|
||||
CNB_CORS_URL: z.url('Must be a valid URL'),
|
||||
ENABLE_CORS: z.boolean(),
|
||||
AI_BASE_URL: z.url('Must be a valid URL'),
|
||||
AI_MODEL: z.string().min(1, 'AI Model is required'),
|
||||
AI_API_KEY: z.string().min(1, 'AI API Key is required'),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
|
||||
export const defaultConfig: Config = {
|
||||
CNB_API_KEY: '',
|
||||
CNB_COOKIE: '',
|
||||
CNB_CORS_URL: 'https://cors.kevisual.cn',
|
||||
ENABLE_CORS: true,
|
||||
AI_BASE_URL: '',
|
||||
AI_MODEL: '',
|
||||
AI_API_KEY: ''
|
||||
};
|
||||
3
src/app/page.tsx
Normal file
3
src/app/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import App from './repo/page'
|
||||
|
||||
export default App;
|
||||
250
src/app/repo/components/RepoCard.tsx
Normal file
250
src/app/repo/components/RepoCard.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Star, GitFork, FileText, Edit, FolderGit2, MoreVertical, FileText as IssueIcon, Settings, Play, Trash2, RefreshCw, BookOpen } from 'lucide-react'
|
||||
import { useRepoStore } from '../store'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { myOrgs } from '../store/build'
|
||||
import { app, cnb } from '@/agents/app'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface RepoCardProps {
|
||||
repo: any
|
||||
onStartWorkspace: (repo: any) => void
|
||||
onEdit: (repo: any) => void
|
||||
onIssue: (repo: any) => void
|
||||
onSettings: (repo: any) => void
|
||||
onDelete: (repo: any) => void
|
||||
onSync?: (repo: any) => void
|
||||
}
|
||||
|
||||
export function RepoCard({ repo, onStartWorkspace, onEdit, onIssue, onSettings, onDelete, onSync }: RepoCardProps) {
|
||||
const [deletePopoverOpen, setDeletePopoverOpen] = useState(false)
|
||||
const { workspaceList, getWorkspaceDetail, getList } = useRepoStore();
|
||||
const workspace = useMemo(() => {
|
||||
return workspaceList.find(ws => ws.slug === repo.path)
|
||||
}, [workspaceList, repo.path])
|
||||
const isWorkspaceActive = !!workspace
|
||||
const owner = repo.path.split('/')[0]
|
||||
const isMine = myOrgs.includes(owner)
|
||||
|
||||
const isKnowledge = repo?.flags === "KnowledgeBase"
|
||||
const createKnow = async () => {
|
||||
const res = await app.run({ path: 'cnb', key: 'build-knowledge-base', payload: { repo: repo.path } })
|
||||
if (res.code === 200) {
|
||||
toast.success("知识库创建中")
|
||||
getList(true)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Card className="relative p-0 overflow-hidden border border-neutral-200 bg-white hover:shadow-xl hover:border-neutral-300 transition-all duration-300 group pb-14">
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{isKnowledge && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div className="shrink-0">
|
||||
<BookOpen className="w-5 h-5 text-neutral-700" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>知识库</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<a
|
||||
href={repo.web_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-lg font-bold text-neutral-900 hover:text-neutral-600 transition-colors line-clamp-1 group-hover:underline"
|
||||
>
|
||||
{repo.path}
|
||||
</a>
|
||||
{isWorkspaceActive && (
|
||||
<span className="relative flex h-2.5 w-2.5 shrink-0">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onStartWorkspace(repo)}
|
||||
className="h-8 w-8 p-0 border-neutral-200 hover:border-neutral-900 hover:bg-neutral-900 hover:text-white transition-all cursor-pointer"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
<p>启动工作区</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0 border-neutral-200 hover:border-neutral-900 hover:bg-neutral-900 hover:text-white transition-all cursor-pointer"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onClick={() => onEdit(repo)} className="cursor-pointer">
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onIssue(repo)} className="cursor-pointer">
|
||||
<IssueIcon className="w-4 h-4 mr-2" />
|
||||
Issue
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onSettings(repo)} className="cursor-pointer">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
设置
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => {
|
||||
createKnow()
|
||||
}} className="cursor-pointer">
|
||||
<BookOpen className="w-4 h-4 mr-2" />
|
||||
知识库创建
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setDeletePopoverOpen(true)
|
||||
}}
|
||||
className="cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Popover open={deletePopoverOpen} onOpenChange={setDeletePopoverOpen}>
|
||||
<PopoverTrigger >
|
||||
<div style={{ display: 'none' }} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-sm">确认删除</h4>
|
||||
<p className="text-sm text-neutral-500">
|
||||
确定要删除仓库 <span className="font-semibold text-neutral-900">{repo.path}</span> 吗?此操作无法撤销。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDeletePopoverOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-red-600 text-white border-red-600 hover:bg-red-700 hover:border-red-700"
|
||||
onClick={() => {
|
||||
onDelete(repo)
|
||||
setDeletePopoverOpen(false)
|
||||
}}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{repo.topics && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{repo.topics.split(',').map((topic: string, idx: number) => (
|
||||
<Badge key={idx} variant="outline" className="text-xs border-neutral-300 text-neutral-700 hover:bg-neutral-100 transition-colors">
|
||||
{topic.trim()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{repo.site && (
|
||||
<a
|
||||
href={repo.site}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-900 hover:underline block truncate transition-colors"
|
||||
>
|
||||
🔗 {repo.site}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{repo.description && (
|
||||
<p className="text-sm text-neutral-600 line-clamp-2 min-h-[2.5rem]">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 flex items-center gap-4 text-xs text-neutral-500 px-6 py-3 border-t border-neutral-100 bg-neutral-50">
|
||||
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
|
||||
<Star className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">{repo.star_count}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
|
||||
<GitFork className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">{repo.fork_count}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors">
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">{repo.open_issue_count}</span>
|
||||
</span>
|
||||
{isWorkspaceActive && <span className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
getWorkspaceDetail(workspace)
|
||||
}}>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">运行中</span>
|
||||
</span>}
|
||||
{isMine && (
|
||||
<span
|
||||
className="flex items-center gap-1.5 hover:text-neutral-900 transition-colors cursor-pointer"
|
||||
onClick={() => onSync?.(repo)}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<span className="font-medium">同步</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
128
src/app/repo/modules/CreateRepoDialog.tsx
Normal file
128
src/app/repo/modules/CreateRepoDialog.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useRepoStore } from '../store'
|
||||
|
||||
interface CreateRepoDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
path: string
|
||||
license: string
|
||||
description: string
|
||||
visibility: string
|
||||
}
|
||||
|
||||
export function CreateRepoDialog({ open, onOpenChange }: CreateRepoDialogProps) {
|
||||
const { createRepo, getList } = useRepoStore()
|
||||
const { register, handleSubmit, reset } = useForm<FormData>()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// 重置表单
|
||||
reset({
|
||||
path: '',
|
||||
license: '',
|
||||
description: '',
|
||||
visibility: 'public'
|
||||
})
|
||||
}
|
||||
}, [open, reset])
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const submitData = {
|
||||
...data,
|
||||
}
|
||||
|
||||
const res = await createRepo(submitData)
|
||||
if (res?.code === 200) {
|
||||
onOpenChange(false)
|
||||
await getList(true)
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建仓库</DialogTitle>
|
||||
<DialogDescription>
|
||||
填写仓库信息以创建新的代码仓库
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="path">仓库路径 *</Label>
|
||||
<Input
|
||||
id="path"
|
||||
placeholder="例如: username/repository"
|
||||
{...register('path', { required: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="简短描述你的仓库..."
|
||||
rows={3}
|
||||
{...register('description')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="visibility">可见性</Label>
|
||||
<Input
|
||||
id="visibility"
|
||||
placeholder="public 或 private"
|
||||
{...register('visibility')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="topics">主题标签</Label>
|
||||
<Input
|
||||
id="license"
|
||||
placeholder="例如: MIT, Apache-2.0"
|
||||
{...register('license')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? '创建中...' : '创建仓库'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
140
src/app/repo/modules/EditRepoDialog.tsx
Normal file
140
src/app/repo/modules/EditRepoDialog.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { TagsInput } from '@/components/tags-input'
|
||||
import { useRepoStore } from '../store'
|
||||
|
||||
interface EditRepoDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
repo: {
|
||||
id: string
|
||||
path: string
|
||||
description: string
|
||||
site: string
|
||||
topics: string
|
||||
license: string
|
||||
} | null
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
description: string
|
||||
site: string
|
||||
topics: string
|
||||
license: string
|
||||
}
|
||||
|
||||
export function EditRepoDialog({ open, onOpenChange, repo }: EditRepoDialogProps) {
|
||||
const { updateRepoInfo, getList } = useRepoStore()
|
||||
const { register, handleSubmit, reset, setValue } = useForm<FormData>()
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (repo) {
|
||||
const topicsArray = repo.topics ? repo.topics.split(',').map(t => t.trim()).filter(Boolean) : []
|
||||
setTags(topicsArray)
|
||||
reset({
|
||||
description: repo.description || '',
|
||||
site: repo.site || '',
|
||||
topics: repo.topics || '',
|
||||
license: repo.license || ''
|
||||
})
|
||||
}
|
||||
}, [repo, reset])
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
if (!repo) return
|
||||
|
||||
await updateRepoInfo({
|
||||
path: repo.path,
|
||||
description: data.description?.trim() || '',
|
||||
site: data.site?.trim() || '',
|
||||
topics: tags.join(','),
|
||||
license: data.license?.trim() || '',
|
||||
})
|
||||
|
||||
await getList(true)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
if (!repo) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl!">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑仓库信息</DialogTitle>
|
||||
<DialogDescription>{repo.path}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
{...register('description')}
|
||||
placeholder="输入仓库描述"
|
||||
className="w-full min-h-[100px]"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="site">网站</Label>
|
||||
<Input
|
||||
id="site"
|
||||
{...register('site')}
|
||||
placeholder="https://example.com"
|
||||
type="url"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="topics">标签</Label>
|
||||
<TagsInput
|
||||
value={tags}
|
||||
onChange={setTags}
|
||||
placeholder="输入标签后按 Enter 或逗号添加"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">按 Enter 或逗号添加标签,点击 × 删除</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="license">许可证</Label>
|
||||
<Input
|
||||
id="license"
|
||||
{...register('license')}
|
||||
placeholder="MIT, Apache-2.0, GPL-3.0 等"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
85
src/app/repo/modules/SyncRepoDialog.tsx
Normal file
85
src/app/repo/modules/SyncRepoDialog.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useRepoStore } from '../store'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { get, set } from 'idb-keyval'
|
||||
|
||||
const SYNC_REPO_STORAGE_KEY = 'sync-repo-mapping'
|
||||
|
||||
export function SyncRepoDialog() {
|
||||
const { syncDialogOpen, setSyncDialogOpen, selectedSyncRepo, buildSync } = useRepoStore()
|
||||
const [toRepo, setToRepo] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const loadSavedMapping = async () => {
|
||||
if (syncDialogOpen && selectedSyncRepo) {
|
||||
const currentPath = selectedSyncRepo.path || ''
|
||||
// 从 idb-keyval 获取存储的映射
|
||||
const mapping = await get<Record<string, string>>(SYNC_REPO_STORAGE_KEY)
|
||||
// 如果有存储的值,使用存储的值,否则使用当前仓库路径
|
||||
setToRepo(mapping?.[currentPath] || currentPath)
|
||||
}
|
||||
}
|
||||
loadSavedMapping()
|
||||
}, [syncDialogOpen, selectedSyncRepo])
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!selectedSyncRepo || !toRepo.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
// 保存映射到 idb-keyval
|
||||
const currentPath = selectedSyncRepo.path || ''
|
||||
const mapping = await get<Record<string, string>>(SYNC_REPO_STORAGE_KEY) || {}
|
||||
mapping[currentPath] = toRepo
|
||||
await set(SYNC_REPO_STORAGE_KEY, mapping)
|
||||
|
||||
await buildSync(selectedSyncRepo, { toRepo })
|
||||
setSyncDialogOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={syncDialogOpen} onOpenChange={setSyncDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>同步仓库到 Gitea</DialogTitle>
|
||||
<DialogDescription>
|
||||
将仓库 <span className="font-semibold text-neutral-900">{selectedSyncRepo?.path}</span> 同步到目标仓库
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="toRepo">目标仓库路径</Label>
|
||||
<Input
|
||||
id="toRepo"
|
||||
placeholder="例如: kevisual/my-repo"
|
||||
value={toRepo}
|
||||
onChange={(e) => setToRepo(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500">
|
||||
格式: owner/repo-name
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSyncDialogOpen(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSync}
|
||||
disabled={!toRepo.trim()}
|
||||
>
|
||||
开始同步
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
193
src/app/repo/modules/WorkspaceDetailDialog.tsx
Normal file
193
src/app/repo/modules/WorkspaceDetailDialog.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useRepoStore } from '../store'
|
||||
import type { WorkspaceOpen } from '../store'
|
||||
import {
|
||||
Code2,
|
||||
Terminal,
|
||||
Sparkles,
|
||||
MousePointer2,
|
||||
Box,
|
||||
Lock,
|
||||
Radio,
|
||||
Bot,
|
||||
Zap,
|
||||
Copy,
|
||||
Check,
|
||||
Square
|
||||
} from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type LinkItemKey = keyof WorkspaceOpen;
|
||||
interface LinkItem {
|
||||
key: LinkItemKey
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
order?: number
|
||||
getUrl: (data: Partial<WorkspaceOpen>) => string | undefined
|
||||
}
|
||||
|
||||
const LinkItem = ({ label, icon, url }: { label: string; icon: React.ReactNode; url?: string }) => {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const handleClick = () => {
|
||||
if (url?.startsWith?.('ssh') || url?.startsWith?.('cnb')) {
|
||||
copy()
|
||||
return;
|
||||
}
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url!)
|
||||
setIsCopied(true)
|
||||
toast.success('已复制到剪贴板')
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
} catch (error) {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!url) return
|
||||
copy()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={!url}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className="relative flex items-center gap-3 p-3 rounded-lg border border-neutral-200 hover:border-neutral-900 hover:bg-neutral-50 transition-all disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-neutral-200 disabled:hover:bg-transparent group"
|
||||
>
|
||||
<div className="w-8 h-8 flex items-center justify-center text-neutral-700">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-neutral-900 flex-1 text-left truncate">{label}</span>
|
||||
{url && isHovered && (
|
||||
<div
|
||||
onClick={handleCopy}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleCopy(e as any)
|
||||
}
|
||||
}}
|
||||
className="w-6 h-6 flex items-center justify-center text-neutral-500 hover:text-neutral-900 hover:bg-neutral-100 rounded transition-colors cursor-pointer"
|
||||
>
|
||||
{isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function WorkspaceDetailDialog() {
|
||||
const { showWorkspaceDialog, setShowWorkspaceDialog, workspaceLink, stopWorkspace } = useRepoStore()
|
||||
|
||||
const linkItems: LinkItem[] = [
|
||||
{
|
||||
key: 'webide' as LinkItemKey,
|
||||
label: 'Web IDE',
|
||||
icon: <Code2 className="w-5 h-5" />,
|
||||
order: 1,
|
||||
getUrl: (data) => data.webide
|
||||
},
|
||||
{
|
||||
key: 'vscode' as LinkItemKey,
|
||||
label: 'VS Code',
|
||||
icon: <Code2 className="w-5 h-5" />,
|
||||
order: 2,
|
||||
getUrl: (data) => data.vscode
|
||||
},
|
||||
{
|
||||
key: 'vscode-insiders' as LinkItemKey,
|
||||
label: 'VS Code Insiders',
|
||||
icon: <Sparkles className="w-5 h-5" />,
|
||||
order: 5,
|
||||
getUrl: (data) => data['vscode-insiders']
|
||||
},
|
||||
{
|
||||
key: 'cursor' as LinkItemKey,
|
||||
label: 'Cursor',
|
||||
icon: <MousePointer2 className="w-5 h-5" />,
|
||||
order: 6,
|
||||
getUrl: (data) => data.cursor
|
||||
},
|
||||
{
|
||||
key: 'jetbrains' as LinkItemKey,
|
||||
label: 'JetBrains IDEs',
|
||||
icon: <Box className="w-5 h-5" />,
|
||||
order: 7,
|
||||
getUrl: (data) => Object.values(data.jetbrains || {}).find(Boolean)
|
||||
},
|
||||
{
|
||||
key: 'ssh' as LinkItemKey,
|
||||
label: 'SSH',
|
||||
icon: <Lock className="w-5 h-5" />,
|
||||
order: 4,
|
||||
getUrl: (data) => data.ssh
|
||||
},
|
||||
{
|
||||
key: 'remoteSsh' as LinkItemKey,
|
||||
label: 'Remote SSH',
|
||||
icon: <Radio className="w-5 h-5" />,
|
||||
order: 8,
|
||||
getUrl: (data) => data.remoteSsh
|
||||
},
|
||||
{
|
||||
key: 'codebuddy' as LinkItemKey,
|
||||
label: 'CodeBuddy',
|
||||
icon: <Bot className="w-5 h-5" />,
|
||||
order: 9,
|
||||
getUrl: (data) => data.codebuddy
|
||||
},
|
||||
{
|
||||
key: 'codebuddycn' as LinkItemKey,
|
||||
label: 'CodeBuddy CN',
|
||||
icon: <Zap className="w-5 h-5" />,
|
||||
order: 3,
|
||||
getUrl: (data) => data.codebuddycn
|
||||
},
|
||||
].sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||
|
||||
return (
|
||||
<Dialog open={showWorkspaceDialog} onOpenChange={setShowWorkspaceDialog}>
|
||||
<DialogContent className="max-w-md! bg-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-neutral-900">打开工作区</DialogTitle>
|
||||
<DialogDescription className="text-neutral-500">选择一个编辑器或方式来打开工作区</DialogDescription>
|
||||
</DialogHeader>
|
||||
<button
|
||||
onClick={() => stopWorkspace()}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium transition-colors"
|
||||
>
|
||||
<Square className="w-4 h-4" />
|
||||
停止工作区
|
||||
</button>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{linkItems.map((item) => (
|
||||
<LinkItem
|
||||
key={item.key}
|
||||
label={item.label}
|
||||
icon={item.icon}
|
||||
url={item.getUrl(workspaceLink)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
109
src/app/repo/page.tsx
Normal file
109
src/app/repo/page.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useRepoStore } from './store/index'
|
||||
import { RepoCard } from './components/RepoCard'
|
||||
import { EditRepoDialog } from './modules/EditRepoDialog'
|
||||
import { CreateRepoDialog } from './modules/CreateRepoDialog'
|
||||
import { WorkspaceDetailDialog } from './modules/WorkspaceDetailDialog'
|
||||
import { SyncRepoDialog } from './modules/SyncRepoDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus } from 'lucide-react'
|
||||
|
||||
export const App = () => {
|
||||
const { list, getList, loading, editRepo, setEditRepo, showEditDialog, setShowEditDialog, showCreateDialog, setShowCreateDialog, startWorkspace, getWorkspaceList, deleteItem, setSelectedSyncRepo, setSyncDialogOpen } = useRepoStore()
|
||||
|
||||
useEffect(() => {
|
||||
getList()
|
||||
getWorkspaceList()
|
||||
}, [])
|
||||
|
||||
const handleEdit = (repo: any) => {
|
||||
setEditRepo(repo)
|
||||
setShowEditDialog(true)
|
||||
}
|
||||
|
||||
const handleIssue = (repo: any) => {
|
||||
window.open(`https://cnb.cool/${repo.path}/-/issues`)
|
||||
}
|
||||
|
||||
const handleSettings = (repo: any) => {
|
||||
window.open(`https://cnb.cool/${repo.path}/-/settings`)
|
||||
}
|
||||
|
||||
const handleDelete = (repo: any) => {
|
||||
if (repo.path)
|
||||
deleteItem(repo.path)
|
||||
}
|
||||
|
||||
const handleSync = (repo: any) => {
|
||||
setSelectedSyncRepo(repo)
|
||||
setSyncDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50 flex flex-col">
|
||||
<div className="container mx-auto p-6 max-w-7xl flex-1">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-neutral-900 mb-2">仓库列表</h1>
|
||||
<p className="text-neutral-600">共 {list.length} 个仓库</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新建仓库
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{list.map((repo) => (
|
||||
<RepoCard
|
||||
key={repo.id}
|
||||
repo={repo}
|
||||
onStartWorkspace={startWorkspace}
|
||||
onEdit={handleEdit}
|
||||
onIssue={handleIssue}
|
||||
onSettings={handleSettings}
|
||||
onDelete={handleDelete}
|
||||
onSync={handleSync}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{list.length === 0 && !loading && (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-neutral-400 text-lg">暂无仓库数据</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="border-t border-neutral-200 bg-white py-6 mt-auto">
|
||||
<div className="container mx-auto px-6 max-w-7xl">
|
||||
<div className="flex items-center justify-between text-sm text-neutral-500">
|
||||
<div>© 2026 仓库管理系统</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<a href="#" className="hover:text-neutral-900 transition-colors">关于</a>
|
||||
<a href="#" className="hover:text-neutral-900 transition-colors">帮助</a>
|
||||
<a href="#" className="hover:text-neutral-900 transition-colors">联系我们</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<EditRepoDialog
|
||||
open={showEditDialog}
|
||||
onOpenChange={setShowEditDialog}
|
||||
repo={editRepo}
|
||||
/>
|
||||
<CreateRepoDialog
|
||||
open={showCreateDialog}
|
||||
onOpenChange={setShowCreateDialog}
|
||||
/>
|
||||
<WorkspaceDetailDialog />
|
||||
<SyncRepoDialog />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App;
|
||||
33
src/app/repo/store/build.ts
Normal file
33
src/app/repo/store/build.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const myOrgs = ['kevisual', 'kevision', 'skillpod', 'zxj.im', 'abearxiong']
|
||||
|
||||
export const createBuildConfig = (params: { repo: string }) => {
|
||||
const toRepo = params.repo!;
|
||||
return `
|
||||
# .cnb.yml
|
||||
include:
|
||||
- https://cnb.cool/kevisual/cnb/-/blob/main/.cnb/template.yml
|
||||
|
||||
.common_env: &common_env
|
||||
env:
|
||||
TO_REPO: ${toRepo}
|
||||
TO_URL: git.xiongxiao.me
|
||||
imports:
|
||||
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
|
||||
|
||||
.common_sync_to_gitea: &common_sync_to_gitea
|
||||
- <<: *common_env
|
||||
services: !reference [.common_sync_to_gitea_template, services]
|
||||
stages: !reference [.common_sync_to_gitea_template, stages]
|
||||
|
||||
.common_sync_from_gitea: &common_sync_from_gitea
|
||||
- <<: *common_env
|
||||
services: !reference [.common_sync_from_gitea_template, services]
|
||||
stages: !reference [.common_sync_from_gitea_template, stages]
|
||||
|
||||
main:
|
||||
api_trigger_sync_to_gitea:
|
||||
- <<: *common_sync_to_gitea
|
||||
api_trigger_sync_from_gitea:
|
||||
- <<: *common_sync_from_gitea
|
||||
`
|
||||
};
|
||||
364
src/app/repo/store/index.ts
Normal file
364
src/app/repo/store/index.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { create } from 'zustand';
|
||||
import { query } from '@/modules/query';
|
||||
import { toast } from 'sonner';
|
||||
import { cnb } from '@/agents/app'
|
||||
import { WorkspaceInfo } from '@kevisual/cnb'
|
||||
import { createBuildConfig } from './build';
|
||||
interface DisplayModule {
|
||||
activity: boolean;
|
||||
contributors: boolean;
|
||||
release: boolean;
|
||||
}
|
||||
|
||||
interface Languages {
|
||||
language: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
id: string;
|
||||
name: string;
|
||||
freeze: boolean;
|
||||
status: number;
|
||||
visibility_level: string;
|
||||
flags: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
description: string;
|
||||
site: string;
|
||||
topics: string;
|
||||
license: string;
|
||||
display_module: DisplayModule;
|
||||
star_count: number;
|
||||
fork_count: number;
|
||||
mark_count: number;
|
||||
last_updated_at?: string | null;
|
||||
web_url: string;
|
||||
path: string;
|
||||
tags: any;
|
||||
open_issue_count: number;
|
||||
open_pull_request_count: number;
|
||||
languages: Languages;
|
||||
second_languages: Languages;
|
||||
last_update_username: string;
|
||||
last_update_nickname: string;
|
||||
access: string;
|
||||
stared: boolean;
|
||||
star_time: string;
|
||||
pinned: boolean;
|
||||
pinned_time: string;
|
||||
}
|
||||
|
||||
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[];
|
||||
editRepo: Data | null;
|
||||
setEditRepo: (repo: Data | null) => void;
|
||||
showEditDialog: boolean;
|
||||
setShowEditDialog: (show: boolean) => void;
|
||||
showCreateDialog: boolean;
|
||||
setShowCreateDialog: (show: boolean) => void;
|
||||
getList: (silent?: boolean) => Promise<any>;
|
||||
updateRepoInfo: (data: Partial<Data>) => Promise<any>;
|
||||
createRepo: (data: { visibility: any, path: string, description: string, license: string }) => Promise<any>;
|
||||
deleteItem: (repo: string) => Promise<any>;
|
||||
workspaceList: WorkspaceInfo[];
|
||||
getWorkspaceList: () => Promise<any>;
|
||||
startWorkspace: (data: Partial<Data>, params?: { open?: boolean, branch?: string }) => Promise<any>;
|
||||
stopWorkspace: () => Promise<any>;
|
||||
getWorkspaceDetail: (data: WorkspaceInfo) => Promise<any>;
|
||||
workspaceLink: Partial<WorkspaceOpen>;
|
||||
selectWorkspace?: WorkspaceInfo,
|
||||
showWorkspaceDialog: boolean;
|
||||
setShowWorkspaceDialog: (show: boolean) => void;
|
||||
syncDialogOpen: boolean;
|
||||
setSyncDialogOpen: (open: boolean) => void;
|
||||
selectedSyncRepo: Data | null;
|
||||
setSelectedSyncRepo: (repo: Data | null) => void;
|
||||
buildSync: (data: Partial<Data>, params: { toRepo?: string, fromRepo?: string }) => Promise<any>;
|
||||
}
|
||||
|
||||
export const useRepoStore = create<State>((set, get) => {
|
||||
return {
|
||||
formData: {},
|
||||
setFormData: (data) => set({ formData: data }),
|
||||
showEdit: false,
|
||||
setShowEdit: (showEdit) => set({ showEdit }),
|
||||
loading: false,
|
||||
setLoading: (loading) => set({ loading }),
|
||||
list: [],
|
||||
editRepo: null,
|
||||
setEditRepo: (repo) => set({ editRepo: repo }),
|
||||
showEditDialog: false,
|
||||
setShowEditDialog: (show) => set({ showEditDialog: show }),
|
||||
showCreateDialog: false,
|
||||
setShowCreateDialog: (show) => set({ showCreateDialog: show }),
|
||||
showWorkspaceDialog: false,
|
||||
setShowWorkspaceDialog: (show) => set({ showWorkspaceDialog: show }),
|
||||
syncDialogOpen: false,
|
||||
setSyncDialogOpen: (open) => set({ syncDialogOpen: open }),
|
||||
selectedSyncRepo: null,
|
||||
setSelectedSyncRepo: (repo) => set({ selectedSyncRepo: repo }),
|
||||
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 (silent = false) => {
|
||||
const { setLoading } = get();
|
||||
if (!silent) {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
const res = await cnb.repo.getRepoList({})
|
||||
if (res.code === 200) {
|
||||
const list = res.data! || []
|
||||
set({ list });
|
||||
} else {
|
||||
toast.error(res.message || '请求失败');
|
||||
}
|
||||
return res;
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
updateRepoInfo: async (data) => {
|
||||
const repo = data.path!;
|
||||
const topics = data.topics?.split?.(',');
|
||||
if (topics?.length === 0) {
|
||||
topics.push('')
|
||||
}
|
||||
const updateData = {
|
||||
description: data.description,
|
||||
license: data?.license as any,
|
||||
site: data.site,
|
||||
topics: topics,
|
||||
}
|
||||
const res = await cnb.repo.updateRepoInfo(repo, updateData)
|
||||
if (res.code === 200) {
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
toast.error(res.message || '更新失败');
|
||||
}
|
||||
},
|
||||
createRepo: async (data) => {
|
||||
try {
|
||||
const createData = {
|
||||
name: data.path || '',
|
||||
visibility: data.visibility || 'public' as const,
|
||||
description: data.description || '',
|
||||
license: data?.license as any,
|
||||
};
|
||||
const res = await cnb.repo.createRepo(createData);
|
||||
console.log('res', res)
|
||||
// if (res.code === 200) {
|
||||
// toast.success('仓库创建成功');
|
||||
// } else {
|
||||
// toast.error(res.message || '创建失败');
|
||||
// }
|
||||
return res;
|
||||
} catch (e: any) {
|
||||
// toast.error(e.message || '创建失败');
|
||||
// throw e;
|
||||
toast.success('仓库创建成功');
|
||||
}
|
||||
},
|
||||
deleteItem: async (repo: string) => {
|
||||
try {
|
||||
const res = await cnb.repo.deleteRepoCookie(repo)
|
||||
if (res.code === 200) {
|
||||
toast.success('删除成功');
|
||||
// 刷新列表
|
||||
await get().getList(true);
|
||||
} else {
|
||||
toast.error(res.message || '删除失败');
|
||||
}
|
||||
} catch (e: any) {
|
||||
// 如果是 JSON 解析错误,说明删除成功但响应为空
|
||||
if (e.message?.includes('JSON') || e.message?.includes('json')) {
|
||||
toast.success('删除成功');
|
||||
// 刷新列表
|
||||
await get().getList(true);
|
||||
} else {
|
||||
toast.error('删除失败');
|
||||
console.error('删除错误:', e);
|
||||
}
|
||||
}
|
||||
},
|
||||
workspaceList: [],
|
||||
getWorkspaceList: async () => {
|
||||
const res = await cnb.workspace.list({
|
||||
status: 'running',
|
||||
pageSize: 100
|
||||
})
|
||||
if (res.code === 200) {
|
||||
const list: WorkspaceInfo[] = res.data?.list as any;
|
||||
set({ workspaceList: list || [] })
|
||||
} else {
|
||||
toast.error(res.message || '请求失败');
|
||||
}
|
||||
},
|
||||
startWorkspace: async (data, params = { open: true, branch: 'main' }) => {
|
||||
const repo = data.path;
|
||||
const checkOpen = async () => {
|
||||
const res = await cnb.workspace.startWorkspace(repo!, {
|
||||
branch: params.branch || 'main'
|
||||
})
|
||||
if (res.code === 200) {
|
||||
if (!res?.data?.sn) {
|
||||
const url = res.data?.url! || '';
|
||||
if (url.includes('loading')) {
|
||||
return {
|
||||
code: 400,
|
||||
data: res.data
|
||||
}
|
||||
}
|
||||
return {
|
||||
code: 200,
|
||||
data: res.data
|
||||
};
|
||||
}
|
||||
toast.success(`新创建了一个工作区,sn: ${res.data.sn}`)
|
||||
return {
|
||||
code: 300,
|
||||
data: res.data,
|
||||
message: '第一次启动'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
code: res.code,
|
||||
message: res.message || '请求失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await checkOpen()
|
||||
if (res.code === 300) {
|
||||
toast.success(`新创建了一个工作区,sn: ${res.data?.sn}`)
|
||||
if (params.open) {
|
||||
const loadingToastId = toast.loading('正在启动工作区...')
|
||||
const interval = setInterval(async () => {
|
||||
const check = await checkOpen()
|
||||
if (check.code === 200 && check.data?.url) {
|
||||
clearInterval(interval)
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.success(`工作区已启动,正在打开: ${check.data.url}`)
|
||||
setTimeout(() => {
|
||||
window.open(check.data?.url, '_blank')
|
||||
}, 200)
|
||||
} else if (check.code === 400) {
|
||||
//
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// 30秒后自动停止检测
|
||||
setTimeout(() => {
|
||||
clearInterval(interval)
|
||||
toast.dismiss(loadingToastId)
|
||||
toast.error('工作区启动超时')
|
||||
}, 3 * 60 * 1000)
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (res.code === 200 && res.data?.url) {
|
||||
console.log('res', res)
|
||||
toast.success(`工作区已启动,正在打开: ${res.data.url}`)
|
||||
setTimeout(() => {
|
||||
window.open(res.data?.url, '_blank')
|
||||
}, 200)
|
||||
} else {
|
||||
toast.error(res.message || '启动失败');
|
||||
}
|
||||
return res;
|
||||
},
|
||||
stopWorkspace: async () => {
|
||||
const sn = get().selectWorkspace?.sn;
|
||||
if (!sn) {
|
||||
toast.error('未选择工作区');
|
||||
return;
|
||||
}
|
||||
const res = await cnb.workspace.stopWorkspace({ sn });
|
||||
// @ts-ignore
|
||||
if (res?.code === 200) {
|
||||
toast.success('工作区已停止');
|
||||
// 停止成功后关闭弹窗
|
||||
set({ showWorkspaceDialog: false });
|
||||
get().getList(true)
|
||||
} else {
|
||||
toast.error(res.message || '停止失败');
|
||||
}
|
||||
},
|
||||
selectWorkspace: undefined,
|
||||
getWorkspaceDetail: async (workspaceInfo) => {
|
||||
const res = await cnb.workspace.getDetail(workspaceInfo.slug, workspaceInfo.sn) as any;
|
||||
if (res.code === 200) {
|
||||
set({
|
||||
workspaceLink: res.data,
|
||||
showWorkspaceDialog: true,
|
||||
selectWorkspace: workspaceInfo
|
||||
})
|
||||
}
|
||||
},
|
||||
workspaceLink: {},
|
||||
buildSync: async (data, params) => {
|
||||
const repo = data.path!;
|
||||
const toRepo = params.toRepo;
|
||||
const fromRepo = params.fromRepo;
|
||||
if (!toRepo && !fromRepo) {
|
||||
toast.error('请选择同步的目标仓库或来源仓库')
|
||||
return;
|
||||
}
|
||||
let event = toRepo ? 'api_trigger_sync_to_gitea' : 'api_trigger_sync_from_gitea';
|
||||
const res = await cnb.build.startBuild(repo, {
|
||||
branch: 'main',
|
||||
env: {},
|
||||
event: event,
|
||||
config: createBuildConfig({ repo: toRepo! || fromRepo! }),
|
||||
})
|
||||
if (res.code === 200) {
|
||||
toast.success('同步提交成功')
|
||||
} else {
|
||||
toast.error(res.message || '同步提交失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export type WorkspaceOpen = {
|
||||
codebuddy: string;
|
||||
codebuddycn: string;
|
||||
cursor: string;
|
||||
jetbrains: Record<string, string>;
|
||||
jumpUrl: string;
|
||||
remoteSsh: string;
|
||||
ssh: string;
|
||||
vscode: string;
|
||||
'vscode-insiders': string;
|
||||
webide: string;
|
||||
}
|
||||
const openWorkspace = (workspace: WorkspaceInfo, params: { vscode?: boolean, ssh?: boolean }) => {
|
||||
const openVsCode = params?.vscode ?? true;
|
||||
if (openVsCode) {
|
||||
const pipeline_id = workspace.pipeline_id;
|
||||
const url = `vscode://vscode-remote/ssh-remote+${pipeline_id}`
|
||||
}
|
||||
}
|
||||
130
src/app/store/index.ts
Normal file
130
src/app/store/index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { create } from 'zustand';
|
||||
import { query } from '@/modules/query';
|
||||
import { toast } from 'sonner';
|
||||
import { cnb } from '@/agents/app'
|
||||
|
||||
interface DisplayModule {
|
||||
activity: boolean;
|
||||
contributors: boolean;
|
||||
release: boolean;
|
||||
}
|
||||
|
||||
interface Languages {
|
||||
language: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface Data {
|
||||
id: string;
|
||||
name: string;
|
||||
freeze: boolean;
|
||||
status: number;
|
||||
visibility_level: string;
|
||||
flags: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
description: string;
|
||||
site: string;
|
||||
topics: string;
|
||||
license: string;
|
||||
display_module: DisplayModule;
|
||||
star_count: number;
|
||||
fork_count: number;
|
||||
mark_count: number;
|
||||
last_updated_at?: string | null;
|
||||
web_url: string;
|
||||
path: string;
|
||||
tags: any;
|
||||
open_issue_count: number;
|
||||
open_pull_request_count: number;
|
||||
languages: Languages;
|
||||
second_languages: Languages;
|
||||
last_update_username: string;
|
||||
last_update_nickname: string;
|
||||
access: string;
|
||||
stared: boolean;
|
||||
star_time: string;
|
||||
pinned: boolean;
|
||||
pinned_time: string;
|
||||
}
|
||||
|
||||
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[];
|
||||
editRepo: Data | null;
|
||||
setEditRepo: (repo: Data | null) => void;
|
||||
showEditDialog: boolean;
|
||||
setShowEditDialog: (show: boolean) => void;
|
||||
getList: () => Promise<any>;
|
||||
updateRepoInfo: (data: Partial<Data>) => Promise<any>;
|
||||
}
|
||||
|
||||
export const useRepoStore = create<State>((set, get) => {
|
||||
return {
|
||||
formData: {},
|
||||
setFormData: (data) => set({ formData: data }),
|
||||
showEdit: false,
|
||||
setShowEdit: (showEdit) => set({ showEdit }),
|
||||
loading: false,
|
||||
setLoading: (loading) => set({ loading }),
|
||||
list: [],
|
||||
editRepo: null,
|
||||
setEditRepo: (repo) => set({ editRepo: repo }),
|
||||
showEditDialog: false,
|
||||
setShowEditDialog: (show) => set({ showEditDialog: show }),
|
||||
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 cnb.repo.getRepoList({})
|
||||
if (res.code === 200) {
|
||||
const list = res.data! || []
|
||||
set({ list });
|
||||
} else {
|
||||
toast.error(res.message || '请求失败');
|
||||
}
|
||||
return res;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
updateRepoInfo: async (data) => {
|
||||
const repo = data.path!;
|
||||
const updateData = {
|
||||
description: data.description,
|
||||
license: data?.license as any,
|
||||
site: data.site,
|
||||
topics: data.topics?.split?.(','),
|
||||
}
|
||||
const res = await cnb.repo.updateRepoInfo(repo, updateData)
|
||||
if (res.code === 200) {
|
||||
toast.success('更新成功');
|
||||
} else {
|
||||
toast.error(res.message || '更新失败');
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
78
src/components/tags-input.tsx
Normal file
78
src/components/tags-input.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from "react"
|
||||
import { X } from "lucide-react"
|
||||
import { Badge } from "./ui/badge"
|
||||
import { Input } from "./ui/input"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface TagsInputProps {
|
||||
value: string[]
|
||||
onChange: (tags: string[]) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function TagsInput({ value, onChange, placeholder, className }: TagsInputProps) {
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" || e.key === ",") {
|
||||
e.preventDefault()
|
||||
addTag()
|
||||
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
|
||||
removeTag(value.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
const trimmedValue = inputValue.trim()
|
||||
if (trimmedValue && !value.includes(trimmedValue)) {
|
||||
onChange([...value, trimmedValue])
|
||||
setInputValue("")
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (index: number) => {
|
||||
onChange(value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-wrap gap-2 rounded-md border border-neutral-200 bg-white px-3 py-3 text-sm ring-offset-white transition-all hover:border-neutral-300 focus-within:ring-2 focus-within:ring-neutral-950 focus-within:ring-offset-2 cursor-text",
|
||||
className
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{value.map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="gap-1.5 pl-2.5 pr-1.5 py-1 border-neutral-300 text-neutral-700 bg-neutral-50 hover:bg-neutral-100 transition-colors h-7 font-normal"
|
||||
>
|
||||
<span className="text-xs">{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeTag(index)
|
||||
}}
|
||||
className="ml-0.5 rounded-full hover:bg-neutral-200 hover:text-neutral-900 p-0.5 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={addTag}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[150px] border-0 p-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-7 placeholder:text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
src/components/ui/avatar.tsx
Normal file
104
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 after:border-border group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:mix-blend-darken dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"rounded-full aspect-square size-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground rounded-full flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn("bg-muted text-muted-foreground size-8 rounded-full text-sm group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
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 }
|
||||
@@ -1,30 +1,31 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
"use client"
|
||||
|
||||
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(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"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 shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
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-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
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: {
|
||||
@@ -34,24 +35,19 @@ const buttonVariants = cva(
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
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,
|
||||
}
|
||||
27
src/components/ui/checkbox.tsx
Normal file
27
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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 }
|
||||
151
src/components/ui/dialog.tsx
Normal file
151
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client"
|
||||
|
||||
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/20 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 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-md 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,
|
||||
}
|
||||
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 }
|
||||
20
src/components/ui/label.tsx
Normal file
20
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
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 }
|
||||
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,
|
||||
}
|
||||
25
src/components/ui/separator.tsx
Normal file
25
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
47
src/components/ui/sonner.tsx
Normal file
47
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
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 }
|
||||
101
src/components/ui/table.tsx
Normal file
101
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div data-slot="table-container" className="relative w-full overflow-x-auto">
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn("hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn("text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn("p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
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-neutral-900 text-white shadow-lg 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-neutral-900 fill-neutral-900 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 }
|
||||
@@ -3,7 +3,7 @@ import { RouterProvider, createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import './index.css'
|
||||
import { basename } from './modules/basename'
|
||||
|
||||
// import './agents/app'
|
||||
// Set up a Router instance
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export const Home = () => {
|
||||
return <div>Home Page</div>
|
||||
}
|
||||
@@ -9,8 +9,14 @@
|
||||
// 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 ConfigRouteImport } from './routes/config'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const ConfigRoute = ConfigRouteImport.update({
|
||||
id: '/config',
|
||||
path: '/config',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -19,28 +25,39 @@ const IndexRoute = IndexRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/config': typeof ConfigRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/config': typeof ConfigRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/config': typeof ConfigRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/'
|
||||
fullPaths: '/' | '/config'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/'
|
||||
id: '__root__' | '/'
|
||||
to: '/' | '/config'
|
||||
id: '__root__' | '/' | '/config'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ConfigRoute: typeof ConfigRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/config': {
|
||||
id: '/config'
|
||||
path: '/config'
|
||||
fullPath: '/config'
|
||||
preLoaderRoute: typeof ConfigRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -53,6 +70,7 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ConfigRoute: ConfigRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { Link, Outlet, createRootRoute } from '@tanstack/react-router'
|
||||
import { Link, Outlet, createRootRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { useConfigStore } from '@/app/config/store'
|
||||
import { useEffect } from 'react'
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
const config = useConfigStore.getState().config;
|
||||
if (!config.CNB_API_KEY) {
|
||||
navigate({
|
||||
to: '/config'
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
return (
|
||||
<>
|
||||
<div className='h-full overflow-hidden'>
|
||||
|
||||
<div className="p-2 flex gap-2 text-lg">
|
||||
<Link
|
||||
to="/"
|
||||
@@ -16,12 +28,20 @@ function RootComponent() {
|
||||
}}
|
||||
activeOptions={{ exact: true }}
|
||||
>
|
||||
Home
|
||||
仓库列表
|
||||
</Link>
|
||||
<Link to='/config'>
|
||||
配置项
|
||||
</Link>
|
||||
</div>
|
||||
<hr />
|
||||
<Outlet />
|
||||
<main className='h-[calc(100%-4rem)] overflow-auto scrollbar'>
|
||||
|
||||
<Outlet />
|
||||
</main>
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
</>
|
||||
<Toaster />
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
9
src/routes/config.tsx
Normal file
9
src/routes/config.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import Home from '@/app/config/page'
|
||||
export const Route = createFileRoute('/config')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <Home />
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Home } from '@/pages/Home'
|
||||
import App from '@/app/page'
|
||||
export const Route = createFileRoute('/')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <Home />
|
||||
return <App />
|
||||
}
|
||||
17
test/ai.ts
Normal file
17
test/ai.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { generateText } from 'ai';
|
||||
|
||||
|
||||
const cnb = createOpenAICompatible({
|
||||
baseURL: 'https://api.cnb.cool/kevisual/cnb-ai/-/ai',
|
||||
name: 'custom-cnb',
|
||||
apiKey: 'cIDfLOOIr1Trt15cdnwfndupEZG',
|
||||
});
|
||||
// const model = config.AI_MODEL;
|
||||
const model = 'hunyuan';
|
||||
const { text } = await generateText({
|
||||
model: cnb(model),
|
||||
prompt: 'Say hello in one sentence.',
|
||||
});
|
||||
console.log('text', text)
|
||||
// https://api.cnb.cool
|
||||
Reference in New Issue
Block a user