feat: 添加仓库管理页面和 AI 功能,优化路由和导航

- 新增仓库列表页面,支持查看和管理 CNB 仓库
- 添加 AI 代理系统和状态管理
- 新增 tags-input、popover、textarea、tooltip 等 UI 组件
- 更新依赖:@kevisual/cnb 升级至 0.0.22,添加 idb-keyval
- 改进路由守卫:未配置 API Key 时自动跳转配置页
- 优化 Dialog 遮罩层样式和整体布局

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 04:44:28 +08:00
parent 0ced574b8b
commit a2629fec7b
22 changed files with 1606 additions and 62 deletions

48
AGENTS.md Normal file
View 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 插件进行样式处理

View File

@@ -1,5 +1,5 @@
{
"name": "vite-react",
"name": "@kevisual/cnb-center",
"private": true,
"version": "0.0.1",
"type": "module",
@@ -21,7 +21,7 @@
"@ai-sdk/openai": "^3.0.26",
"@ai-sdk/openai-compatible": "^2.0.28",
"@base-ui/react": "^1.1.0",
"@kevisual/cnb": "^0.0.20",
"@kevisual/cnb": "^0.0.22",
"@kevisual/context": "^0.0.4",
"@kevisual/router": "0.0.70",
"@tanstack/react-router": "^1.158.4",
@@ -30,6 +30,7 @@
"clsx": "^2.1.1",
"dayjs": "^1.11.19",
"es-toolkit": "^1.44.0",
"idb-keyval": "^6.2.2",
"lucide-react": "^0.563.0",
"nanoid": "^5.1.6",
"next-themes": "^0.4.6",

24
pnpm-lock.yaml generated
View File

@@ -21,8 +21,8 @@ importers:
specifier: ^1.1.0
version: 1.1.0(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@kevisual/cnb':
specifier: ^0.0.20
version: 0.0.20(dotenv@17.2.4)(idb-keyval@6.2.1)
specifier: ^0.0.22
version: 0.0.22(dotenv@17.2.4)(idb-keyval@6.2.2)
'@kevisual/context':
specifier: ^0.0.4
version: 0.0.4
@@ -47,6 +47,9 @@ importers:
es-toolkit:
specifier: ^1.44.0
version: 1.44.0
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
lucide-react:
specifier: ^0.563.0
version: 0.563.0(react@19.2.4)
@@ -638,8 +641,8 @@ packages:
'@kevisual/cache@0.0.2':
resolution: {integrity: sha512-2Cl5KF2Gi27uLfhO6CdTMFnRzx9vYnqevAo7d9ab3rOaqTgF8tLeAXglXyRbaWW3WUbHU2XaOb4r98uUsqIQQw==}
'@kevisual/cnb@0.0.20':
resolution: {integrity: sha512-3ODGAT8vEnU90X/6SUeqMK1ZJCcvyn44bMsC7Joz0kvDKhntstbf/nZIm5TRhngvPEcOPyc+KROchTweC/qcNA==}
'@kevisual/cnb@0.0.22':
resolution: {integrity: sha512-KX8oSmmaHnT4qqCfAoQoHZbkcohUVSK7LfdsEKTlItrE77rPyZcvD+APByroxH4FMQ80ItRW9tQlxBO8iRlrIw==}
'@kevisual/context@0.0.4':
resolution: {integrity: sha512-HJeLeZQLU+7tCluSfOyvkgKLs0HjCZrdJlZgEgKRSa8XTwZfMAUt6J7qZTbrZAHBlPtX68EPu/PI8JMCeu3WAQ==}
@@ -1490,6 +1493,9 @@ packages:
idb-keyval@6.2.1:
resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==}
idb-keyval@6.2.2:
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
immer@10.1.1:
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
@@ -2730,14 +2736,14 @@ snapshots:
- tslib
- typescript
'@kevisual/cnb@0.0.20(dotenv@17.2.4)(idb-keyval@6.2.1)':
'@kevisual/cnb@0.0.22(dotenv@17.2.4)(idb-keyval@6.2.2)':
dependencies:
'@kevisual/query': 0.0.40
'@kevisual/router': 0.0.70
'@kevisual/use-config': 1.0.30(dotenv@17.2.4)
es-toolkit: 1.44.0
nanoid: 5.1.6
unstorage: 1.17.4(idb-keyval@6.2.1)
unstorage: 1.17.4(idb-keyval@6.2.2)
ws: '@kevisual/ws@8.19.0'
zod: 4.3.6
transitivePeerDependencies:
@@ -3532,6 +3538,8 @@ snapshots:
idb-keyval@6.2.1: {}
idb-keyval@6.2.2: {}
immer@10.1.1:
optional: true
@@ -3996,7 +4004,7 @@ snapshots:
picomatch: 4.0.3
webpack-virtual-modules: 0.6.2
unstorage@1.17.4(idb-keyval@6.2.1):
unstorage@1.17.4(idb-keyval@6.2.2):
dependencies:
anymatch: 3.1.3
chokidar: 5.0.0
@@ -4007,7 +4015,7 @@ snapshots:
ofetch: 1.5.1
ufo: 1.6.3
optionalDependencies:
idb-keyval: 6.2.1
idb-keyval: 6.2.2
update-browserslist-db@1.1.1(browserslist@4.24.2):
dependencies:

21
src/agents/app.ts Normal file
View File

@@ -0,0 +1,21 @@
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('router', 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'
}
return new CNB({
token: config.CNB_API_KEY,
cookie: config.CNB_COOKIE,
cors
})
})

