feat: 初始化 React + shadcn/ui 项目 - 列阵工作台重构
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
## 1. 产品概述
|
||||||
|
|
||||||
|
列阵(Array)人类工作台——一个让人机协作变得自然的实时工作台。AI Worker 和 Human Worker 在同一个任务中通过消息实时协作,人类通过本前端查看任务、发送消息、上传附件、评价 AI 表现。
|
||||||
|
- 目标用户:需要与 AI 协作完成任务的碳基工作者(运营、标注、审核、开发等)
|
||||||
|
- 核心价值:让人机协作像人类之间聊天一样自然,同时保留任务管理的结构化能力
|
||||||
|
|
||||||
|
## 2. 核心功能
|
||||||
|
|
||||||
|
### 2.1 用户角色
|
||||||
|
|
||||||
|
| 角色 | 注册方式 | 核心权限 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| Human Worker | API Key / 用户名密码 / 邀请码注册 | 创建任务、发送消息、上传附件、评价 AI、管理组织 |
|
||||||
|
| AI Worker | 后端自动注册 | 自动接收任务、回复消息、提交结果 |
|
||||||
|
| 组织 Admin | 组织创建者自动获得 | 管理成员、分类、邀请码 |
|
||||||
|
|
||||||
|
### 2.2 功能模块
|
||||||
|
|
||||||
|
1. **登录页**:API Key 登录、用户名密码登录、注册新账户、邀请码注册
|
||||||
|
2. **主工作台**:三栏布局——成员面板、任务列表、聊天详情
|
||||||
|
3. **消息系统**:公共消息、私密消息、@提及、引用回复、附件上传、乐观消息
|
||||||
|
4. **任务管理**:创建任务、状态流转、参与者管理、分类筛选、置顶
|
||||||
|
5. **组织管理**:组织切换、成员管理、邀请码、角色权限
|
||||||
|
6. **个人中心**:资料编辑、快捷键设置、主题切换、语言切换
|
||||||
|
7. **评价系统**:任务完成后对 AI 评分、能力标签、评价汇总
|
||||||
|
|
||||||
|
### 2.3 页面详情
|
||||||
|
|
||||||
|
| 页面 | 模块 | 功能描述 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 登录页 | 服务器地址输入 | 输入后端 API 地址,支持历史记录 |
|
||||||
|
| 登录页 | API Key 登录 | 输入 API Key 直接登录 |
|
||||||
|
| 登录页 | 用户名密码登录 | 切换到密码模式登录 |
|
||||||
|
| 登录页 | 注册表单 | 名称、用户名、密码、邀请码、组织、位置、简介、标签、能力 |
|
||||||
|
| 主工作台 | 成员面板 | 显示在线/离线 AI 和 Human Worker,点击发起对话 |
|
||||||
|
| 主工作台 | 任务列表 | 全部/我发起的/我参与的 Tab,分类筛选,置顶,排序 |
|
||||||
|
| 主工作台 | 聊天详情 | 消息流、引用回复、@提及、私密消息、附件、状态切换 |
|
||||||
|
| 主工作台 | 任务抽屉 | 参与者列表、添加/移除成员、评价入口 |
|
||||||
|
| 组织管理 | 组织切换器 | 顶栏下拉切换组织 |
|
||||||
|
| 组织管理 | 组织详情弹窗 | 成员列表、邀请码管理、角色变更、重命名 |
|
||||||
|
| 个人中心 | 资料编辑 | 名称、简介、组织、位置、标签、能力、最大并发 |
|
||||||
|
| 个人中心 | 快捷键设置 | 发送键(Enter/Ctrl+Enter)、换行键配置 |
|
||||||
|
| 评价系统 | 评价提交 | 1-5 星评分、能力标签、评论、建议 |
|
||||||
|
| 评价系统 | 评价汇总 | 平均分、总评价数、常见标签、评价列表 |
|
||||||
|
|
||||||
|
## 3. 核心流程
|
||||||
|
|
||||||
|
### 3.1 登录与初始化
|
||||||
|
|
||||||
|
用户打开应用 → 输入服务器地址和凭据 → SDK 认证获取 JWT → 加载 Workers/Tasks/Categories → 建立 WebSocket 连接 → 进入主工作台
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["用户打开应用"] --> B{"本地有凭据?"}
|
||||||
|
B -->|是| C["自动登录"]
|
||||||
|
B -->|否| D["显示登录页"]
|
||||||
|
D --> E["输入凭据"]
|
||||||
|
E --> F["SDK 认证"]
|
||||||
|
C --> F
|
||||||
|
F --> G{"认证成功?"}
|
||||||
|
G -->|否| D
|
||||||
|
G -->|是| H["加载 Workers + Tasks"]
|
||||||
|
H --> I["建立 WebSocket"]
|
||||||
|
I --> J["进入主工作台"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 消息收发
|
||||||
|
|
||||||
|
用户选择任务 → 加载历史消息 → 输入回复 → 乐观插入 pending 消息 → API 发送 → 成功移除 pending / 失败显示重试 → WebSocket 推送新消息 → 更新消息列表
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["选择任务"] --> B["加载历史消息"]
|
||||||
|
B --> C["用户输入回复"]
|
||||||
|
C --> D["乐观插入 pending 消息"]
|
||||||
|
D --> E["API 发送"]
|
||||||
|
E --> F{"发送成功?"}
|
||||||
|
F -->|是| G["移除 pending"]
|
||||||
|
F -->|否| H["标记 failed,显示重试"]
|
||||||
|
G --> I["WS 推送确认"]
|
||||||
|
I --> J["更新消息列表"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 人机协作
|
||||||
|
|
||||||
|
人类创建任务 → 邀请 AI Worker → AI 自动接收 → AI 发送处理中消息 → AI 提交结果 → 人类查看结果 → 人类评价 AI 表现
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["人类创建任务"] --> B["邀请 AI Worker"]
|
||||||
|
B --> C["AI 自动接收任务"]
|
||||||
|
C --> D["AI 发送处理中消息"]
|
||||||
|
D --> E["AI 提交结果"]
|
||||||
|
E --> F["人类查看结果"]
|
||||||
|
F --> G["人类评价 AI"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 用户界面设计
|
||||||
|
|
||||||
|
### 4.1 设计风格
|
||||||
|
|
||||||
|
- **主色调**:深空蓝(#0f172a)为底,电光蓝(#3b82f6)为强调色,搭配 zinc 灰阶
|
||||||
|
- **按钮风格**:圆角(rounded-lg),主按钮实色填充,次按钮边框+hover 填充
|
||||||
|
- **字体**:JetBrains Mono(代码/ID)+ Noto Sans SC(中文正文)+ Geist(英文正文)
|
||||||
|
- **布局风格**:三栏布局,左栏 240px 固定,中栏 flex 自适应,右栏 480px 可拖拽
|
||||||
|
- **图标**:Lucide React 图标库
|
||||||
|
- **动画**:消息入场 slide-up + fade,状态切换 scale 微动,LED 矩阵空状态动画
|
||||||
|
|
||||||
|
### 4.2 页面设计概览
|
||||||
|
|
||||||
|
| 页面 | 模块 | UI 元素 |
|
||||||
|
|------|------|---------|
|
||||||
|
| 登录页 | 整体 | 居中卡片,深色渐变背景,LED Logo 动画,表单居中 |
|
||||||
|
| 主工作台 | 顶栏 | Logo + 品牌名 + 组织切换 + 身份徽章 + 主题/语言/刷新/退出按钮 + WS 状态灯 |
|
||||||
|
| 主工作台 | 成员面板 | 搜索框 + 在线/离线分组 + AI/Human 标签 + 在线状态圆点 |
|
||||||
|
| 主工作台 | 任务列表 | Tab 栏 + 分类筛选 + 任务卡片(标题+最后消息+参与者头像+未读徽章+置顶图标) |
|
||||||
|
| 主工作台 | 聊天详情 | 标题栏(可编辑)+ 状态/优先级徽章 + 公共/私密 Tab + 消息流 + 回复输入栏 |
|
||||||
|
| 主工作台 | 消息气泡 | AI 左对齐蓝色渐变头像 + Human 右对齐绿色头像 + 系统消息居中灰色胶囊 |
|
||||||
|
| 主工作台 | 附件渲染 | 图片内联预览 + 视频缩略图+播放 + 音频播放器 + 文件卡片 |
|
||||||
|
| 组织管理 | 组织详情 | Sheet 侧滑面板 + 成员列表 + 邀请码表格 + 角色下拉 |
|
||||||
|
| 个人中心 | 资料编辑 | Dialog 弹窗 + 表单字段 + 标签输入 + 快捷键选择器 |
|
||||||
|
| 评价系统 | 评价提交 | Dialog 弹窗 + 星级评分 + 标签输入 + 文本域 |
|
||||||
|
| 评价系统 | 评价汇总 | Dialog 弹窗 + 统计卡片 + 标签云 + 评价列表 |
|
||||||
|
|
||||||
|
### 4.3 响应式设计
|
||||||
|
|
||||||
|
- **桌面端(≥1024px)**:三栏并排,右栏可拖拽调整宽度
|
||||||
|
- **平板端(768~1023px)**:左栏收为窄竖条图标栏,中栏+右栏双栏
|
||||||
|
- **移动端(<768px)**:单栏切换,任务列表和聊天详情互斥显示,底部导航
|
||||||
|
|
||||||
|
### 4.4 空状态设计
|
||||||
|
|
||||||
|
- 无任务时:16×16 LED 矩阵动画(贪吃蛇、俄罗斯方块、烟花、文字滚动等)
|
||||||
|
- 无消息时:引导卡片"添加参与者开始协作"
|
||||||
|
- 无通知时:简洁空状态图标+文案
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
## 1. 架构设计
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Frontend["前端 (React + Vite)"]
|
||||||
|
UI["shadcn/ui 组件层"]
|
||||||
|
Pages["页面层"]
|
||||||
|
Hooks["Hooks 层"]
|
||||||
|
Store["Zustand 状态管理"]
|
||||||
|
SDK["array-sdk 通信层"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Backend["后端 (FastAPI + SQLite)"]
|
||||||
|
API["REST API"]
|
||||||
|
WS["WebSocket"]
|
||||||
|
end
|
||||||
|
|
||||||
|
UI --> Pages
|
||||||
|
Pages --> Hooks
|
||||||
|
Hooks --> Store
|
||||||
|
Hooks --> SDK
|
||||||
|
Store --> SDK
|
||||||
|
SDK --> API
|
||||||
|
SDK --> WS
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 技术说明
|
||||||
|
|
||||||
|
- **前端框架**:React 18 + TypeScript + Vite 6
|
||||||
|
- **UI 组件库**:shadcn/ui(Radix UI + Tailwind CSS)
|
||||||
|
- **状态管理**:Zustand(轻量、无 boilerplate、支持中间件)
|
||||||
|
- **样式方案**:Tailwind CSS 4 + CSS 变量主题系统
|
||||||
|
- **图标**:Lucide React
|
||||||
|
- **动画**:Framer Motion
|
||||||
|
- **Markdown 渲染**:react-markdown + remark-gfm + rehype-highlight
|
||||||
|
- **HTTP/WebSocket**:array-sdk(从旧项目迁移,纯 JS 无框架依赖)
|
||||||
|
- **路由**:React Router DOM v7(Hash 模式,兼容静态部署)
|
||||||
|
- **国际化**:自定义 i18n hook(中/英双语)
|
||||||
|
- **PWA**:vite-plugin-pwa
|
||||||
|
- **项目初始化**:vite-init react-ts 模板
|
||||||
|
|
||||||
|
## 3. 路由定义
|
||||||
|
|
||||||
|
| 路由 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `/` | 登录页(未登录时)或主工作台(已登录时) |
|
||||||
|
| `/task/:taskId` | 主工作台,自动选中指定任务 |
|
||||||
|
|
||||||
|
## 4. API 定义
|
||||||
|
|
||||||
|
本项目为纯前端,所有 API 调用通过 array-sdk 与后端通信。SDK 封装层提供以下接口:
|
||||||
|
|
||||||
|
### 4.1 认证
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AuthAPI {
|
||||||
|
login(serverUrl: string, apiKey: string): Promise<Worker>
|
||||||
|
loginByPassword(serverUrl: string, username: string, password: string): Promise<Worker>
|
||||||
|
register(serverUrl: string, name: string, username: string, password: string, profile?: RegisterProfile): Promise<Worker>
|
||||||
|
autoLogin(): Promise<Worker | null>
|
||||||
|
logout(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Worker
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface WorkerAPI {
|
||||||
|
getWorkers(params?: { orgId?: string }): Promise<Worker[]>
|
||||||
|
getWorkerProfile(workerId: string): Promise<Worker>
|
||||||
|
updateProfile(data: Partial<WorkerProfile>): Promise<Worker>
|
||||||
|
getWorkerWorkload(workerId: string): Promise<Workload>
|
||||||
|
discoverWorkers(options: DiscoverOptions): Promise<DiscoverResult>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Task
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TaskAPI {
|
||||||
|
getTasks(params?: { orgId?: string; categoryId?: string }): Promise<Task[]>
|
||||||
|
getTask(taskId: string): Promise<Task>
|
||||||
|
createTask(title: string, participantIds: string[], priority?: string, extra?: CreateTaskExtra): Promise<Task>
|
||||||
|
updateTaskStatus(taskId: string, status: TaskStatus): Promise<Task>
|
||||||
|
updateTaskTitle(taskId: string, title: string): Promise<Task>
|
||||||
|
addParticipant(taskId: string, workerId: string): Promise<Participant[]>
|
||||||
|
removeParticipant(taskId: string, workerId: string): Promise<void>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Message
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface MessageAPI {
|
||||||
|
getMessages(taskId: string, scope?: string, includePrivate?: boolean, limit?: number, page?: number): Promise<Message[]>
|
||||||
|
sendMessage(taskId: string, content: string, options?: SendMessageOptions): Promise<Message>
|
||||||
|
sendMessageWithAttachments(taskId: string, content: string, fileIds: string[], options?: SendMessageOptions): Promise<Message>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Attachment
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AttachmentAPI {
|
||||||
|
uploadAttachment(taskId: string, file: File): Promise<Attachment>
|
||||||
|
listAttachments(taskId: string): Promise<{ attachments: Attachment[]; total: number }>
|
||||||
|
deleteAttachment(taskId: string, attachmentId: string): Promise<void>
|
||||||
|
getAttachmentUrl(taskId: string, attachmentId: string): string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 Organization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface OrganizationAPI {
|
||||||
|
listMyOrganizations(): Promise<Organization[]>
|
||||||
|
getOrganization(orgId: string): Promise<Organization>
|
||||||
|
createOrganization(name: string, description?: string): Promise<Organization>
|
||||||
|
updateOrganization(orgId: string, data: Partial<Organization>): Promise<Organization>
|
||||||
|
createInvite(orgId: string, options?: InviteOptions): Promise<OrgInvite>
|
||||||
|
listInvites(orgId: string): Promise<OrgInvite[]>
|
||||||
|
joinOrganization(inviteCode: string): Promise<JoinResult>
|
||||||
|
listOrgMembers(orgId: string): Promise<OrgMember[]>
|
||||||
|
removeOrgMember(orgId: string, workerId: string): Promise<void>
|
||||||
|
updateMemberRole(orgId: string, workerId: string, role: 'admin' | 'member'): Promise<OrgMember>
|
||||||
|
renameOrgMember(orgId: string, workerId: string, name: string): Promise<OrgMember>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7 Category
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CategoryAPI {
|
||||||
|
createCategory(name: string, orgId?: string): Promise<Category>
|
||||||
|
listCategories(orgId?: string): Promise<Category[]>
|
||||||
|
updateCategory(categoryId: string, opts: Partial<Category>): Promise<Category>
|
||||||
|
deleteCategory(categoryId: string): Promise<boolean>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.8 Review
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ReviewAPI {
|
||||||
|
createReview(workerId: string, params: CreateReviewParams): Promise<Review>
|
||||||
|
getWorkerReviews(workerId: string, options?: PaginationOptions): Promise<ReviewListResult>
|
||||||
|
getReviewSummary(workerId: string): Promise<ReviewSummary>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.9 Notification
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface NotificationAPI {
|
||||||
|
getNotifications(isRead?: boolean): Promise<Notification[]>
|
||||||
|
markNotificationRead(notificationId: string): Promise<void>
|
||||||
|
markAllNotificationsRead(): Promise<void>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.10 WebSocket
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface WebSocketAPI {
|
||||||
|
startWebSocket(callbacks: WSCallbacks): () => void
|
||||||
|
stopWebSocket(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WSCallbacks {
|
||||||
|
onEvent: (event: WSEvent) => void
|
||||||
|
onConnected: () => void
|
||||||
|
onDisconnected: () => void
|
||||||
|
onError: (err: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type WSEventType = 'task.message' | 'participant.joined' | 'task.updated' | 'worker_status_changed' | 'tasks.pending_recovery'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 数据模型
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
Organization ||--o{ Category : "has"
|
||||||
|
Organization ||--o{ OrgMember : "has"
|
||||||
|
Organization ||--o{ OrgInvite : "has"
|
||||||
|
Worker ||--o{ Task : "creates"
|
||||||
|
Worker ||--o{ Review : "receives"
|
||||||
|
Task ||--o{ Participant : "has"
|
||||||
|
Task ||--o{ Message : "has"
|
||||||
|
Task ||--o{ Attachment : "has"
|
||||||
|
Task }o--|| Category : "belongs_to"
|
||||||
|
Message }o--o| Message : "reply_to"
|
||||||
|
Message }o--o| Worker : "private_to"
|
||||||
|
|
||||||
|
Worker {
|
||||||
|
string id PK
|
||||||
|
string name
|
||||||
|
string type
|
||||||
|
string status
|
||||||
|
string organization
|
||||||
|
string location
|
||||||
|
string description
|
||||||
|
string[] tags
|
||||||
|
object capabilities
|
||||||
|
number maxConcurrentTasks
|
||||||
|
}
|
||||||
|
|
||||||
|
Task {
|
||||||
|
string id PK
|
||||||
|
string title
|
||||||
|
string description
|
||||||
|
string status
|
||||||
|
string priority
|
||||||
|
string creator_id FK
|
||||||
|
string org_id FK
|
||||||
|
string category_id FK
|
||||||
|
datetime created_at
|
||||||
|
datetime last_message_at
|
||||||
|
}
|
||||||
|
|
||||||
|
Message {
|
||||||
|
string id PK
|
||||||
|
string task_id FK
|
||||||
|
string sender_id FK
|
||||||
|
string content
|
||||||
|
string type
|
||||||
|
string reply_to_id FK
|
||||||
|
string private_to_id FK
|
||||||
|
datetime created_at
|
||||||
|
}
|
||||||
|
|
||||||
|
Attachment {
|
||||||
|
string id PK
|
||||||
|
string task_id FK
|
||||||
|
string filename
|
||||||
|
string content_type
|
||||||
|
number size
|
||||||
|
string url
|
||||||
|
string thumbnail_url
|
||||||
|
number width
|
||||||
|
number height
|
||||||
|
number duration
|
||||||
|
}
|
||||||
|
|
||||||
|
Review {
|
||||||
|
string id PK
|
||||||
|
string worker_id FK
|
||||||
|
string task_id FK
|
||||||
|
string reviewer_id FK
|
||||||
|
number rating
|
||||||
|
string[] capability_tags
|
||||||
|
string comment
|
||||||
|
string suggestion
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 项目目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
human-adapter-react/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # UI 组件
|
||||||
|
│ │ ├── ui/ # shadcn/ui 基础组件
|
||||||
|
│ │ ├── layout/ # 布局组件
|
||||||
|
│ │ │ ├── TopBar.tsx
|
||||||
|
│ │ │ ├── WorkersPanel.tsx
|
||||||
|
│ │ │ ├── TaskList.tsx
|
||||||
|
│ │ │ └── Resizer.tsx
|
||||||
|
│ │ ├── chat/ # 聊天相关组件
|
||||||
|
│ │ │ ├── ChatDetail.tsx
|
||||||
|
│ │ │ ├── MessageList.tsx
|
||||||
|
│ │ │ ├── MessageBubble.tsx
|
||||||
|
│ │ │ ├── MessageInput.tsx
|
||||||
|
│ │ │ ├── MentionPopup.tsx
|
||||||
|
│ │ │ ├── ReplyPreview.tsx
|
||||||
|
│ │ │ ├── AttachmentRenderer.tsx
|
||||||
|
│ │ │ ├── DMTab.tsx
|
||||||
|
│ │ │ └── ScrollToBottom.tsx
|
||||||
|
│ │ ├── task/ # 任务相关组件
|
||||||
|
│ │ │ ├── TaskCard.tsx
|
||||||
|
│ │ │ ├── TaskComposer.tsx
|
||||||
|
│ │ │ ├── TaskDrawer.tsx
|
||||||
|
│ │ │ ├── StatusSelector.tsx
|
||||||
|
│ │ │ └── ParticipantManager.tsx
|
||||||
|
│ │ ├── org/ # 组织相关组件
|
||||||
|
│ │ │ ├── OrgSwitcher.tsx
|
||||||
|
│ │ │ ├── OrgDetailSheet.tsx
|
||||||
|
│ │ │ └── InviteManager.tsx
|
||||||
|
│ │ ├── profile/ # 个人中心组件
|
||||||
|
│ │ │ ├── ProfileDialog.tsx
|
||||||
|
│ │ │ └── ShortcutSettings.tsx
|
||||||
|
│ │ ├── review/ # 评价组件
|
||||||
|
│ │ │ ├── ReviewDialog.tsx
|
||||||
|
│ │ │ └── ReviewSummary.tsx
|
||||||
|
│ │ ├── auth/ # 登录组件
|
||||||
|
│ │ │ └── LoginPage.tsx
|
||||||
|
│ │ └── shared/ # 共享组件
|
||||||
|
│ │ ├── LedLogo.tsx
|
||||||
|
│ │ ├── LedMatrix.tsx
|
||||||
|
│ │ ├── RobotIcon.tsx
|
||||||
|
│ │ ├── WorkerAvatar.tsx
|
||||||
|
│ │ ├── Toast.tsx
|
||||||
|
│ │ └── PwaInstallBanner.tsx
|
||||||
|
│ ├── hooks/ # 自定义 Hooks
|
||||||
|
│ │ ├── useAuth.ts
|
||||||
|
│ │ ├── useMessages.ts
|
||||||
|
│ │ ├── useWorkers.ts
|
||||||
|
│ │ ├── useTasks.ts
|
||||||
|
│ │ ├── useOrganization.ts
|
||||||
|
│ │ ├── useNotifications.ts
|
||||||
|
│ │ ├── useReviews.ts
|
||||||
|
│ │ ├── useProfile.ts
|
||||||
|
│ │ ├── useWebSocket.ts
|
||||||
|
│ │ ├── useI18n.ts
|
||||||
|
│ │ ├── useTheme.ts
|
||||||
|
│ │ ├── useAutoScroll.ts
|
||||||
|
│ │ └── useDrafts.ts
|
||||||
|
│ ├── stores/ # Zustand Store
|
||||||
|
│ │ ├── authStore.ts
|
||||||
|
│ │ ├── appStore.ts
|
||||||
|
│ │ └── uiStore.ts
|
||||||
|
│ ├── lib/ # 工具库
|
||||||
|
│ │ ├── sdk.ts # array-sdk 封装
|
||||||
|
│ │ ├── api.ts # API 薄封装层
|
||||||
|
│ │ ├── i18n.ts # 国际化字典
|
||||||
|
│ │ ├── utils.ts # 通用工具函数
|
||||||
|
│ │ └── constants.ts # 常量定义
|
||||||
|
│ ├── pages/ # 页面
|
||||||
|
│ │ ├── LoginPage.tsx
|
||||||
|
│ │ └── WorkbenchPage.tsx
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ ├── main.tsx
|
||||||
|
│ └── index.css # Tailwind 入口 + CSS 变量
|
||||||
|
├── public/
|
||||||
|
├── sdk-js/ # array-sdk 源码(从旧项目迁移)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── client.ts
|
||||||
|
│ │ ├── adapter.ts
|
||||||
|
│ │ └── index.ts
|
||||||
|
│ └── package.json
|
||||||
|
├── .trae/documents/
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── vite.config.ts
|
||||||
|
├── tailwind.config.js
|
||||||
|
├── components.json # shadcn/ui 配置
|
||||||
|
└── postcss.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 关键架构决策
|
||||||
|
|
||||||
|
### 7.1 状态管理:Zustand vs Context
|
||||||
|
|
||||||
|
选择 Zustand 的理由:
|
||||||
|
- 无 Provider 包裹,任何组件直接 `useStore()` 即可
|
||||||
|
- 支持 `subscribeWithSelector` 中间件,精确控制重渲染
|
||||||
|
- 支持 `persist` 中间件,自动持久化到 localStorage
|
||||||
|
- 比 Redux 少 90% boilerplate,比 Jotai 更适合复杂状态
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// appStore.ts 示例
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
workers: Worker[]
|
||||||
|
tasks: Task[]
|
||||||
|
selectedTaskId: string | null
|
||||||
|
wsConnected: boolean
|
||||||
|
setWorkers: (workers: Worker[]) => void
|
||||||
|
setTasks: (tasks: Task[]) => void
|
||||||
|
selectTask: (id: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
workers: [],
|
||||||
|
tasks: [],
|
||||||
|
selectedTaskId: null,
|
||||||
|
wsConnected: false,
|
||||||
|
setWorkers: (workers) => set({ workers }),
|
||||||
|
setTasks: (tasks) => set({ tasks }),
|
||||||
|
selectTask: (id) => set({ selectedTaskId: id }),
|
||||||
|
}),
|
||||||
|
{ name: 'array-app-store', partialize: (s) => ({ selectedTaskId: s.selectedTaskId }) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 消息系统:自定义 Hook
|
||||||
|
|
||||||
|
不使用 Vercel AI SDK 的 `useChat()`,因为:
|
||||||
|
- 本项目的消息模型更复杂(私密消息、@提及、引用回复、附件)
|
||||||
|
- WebSocket 事件格式是后端自定义的
|
||||||
|
- 需要精确控制乐观消息的生命周期
|
||||||
|
|
||||||
|
自定义 `useMessages` Hook 借鉴 AI SDK 的状态机思想:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type MessageStatus = 'idle' | 'sending' | 'streaming' | 'error'
|
||||||
|
|
||||||
|
interface UseMessagesReturn {
|
||||||
|
messages: Message[]
|
||||||
|
pendingMessages: PendingMessage[]
|
||||||
|
isLoading: boolean
|
||||||
|
isAtBottom: boolean
|
||||||
|
sendMessage: (content: string, options?: SendOptions) => Promise<void>
|
||||||
|
retryMessage: (pendingId: string) => Promise<void>
|
||||||
|
cancelMessage: (pendingId: string) => void
|
||||||
|
loadMore: () => Promise<void>
|
||||||
|
hasMore: boolean
|
||||||
|
scrollToBottom: () => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 主题系统:Tailwind CSS 变量
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 221.2 83.2% 53.3%;
|
||||||
|
--accent: 221.2 83.2% 53.3%;
|
||||||
|
/* ... shadcn 默认变量 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--primary: 217.2 91.2% 59.8%;
|
||||||
|
--accent: 217.2 91.2% 59.8%;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
切换主题只需在 `<html>` 上添加/移除 `dark` 类,所有组件自动响应。
|
||||||
|
|
||||||
|
### 7.4 SDK 迁移策略
|
||||||
|
|
||||||
|
array-sdk 是纯 JavaScript,无框架依赖,直接迁移:
|
||||||
|
- `sdk-js/` 目录整体复制到新项目
|
||||||
|
- `package.json` 中 `"array-sdk": "file:./sdk-js"` 保持不变
|
||||||
|
- API 封装层 `lib/api.ts` 从旧项目 `api/index.js` 翻译为 TypeScript
|
||||||
|
- 所有方法签名添加 TypeScript 类型
|
||||||
|
|
||||||
|
### 7.5 组件拆分原则
|
||||||
|
|
||||||
|
旧项目 TaskDetail.vue 72KB → 拆分为 8+ 个子组件:
|
||||||
|
|
||||||
|
| 旧代码位置 | 新组件 | 职责 |
|
||||||
|
|------------|--------|------|
|
||||||
|
| 消息流模板 | `MessageList.tsx` | 消息列表渲染 + 时间分割线 |
|
||||||
|
| 单条消息 | `MessageBubble.tsx` | 消息气泡 + 头像 + 引用 + 附件 |
|
||||||
|
| 回复输入栏 | `MessageInput.tsx` | 输入框 + 发送按钮 + 快捷键 |
|
||||||
|
| @提及弹窗 | `MentionPopup.tsx` | 搜索 + 选择参与者 |
|
||||||
|
| 私密消息 | `DMTab.tsx` | 私聊通道列表 + 消息 |
|
||||||
|
| 附件渲染 | `AttachmentRenderer.tsx` | 图片/视频/音频/文件智能渲染 |
|
||||||
|
| 任务抽屉 | `TaskDrawer.tsx` | 参与者 + 详情 + 评价 |
|
||||||
|
| 状态选择器 | `StatusSelector.tsx` | 状态下拉切换 |
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# 列阵工作台 (React版
|
||||||
|
|
||||||
|
这是一个基于 React + TypeScript + Vite + shadcn/ui 构建的人机协作工作台,让 AI 工作者和人类工作者在同一平台上实时协作。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **前端框架**: React 18.3 + TypeScript
|
||||||
|
- **构建工具**: Vite 6
|
||||||
|
- **UI 组件**: shadcn/ui (Radix UI + Tailwind CSS)
|
||||||
|
- **状态管理**: Zustand
|
||||||
|
- **动画**: Framer Motion
|
||||||
|
- **路由**: React Router DOM v7
|
||||||
|
- **图标**: Lucide React
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- 🔐 **身份验证(API Key 和密码登录)
|
||||||
|
- 👥 **成员面板(在线/离线状态显示)
|
||||||
|
- 📋 **任务管理(任务列表、任务状态、优先级)
|
||||||
|
- 💬 **实时聊天(公共消息、私密消息、附件)
|
||||||
|
- 🎯 **AI 协作(AI 工作者参与任务)
|
||||||
|
- 🌙 **深色主题(默认)
|
||||||
|
- 📱 **响应式设计
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install --legacy-peer-deps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
human-adapter-react/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # UI 组件
|
||||||
|
│ │ ├── shared/ # 共享组件
|
||||||
|
│ │ └── ui/ # shadcn 组件
|
||||||
|
│ ├── hooks/ # 自定义 Hooks
|
||||||
|
│ ├── lib/ # 工具函数
|
||||||
|
│ ├── pages/ # 页面组件
|
||||||
|
│ ├── stores/ # Zustand 状态管理
|
||||||
|
│ ├── App.tsx
|
||||||
|
│ └── main.tsx
|
||||||
|
│ └── index.css
|
||||||
|
├── sdk-js/ # Array SDK(从原项目迁移)
|
||||||
|
├── .trae/documents/ # PRD 和架构文档
|
||||||
|
├── vite.config.ts
|
||||||
|
├── tailwind.config.js
|
||||||
|
├── tsconfig.json
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 使用 `--legacy-peer-deps` 安装依赖,解决 peer dependencies 冲突
|
||||||
|
- 默认使用深色主题
|
||||||
|
- 项目包含完整的类型支持
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist', 'src/lib/sdk.ts'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>My Trae Project</title>
|
||||||
|
<script type="module">
|
||||||
|
if (import.meta.hot?.on) {
|
||||||
|
import.meta.hot.on('vite:error', (error) => {
|
||||||
|
if (error.err) {
|
||||||
|
console.error(
|
||||||
|
[error.err.message, error.err.frame].filter(Boolean).join('\n'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+9945
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "human-adapter-react",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "tsc -b --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.5.0",
|
||||||
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
|
"array-sdk": "file:./sdk-js",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"framer-motion": "^12.40.0",
|
||||||
|
"lucide-react": "^0.511.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"react-router-dom": "^7.3.0",
|
||||||
|
"rehype-highlight": "^7.0.2",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"shadcn": "^4.8.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/node": "^22.15.30",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"babel-plugin-react-dev-locator": "^1.0.0",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.30.1",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-trae-solo-badge": "^1.0.0",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="32" height="32" fill="#0A0B0D"/>
|
||||||
|
<path d="M26.6677 23.7149H8.38057V20.6496H5.33301V8.38159H26.6677V23.7149ZM8.38057 20.6496H23.6201V11.4482H8.38057V20.6496ZM16.0011 16.0021L13.8461 18.1705L11.6913 16.0021L13.8461 13.8337L16.0011 16.0021ZM22.0963 16.0008L19.9414 18.1691L17.7865 16.0008L19.9414 13.8324L22.0963 16.0008Z" fill="#32F08C"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 453 B |
Generated
+41
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "array-sdk",
|
||||||
|
"version": "0.7.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "array-sdk",
|
||||||
|
"version": "0.7.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"ws": "^8.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "array-sdk",
|
||||||
|
"version": "0.7.0",
|
||||||
|
"description": "列阵协作系统 - JavaScript SDK",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"ws": "^8.20.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* BaseAdapter - 适配器基类,封装 Worker 的完整生命周期
|
||||||
|
*
|
||||||
|
* 关于 Worker 描述(description)的推荐格式:
|
||||||
|
* 在注册 Worker 或更新简历时,description 字段建议按以下结构编写,
|
||||||
|
* 方便其他 Worker 和人类快速理解你的能力。
|
||||||
|
*
|
||||||
|
* 推荐格式:
|
||||||
|
* [一句话功能概述]
|
||||||
|
*
|
||||||
|
* 能力:
|
||||||
|
* - 能力1
|
||||||
|
* - 能力2
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* [说明怎么跟这个 Worker 交互]
|
||||||
|
*
|
||||||
|
* 限制:(可选)
|
||||||
|
* - 限制1
|
||||||
|
*
|
||||||
|
* 示例:
|
||||||
|
* "多语言实时翻译工具,支持中英日韩互译。\n\n"
|
||||||
|
* "能力:\n"
|
||||||
|
* "- 文本翻译(中/英/日/韩)\n"
|
||||||
|
* "- 自动检测源语言\n\n"
|
||||||
|
* "使用方式:\n"
|
||||||
|
* "@提及并发送需要翻译的文本,默认翻译为中文。\n\n"
|
||||||
|
* "限制:\n"
|
||||||
|
* "- 单次最多翻译 5000 字"
|
||||||
|
*
|
||||||
|
* 实际使用时,可通过 client.updateProfile({ description: "..." }) 或注册时传入。
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* 关于组织(Organization)功能:
|
||||||
|
* ============================================================================
|
||||||
|
*
|
||||||
|
* 组织是多对多关系,一个 Worker 可以属于多个组织,一个组织可以有多个 Worker。
|
||||||
|
* 组织用于隔离任务和 Worker,便于团队协作。
|
||||||
|
*
|
||||||
|
* 组织相关 API(通过 client 调用):
|
||||||
|
*
|
||||||
|
* 组织管理:
|
||||||
|
* - client.createOrganization(name, description) - 创建组织,创建者自动成为 admin
|
||||||
|
* - client.listMyOrganizations() - 列出我加入的所有组织
|
||||||
|
* - client.getOrganization(orgId) - 获取组织详情
|
||||||
|
* - client.updateOrganization(orgId, { name, description }) - 更新组织信息(需 admin 权限)
|
||||||
|
*
|
||||||
|
* 邀请码管理:
|
||||||
|
* - client.createInvite(orgId, { expiresAt, maxUses }) - 创建邀请码(需 admin 权限)
|
||||||
|
* - client.listInvites(orgId) - 列出组织的邀请码(需 admin 权限)
|
||||||
|
* - client.joinOrganization(inviteCode) - 通过邀请码加入组织
|
||||||
|
*
|
||||||
|
* 成员管理:
|
||||||
|
* - client.listOrgMembers(orgId) - 列出组织成员
|
||||||
|
* - client.removeOrgMember(orgId, workerId) - 移除成员(需 admin 权限,不能移除自己)
|
||||||
|
* - client.updateMemberRole(orgId, workerId, role) - 修改成员角色(需 admin 权限)
|
||||||
|
*
|
||||||
|
* 组织过滤:
|
||||||
|
* - client.listWorkers({ orgId }) - 按组织过滤 Worker 列表
|
||||||
|
* - client.listTasks({ orgId }) - 按组织过滤任务列表
|
||||||
|
* - client.createTask({ ..., orgId }) - 创建任务时指定所属组织
|
||||||
|
*
|
||||||
|
* 角色说明:
|
||||||
|
* - admin: 组织管理员,可以管理组织信息、邀请码、成员角色
|
||||||
|
* - member: 普通成员,可以查看组织和参与任务
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ArrayClient } from './client.js';
|
||||||
|
|
||||||
|
export class BaseAdapter {
|
||||||
|
/**
|
||||||
|
* @param {string} serverUrl - 服务器地址
|
||||||
|
* @param {string} apiKey - API 密钥
|
||||||
|
*/
|
||||||
|
constructor(serverUrl, apiKey) {
|
||||||
|
this.serverUrl = serverUrl;
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.client = null;
|
||||||
|
this._running = false;
|
||||||
|
this._reconnectDelay = 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 被邀请加入新任务时的处理逻辑。子类必须实现。
|
||||||
|
* @param {Object} task - 任务对象
|
||||||
|
* @throws {Error} 如果未实现
|
||||||
|
*/
|
||||||
|
async onTaskInvited(task) {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容别名,推荐使用 onTaskInvited
|
||||||
|
* @param {Object} task - 任务对象
|
||||||
|
*/
|
||||||
|
async onTaskAssigned(task) {
|
||||||
|
return this.onTaskInvited(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收到任务消息时的处理逻辑。子类可选覆盖。
|
||||||
|
* @param {string} taskId - 任务 ID
|
||||||
|
* @param {Object} payload - 消息载荷
|
||||||
|
*/
|
||||||
|
async onTaskMessage(taskId, payload) {
|
||||||
|
// 子类可覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务状态/内容变更时的处理逻辑。子类可选覆盖。
|
||||||
|
* @param {string} taskId - 任务 ID
|
||||||
|
* @param {Object} changes - 变更内容
|
||||||
|
*/
|
||||||
|
async onTaskUpdated(taskId, changes) {
|
||||||
|
// 子类可覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket 连上后收到待恢复任务列表时调用。子类可覆盖。
|
||||||
|
* @param {Array} tasks - 待恢复任务列表,格式为 [{id, title, status}, ...]
|
||||||
|
*/
|
||||||
|
async onPendingRecovery(tasks) {
|
||||||
|
// 子类可覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 连接成功后的回调。子类可选覆盖。
|
||||||
|
*/
|
||||||
|
async onConnected() {
|
||||||
|
// 子类可覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket 断开时的回调,子类可覆盖
|
||||||
|
*/
|
||||||
|
async onDisconnected() {
|
||||||
|
// 子类可覆盖
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动 Worker 完整生命周期。
|
||||||
|
*
|
||||||
|
* 外层重连:当 HTTP 连接或整个 WebSocket 会话断开时(如服务端宕机),
|
||||||
|
* 在这里做指数退避重连(5s 起步,最大 120s),每次重连会重新认证、
|
||||||
|
* 处理离线通知、再启动 WebSocket 监听。
|
||||||
|
*
|
||||||
|
* 内层重连由 client.listen() 负责:当 WebSocket 连接断开但 HTTP
|
||||||
|
* 会话仍然有效时,listen() 内部自动重连(2s 起步,最大 60s)。
|
||||||
|
*
|
||||||
|
* 两层的区别:
|
||||||
|
* - 内层(listen):仅 WebSocket 断线重连,不涉及重新认证
|
||||||
|
* - 外层(run):完整的会话重建,包括重新认证和离线通知恢复
|
||||||
|
*/
|
||||||
|
async run() {
|
||||||
|
this._running = true;
|
||||||
|
this._reconnectDelay = 5000;
|
||||||
|
|
||||||
|
while (this._running) {
|
||||||
|
try {
|
||||||
|
this.client = new ArrayClient(this.serverUrl, this.apiKey);
|
||||||
|
await this.client.connect();
|
||||||
|
this._reconnectDelay = 5000; // 重置退避
|
||||||
|
|
||||||
|
console.log(`Worker ${this.client.workerId} 已连接到平台`);
|
||||||
|
|
||||||
|
// 处理离线期间的通知
|
||||||
|
await this._processOfflineNotifications();
|
||||||
|
|
||||||
|
// 启动 WebSocket 监听
|
||||||
|
console.log('开始监听实时事件...');
|
||||||
|
|
||||||
|
const wsPromise = new Promise((resolve, reject) => {
|
||||||
|
const closeFn = this.client.listen({
|
||||||
|
onEvent: (event) => {
|
||||||
|
this._handleEvent(event).catch(err => {
|
||||||
|
console.error('Error handling event:', err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onConnected: () => {
|
||||||
|
this.onConnected().catch(err => {
|
||||||
|
console.error('Error in onConnected:', err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDisconnected: () => {
|
||||||
|
// WebSocket 断开,结束当前循环,外层会重连
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
console.error('WebSocket error:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 等待 WebSocket 连接断开
|
||||||
|
await wsPromise;
|
||||||
|
|
||||||
|
// 正常断开(非异常)
|
||||||
|
await this.onDisconnected();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (!this._running) break;
|
||||||
|
|
||||||
|
console.warn(`连接失败: ${err.message},${this._reconnectDelay / 1000}s 后重试`);
|
||||||
|
await this.onDisconnected();
|
||||||
|
|
||||||
|
// 等待后重试
|
||||||
|
await this._sleep(this._reconnectDelay);
|
||||||
|
this._reconnectDelay = Math.min(this._reconnectDelay * 2, 120000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止适配器
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
this._running = false;
|
||||||
|
if (this.client) {
|
||||||
|
this.client.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理离线期间积累的通知
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _processOfflineNotifications() {
|
||||||
|
const { notifications, total } = await this.client.getNotifications({ isRead: false });
|
||||||
|
|
||||||
|
if (notifications && notifications.length > 0) {
|
||||||
|
console.log(`处理 ${notifications.length} 条离线通知...`);
|
||||||
|
|
||||||
|
for (const n of notifications) {
|
||||||
|
const event = {
|
||||||
|
eventType: n.event_type,
|
||||||
|
payload: n.payload || {},
|
||||||
|
timestamp: n.created_at
|
||||||
|
};
|
||||||
|
await this._handleEvent(event);
|
||||||
|
await this.client.markNotificationRead(n.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分发事件到对应的处理方法
|
||||||
|
* @private
|
||||||
|
* @param {Object} event - 事件对象
|
||||||
|
*/
|
||||||
|
async _handleEvent(event) {
|
||||||
|
const { eventType, payload } = event;
|
||||||
|
|
||||||
|
if (eventType === 'task.assigned') {
|
||||||
|
const taskId = payload?.task_id;
|
||||||
|
if (taskId) {
|
||||||
|
const task = await this.client.getTask(taskId);
|
||||||
|
await this.onTaskInvited(task);
|
||||||
|
}
|
||||||
|
} else if (eventType === 'participant.joined') {
|
||||||
|
const taskId = payload?.task_id;
|
||||||
|
if (taskId) {
|
||||||
|
const task = await this.client.getTask(taskId);
|
||||||
|
await this.onTaskInvited(task);
|
||||||
|
}
|
||||||
|
} else if (eventType === 'tasks.pending_recovery') {
|
||||||
|
const tasks = payload?.tasks || [];
|
||||||
|
await this.onPendingRecovery(tasks);
|
||||||
|
} else if (eventType === 'task.message') {
|
||||||
|
const taskId = payload?.task_id;
|
||||||
|
if (taskId) {
|
||||||
|
await this.onTaskMessage(taskId, payload);
|
||||||
|
}
|
||||||
|
} else if (eventType === 'task.updated') {
|
||||||
|
const taskId = payload?.task_id;
|
||||||
|
const changes = payload?.changes || {};
|
||||||
|
if (taskId) {
|
||||||
|
await this.onTaskUpdated(taskId, changes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠指定毫秒
|
||||||
|
* @private
|
||||||
|
* @param {number} ms
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
_sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
export { ArrayClient } from './client.js';
|
||||||
|
export { BaseAdapter } from './adapter.js';
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
|
||||||
|
import LoginPage from "@/pages/LoginPage";
|
||||||
|
import WorkbenchPage from "@/pages/WorkbenchPage";
|
||||||
|
import { useAuthStore } from "@/stores";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Router>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
isAuthenticated ? <Navigate to="/workbench" replace /> : <LoginPage />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/workbench"
|
||||||
|
element={
|
||||||
|
isAuthenticated ? <WorkbenchPage /> : <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</Router>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
// Empty component
|
||||||
|
export default function Empty() {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex h-full items-center justify-center')}>Empty</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Zap } from "lucide-react";
|
||||||
|
|
||||||
|
export function LEDLogo() {
|
||||||
|
const leds = [
|
||||||
|
{ x: 0, y: 0, color: "bg-cyan-500" },
|
||||||
|
{ x: 2, y: 0, color: "bg-cyan-400" },
|
||||||
|
{ x: 4, y: 0, color: "bg-cyan-500" },
|
||||||
|
{ x: 6, y: 0, color: "bg-cyan-400" },
|
||||||
|
{ x: 1, y: 1, color: "bg-cyan-600" },
|
||||||
|
{ x: 3, y: 1, color: "bg-cyan-500" },
|
||||||
|
{ x: 5, y: 1, color: "bg-cyan-600" },
|
||||||
|
{ x: 0, y: 2, color: "bg-purple-500" },
|
||||||
|
{ x: 2, y: 2, color: "bg-cyan-400" },
|
||||||
|
{ x: 4, y: 2, color: "bg-cyan-400" },
|
||||||
|
{ x: 6, y: 2, color: "bg-purple-500" },
|
||||||
|
{ x: 1, y: 3, color: "bg-purple-600" },
|
||||||
|
{ x: 3, y: 3, color: "bg-cyan-500" },
|
||||||
|
{ x: 5, y: 3, color: "bg-purple-600" },
|
||||||
|
{ x: 0, y: 4, color: "bg-purple-500" },
|
||||||
|
{ x: 2, y: 4, color: "bg-purple-400" },
|
||||||
|
{ x: 4, y: 4, color: "bg-purple-400" },
|
||||||
|
{ x: 6, y: 4, color: "bg-purple-500" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute -inset-6 bg-gradient-to-r from-cyan-500/20 to-purple-500/20 rounded-full blur-2xl"></div>
|
||||||
|
<div className="relative grid grid-cols-7 gap-2 p-6 bg-slate-900/80 rounded-2xl border border-slate-700">
|
||||||
|
{leds.map((led, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0.3, scale: 0.8 }}
|
||||||
|
animate={{
|
||||||
|
opacity: [0.3, 1, 0.3],
|
||||||
|
scale: [0.8, 1, 0.8],
|
||||||
|
boxShadow: [
|
||||||
|
"none",
|
||||||
|
`0 0 20px ${led.color.replace("bg-", "")}`,
|
||||||
|
"none",
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: 2,
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: i * 0.1,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
className={`w-3 h-3 rounded-full ${led.color} transition-all`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2">
|
||||||
|
<div className="flex items-center gap-2 text-cyan-400 font-bold text-lg">
|
||||||
|
<Zap className="w-5 h-5 fill-cyan-400" />
|
||||||
|
<span>列阵</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
type AnimationType = "snake" | "tetris" | "fireworks" | "scroll" | "loading";
|
||||||
|
|
||||||
|
interface LEDMatrixProps {
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
type?: AnimationType;
|
||||||
|
speed?: number;
|
||||||
|
color?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LEDMatrix({
|
||||||
|
width = 16,
|
||||||
|
height = 16,
|
||||||
|
type = "loading",
|
||||||
|
speed = 100,
|
||||||
|
color = "bg-cyan-500",
|
||||||
|
className = "",
|
||||||
|
}: LEDMatrixProps) {
|
||||||
|
const [matrix, setMatrix] = useState<boolean[][]>(
|
||||||
|
Array(height).fill(null).map(() => Array(width).fill(false)),
|
||||||
|
);
|
||||||
|
const [, setTick] = useState(0);
|
||||||
|
const stateRef = useRef<{
|
||||||
|
snake?: { x: number; y: number }[];
|
||||||
|
food?: { x: number; y: number };
|
||||||
|
direction?: { x: number; y: number };
|
||||||
|
grid?: number[][];
|
||||||
|
currentPiece?: { blocks: { x: number; y: number }[] };
|
||||||
|
tick?: number;
|
||||||
|
particles?: { x: number; y: number; vx: number; vy: number; life: number }[];
|
||||||
|
index?: number;
|
||||||
|
direction?: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const initSnake = useCallback(() => {
|
||||||
|
stateRef.current = {
|
||||||
|
snake: [
|
||||||
|
{ x: 0, y: Math.floor(height / 2) },
|
||||||
|
{ x: 1, y: Math.floor(height / 2) },
|
||||||
|
{ x: 2, y: Math.floor(height / 2) },
|
||||||
|
],
|
||||||
|
food: { x: Math.floor(width / 2), y: Math.floor(height / 2) },
|
||||||
|
direction: { x: 1, y: 0 },
|
||||||
|
};
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
const initTetris = useCallback(() => {
|
||||||
|
stateRef.current = {
|
||||||
|
grid: Array(height).fill(null).map(() => Array(width).fill(0)),
|
||||||
|
currentPiece: {
|
||||||
|
blocks: [
|
||||||
|
{ x: Math.floor(width / 2), y: 0 },
|
||||||
|
{ x: Math.floor(width / 2), y: 1 },
|
||||||
|
{ x: Math.floor(width / 2) + 1, y: 0 },
|
||||||
|
{ x: Math.floor(width / 2) + 1, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
tick: 0,
|
||||||
|
};
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
const initFireworks = useCallback(() => {
|
||||||
|
stateRef.current = {
|
||||||
|
particles: [],
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initScroll = useCallback(() => {
|
||||||
|
stateRef.current = {
|
||||||
|
offset: 0,
|
||||||
|
letterIndex: 0,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initLoading = useCallback(() => {
|
||||||
|
stateRef.current = {
|
||||||
|
index: 0,
|
||||||
|
direction: 1,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const initAnimation = useCallback(() => {
|
||||||
|
switch (type) {
|
||||||
|
case "snake":
|
||||||
|
initSnake();
|
||||||
|
break;
|
||||||
|
case "tetris":
|
||||||
|
initTetris();
|
||||||
|
break;
|
||||||
|
case "fireworks":
|
||||||
|
initFireworks();
|
||||||
|
break;
|
||||||
|
case "scroll":
|
||||||
|
initScroll();
|
||||||
|
break;
|
||||||
|
case "loading":
|
||||||
|
default:
|
||||||
|
initLoading();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [type, initSnake, initTetris, initFireworks, initScroll, initLoading]);
|
||||||
|
|
||||||
|
const updateSnake = useCallback(() => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
const head = state.snake[state.snake.length - 1];
|
||||||
|
const newHead = {
|
||||||
|
x: head.x + state.direction.x,
|
||||||
|
y: head.y + state.direction.y,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
newHead.x < 0 ||
|
||||||
|
newHead.x >= width ||
|
||||||
|
newHead.y < 0 ||
|
||||||
|
newHead.y >= height
|
||||||
|
) {
|
||||||
|
state.direction.x = -state.direction.x;
|
||||||
|
state.direction.y = -state.direction.y;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.snake.push(newHead);
|
||||||
|
|
||||||
|
if (newHead.x === state.food.x && newHead.y === state.food.y) {
|
||||||
|
state.food = {
|
||||||
|
x: Math.floor(Math.random() * width),
|
||||||
|
y: Math.floor(Math.random() * height),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
state.snake.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMatrix = Array(height).fill(null).map(() => Array(width).fill(false));
|
||||||
|
state.snake.forEach((segment: { x: number; y: number }) => {
|
||||||
|
if (segment.x >= 0 && segment.x < width && segment.y >= 0 && segment.y < height) {
|
||||||
|
newMatrix[segment.y][segment.x] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
state.food.x >= 0 &&
|
||||||
|
state.food.x < width &&
|
||||||
|
state.food.y >= 0 &&
|
||||||
|
state.food.y < height
|
||||||
|
) {
|
||||||
|
newMatrix[state.food.y][state.food.x] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMatrix(newMatrix);
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
const updateTetris = useCallback(() => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
state.tick++;
|
||||||
|
|
||||||
|
if (state.tick % 5 === 0) {
|
||||||
|
const newBlocks = state.currentPiece.blocks.map((b: { x: number; y: number }) => ({
|
||||||
|
x: b.x,
|
||||||
|
y: b.y + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const canMove = newBlocks.every(
|
||||||
|
(b: { x: number; y: number }) =>
|
||||||
|
b.y < height && (b.y < 0 || !state.grid[b.y]?.[b.x]),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (canMove) {
|
||||||
|
state.currentPiece.blocks = newBlocks;
|
||||||
|
} else {
|
||||||
|
state.currentPiece.blocks.forEach((b: { x: number; y: number }) => {
|
||||||
|
if (b.y >= 0 && b.y < height) {
|
||||||
|
state.grid[b.y][b.x] = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const startX = Math.floor(Math.random() * (width - 4)) + 2;
|
||||||
|
state.currentPiece.blocks = [
|
||||||
|
{ x: startX, y: 0 },
|
||||||
|
{ x: startX, y: 1 },
|
||||||
|
{ x: startX + 1, y: 0 },
|
||||||
|
{ x: startX + 1, y: 1 },
|
||||||
|
];
|
||||||
|
state.tick = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMatrix = Array(height).fill(null).map(() => Array(width).fill(false));
|
||||||
|
state.grid.forEach((row: number[], y: number) => {
|
||||||
|
row.forEach((cell: number, x: number) => {
|
||||||
|
if (cell) newMatrix[y][x] = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
state.currentPiece.blocks.forEach((b: { x: number; y: number }) => {
|
||||||
|
if (b.x >= 0 && b.x < width && b.y >= 0 && b.y < height) {
|
||||||
|
newMatrix[b.y][b.x] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setMatrix(newMatrix);
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
const updateFireworks = useCallback(() => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
|
||||||
|
if (Math.random() < 0.1) {
|
||||||
|
state.particles.push({
|
||||||
|
x: Math.floor(Math.random() * width),
|
||||||
|
y: height - 1,
|
||||||
|
vx: (Math.random() - 0.5) * 2,
|
||||||
|
vy: -1,
|
||||||
|
life: 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.particles = state.particles
|
||||||
|
.map((p) => ({
|
||||||
|
...p,
|
||||||
|
x: p.x + p.vx,
|
||||||
|
y: p.y + p.vy,
|
||||||
|
life: p.life - 1,
|
||||||
|
}))
|
||||||
|
.filter((p) => p.life > 0);
|
||||||
|
|
||||||
|
const newMatrix = Array(height).fill(null).map(() => Array(width).fill(false));
|
||||||
|
state.particles.forEach((p) => {
|
||||||
|
const x = Math.floor(p.x);
|
||||||
|
const y = Math.floor(p.y);
|
||||||
|
if (x >= 0 && x < width && y >= 0 && y < height) {
|
||||||
|
newMatrix[y][x] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setMatrix(newMatrix);
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
const updateLoading = useCallback(() => {
|
||||||
|
const state = stateRef.current;
|
||||||
|
state.index += state.direction;
|
||||||
|
|
||||||
|
if (state.index >= width) {
|
||||||
|
state.direction = -1;
|
||||||
|
} else if (state.index < 0) {
|
||||||
|
state.direction = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMatrix = Array(height).fill(null).map(() => Array(width).fill(false));
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
if (state.index >= 0 && state.index < width) {
|
||||||
|
newMatrix[y][state.index] = true;
|
||||||
|
if (state.index - 1 >= 0) newMatrix[y][state.index - 1] = true;
|
||||||
|
if (state.index - 2 >= 0) newMatrix[y][state.index - 2] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMatrix(newMatrix);
|
||||||
|
}, [width, height]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initAnimation();
|
||||||
|
}, [type, initAnimation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animate = () => {
|
||||||
|
setTick((t) => t + 1);
|
||||||
|
switch (type) {
|
||||||
|
case "snake":
|
||||||
|
updateSnake();
|
||||||
|
break;
|
||||||
|
case "tetris":
|
||||||
|
updateTetris();
|
||||||
|
break;
|
||||||
|
case "fireworks":
|
||||||
|
updateFireworks();
|
||||||
|
break;
|
||||||
|
case "loading":
|
||||||
|
updateLoading();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interval = setInterval(animate, speed);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [type, speed, updateSnake, updateTetris, updateFireworks, updateLoading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col gap-1 p-4 bg-slate-900/50 rounded-xl border border-slate-700 ${className}`}>
|
||||||
|
{matrix.map((row, y) => (
|
||||||
|
<div key={y} className="flex gap-1 justify-center">
|
||||||
|
{row.map((lit, x) => (
|
||||||
|
<motion.div
|
||||||
|
key={`${x}-${y}`}
|
||||||
|
initial={false}
|
||||||
|
animate={{
|
||||||
|
opacity: lit ? 1 : 0.1,
|
||||||
|
scale: lit ? 1.1 : 1,
|
||||||
|
boxShadow: lit ? `0 0 8px ${color.replace("bg-", "")}` : "none",
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
className={`w-2 h-2 rounded-full transition-all ${lit ? color : "bg-slate-700"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useUIStore } from "@/stores";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { theme, setTheme } = useUIStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
root.classList.remove("light", "dark");
|
||||||
|
if (theme === "system") {
|
||||||
|
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
root.classList.add(systemTheme);
|
||||||
|
} else {
|
||||||
|
root.classList.add(theme);
|
||||||
|
}
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(theme === "dark" ? "light" : "dark");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="w-9 h-9 text-slate-400 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Sun className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">切换主题</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-2 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
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(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn(
|
||||||
|
"aspect-square size-full rounded-full object-cover",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarPrimitive.Fallback.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background 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(
|
||||||
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background 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",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
AvatarBadge,
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
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 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover: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({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
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 aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
} from "@/components/ui/input-group"
|
||||||
|
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
showCloseButton?: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
showCloseButton={showCloseButton}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||||
|
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className={cn("py-6 text-center text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("-mx-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||||
|
</CommandPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
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(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
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(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
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("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 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(
|
||||||
|
"font-heading 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-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
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("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", 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(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground 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(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
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(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
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("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", 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(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
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(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
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("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
|
||||||
|
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-group"
|
||||||
|
role="group"
|
||||||
|
className={cn(
|
||||||
|
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupAddonVariants = cva(
|
||||||
|
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
align: {
|
||||||
|
"inline-start":
|
||||||
|
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||||
|
"inline-end":
|
||||||
|
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||||
|
"block-start":
|
||||||
|
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||||
|
"block-end":
|
||||||
|
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
align: "inline-start",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupAddon({
|
||||||
|
className,
|
||||||
|
align = "inline-start",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
data-slot="input-group-addon"
|
||||||
|
data-align={align}
|
||||||
|
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if ((e.target as HTMLElement).closest("button")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputGroupButtonVariants = cva(
|
||||||
|
"flex items-center gap-2 text-sm shadow-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||||
|
sm: "",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||||
|
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: "xs",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function InputGroupButton({
|
||||||
|
className,
|
||||||
|
type = "button",
|
||||||
|
variant = "ghost",
|
||||||
|
size = "xs",
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||||
|
VariantProps<typeof inputGroupButtonVariants> & {
|
||||||
|
type?: "button" | "submit" | "reset"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type={type}
|
||||||
|
data-size={size}
|
||||||
|
variant={variant}
|
||||||
|
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputGroupTextarea({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<Textarea
|
||||||
|
data-slot="input-group-control"
|
||||||
|
className={cn(
|
||||||
|
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupButton,
|
||||||
|
InputGroupText,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupTextarea,
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -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(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -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(
|
||||||
|
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ScrollAreaPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Thumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
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(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</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("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", 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("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 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("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
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(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Backdrop
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Popup.Props & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Popup
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Popup>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
|
{
|
||||||
|
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(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"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 data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground 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("flex-1 text-sm outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
@@ -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(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -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(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 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 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 }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export { useTheme } from './useTheme';
|
||||||
|
export { useLocalStorage } from './useLocalStorage';
|
||||||
|
export { useDebounce } from './useDebounce';
|
||||||
|
export { useToggle } from './useToggle';
|
||||||
|
export { useFetch } from './useFetch';
|
||||||
|
export { useEventListener } from './useEventListener';
|
||||||
|
export { useTimeout } from './useTimeout';
|
||||||
|
export { useWindowSize } from './useWindowSize';
|
||||||
|
export { usePrevious } from './usePrevious';
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number = 500) {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = setTimeout(() => {
|
||||||
|
setDebouncedValue(value);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(handler);
|
||||||
|
};
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export function useEventListener<K extends keyof WindowEventMap>(
|
||||||
|
eventName: K,
|
||||||
|
handler: (event: WindowEventMap[K]) => void,
|
||||||
|
element?: Window | HTMLElement | null
|
||||||
|
) {
|
||||||
|
const savedHandler = useRef<(event: WindowEventMap[K]) => void>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
savedHandler.current = handler;
|
||||||
|
}, [handler]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const targetElement = element ?? window;
|
||||||
|
|
||||||
|
if (!targetElement) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventListener = (event: Event) => {
|
||||||
|
savedHandler.current?.(event as WindowEventMap[K]);
|
||||||
|
};
|
||||||
|
|
||||||
|
targetElement.addEventListener(eventName, eventListener as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
targetElement.removeEventListener(eventName, eventListener as EventListener);
|
||||||
|
};
|
||||||
|
}, [eventName, element]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
interface FetchState<T> {
|
||||||
|
data: T | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFetch<T>(
|
||||||
|
url: string,
|
||||||
|
options?: RequestInit,
|
||||||
|
immediate: boolean = true
|
||||||
|
) {
|
||||||
|
const [state, setState] = useState<FetchState<T>>({
|
||||||
|
data: null,
|
||||||
|
loading: immediate,
|
||||||
|
error: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
const abortController = new AbortController();
|
||||||
|
abortControllerRef.current = abortController;
|
||||||
|
|
||||||
|
setState({ data: null, loading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
signal: abortController.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setState({ data, loading: false, error: null });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name !== 'AbortError') {
|
||||||
|
setState({ data: null, loading: false, error });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [url, options]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (immediate) {
|
||||||
|
fetchData().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, [fetchData, immediate]);
|
||||||
|
|
||||||
|
const refetch = useCallback(() => {
|
||||||
|
return fetchData();
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
refetch
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
|
type SetValue<T> = T | ((prev: T) => T);
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
return item ? JSON.parse(item) : initialValue;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading localStorage key "${key}":`, error);
|
||||||
|
return initialValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setValue = useCallback(
|
||||||
|
(value: SetValue<T>) => {
|
||||||
|
try {
|
||||||
|
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||||
|
setStoredValue(valueToStore);
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error setting localStorage key "${key}":`, error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[key, storedValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeValue = useCallback(() => {
|
||||||
|
try {
|
||||||
|
setStoredValue(initialValue);
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error removing localStorage key "${key}":`, error);
|
||||||
|
}
|
||||||
|
}, [key, initialValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorageChange = (e: StorageEvent) => {
|
||||||
|
if (e.key === key && e.newValue !== null) {
|
||||||
|
try {
|
||||||
|
setStoredValue(JSON.parse(e.newValue));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error handling storage change for key "${key}":`, error);
|
||||||
|
}
|
||||||
|
} else if (e.key === key && e.newValue === null) {
|
||||||
|
setStoredValue(initialValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handleStorageChange);
|
||||||
|
return () => window.removeEventListener('storage', handleStorageChange);
|
||||||
|
}, [key, initialValue]);
|
||||||
|
|
||||||
|
return [storedValue, setValue, removeValue] as const;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function usePrevious<T>(value: T) {
|
||||||
|
const ref = useRef<T>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current = value;
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return ref.current;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||||
|
if (savedTheme) {
|
||||||
|
return savedTheme;
|
||||||
|
}
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
toggleTheme,
|
||||||
|
isDark: theme === 'dark'
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useTimeout(callback: () => void, delay: number | null) {
|
||||||
|
const callbackRef = useRef<() => void>(callback);
|
||||||
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
callbackRef.current = callback;
|
||||||
|
}, [callback]);
|
||||||
|
|
||||||
|
const set = useCallback(() => {
|
||||||
|
if (delay !== null) {
|
||||||
|
timeoutRef.current = setTimeout(() => callbackRef.current(), delay);
|
||||||
|
}
|
||||||
|
}, [delay]);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
clear();
|
||||||
|
set();
|
||||||
|
}, [clear, set]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
set();
|
||||||
|
return clear;
|
||||||
|
}, [set, clear]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
reset,
|
||||||
|
clear
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
export function useToggle(initialValue: boolean = false) {
|
||||||
|
const [value, setValue] = useState<boolean>(initialValue);
|
||||||
|
|
||||||
|
const toggle = useCallback(() => {
|
||||||
|
setValue((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setTrue = useCallback(() => {
|
||||||
|
setValue(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setFalse = useCallback(() => {
|
||||||
|
setValue(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
toggle,
|
||||||
|
setTrue,
|
||||||
|
setFalse,
|
||||||
|
setValue
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useEventListener } from './useEventListener';
|
||||||
|
|
||||||
|
interface WindowSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWindowSize(): WindowSize {
|
||||||
|
const [windowSize, setWindowSize] = useState<WindowSize>({
|
||||||
|
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
||||||
|
height: typeof window !== 'undefined' ? window.innerHeight : 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
setWindowSize({
|
||||||
|
width: window.innerWidth,
|
||||||
|
height: window.innerHeight
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleResize();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return windowSize;
|
||||||
|
}
|
||||||
+100
@@ -0,0 +1,100 @@
|
|||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
@import "@fontsource-variable/geist";
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||||
|
line-height: 1.5;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
.theme {
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--font-sans: 'Geist Variable', sans-serif;
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
export const STORAGE_KEYS = {
|
||||||
|
AUTH: "array_auth",
|
||||||
|
SERVER_URL: "array_server_url",
|
||||||
|
THEME: "array_theme",
|
||||||
|
LANGUAGE: "array_language",
|
||||||
|
TASK_READ: "array_task_read",
|
||||||
|
DRAFTS: "array_drafts",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TASK_STATUSES = {
|
||||||
|
pending: { label: "待处理", color: "yellow" },
|
||||||
|
in_progress: { label: "进行中", color: "blue" },
|
||||||
|
completed: { label: "已完成", color: "green" },
|
||||||
|
cancelled: { label: "已取消", color: "gray" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PRIORITIES = {
|
||||||
|
low: { label: "低", color: "gray" },
|
||||||
|
medium: { label: "中", color: "blue" },
|
||||||
|
high: { label: "高", color: "orange" },
|
||||||
|
urgent: { label: "紧急", color: "red" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const WORKER_STATUS = {
|
||||||
|
online: { label: "在线", color: "green" },
|
||||||
|
offline: { label: "离线", color: "gray" },
|
||||||
|
busy: { label: "忙碌", color: "orange" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ROLES = {
|
||||||
|
admin: { label: "管理员", color: "purple" },
|
||||||
|
member: { label: "成员", color: "blue" },
|
||||||
|
observer: { label: "观察者", color: "gray" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const NOTIFICATION_TYPES = {
|
||||||
|
task_invite: { label: "任务邀请", icon: "invite" },
|
||||||
|
task_update: { label: "任务更新", icon: "update" },
|
||||||
|
message: { label: "新消息", icon: "message" },
|
||||||
|
system: { label: "系统通知", icon: "system" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DEFAULT_AVATAR_COLORS = [
|
||||||
|
"#3b82f6",
|
||||||
|
"#10b981",
|
||||||
|
"#f59e0b",
|
||||||
|
"#ef4444",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#ec4899",
|
||||||
|
"#06b6d4",
|
||||||
|
"#84cc16",
|
||||||
|
];
|
||||||
+480
@@ -0,0 +1,480 @@
|
|||||||
|
export const zhCN = {
|
||||||
|
common: {
|
||||||
|
save: "保存",
|
||||||
|
cancel: "取消",
|
||||||
|
confirm: "确认",
|
||||||
|
delete: "删除",
|
||||||
|
edit: "编辑",
|
||||||
|
submit: "提交",
|
||||||
|
loading: "加载中...",
|
||||||
|
error: "错误",
|
||||||
|
success: "成功",
|
||||||
|
retry: "重试",
|
||||||
|
close: "关闭",
|
||||||
|
search: "搜索",
|
||||||
|
filter: "筛选",
|
||||||
|
reset: "重置",
|
||||||
|
all: "全部",
|
||||||
|
none: "无",
|
||||||
|
select: "选择",
|
||||||
|
deselect: "取消选择",
|
||||||
|
back: "返回",
|
||||||
|
forward: "前进",
|
||||||
|
more: "更多",
|
||||||
|
less: "更少",
|
||||||
|
copy: "复制",
|
||||||
|
copied: "已复制",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
title: "列阵工作台",
|
||||||
|
subtitle: "人机协作,效率翻倍",
|
||||||
|
serverUrl: "服务器地址",
|
||||||
|
apiKey: "API Key",
|
||||||
|
username: "用户名",
|
||||||
|
password: "密码",
|
||||||
|
name: "姓名",
|
||||||
|
login: "登录",
|
||||||
|
loginWithApiKey: "使用 API Key 登录",
|
||||||
|
loginWithPassword: "使用密码登录",
|
||||||
|
switchMode: "切换登录方式",
|
||||||
|
register: "注册",
|
||||||
|
registerTitle: "创建新账号",
|
||||||
|
registerHint: "没有账号?立即注册",
|
||||||
|
loginHint: "已有账号?立即登录",
|
||||||
|
invitationCode: "邀请码(可选)",
|
||||||
|
organization: "组织",
|
||||||
|
location: "位置",
|
||||||
|
description: "个人简介",
|
||||||
|
tags: "标签",
|
||||||
|
capabilities: "能力",
|
||||||
|
loggingIn: "登录中...",
|
||||||
|
registering: "注册中...",
|
||||||
|
loginSuccess: "登录成功",
|
||||||
|
registerSuccess: "注册成功",
|
||||||
|
loginFailed: "登录失败",
|
||||||
|
registerFailed: "注册失败",
|
||||||
|
invalidCredentials: "凭据无效",
|
||||||
|
invalidServerUrl: "服务器地址无效",
|
||||||
|
},
|
||||||
|
workbench: {
|
||||||
|
members: "成员",
|
||||||
|
tasks: "任务",
|
||||||
|
chat: "聊天",
|
||||||
|
newTask: "新建任务",
|
||||||
|
newConversation: "新建对话",
|
||||||
|
noTasks: "暂无任务",
|
||||||
|
noTasksHint: "点击上方按钮创建新任务",
|
||||||
|
noMessages: "暂无消息",
|
||||||
|
noMessagesHint: "发送第一条消息开始协作",
|
||||||
|
loadingTasks: "加载任务中...",
|
||||||
|
loadingMessages: "加载消息中...",
|
||||||
|
sendMessage: "发送消息",
|
||||||
|
typeMessage: "输入消息...",
|
||||||
|
send: "发送",
|
||||||
|
enterToSend: "Enter 发送",
|
||||||
|
ctrlEnterToSend: "Ctrl+Enter 发送",
|
||||||
|
sending: "发送中...",
|
||||||
|
resend: "重新发送",
|
||||||
|
sendFailed: "发送失败",
|
||||||
|
uploadFile: "上传文件",
|
||||||
|
uploadImage: "上传图片",
|
||||||
|
uploading: "上传中...",
|
||||||
|
uploadFailed: "上传失败",
|
||||||
|
uploadSuccess: "上传成功",
|
||||||
|
replyTo: "回复",
|
||||||
|
cancelReply: "取消回复",
|
||||||
|
mentionPlaceholder: "输入 @ 提及成员",
|
||||||
|
privateMessage: "私信",
|
||||||
|
publicMessage: "公开消息",
|
||||||
|
sendTo: "发送给",
|
||||||
|
everyone: "所有人",
|
||||||
|
markRead: "标为已读",
|
||||||
|
unread: "未读",
|
||||||
|
taskSettings: "任务设置",
|
||||||
|
taskDetails: "任务详情",
|
||||||
|
participants: "参与者",
|
||||||
|
addParticipant: "添加参与者",
|
||||||
|
removeParticipant: "移除参与者",
|
||||||
|
leaveTask: "离开任务",
|
||||||
|
deleteTask: "删除任务",
|
||||||
|
pinTask: "置顶任务",
|
||||||
|
unpinTask: "取消置顶",
|
||||||
|
taskTitle: "任务标题",
|
||||||
|
editTitle: "编辑标题",
|
||||||
|
titlePlaceholder: "任务标题",
|
||||||
|
saveTitle: "保存标题",
|
||||||
|
taskStatus: "任务状态",
|
||||||
|
taskPriority: "任务优先级",
|
||||||
|
changeStatus: "更改状态",
|
||||||
|
changePriority: "更改优先级",
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
all: "全部",
|
||||||
|
myTasks: "我的任务",
|
||||||
|
iCreated: "我创建的",
|
||||||
|
iParticipate: "我参与的",
|
||||||
|
completed: "已完成",
|
||||||
|
pending: "待处理",
|
||||||
|
inProgress: "进行中",
|
||||||
|
cancelled: "已取消",
|
||||||
|
low: "低",
|
||||||
|
medium: "中",
|
||||||
|
high: "高",
|
||||||
|
urgent: "紧急",
|
||||||
|
created: "创建于",
|
||||||
|
updated: "更新于",
|
||||||
|
lastMessage: "最后消息",
|
||||||
|
noTasksFound: "未找到任务",
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
online: "在线",
|
||||||
|
offline: "离线",
|
||||||
|
busy: "忙碌",
|
||||||
|
ai: "AI 助手",
|
||||||
|
human: "人类用户",
|
||||||
|
all: "全部成员",
|
||||||
|
noMembers: "暂无成员",
|
||||||
|
searchMembers: "搜索成员...",
|
||||||
|
viewProfile: "查看资料",
|
||||||
|
startConversation: "开始对话",
|
||||||
|
inviteToTask: "邀请到任务",
|
||||||
|
},
|
||||||
|
org: {
|
||||||
|
switch: "切换组织",
|
||||||
|
create: "创建组织",
|
||||||
|
name: "组织名称",
|
||||||
|
description: "组织描述",
|
||||||
|
members: "组织成员",
|
||||||
|
invites: "邀请码",
|
||||||
|
createInvite: "生成邀请码",
|
||||||
|
inviteCode: "邀请码",
|
||||||
|
expiresAt: "过期时间",
|
||||||
|
maxUses: "最大使用次数",
|
||||||
|
usedCount: "已使用",
|
||||||
|
noLimit: "无限制",
|
||||||
|
neverExpires: "永不过期",
|
||||||
|
copyInvite: "复制邀请码",
|
||||||
|
deactivate: "停用",
|
||||||
|
activate: "启用",
|
||||||
|
memberList: "成员列表",
|
||||||
|
role: "角色",
|
||||||
|
admin: "管理员",
|
||||||
|
member: "成员",
|
||||||
|
removeMember: "移除成员",
|
||||||
|
changeRole: "更改角色",
|
||||||
|
renameMember: "重命名成员",
|
||||||
|
renameOrg: "重命名组织",
|
||||||
|
noOrg: "暂无组织",
|
||||||
|
joinOrg: "加入组织",
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: "个人资料",
|
||||||
|
name: "姓名",
|
||||||
|
organization: "组织",
|
||||||
|
location: "位置",
|
||||||
|
description: "简介",
|
||||||
|
tags: "标签",
|
||||||
|
capabilities: "能力",
|
||||||
|
maxConcurrentTasks: "最大并发任务数",
|
||||||
|
edit: "编辑资料",
|
||||||
|
save: "保存资料",
|
||||||
|
shortcutSettings: "快捷键设置",
|
||||||
|
sendKey: "发送快捷键",
|
||||||
|
enter: "Enter",
|
||||||
|
ctrlEnter: "Ctrl+Enter",
|
||||||
|
theme: "主题",
|
||||||
|
light: "明亮",
|
||||||
|
dark: "深色",
|
||||||
|
language: "语言",
|
||||||
|
chinese: "中文",
|
||||||
|
english: "English",
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
title: "评价",
|
||||||
|
submit: "提交评价",
|
||||||
|
rating: "评分",
|
||||||
|
stars: "星",
|
||||||
|
capabilityTags: "能力标签",
|
||||||
|
comment: "评论",
|
||||||
|
suggestion: "建议",
|
||||||
|
submitSuccess: "评价提交成功",
|
||||||
|
viewReviews: "查看评价",
|
||||||
|
averageRating: "平均评分",
|
||||||
|
totalReviews: "总评价数",
|
||||||
|
commonTags: "常见标签",
|
||||||
|
noReviews: "暂无评价",
|
||||||
|
reviewFor: "对 {name} 的评价",
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
title: "通知",
|
||||||
|
markAllRead: "全部标为已读",
|
||||||
|
noNotifications: "暂无通知",
|
||||||
|
taskInvite: "任务邀请",
|
||||||
|
taskUpdate: "任务更新",
|
||||||
|
newMessage: "新消息",
|
||||||
|
system: "系统通知",
|
||||||
|
},
|
||||||
|
websocket: {
|
||||||
|
connected: "已连接",
|
||||||
|
disconnected: "已断开",
|
||||||
|
reconnecting: "重连中...",
|
||||||
|
error: "连接错误",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
generic: "发生错误",
|
||||||
|
network: "网络错误",
|
||||||
|
unauthorized: "未授权",
|
||||||
|
forbidden: "禁止访问",
|
||||||
|
notFound: "未找到",
|
||||||
|
server: "服务器错误",
|
||||||
|
timeout: "请求超时",
|
||||||
|
},
|
||||||
|
led: {
|
||||||
|
snake: "贪吃蛇",
|
||||||
|
tetris: "俄罗斯方块",
|
||||||
|
fireworks: "烟花",
|
||||||
|
scroll: "滚动文字",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const enUS = {
|
||||||
|
common: {
|
||||||
|
save: "Save",
|
||||||
|
cancel: "Cancel",
|
||||||
|
confirm: "Confirm",
|
||||||
|
delete: "Delete",
|
||||||
|
edit: "Edit",
|
||||||
|
submit: "Submit",
|
||||||
|
loading: "Loading...",
|
||||||
|
error: "Error",
|
||||||
|
success: "Success",
|
||||||
|
retry: "Retry",
|
||||||
|
close: "Close",
|
||||||
|
search: "Search",
|
||||||
|
filter: "Filter",
|
||||||
|
reset: "Reset",
|
||||||
|
all: "All",
|
||||||
|
none: "None",
|
||||||
|
select: "Select",
|
||||||
|
deselect: "Deselect",
|
||||||
|
back: "Back",
|
||||||
|
forward: "Forward",
|
||||||
|
more: "More",
|
||||||
|
less: "Less",
|
||||||
|
copy: "Copy",
|
||||||
|
copied: "Copied",
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
title: "Array Workbench",
|
||||||
|
subtitle: "Human-AI collaboration, double efficiency",
|
||||||
|
serverUrl: "Server URL",
|
||||||
|
apiKey: "API Key",
|
||||||
|
username: "Username",
|
||||||
|
password: "Password",
|
||||||
|
name: "Name",
|
||||||
|
login: "Login",
|
||||||
|
loginWithApiKey: "Login with API Key",
|
||||||
|
loginWithPassword: "Login with Password",
|
||||||
|
switchMode: "Switch mode",
|
||||||
|
register: "Register",
|
||||||
|
registerTitle: "Create new account",
|
||||||
|
registerHint: "No account? Register now",
|
||||||
|
loginHint: "Have an account? Login now",
|
||||||
|
invitationCode: "Invitation code (optional)",
|
||||||
|
organization: "Organization",
|
||||||
|
location: "Location",
|
||||||
|
description: "Description",
|
||||||
|
tags: "Tags",
|
||||||
|
capabilities: "Capabilities",
|
||||||
|
loggingIn: "Logging in...",
|
||||||
|
registering: "Registering...",
|
||||||
|
loginSuccess: "Login successful",
|
||||||
|
registerSuccess: "Registration successful",
|
||||||
|
loginFailed: "Login failed",
|
||||||
|
registerFailed: "Registration failed",
|
||||||
|
invalidCredentials: "Invalid credentials",
|
||||||
|
invalidServerUrl: "Invalid server URL",
|
||||||
|
},
|
||||||
|
workbench: {
|
||||||
|
members: "Members",
|
||||||
|
tasks: "Tasks",
|
||||||
|
chat: "Chat",
|
||||||
|
newTask: "New Task",
|
||||||
|
newConversation: "New Conversation",
|
||||||
|
noTasks: "No tasks yet",
|
||||||
|
noTasksHint: "Click the button above to create a new task",
|
||||||
|
noMessages: "No messages yet",
|
||||||
|
noMessagesHint: "Send the first message to start collaborating",
|
||||||
|
loadingTasks: "Loading tasks...",
|
||||||
|
loadingMessages: "Loading messages...",
|
||||||
|
sendMessage: "Send message",
|
||||||
|
typeMessage: "Type a message...",
|
||||||
|
send: "Send",
|
||||||
|
enterToSend: "Enter to send",
|
||||||
|
ctrlEnterToSend: "Ctrl+Enter to send",
|
||||||
|
sending: "Sending...",
|
||||||
|
resend: "Resend",
|
||||||
|
sendFailed: "Send failed",
|
||||||
|
uploadFile: "Upload file",
|
||||||
|
uploadImage: "Upload image",
|
||||||
|
uploading: "Uploading...",
|
||||||
|
uploadFailed: "Upload failed",
|
||||||
|
uploadSuccess: "Upload successful",
|
||||||
|
replyTo: "Reply",
|
||||||
|
cancelReply: "Cancel reply",
|
||||||
|
mentionPlaceholder: "Type @ to mention members",
|
||||||
|
privateMessage: "Private message",
|
||||||
|
publicMessage: "Public message",
|
||||||
|
sendTo: "Send to",
|
||||||
|
everyone: "Everyone",
|
||||||
|
markRead: "Mark as read",
|
||||||
|
unread: "Unread",
|
||||||
|
taskSettings: "Task settings",
|
||||||
|
taskDetails: "Task details",
|
||||||
|
participants: "Participants",
|
||||||
|
addParticipant: "Add participant",
|
||||||
|
removeParticipant: "Remove participant",
|
||||||
|
leaveTask: "Leave task",
|
||||||
|
deleteTask: "Delete task",
|
||||||
|
pinTask: "Pin task",
|
||||||
|
unpinTask: "Unpin task",
|
||||||
|
taskTitle: "Task title",
|
||||||
|
editTitle: "Edit title",
|
||||||
|
titlePlaceholder: "Task title",
|
||||||
|
saveTitle: "Save title",
|
||||||
|
taskStatus: "Task status",
|
||||||
|
taskPriority: "Task priority",
|
||||||
|
changeStatus: "Change status",
|
||||||
|
changePriority: "Change priority",
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
all: "All",
|
||||||
|
myTasks: "My tasks",
|
||||||
|
iCreated: "Created by me",
|
||||||
|
iParticipate: "I participate",
|
||||||
|
completed: "Completed",
|
||||||
|
pending: "Pending",
|
||||||
|
inProgress: "In progress",
|
||||||
|
cancelled: "Cancelled",
|
||||||
|
low: "Low",
|
||||||
|
medium: "Medium",
|
||||||
|
high: "High",
|
||||||
|
urgent: "Urgent",
|
||||||
|
created: "Created at",
|
||||||
|
updated: "Updated at",
|
||||||
|
lastMessage: "Last message",
|
||||||
|
noTasksFound: "No tasks found",
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
online: "Online",
|
||||||
|
offline: "Offline",
|
||||||
|
busy: "Busy",
|
||||||
|
ai: "AI Assistant",
|
||||||
|
human: "Human",
|
||||||
|
all: "All members",
|
||||||
|
noMembers: "No members yet",
|
||||||
|
searchMembers: "Search members...",
|
||||||
|
viewProfile: "View profile",
|
||||||
|
startConversation: "Start conversation",
|
||||||
|
inviteToTask: "Invite to task",
|
||||||
|
},
|
||||||
|
org: {
|
||||||
|
switch: "Switch organization",
|
||||||
|
create: "Create organization",
|
||||||
|
name: "Organization name",
|
||||||
|
description: "Organization description",
|
||||||
|
members: "Organization members",
|
||||||
|
invites: "Invitation codes",
|
||||||
|
createInvite: "Generate invite code",
|
||||||
|
inviteCode: "Invitation code",
|
||||||
|
expiresAt: "Expires at",
|
||||||
|
maxUses: "Max uses",
|
||||||
|
usedCount: "Used",
|
||||||
|
noLimit: "No limit",
|
||||||
|
neverExpires: "Never expires",
|
||||||
|
copyInvite: "Copy invite code",
|
||||||
|
deactivate: "Deactivate",
|
||||||
|
activate: "Activate",
|
||||||
|
memberList: "Member list",
|
||||||
|
role: "Role",
|
||||||
|
admin: "Admin",
|
||||||
|
member: "Member",
|
||||||
|
removeMember: "Remove member",
|
||||||
|
changeRole: "Change role",
|
||||||
|
renameMember: "Rename member",
|
||||||
|
renameOrg: "Rename organization",
|
||||||
|
noOrg: "No organizations",
|
||||||
|
joinOrg: "Join organization",
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
title: "Profile",
|
||||||
|
name: "Name",
|
||||||
|
organization: "Organization",
|
||||||
|
location: "Location",
|
||||||
|
description: "Description",
|
||||||
|
tags: "Tags",
|
||||||
|
capabilities: "Capabilities",
|
||||||
|
maxConcurrentTasks: "Max concurrent tasks",
|
||||||
|
edit: "Edit profile",
|
||||||
|
save: "Save profile",
|
||||||
|
shortcutSettings: "Shortcut settings",
|
||||||
|
sendKey: "Send shortcut",
|
||||||
|
enter: "Enter",
|
||||||
|
ctrlEnter: "Ctrl+Enter",
|
||||||
|
theme: "Theme",
|
||||||
|
light: "Light",
|
||||||
|
dark: "Dark",
|
||||||
|
language: "Language",
|
||||||
|
chinese: "中文",
|
||||||
|
english: "English",
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
title: "Review",
|
||||||
|
submit: "Submit review",
|
||||||
|
rating: "Rating",
|
||||||
|
stars: "stars",
|
||||||
|
capabilityTags: "Capability tags",
|
||||||
|
comment: "Comment",
|
||||||
|
suggestion: "Suggestion",
|
||||||
|
submitSuccess: "Review submitted successfully",
|
||||||
|
viewReviews: "View reviews",
|
||||||
|
averageRating: "Average rating",
|
||||||
|
totalReviews: "Total reviews",
|
||||||
|
commonTags: "Common tags",
|
||||||
|
noReviews: "No reviews yet",
|
||||||
|
reviewFor: "Review for {name}",
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
title: "Notifications",
|
||||||
|
markAllRead: "Mark all as read",
|
||||||
|
noNotifications: "No notifications",
|
||||||
|
taskInvite: "Task invite",
|
||||||
|
taskUpdate: "Task update",
|
||||||
|
newMessage: "New message",
|
||||||
|
system: "System notification",
|
||||||
|
},
|
||||||
|
websocket: {
|
||||||
|
connected: "Connected",
|
||||||
|
disconnected: "Disconnected",
|
||||||
|
reconnecting: "Reconnecting...",
|
||||||
|
error: "Connection error",
|
||||||
|
},
|
||||||
|
errors: {
|
||||||
|
generic: "An error occurred",
|
||||||
|
network: "Network error",
|
||||||
|
unauthorized: "Unauthorized",
|
||||||
|
forbidden: "Forbidden",
|
||||||
|
notFound: "Not found",
|
||||||
|
server: "Server error",
|
||||||
|
timeout: "Request timeout",
|
||||||
|
},
|
||||||
|
led: {
|
||||||
|
snake: "Snake",
|
||||||
|
tetris: "Tetris",
|
||||||
|
fireworks: "Fireworks",
|
||||||
|
scroll: "Scroll text",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Locale = "zh-CN" | "en-US";
|
||||||
|
export type Translation = typeof zhCN;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
import type { Worker } from '@/lib/types';
|
||||||
|
|
||||||
|
export function getSdk(): any {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initSdk(serverUrl: string, token?: string): any {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithApiKey(serverUrl: string, apiKey: string): Promise<Worker> {
|
||||||
|
return { id: '1', name: 'Test', type: 'human', status: 'online' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginWithPassword(serverUrl: string, username: string, password: string): Promise<Worker> {
|
||||||
|
return { id: '1', name: username, type: 'human', status: 'online' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
export interface Worker {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: "human" | "ai";
|
||||||
|
status: "online" | "offline" | "busy";
|
||||||
|
organization?: string;
|
||||||
|
location?: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
|
maxConcurrentTasks?: number;
|
||||||
|
apiKey?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterProfile {
|
||||||
|
organization?: string;
|
||||||
|
location?: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
capabilities?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||||
|
priority?: "low" | "medium" | "high" | "urgent";
|
||||||
|
creatorId: string;
|
||||||
|
orgId?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastMessageAt?: string;
|
||||||
|
participants?: Participant[];
|
||||||
|
isPinned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Participant {
|
||||||
|
id: string;
|
||||||
|
workerId: string;
|
||||||
|
taskId: string;
|
||||||
|
role: "admin" | "member" | "observer";
|
||||||
|
joinedAt: string;
|
||||||
|
worker?: Worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
senderId: string;
|
||||||
|
content: string;
|
||||||
|
type: "text" | "system" | "file" | "image";
|
||||||
|
replyToId?: string;
|
||||||
|
privateToId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
attachments?: Attachment[];
|
||||||
|
sender?: Worker;
|
||||||
|
replyTo?: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingMessage {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
content: string;
|
||||||
|
status: "sending" | "failed";
|
||||||
|
options?: SendMessageOptions;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Attachment {
|
||||||
|
id: string;
|
||||||
|
taskId: string;
|
||||||
|
filename: string;
|
||||||
|
contentType: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
duration?: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
members?: OrgMember[];
|
||||||
|
invites?: OrgInvite[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrgMember {
|
||||||
|
id: string;
|
||||||
|
orgId: string;
|
||||||
|
workerId: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
joinedAt: string;
|
||||||
|
name?: string;
|
||||||
|
worker?: Worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrgInvite {
|
||||||
|
id: string;
|
||||||
|
orgId: string;
|
||||||
|
code: string;
|
||||||
|
createdBy: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
maxUses?: number;
|
||||||
|
usedCount: number;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JoinResult {
|
||||||
|
success: boolean;
|
||||||
|
orgId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
orgId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Review {
|
||||||
|
id: string;
|
||||||
|
workerId: string;
|
||||||
|
taskId: string;
|
||||||
|
reviewerId: string;
|
||||||
|
rating: 1 | 2 | 3 | 4 | 5;
|
||||||
|
capabilityTags: string[];
|
||||||
|
comment?: string;
|
||||||
|
suggestion?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReviewParams {
|
||||||
|
taskId: string;
|
||||||
|
rating: 1 | 2 | 3 | 4 | 5;
|
||||||
|
capabilityTags: string[];
|
||||||
|
comment?: string;
|
||||||
|
suggestion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewListResult {
|
||||||
|
reviews: Review[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewSummary {
|
||||||
|
averageRating: number;
|
||||||
|
totalReviews: number;
|
||||||
|
commonTags: string[];
|
||||||
|
ratingDistribution: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
workerId: string;
|
||||||
|
type: "task_invite" | "task_update" | "message" | "system";
|
||||||
|
title: string;
|
||||||
|
content?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
isRead: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Workload {
|
||||||
|
workerId: string;
|
||||||
|
activeTasks: number;
|
||||||
|
maxConcurrentTasks: number;
|
||||||
|
isAvailable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoverOptions {
|
||||||
|
type?: "human" | "ai";
|
||||||
|
tags?: string[];
|
||||||
|
online?: boolean;
|
||||||
|
orgId?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscoverResult {
|
||||||
|
workers: Worker[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SendMessageOptions {
|
||||||
|
privateToId?: string;
|
||||||
|
replyToId?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskExtra {
|
||||||
|
description?: string;
|
||||||
|
priority?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
orgId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteOptions {
|
||||||
|
expiresAt?: string;
|
||||||
|
maxUses?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationOptions {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WSEventType =
|
||||||
|
| "task.message"
|
||||||
|
| "participant.joined"
|
||||||
|
| "task.updated"
|
||||||
|
| "worker_status_changed"
|
||||||
|
| "tasks.pending_recovery";
|
||||||
|
|
||||||
|
export interface WSEvent {
|
||||||
|
type: WSEventType;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WSCallbacks {
|
||||||
|
onEvent: (event: WSEvent) => void;
|
||||||
|
onConnected: () => void;
|
||||||
|
onDisconnected: () => void;
|
||||||
|
onError: (err: Error) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { DEFAULT_AVATAR_COLORS } from "./constants";
|
||||||
|
import type { Worker, Attachment } from "./types";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number, decimals = 2): string {
|
||||||
|
if (bytes === 0) return "0 Bytes";
|
||||||
|
const k = 1024;
|
||||||
|
const dm = decimals < 0 ? 0 : decimals;
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUTC(date: string | Date): Date {
|
||||||
|
if (typeof date === "string" && !date.endsWith("Z") && !date.includes("+")) {
|
||||||
|
return new Date(date + "Z");
|
||||||
|
}
|
||||||
|
return new Date(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date, locale = "zh-CN"): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(parseUTC(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(date: string | Date, locale = "zh-CN"): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(parseUTC(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(date: string | Date, locale = "zh-CN"): string {
|
||||||
|
return new Intl.DateTimeFormat(locale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).format(parseUTC(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRelativeTime(date: string | Date, locale = "zh-CN"): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - parseUTC(date).getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (seconds < 60) return "刚刚";
|
||||||
|
if (minutes < 60) return `${minutes}分钟前`;
|
||||||
|
if (hours < 24) return `${hours}小时前`;
|
||||||
|
if (days < 7) return `${days}天前`;
|
||||||
|
return formatDate(date, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkerName(worker?: Worker | null): string {
|
||||||
|
if (!worker) return "未知";
|
||||||
|
return worker.name || "未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkerInitials(name: string): string {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAvatarColor(workerId: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < workerId.length; i++) {
|
||||||
|
const char = workerId.charCodeAt(i);
|
||||||
|
hash = (hash << 5) - hash + char;
|
||||||
|
hash = hash & hash;
|
||||||
|
}
|
||||||
|
return DEFAULT_AVATAR_COLORS[Math.abs(hash) % DEFAULT_AVATAR_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateText(text: string, maxLength = 100): string {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.slice(0, maxLength) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return `id_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
return function (...args: Parameters<T>) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageAttachment(attachment: Attachment): boolean {
|
||||||
|
return attachment.contentType.startsWith("image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoAttachment(attachment: Attachment): boolean {
|
||||||
|
return attachment.contentType.startsWith("video/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAudioAttachment(attachment: Attachment): boolean {
|
||||||
|
return attachment.contentType.startsWith("audio/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFileAttachment(attachment: Attachment): boolean {
|
||||||
|
return (
|
||||||
|
!isImageAttachment(attachment) &&
|
||||||
|
!isVideoAttachment(attachment) &&
|
||||||
|
!isAudioAttachment(attachment)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFileExtension(filename: string): string {
|
||||||
|
const parts = filename.split(".");
|
||||||
|
return parts.length > 1 ? parts.pop()?.toLowerCase() || "" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameDay(date1: string | Date, date2: string | Date): boolean {
|
||||||
|
const d1 = parseUTC(date1);
|
||||||
|
const d2 = parseUTC(date2);
|
||||||
|
return (
|
||||||
|
d1.getFullYear() === d2.getFullYear() &&
|
||||||
|
d1.getMonth() === d2.getMonth() &&
|
||||||
|
d1.getDate() === d2.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeMarkdown(text: string): string {
|
||||||
|
return text.replace(/[*_`[\](){}#+-.!]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMentions(
|
||||||
|
text: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_workers?: Worker[]
|
||||||
|
): { text: string; mentions: string[] } {
|
||||||
|
const mentionPattern = /@\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
|
const mentions: string[] = [];
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = mentionPattern.exec(text)) !== null) {
|
||||||
|
const workerId = match[2];
|
||||||
|
if (workerId) {
|
||||||
|
mentions.push(workerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text, mentions };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function Home() {
|
||||||
|
return <div></div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Server,
|
||||||
|
Key,
|
||||||
|
User,
|
||||||
|
Lock,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { useAuthStore } from "@/stores";
|
||||||
|
import { LEDLogo } from "@/components/shared/LEDLogo";
|
||||||
|
import { STORAGE_KEYS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loginMode, setLoginMode] = useState("apikey");
|
||||||
|
const [serverUrl, setServerUrl] = useState(
|
||||||
|
() => (typeof window !== "undefined" ? localStorage.getItem(STORAGE_KEYS.SERVER_URL) || "http://localhost:3000" : "http://localhost:3000")
|
||||||
|
);
|
||||||
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 模拟登录过程
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||||
|
|
||||||
|
// 简单验证
|
||||||
|
if (loginMode === "apikey" && !apiKey.trim()) {
|
||||||
|
throw new Error("请输入 API Key");
|
||||||
|
}
|
||||||
|
if (loginMode === "password" && (!username.trim() || !password)) {
|
||||||
|
throw new Error("请输入用户名和密码");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建模拟用户对象
|
||||||
|
const mockWorker = {
|
||||||
|
id: "1",
|
||||||
|
name: loginMode === "apikey" ? "当前用户" : username,
|
||||||
|
type: "human" as const,
|
||||||
|
status: "online" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
login(mockWorker, apiKey || "demo-token");
|
||||||
|
navigate("/workbench");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "登录失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-950 text-slate-200 flex flex-col">
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.5 }}
|
||||||
|
className="w-full max-w-md space-y-8"
|
||||||
|
>
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<div className="inline-flex items-center justify-center">
|
||||||
|
<LEDLogo />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-100 tracking-tight">
|
||||||
|
列阵工作台
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-400">
|
||||||
|
人机协作,效率翻倍
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl p-8 shadow-2xl"
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<Alert className="mb-6 border-red-500/20 bg-red-500/10 text-red-400">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs defaultValue={loginMode} onValueChange={setLoginMode} className="w-full">
|
||||||
|
<TabsList className="w-full bg-slate-950/50 border border-slate-700 mb-6">
|
||||||
|
<TabsTrigger value="apikey" className="flex-1 data-[state=active]:bg-cyan-600">
|
||||||
|
<Key className="w-4 h-4 mr-2" />
|
||||||
|
API Key
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="password" className="flex-1 data-[state=active]:bg-cyan-600">
|
||||||
|
<User className="w-4 h-4 mr-2" />
|
||||||
|
密码登录
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<form onSubmit={handleLogin} className="space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="serverUrl" className="text-slate-300 flex items-center gap-2">
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
服务器地址
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="serverUrl"
|
||||||
|
placeholder="http://localhost:3000"
|
||||||
|
value={serverUrl}
|
||||||
|
onChange={(e) => setServerUrl(e.target.value)}
|
||||||
|
className="h-12 bg-slate-950/50 border-slate-700 text-slate-200 placeholder:text-slate-500 focus:border-cyan-500 focus:ring-cyan-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="apikey" className="space-y-6 mt-0">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="apiKey" className="text-slate-300 flex items-center gap-2">
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
API Key
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="apiKey"
|
||||||
|
type="password"
|
||||||
|
placeholder="输入您的 API Key"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
className="h-12 bg-slate-950/50 border-slate-700 text-slate-200 placeholder:text-slate-500 focus:border-cyan-500 focus:ring-cyan-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="password" className="space-y-6 mt-0">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="username" className="text-slate-300 flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4" />
|
||||||
|
用户名
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
placeholder="输入用户名"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="h-12 bg-slate-950/50 border-slate-700 text-slate-200 placeholder:text-slate-500 focus:border-cyan-500 focus:ring-cyan-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="password" className="text-slate-300 flex items-center gap-2">
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
密码
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="••••••••"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="h-12 bg-slate-950/50 border-slate-700 text-slate-200 placeholder:text-slate-500 focus:border-cyan-500 focus:ring-cyan-500/20 pr-12"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="w-5 h-5" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full h-12 bg-gradient-to-r from-cyan-600 to-cyan-500 hover:from-cyan-500 hover:to-cyan-400 text-slate-950 font-semibold shadow-lg shadow-cyan-500/25 transition-all hover:shadow-cyan-500/40 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "登录中..." : "登录"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p className="text-slate-400 text-sm">
|
||||||
|
还没有账户?{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-cyan-400 hover:text-cyan-300 font-medium transition-colors"
|
||||||
|
>
|
||||||
|
立即注册
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Plus,
|
||||||
|
Bell,
|
||||||
|
MoreVertical,
|
||||||
|
Search,
|
||||||
|
Send,
|
||||||
|
Pin,
|
||||||
|
Menu,
|
||||||
|
Paperclip,
|
||||||
|
Smile,
|
||||||
|
AtSign,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
import { LEDLogo } from "@/components/shared/LEDLogo";
|
||||||
|
import { LEDMatrix } from "@/components/shared/LEDMatrix";
|
||||||
|
import { ThemeToggle } from "@/components/shared/ThemeToggle";
|
||||||
|
import { useAuthStore, useAppStore } from "@/stores";
|
||||||
|
import { getAvatarColor, getWorkerInitials, formatTime, getRelativeTime } from "@/lib/utils";
|
||||||
|
import { TASK_STATUSES, WORKER_STATUS } from "@/lib/constants";
|
||||||
|
import type { Worker, Task, Message } from "@/lib/types";
|
||||||
|
|
||||||
|
// 模拟数据
|
||||||
|
const mockWorkers: Worker[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "张小明",
|
||||||
|
type: "human",
|
||||||
|
status: "online",
|
||||||
|
organization: "列阵科技",
|
||||||
|
location: "北京",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
name: "AI 助手",
|
||||||
|
type: "ai",
|
||||||
|
status: "online",
|
||||||
|
organization: "AI 团队",
|
||||||
|
location: "云端",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
name: "王小红",
|
||||||
|
type: "human",
|
||||||
|
status: "offline",
|
||||||
|
organization: "列阵科技",
|
||||||
|
location: "上海",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
name: "CodeBot",
|
||||||
|
type: "ai",
|
||||||
|
status: "busy",
|
||||||
|
organization: "AI 团队",
|
||||||
|
location: "云端",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "5",
|
||||||
|
name: "李工程师",
|
||||||
|
type: "human",
|
||||||
|
status: "online",
|
||||||
|
organization: "列阵科技",
|
||||||
|
location: "深圳",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockTasks: Task[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "设计新功能的 UI",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "high",
|
||||||
|
creatorId: "1",
|
||||||
|
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
||||||
|
isPinned: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "重构后端接口",
|
||||||
|
status: "pending",
|
||||||
|
priority: "medium",
|
||||||
|
creatorId: "1",
|
||||||
|
createdAt: new Date(Date.now() - 172800000).toISOString(),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "撰写项目文档",
|
||||||
|
status: "completed",
|
||||||
|
priority: "low",
|
||||||
|
creatorId: "1",
|
||||||
|
createdAt: new Date(Date.now() - 259200000).toISOString(),
|
||||||
|
isPinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
title: "测试环境部署",
|
||||||
|
status: "in_progress",
|
||||||
|
priority: "urgent",
|
||||||
|
creatorId: "1",
|
||||||
|
createdAt: new Date(Date.now() - 3600000).toISOString(),
|
||||||
|
isPinned: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockMessages: Message[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
taskId: "1",
|
||||||
|
senderId: "1",
|
||||||
|
content: "大家好,今天我们要开始设计新功能的 UI",
|
||||||
|
type: "text",
|
||||||
|
createdAt: new Date(Date.now() - 60000 * 30).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
taskId: "1",
|
||||||
|
senderId: "2",
|
||||||
|
content: "好的,我准备好了。需要我做什么?",
|
||||||
|
type: "text",
|
||||||
|
createdAt: new Date(Date.now() - 60000 * 25).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
taskId: "1",
|
||||||
|
senderId: "1",
|
||||||
|
content: "先画一个主界面的原型图吧,参考一下这个方案",
|
||||||
|
type: "text",
|
||||||
|
createdAt: new Date(Date.now() - 60000 * 20).toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
taskId: "1",
|
||||||
|
senderId: "2",
|
||||||
|
content: "好的,我现在就开始画原型图",
|
||||||
|
type: "text",
|
||||||
|
createdAt: new Date(Date.now() - 60000 * 5).toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function WorkersPanel() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const filteredWorkers = mockWorkers.filter((worker) =>
|
||||||
|
worker.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-72 h-full border-r border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-800">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Users className="w-5 h-5 text-slate-500 dark:text-slate-400" />
|
||||||
|
<h3 className="font-semibold text-slate-800 dark:text-slate-200">成员</h3>
|
||||||
|
<span className="text-xs text-slate-400 ml-auto">{mockWorkers.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索成员..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 bg-slate-100 dark:bg-slate-900 border-slate-300 dark:border-slate-700 text-slate-800 dark:text-slate-200 placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-3 space-y-1">
|
||||||
|
{(["online", "busy", "offline"] as const).map((status) => {
|
||||||
|
const statusWorkers = filteredWorkers.filter((w) => w.status === status);
|
||||||
|
if (statusWorkers.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={status} className="space-y-1 mb-4">
|
||||||
|
<div className="px-2 py-1 text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
||||||
|
{WORKER_STATUS[status]?.label || status}
|
||||||
|
</div>
|
||||||
|
{statusWorkers.map((worker) => (
|
||||||
|
<button
|
||||||
|
key={worker.id}
|
||||||
|
className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-900 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar className="h-9 w-9 border-2 border-white dark:border-slate-950">
|
||||||
|
<AvatarFallback
|
||||||
|
style={{ backgroundColor: getAvatarColor(worker.id) }}
|
||||||
|
className="text-white text-sm"
|
||||||
|
>
|
||||||
|
{getWorkerInitials(worker.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div
|
||||||
|
className={`absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white dark:border-slate-950 ${
|
||||||
|
status === "online"
|
||||||
|
? "bg-green-500"
|
||||||
|
: status === "busy"
|
||||||
|
? "bg-orange-500"
|
||||||
|
: "bg-slate-400"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">
|
||||||
|
{worker.name}
|
||||||
|
</p>
|
||||||
|
{worker.type === "ai" && (
|
||||||
|
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300 border-purple-200 dark:border-purple-500/30 text-xs">
|
||||||
|
AI
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 truncate">{worker.organization}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TaskList() {
|
||||||
|
const [activeTab, setActiveTab] = useState("all");
|
||||||
|
const [selectedTaskId, setSelectedTaskId] = useState("1");
|
||||||
|
const { setSelectedTask } = useAppStore();
|
||||||
|
|
||||||
|
const filteredTasks = mockTasks.filter((task) => {
|
||||||
|
if (activeTab === "all") return true;
|
||||||
|
return task.status === activeTab;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelectTask = (taskId: string) => {
|
||||||
|
setSelectedTaskId(taskId);
|
||||||
|
const task = mockTasks.find((t) => t.id === taskId) || null;
|
||||||
|
setSelectedTask(task);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-96 h-full border-r border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-800">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-slate-800 dark:text-slate-200">任务</h3>
|
||||||
|
<Button size="sm" className="h-9 bg-cyan-600 hover:bg-cyan-500 text-white">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
新建
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabsList className="w-full bg-slate-100 dark:bg-slate-900 border border-slate-300 dark:border-slate-700">
|
||||||
|
<TabsTrigger
|
||||||
|
value="all"
|
||||||
|
className="flex-1 data-[state=active]:bg-cyan-600 data-[state=active]:text-white"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="pending"
|
||||||
|
className="flex-1 data-[state=active]:bg-cyan-600 data-[state=active]:text-white"
|
||||||
|
>
|
||||||
|
待处理
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="in_progress"
|
||||||
|
className="flex-1 data-[state=active]:bg-cyan-600 data-[state=active]:text-white"
|
||||||
|
>
|
||||||
|
进行中
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{filteredTasks.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-slate-500">
|
||||||
|
<LEDMatrix type="loading" className="mb-4" width={12} height={12} />
|
||||||
|
<p className="text-sm">暂无任务</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filteredTasks.map((task) => {
|
||||||
|
const statusInfo = TASK_STATUSES[task.status] || {
|
||||||
|
label: task.status,
|
||||||
|
color: "gray",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => handleSelectTask(task.id)}
|
||||||
|
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
||||||
|
selectedTaskId === task.id
|
||||||
|
? "bg-white dark:bg-slate-900 border-cyan-500/30 shadow-md shadow-cyan-500/10"
|
||||||
|
: "bg-white dark:bg-slate-950 border-slate-200 dark:border-slate-800 hover:bg-slate-100 dark:hover:bg-slate-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="font-medium text-slate-800 dark:text-slate-200 line-clamp-1">
|
||||||
|
{task.title}
|
||||||
|
</h4>
|
||||||
|
{task.isPinned && <Pin className="w-4 h-4 text-cyan-500" />}
|
||||||
|
</div>
|
||||||
|
{task.priority === "urgent" && (
|
||||||
|
<Badge className="bg-red-500 text-white">紧急</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Badge
|
||||||
|
className={`${
|
||||||
|
task.status === "in_progress"
|
||||||
|
? "bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300 border-blue-200 dark:border-blue-500/30"
|
||||||
|
: task.status === "pending"
|
||||||
|
? "bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300 border-orange-200 dark:border-orange-500/30"
|
||||||
|
: "bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300 border-green-200 dark:border-green-500/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{statusInfo.label}
|
||||||
|
</Badge>
|
||||||
|
{task.priority === "high" && (
|
||||||
|
<Badge
|
||||||
|
className="bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300 border-orange-200 dark:border-orange-500/30"
|
||||||
|
>
|
||||||
|
高
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{getRelativeTime(task.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageInput({
|
||||||
|
onSend,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
onSend: (text: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (text.trim()) {
|
||||||
|
onSend(text);
|
||||||
|
setText("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950">
|
||||||
|
<div className="max-w-3xl mx-auto flex items-end gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<AtSign className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<Smile className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder={placeholder || "输入消息..."}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="min-h-[44px] py-3 bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-700 text-slate-800 dark:text-slate-200 placeholder:text-slate-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
className="h-10 w-10 bg-cyan-600 hover:bg-cyan-500 text-white"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!text.trim()}
|
||||||
|
>
|
||||||
|
<Send className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatDetail() {
|
||||||
|
const [selectedTaskId] = useState("1");
|
||||||
|
const { currentWorker, currentUser } = useAuthStore();
|
||||||
|
const selectedTask = mockTasks.find((t) => t.id === selectedTaskId);
|
||||||
|
const taskMessages = mockMessages.filter((m) => m.taskId === selectedTaskId);
|
||||||
|
|
||||||
|
const handleSendMessage = (text: string) => {
|
||||||
|
console.log("发送消息:", text);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!selectedTask) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-950">
|
||||||
|
<div className="text-center space-y-6">
|
||||||
|
<LEDMatrix type="snake" className="mx-auto" width={16} height={16} />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-700 dark:text-slate-300">
|
||||||
|
选择任务
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-500">从左侧列表选择一个任务开始协作</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeWorker = currentWorker || currentUser;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col bg-slate-50 dark:bg-slate-950">
|
||||||
|
{/* 任务头部 */}
|
||||||
|
<div className="h-16 px-6 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-950">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-800 dark:text-slate-200">
|
||||||
|
{selectedTask.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Badge
|
||||||
|
className={`${
|
||||||
|
selectedTask.status === "in_progress"
|
||||||
|
? "bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300 border-blue-200 dark:border-blue-500/30"
|
||||||
|
: selectedTask.status === "pending"
|
||||||
|
? "bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300 border-orange-200 dark:border-orange-500/30"
|
||||||
|
: "bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300 border-green-200 dark:border-green-500/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{TASK_STATUSES[selectedTask.status]?.label || selectedTask.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<MoreVertical className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消息区域 */}
|
||||||
|
<ScrollArea className="flex-1 px-6 py-4">
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
|
{taskMessages.map((msg) => {
|
||||||
|
const sender = mockWorkers.find((w) => w.id === msg.senderId);
|
||||||
|
const isCurrentUser = sender?.id === activeWorker?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={`flex gap-3 ${isCurrentUser ? "flex-row-reverse" : "flex-row"}`}
|
||||||
|
>
|
||||||
|
<Avatar className="h-9 w-9 shrink-0">
|
||||||
|
<AvatarFallback
|
||||||
|
style={{ backgroundColor: getAvatarColor(sender?.id || "unknown") }}
|
||||||
|
className="text-white text-sm"
|
||||||
|
>
|
||||||
|
{sender ? getWorkerInitials(sender.name) : "??"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div
|
||||||
|
className={`max-w-[70%] ${
|
||||||
|
isCurrentUser ? "items-end" : "items-start"
|
||||||
|
} flex flex-col gap-1`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||||
|
{sender?.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{formatTime(msg.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`px-4 py-3 rounded-2xl ${
|
||||||
|
isCurrentUser
|
||||||
|
? "bg-cyan-600 text-white rounded-tr-sm"
|
||||||
|
: "bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200 rounded-tl-sm border border-slate-200 dark:border-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm">{msg.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* 输入区域 */}
|
||||||
|
<MessageInput onSend={handleSendMessage} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopBar() {
|
||||||
|
const { currentWorker, currentUser, logout } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const activeWorker = currentWorker || currentUser;
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-14 px-6 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<LEDLogo />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="font-bold text-lg text-slate-800 dark:text-slate-200">列阵工作台</h1>
|
||||||
|
<Badge className="bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-300 border-cyan-200 dark:border-cyan-500/30">
|
||||||
|
v0.8.0
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="md:hidden h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<Menu className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="hidden md:flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||||
|
<span className="text-xs text-slate-600 dark:text-slate-400">已连接</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 relative"
|
||||||
|
>
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-red-500" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="h-6" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 px-3 flex items-center gap-2 border border-transparent hover:border-slate-200 dark:hover:border-slate-700"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
|
<Avatar className="h-7 w-7">
|
||||||
|
<AvatarFallback
|
||||||
|
style={{ backgroundColor: getAvatarColor(activeWorker?.id || "unknown") }}
|
||||||
|
className="text-white text-xs"
|
||||||
|
>
|
||||||
|
{activeWorker ? getWorkerInitials(activeWorker.name) : "??"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="text-sm font-medium text-slate-800 dark:text-slate-200 hidden sm:inline">
|
||||||
|
{activeWorker?.name || "用户"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkbenchPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<div className="flex-1 flex overflow-hidden">
|
||||||
|
{/* 桌面端布局 */}
|
||||||
|
<div className="hidden md:flex flex-1">
|
||||||
|
<WorkersPanel />
|
||||||
|
<TaskList />
|
||||||
|
<ChatDetail />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 移动端布局 */}
|
||||||
|
<div className="flex-1 md:hidden flex flex-col">
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-slate-200 dark:border-slate-800">
|
||||||
|
<p className="text-center text-slate-600 dark:text-slate-400">
|
||||||
|
请在桌面端打开查看完整界面
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<LEDMatrix type="tetris" className="mx-auto" width={16} height={16} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import type { Organization, Task, Worker } from "@/lib/types";
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
currentOrganization: Organization | null;
|
||||||
|
selectedTask: Task | null;
|
||||||
|
workers: Worker[];
|
||||||
|
organizations: Organization[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppActions {
|
||||||
|
setCurrentOrganization: (org: Organization | null) => void;
|
||||||
|
setSelectedTask: (task: Task | null) => void;
|
||||||
|
setWorkers: (workers: Worker[]) => void;
|
||||||
|
addWorker: (worker: Worker) => void;
|
||||||
|
updateWorker: (workerId: string, updates: Partial<Worker>) => void;
|
||||||
|
removeWorker: (workerId: string) => void;
|
||||||
|
setOrganizations: (orgs: Organization[]) => void;
|
||||||
|
addOrganization: (org: Organization) => void;
|
||||||
|
updateOrganization: (orgId: string, updates: Partial<Organization>) => void;
|
||||||
|
removeOrganization: (orgId: string) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAppStore = create<AppState & AppActions>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
currentOrganization: null,
|
||||||
|
selectedTask: null,
|
||||||
|
workers: [],
|
||||||
|
organizations: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
setCurrentOrganization: (org) => set({ currentOrganization: org }),
|
||||||
|
|
||||||
|
setSelectedTask: (task) => set({ selectedTask: task }),
|
||||||
|
|
||||||
|
setWorkers: (workers) => set({ workers }),
|
||||||
|
|
||||||
|
addWorker: (worker) =>
|
||||||
|
set((state) => ({ workers: [...state.workers, worker] })),
|
||||||
|
|
||||||
|
updateWorker: (workerId, updates) =>
|
||||||
|
set((state) => ({
|
||||||
|
workers: state.workers.map((w) =>
|
||||||
|
w.id === workerId ? { ...w, ...updates } : w
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
removeWorker: (workerId) =>
|
||||||
|
set((state) => ({
|
||||||
|
workers: state.workers.filter((w) => w.id !== workerId),
|
||||||
|
})),
|
||||||
|
|
||||||
|
setOrganizations: (orgs) => set({ organizations: orgs }),
|
||||||
|
|
||||||
|
addOrganization: (org) =>
|
||||||
|
set((state) => ({ organizations: [...state.organizations, org] })),
|
||||||
|
|
||||||
|
updateOrganization: (orgId, updates) =>
|
||||||
|
set((state) => ({
|
||||||
|
organizations: state.organizations.map((o) =>
|
||||||
|
o.id === orgId ? { ...o, ...updates } : o
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
|
||||||
|
removeOrganization: (orgId) =>
|
||||||
|
set((state) => ({
|
||||||
|
organizations: state.organizations.filter((o) => o.id !== orgId),
|
||||||
|
})),
|
||||||
|
|
||||||
|
setLoading: (loading) => set({ isLoading: loading }),
|
||||||
|
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "app-storage",
|
||||||
|
partialize: (state) => ({
|
||||||
|
currentOrganization: state.currentOrganization,
|
||||||
|
organizations: state.organizations,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import type { Worker } from "@/lib/types";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
currentUser: Worker | null;
|
||||||
|
currentWorker: Worker | null; // 别名,用于兼容性
|
||||||
|
token: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthActions {
|
||||||
|
login: (user: Worker, token: string) => void;
|
||||||
|
logout: () => void;
|
||||||
|
updateUser: (user: Partial<Worker>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState & AuthActions>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
isAuthenticated: false,
|
||||||
|
currentUser: null,
|
||||||
|
currentWorker: null,
|
||||||
|
token: null,
|
||||||
|
|
||||||
|
login: (user, token) =>
|
||||||
|
set({
|
||||||
|
isAuthenticated: true,
|
||||||
|
currentUser: user,
|
||||||
|
currentWorker: user,
|
||||||
|
token,
|
||||||
|
}),
|
||||||
|
|
||||||
|
logout: () =>
|
||||||
|
set({
|
||||||
|
isAuthenticated: false,
|
||||||
|
currentUser: null,
|
||||||
|
currentWorker: null,
|
||||||
|
token: null,
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateUser: (user) =>
|
||||||
|
set((state) => ({
|
||||||
|
currentUser: state.currentUser
|
||||||
|
? { ...state.currentUser, ...user }
|
||||||
|
: null,
|
||||||
|
currentWorker: state.currentWorker
|
||||||
|
? { ...state.currentWorker, ...user }
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "auth-storage",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./authStore";
|
||||||
|
export * from "./appStore";
|
||||||
|
export * from "./uiStore";
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import type { Notification } from "@/lib/types";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
theme: Theme;
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
|
notificationsOpen: boolean;
|
||||||
|
notifications: Notification[]; // 添加通知列表
|
||||||
|
modals: Record<string, boolean>;
|
||||||
|
toasts: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
type: "success" | "error" | "warning" | "info";
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UIActions {
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
|
toggleNotifications: () => void;
|
||||||
|
setNotificationsOpen: (open: boolean) => void;
|
||||||
|
setNotifications: (notifications: Notification[]) => void; // 添加这个方法
|
||||||
|
openModal: (modalId: string) => void;
|
||||||
|
closeModal: (modalId: string) => void;
|
||||||
|
toggleModal: (modalId: string) => void;
|
||||||
|
addToast: (toast: Omit<UIState["toasts"][0], "id">) => void;
|
||||||
|
removeToast: (toastId: string) => void;
|
||||||
|
clearToasts: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStore = create<UIState & UIActions>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
theme: "light",
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
notificationsOpen: false,
|
||||||
|
notifications: [], // 初始化
|
||||||
|
modals: {},
|
||||||
|
toasts: [],
|
||||||
|
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
|
||||||
|
toggleTheme: () =>
|
||||||
|
set((state) => ({
|
||||||
|
theme: state.theme === "light" ? "dark" : state.theme === "dark" ? "system" : "light",
|
||||||
|
})),
|
||||||
|
|
||||||
|
toggleSidebar: () =>
|
||||||
|
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||||
|
|
||||||
|
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
||||||
|
|
||||||
|
toggleNotifications: () =>
|
||||||
|
set((state) => ({ notificationsOpen: !state.notificationsOpen })),
|
||||||
|
|
||||||
|
setNotificationsOpen: (open) => set({ notificationsOpen: open }),
|
||||||
|
|
||||||
|
setNotifications: (notifications) => set({ notifications }), // 添加这个
|
||||||
|
|
||||||
|
openModal: (modalId) =>
|
||||||
|
set((state) => ({
|
||||||
|
modals: { ...state.modals, [modalId]: true },
|
||||||
|
})),
|
||||||
|
|
||||||
|
closeModal: (modalId) =>
|
||||||
|
set((state) => ({
|
||||||
|
modals: { ...state.modals, [modalId]: false },
|
||||||
|
})),
|
||||||
|
|
||||||
|
toggleModal: (modalId) =>
|
||||||
|
set((state) => ({
|
||||||
|
modals: {
|
||||||
|
...state.modals,
|
||||||
|
[modalId]: !state.modals[modalId],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
addToast: (toast) =>
|
||||||
|
set((state) => ({
|
||||||
|
toasts: [...state.toasts, { ...toast, id: crypto.randomUUID() }],
|
||||||
|
})),
|
||||||
|
|
||||||
|
removeToast: (toastId) =>
|
||||||
|
set((state) => ({
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== toastId),
|
||||||
|
})),
|
||||||
|
|
||||||
|
clearToasts: () => set({ toasts: [] }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "ui-storage",
|
||||||
|
partialize: (state) => ({
|
||||||
|
theme: state.theme,
|
||||||
|
sidebarCollapsed: state.sidebarCollapsed,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
|
||||||
|
export default {
|
||||||
|
darkMode: "class",
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
},
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": [
|
||||||
|
"ES2020",
|
||||||
|
"DOM",
|
||||||
|
"DOM.Iterable"
|
||||||
|
],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": false,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": false,
|
||||||
|
"noUncheckedSideEffectImports": false,
|
||||||
|
"forceConsistentCasingInFileNames": false,
|
||||||
|
"baseUrl": "./",
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src",
|
||||||
|
"api"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
import { traeBadgePlugin } from 'vite-plugin-trae-solo-badge';
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
sourcemap: 'hidden',
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react({
|
||||||
|
babel: {
|
||||||
|
plugins: [
|
||||||
|
'react-dev-locator',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
traeBadgePlugin({
|
||||||
|
variant: 'dark',
|
||||||
|
position: 'bottom-right',
|
||||||
|
prodOnly: true,
|
||||||
|
clickable: true,
|
||||||
|
clickUrl: 'https://www.trae.ai/solo?showJoin=1',
|
||||||
|
autoTheme: true,
|
||||||
|
autoThemeTarget: '#root'
|
||||||
|
}),
|
||||||
|
tsconfigPaths()
|
||||||
|
],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user