43
src/app/ai/page.tsx Normal file
View 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;

View File

@@ -5,6 +5,7 @@ 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();
@@ -13,8 +14,10 @@ export const ConfigPage = () => {
e.preventDefault();
const result = configSchema.safeParse(config);
if (result.success) {
console.log('配置已保存:', config);
// 可以在此处添加 toast 通知
toast.success('配置已保存')
setTimeout(() => {
location.reload()
}, 400)
} else {
console.error('验证错误:', result.error.format());
}

View File

@@ -1,43 +1,3 @@
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)
}
import App from './repo/page'
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;
export default App;

View File

@@ -0,0 +1,217 @@
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 } from 'lucide-react'
import { useRepoStore } from '../store'
import { useMemo, useState } from 'react'
import { myOrgs } from '../store/build'
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 } = 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)
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">
<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={(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>
</>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,185 @@
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
} 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 } = 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>
<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>
)
}

93
src/app/repo/page.tsx Normal file
View File

@@ -0,0 +1,93 @@
import { useEffect } from 'react'
import { useRepoStore } from './store/index'
import { RepoCard } from './components/RepoCard'
import { EditRepoDialog } from './modules/EditRepoDialog'
import { WorkspaceDetailDialog } from './modules/WorkspaceDetailDialog'
import { SyncRepoDialog } from './modules/SyncRepoDialog'
export const App = () => {
const { list, getList, loading, editRepo, setEditRepo, showEditDialog, setShowEditDialog, 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">
<h1 className="text-4xl font-bold text-neutral-900 mb-2"></h1>
<p className="text-neutral-600"> {list.length} </p>
</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}
/>
<WorkspaceDetailDialog />
<SyncRepoDialog />
</div>
)
}
export default App;

View 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
`
};

313
src/app/repo/store/index.ts Normal file
View File

@@ -0,0 +1,313 @@
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;
getList: (silent?: boolean) => Promise<any>;
updateRepoInfo: (data: Partial<Data>) => Promise<any>;
deleteItem: (repo: string) => Promise<any>;
workspaceList: WorkspaceInfo[];
getWorkspaceList: () => Promise<any>;
startWorkspace: (data: Partial<Data>, params?: { open?: boolean, branch?: string }) => Promise<any>;
getWorkspaceDetail: (data: WorkspaceInfo) => Promise<any>;
workspaceLink: Partial<WorkspaceOpen>;
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 }),
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?.(',');
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 || '更新失败');
}
},
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;
},
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
})
}
},
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
View 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 || '更新失败');
}
},
}
})

View 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>
)
}

View File

@@ -30,7 +30,7 @@ function DialogOverlay({
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 isolate z-50", className)}
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}
/>
)

View 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,
}

View 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 }

View 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 }

View File

@@ -1,14 +1,24 @@
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>
<div className='h-full overflow-hidden'>
<div className="p-2 flex gap-2 text-lg">
<Link
@@ -18,11 +28,17 @@ function RootComponent() {
}}
activeOptions={{ exact: true }}
>
Home
</Link>
<Link to='/config'>
</Link>
</div>
<hr />
<main className='h-[calc(100%-4rem)] overflow-auto scrollbar'>
<Outlet />
</main>
<TanStackRouterDevtools position="bottom-right" />
<Toaster />
</div>

View File

@@ -1,9 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
import Home from '@/app/page'
import App from '@/app/page'
export const Route = createFileRoute('/')({
component: RouteComponent,
})
function RouteComponent() {
return <Home />
return <App />
}