feat(board): dynamic agent profiles from OGraph events (#33)
- Remove hardcoded AGENT_NAME_MAP, load profiles from agent_profile_updated events - OGraphClient.loadAgentProfiles() fetches agent objects and replays profile events (LWW) - Hardcoded fallback map retained for graceful degradation - TaskDialog accepts dynamic agents list as prop - App.tsx loads profiles in parallel with tasks on startup
This commit is contained in:
parent
cdd735e018
commit
6f73544e96
24
packages/board/.gitignore
vendored
Normal file
24
packages/board/.gitignore
vendored
Normal file
@ -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?
|
||||
73
packages/board/README.md
Normal file
73
packages/board/README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
11
packages/board/docs/plan.md
Normal file
11
packages/board/docs/plan.md
Normal file
@ -0,0 +1,11 @@
|
||||
# OGraph Task Board — Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Build a Task Kanban Board Web UI for managing OGraph tasks — view, create, assign, and track task status across agents (小糯、小橘、小墨、NEKO、KUMA).
|
||||
|
||||
**Architecture:** Single-page React app with Vite + shadcn/ui + Tailwind. Talks directly to OGraph API. Kanban columns by status. Deployed as static files, can be served from anywhere.
|
||||
|
||||
**Tech Stack:** React 19, Vite, TypeScript, shadcn/ui (Radix primitives), Tailwind CSS 4, @dnd-kit for drag-and-drop.
|
||||
|
||||
---
|
||||
23
packages/board/eslint.config.js
Normal file
23
packages/board/eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
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'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
packages/board/index.html
Normal file
13
packages/board/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<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>board</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5566
packages/board/package-lock.json
generated
Normal file
5566
packages/board/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
packages/board/package.json
Normal file
41
packages/board/package.json
Normal file
@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@uncaged/ograph-board",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4",
|
||||
"wrangler": "^4.81.1"
|
||||
}
|
||||
}
|
||||
1
packages/board/public/favicon.svg
Normal file
1
packages/board/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
packages/board/public/icons.svg
Normal file
24
packages/board/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
203
packages/board/src/App.tsx
Normal file
203
packages/board/src/App.tsx
Normal file
@ -0,0 +1,203 @@
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import api, { isConfigured, resetClient } from "@/api"
|
||||
import type { Task, TaskStatus, CreateTaskInput, Agent } from "@/types"
|
||||
import { getAgents } from "@/types"
|
||||
import { Header } from "@/components/Header"
|
||||
import { KanbanBoard } from "@/components/KanbanBoard"
|
||||
import { TaskDialog } from "@/components/TaskDialog"
|
||||
import { SettingsDialog } from "@/components/SettingsDialog"
|
||||
|
||||
function App() {
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [agents, setAgents] = useState<Agent[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
|
||||
// Dialog state
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [defaultStatus, setDefaultStatus] = useState<TaskStatus>("backlog")
|
||||
|
||||
// Settings dialog
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
|
||||
const loadTasks = useCallback(async () => {
|
||||
if (!isConfigured()) {
|
||||
setLoading(false)
|
||||
setConnected(false)
|
||||
setTasks([])
|
||||
setAgents([])
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Load agent profiles and tasks in parallel
|
||||
const [data] = await Promise.all([
|
||||
api.getTasks(),
|
||||
api.loadAgentProfiles(),
|
||||
])
|
||||
setTasks(data)
|
||||
setAgents(getAgents())
|
||||
setConnected(true)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Failed to load tasks"
|
||||
setError(msg)
|
||||
setConnected(false)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadTasks()
|
||||
}, [loadTasks])
|
||||
|
||||
function handleNewTask() {
|
||||
setEditingTask(null)
|
||||
setDefaultStatus("backlog")
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleAddTaskInColumn(status: TaskStatus) {
|
||||
setEditingTask(null)
|
||||
setDefaultStatus(status)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function handleTaskClick(task: Task) {
|
||||
setEditingTask(task)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave(data: CreateTaskInput & { id?: number; status?: TaskStatus }) {
|
||||
setError(null)
|
||||
try {
|
||||
if (data.id) {
|
||||
await api.updateTask(data.id, {
|
||||
title: data.title,
|
||||
description: data.description ?? "",
|
||||
priority: data.priority,
|
||||
status: data.status,
|
||||
assigneeId: data.assigneeId ?? null,
|
||||
})
|
||||
// Re-fetch all tasks after mutation to get accurate state
|
||||
await loadTasks()
|
||||
} else {
|
||||
await api.createTask(data)
|
||||
await loadTasks()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Operation failed")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
setError(null)
|
||||
try {
|
||||
await api.deleteTask(id)
|
||||
await loadTasks()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Delete failed")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveTask(taskId: number, newStatus: TaskStatus) {
|
||||
const task = tasks.find((t) => t.id === taskId)
|
||||
if (!task || task.status === newStatus) return
|
||||
|
||||
// Optimistic update
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === taskId ? { ...t, status: newStatus, updatedAt: Date.now() } : t))
|
||||
)
|
||||
|
||||
try {
|
||||
await api.moveTask(taskId, newStatus)
|
||||
} catch {
|
||||
// Revert on error — re-fetch
|
||||
loadTasks()
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsSaved() {
|
||||
resetClient()
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
// Show settings dialog on first visit if not configured
|
||||
useEffect(() => {
|
||||
if (!isConfigured() && !loading) {
|
||||
setSettingsOpen(true)
|
||||
}
|
||||
}, [loading])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex items-center justify-center">
|
||||
<div className="text-zinc-500 text-sm">Loading tasks from OGraph...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100 flex flex-col">
|
||||
<Header
|
||||
onNewTask={handleNewTask}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
taskCount={tasks.length}
|
||||
connected={connected}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mx-6 mt-3 rounded-md bg-red-500/10 border border-red-500/30 px-4 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!connected && !error && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-zinc-500 text-sm">No API connection configured.</p>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="text-sm text-blue-400 hover:text-blue-300 underline underline-offset-2"
|
||||
>
|
||||
Open Settings to connect to OGraph
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connected && (
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<KanbanBoard
|
||||
tasks={tasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
onAddTask={handleAddTaskInColumn}
|
||||
onMoveTask={handleMoveTask}
|
||||
/>
|
||||
</main>
|
||||
)}
|
||||
|
||||
<TaskDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
task={editingTask}
|
||||
defaultStatus={defaultStatus}
|
||||
agents={agents}
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<SettingsDialog
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
onSaved={handleSettingsSaved}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
70
packages/board/src/api/index.ts
Normal file
70
packages/board/src/api/index.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import type { Task, TaskStatus, CreateTaskInput } from "@/types"
|
||||
import { OGraphClient, createClientFromStorage } from "./ograph-client"
|
||||
|
||||
export interface TaskAPI {
|
||||
getTasks(): Promise<Task[]>
|
||||
createTask(data: CreateTaskInput): Promise<Task>
|
||||
updateTask(id: number, data: Partial<Task>): Promise<Task>
|
||||
deleteTask(id: number): Promise<void>
|
||||
moveTask(id: number, status: TaskStatus): Promise<Task>
|
||||
loadAgentProfiles(): Promise<Record<number, { name: string; emoji: string }>>
|
||||
}
|
||||
|
||||
let _client: OGraphClient | null = null
|
||||
|
||||
/** Get or create the OGraph client from localStorage config */
|
||||
export function getClient(): OGraphClient | null {
|
||||
if (!_client) {
|
||||
_client = createClientFromStorage()
|
||||
}
|
||||
return _client
|
||||
}
|
||||
|
||||
/** Reset the cached client (call after settings change) */
|
||||
export function resetClient(): void {
|
||||
_client = null
|
||||
}
|
||||
|
||||
/** Check if the API is configured */
|
||||
export function isConfigured(): boolean {
|
||||
return getClient() !== null
|
||||
}
|
||||
|
||||
/** Create a TaskAPI backed by the real OGraph client */
|
||||
function createAPI(): TaskAPI {
|
||||
return {
|
||||
async getTasks() {
|
||||
const client = getClient()
|
||||
if (!client) throw new Error("OGraph API not configured. Please set up your API token in Settings.")
|
||||
return client.getTasks()
|
||||
},
|
||||
async createTask(data: CreateTaskInput) {
|
||||
const client = getClient()
|
||||
if (!client) throw new Error("OGraph API not configured.")
|
||||
return client.createTask(data)
|
||||
},
|
||||
async updateTask(id: number, data: Partial<Task>) {
|
||||
const client = getClient()
|
||||
if (!client) throw new Error("OGraph API not configured.")
|
||||
return client.updateTask(id, data)
|
||||
},
|
||||
async deleteTask(id: number) {
|
||||
const client = getClient()
|
||||
if (!client) throw new Error("OGraph API not configured.")
|
||||
return client.deleteTask(id)
|
||||
},
|
||||
async moveTask(id: number, status: TaskStatus) {
|
||||
const client = getClient()
|
||||
if (!client) throw new Error("OGraph API not configured.")
|
||||
return client.moveTask(id, status)
|
||||
},
|
||||
async loadAgentProfiles() {
|
||||
const client = getClient()
|
||||
if (!client) throw new Error("OGraph API not configured.")
|
||||
return client.loadAgentProfiles()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const api = createAPI()
|
||||
export default api
|
||||
155
packages/board/src/api/mock-store.ts
Normal file
155
packages/board/src/api/mock-store.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import type { Task, TaskStatus, CreateTaskInput } from "@/types"
|
||||
|
||||
let nextId = 11
|
||||
|
||||
const now = Date.now()
|
||||
const hour = 3600_000
|
||||
const day = 86400_000
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "OGraph Channel Plugin P0",
|
||||
description: "Implement the core channel plugin for OGraph event routing. This is critical for multi-agent communication.",
|
||||
status: "in_progress",
|
||||
priority: "p0",
|
||||
assigneeId: 1,
|
||||
createdAt: now - 7 * day,
|
||||
updatedAt: now - 2 * hour,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Implement AgentClient interface",
|
||||
description: "Design and implement the AgentClient TypeScript interface for communicating with OGraph backend.",
|
||||
status: "in_review",
|
||||
priority: "p1",
|
||||
assigneeId: 4,
|
||||
createdAt: now - 5 * day,
|
||||
updatedAt: now - 6 * hour,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Task Board Web UI",
|
||||
description: "Build a Kanban-style task board using React + Tailwind with shadcn/ui components.",
|
||||
status: "in_progress",
|
||||
priority: "p1",
|
||||
assigneeId: 2,
|
||||
createdAt: now - 3 * day,
|
||||
updatedAt: now - 1 * hour,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Setup CI/CD Pipeline",
|
||||
description: "Configure GitHub Actions for automated testing and deployment of OGraph packages.",
|
||||
status: "todo",
|
||||
priority: "p2",
|
||||
assigneeId: 3,
|
||||
createdAt: now - 4 * day,
|
||||
updatedAt: now - 1 * day,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Write projection definitions",
|
||||
description: "Define OGraph projections for task_summary, agent_workload, and board_stats.",
|
||||
status: "backlog",
|
||||
priority: "p2",
|
||||
assigneeId: null,
|
||||
createdAt: now - 2 * day,
|
||||
updatedAt: now - 2 * day,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Add event replay mechanism",
|
||||
description: "Implement event replay for reconstructing state from event history.",
|
||||
status: "backlog",
|
||||
priority: "p1",
|
||||
assigneeId: null,
|
||||
createdAt: now - 6 * day,
|
||||
updatedAt: now - 6 * day,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Design agent emoji avatars",
|
||||
description: "Create a consistent emoji avatar system for the 5 known agents.",
|
||||
status: "done",
|
||||
priority: "p3",
|
||||
assigneeId: 5,
|
||||
createdAt: now - 8 * day,
|
||||
updatedAt: now - 3 * day,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "Cloudflare Worker deployment",
|
||||
description: "Deploy OGraph API to Cloudflare Workers with D1 database binding.",
|
||||
status: "done",
|
||||
priority: "p0",
|
||||
assigneeId: 1,
|
||||
createdAt: now - 10 * day,
|
||||
updatedAt: now - 5 * day,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: "Schema validation middleware",
|
||||
description: "Add Zod-based schema validation for all event payloads.",
|
||||
status: "todo",
|
||||
priority: "p2",
|
||||
assigneeId: 4,
|
||||
createdAt: now - 1 * day,
|
||||
updatedAt: now - 1 * day,
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: "Multi-tenant support",
|
||||
description: "Implement workspace-level isolation for multi-tenant OGraph deployments.",
|
||||
status: "backlog",
|
||||
priority: "p3",
|
||||
assigneeId: null,
|
||||
createdAt: now - 1 * day,
|
||||
updatedAt: now - 1 * day,
|
||||
},
|
||||
]
|
||||
|
||||
function delay(ms: number = 80): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function getTasks(): Promise<Task[]> {
|
||||
await delay()
|
||||
return [...mockTasks]
|
||||
}
|
||||
|
||||
export async function createTask(data: CreateTaskInput): Promise<Task> {
|
||||
await delay()
|
||||
const task: Task = {
|
||||
id: nextId++,
|
||||
title: data.title,
|
||||
description: data.description ?? "",
|
||||
status: data.status ?? "backlog",
|
||||
priority: data.priority,
|
||||
assigneeId: data.assigneeId ?? null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
mockTasks.push(task)
|
||||
return { ...task }
|
||||
}
|
||||
|
||||
export async function updateTask(id: number, updates: Partial<Task>): Promise<Task> {
|
||||
await delay()
|
||||
const idx = mockTasks.findIndex((t) => t.id === id)
|
||||
if (idx === -1) throw new Error(`Task ${id} not found`)
|
||||
const task = mockTasks[idx]
|
||||
Object.assign(task, updates, { updatedAt: Date.now() })
|
||||
return { ...task }
|
||||
}
|
||||
|
||||
export async function deleteTask(id: number): Promise<void> {
|
||||
await delay()
|
||||
const idx = mockTasks.findIndex((t) => t.id === id)
|
||||
if (idx === -1) throw new Error(`Task ${id} not found`)
|
||||
mockTasks.splice(idx, 1)
|
||||
}
|
||||
|
||||
export async function moveTask(id: number, status: TaskStatus): Promise<Task> {
|
||||
return updateTask(id, { status })
|
||||
}
|
||||
412
packages/board/src/api/ograph-client.ts
Normal file
412
packages/board/src/api/ograph-client.ts
Normal file
@ -0,0 +1,412 @@
|
||||
import type { Task, TaskStatus, TaskPriority, CreateTaskInput } from "@/types"
|
||||
import type { TaskAPI } from "./index"
|
||||
|
||||
/** Agent name+emoji map — populated dynamically from OGraph agent_profile_updated events */
|
||||
export let AGENT_NAME_MAP: Record<number, { name: string; emoji: string }> = {}
|
||||
|
||||
/** Hardcoded fallback — used only if OGraph profile fetch fails */
|
||||
const FALLBACK_AGENT_MAP: Record<number, { name: string; emoji: string }> = {
|
||||
2: { name: "小墨", emoji: "🖊️" },
|
||||
3: { name: "小橘", emoji: "🍊" },
|
||||
5: { name: "KUMA", emoji: "🐻" },
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
interface OGraphObject {
|
||||
id: number
|
||||
type: string
|
||||
}
|
||||
|
||||
interface OGraphPayload {
|
||||
subject?: { ref: number } | number
|
||||
creator?: { ref: number } | number
|
||||
assignee?: { ref: number } | number
|
||||
participant?: { ref: number } | number
|
||||
author?: { ref: number } | number
|
||||
title?: string
|
||||
priority?: string
|
||||
description?: string
|
||||
status?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
interface OGraphEvent {
|
||||
id: number
|
||||
type_name: string
|
||||
type_hash: string
|
||||
payload: OGraphPayload
|
||||
created_at: number
|
||||
}
|
||||
|
||||
function refId(val: { ref: number } | number | undefined): number | undefined {
|
||||
if (val === undefined || val === null) return undefined
|
||||
if (typeof val === "number") return val
|
||||
return val.ref
|
||||
}
|
||||
|
||||
function extractSubjectId(payload: OGraphPayload): number | undefined {
|
||||
return refId(payload.subject)
|
||||
}
|
||||
|
||||
// ---- client ----
|
||||
|
||||
export class OGraphClient implements TaskAPI {
|
||||
private endpoint: string
|
||||
private token: string
|
||||
|
||||
constructor(endpoint: string, token: string) {
|
||||
this.endpoint = endpoint.replace(/\/+$/, "")
|
||||
this.token = token
|
||||
}
|
||||
|
||||
private async request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const url = `${this.endpoint}${path}`
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if (this.token) {
|
||||
headers["Authorization"] = `Bearer ${this.token}`
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init?.headers as Record<string, string> | undefined) },
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "")
|
||||
throw new Error(`OGraph API error ${res.status}: ${body}`)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// ---- public API (implements TaskAPI) ----
|
||||
|
||||
async getTasks(): Promise<Task[]> {
|
||||
// 1. Get all task objects
|
||||
const objectsRes = await this.request<{ objects: OGraphObject[] }>("/objects?type=task")
|
||||
const objects = objectsRes.objects ?? []
|
||||
|
||||
if (objects.length === 0) return []
|
||||
|
||||
// 2. Fetch all events (with a high limit) and group by subject
|
||||
const eventsRes = await this.request<{ events: OGraphEvent[] }>("/events?limit=500")
|
||||
const allEvents = eventsRes.events ?? []
|
||||
|
||||
// Group events by subject ref (task id)
|
||||
const eventsByTask = new Map<number, OGraphEvent[]>()
|
||||
for (const obj of objects) {
|
||||
eventsByTask.set(obj.id, [])
|
||||
}
|
||||
for (const evt of allEvents) {
|
||||
const subjectId = extractSubjectId(evt.payload)
|
||||
if (subjectId !== undefined && eventsByTask.has(subjectId)) {
|
||||
eventsByTask.get(subjectId)!.push(evt)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. For each task, also fetch events by ref to catch anything missing
|
||||
// (the bulk fetch above may not include all events if there are many)
|
||||
// Skip this for efficiency — 500 events should be enough for now.
|
||||
|
||||
// 4. Replay events to build task state
|
||||
const tasks: Task[] = []
|
||||
for (const obj of objects) {
|
||||
const events = eventsByTask.get(obj.id) ?? []
|
||||
const task = this.replayEvents(obj.id, events)
|
||||
if (task) tasks.push(task)
|
||||
}
|
||||
|
||||
return tasks
|
||||
}
|
||||
|
||||
async createTask(data: CreateTaskInput): Promise<Task> {
|
||||
// 1. Create the task object
|
||||
const obj = await this.request<OGraphObject>("/objects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ type: "task" }),
|
||||
})
|
||||
|
||||
// 2. Emit task_created event
|
||||
await this.request<OGraphEvent>("/events", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: "task_created",
|
||||
payload: {
|
||||
subject: { ref: obj.id },
|
||||
title: data.title,
|
||||
priority: data.priority ?? "p2",
|
||||
...(data.description ? { description: data.description } : {}),
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// 3. If assignee specified, emit task_assigned
|
||||
if (data.assigneeId) {
|
||||
await this.request<OGraphEvent>("/events", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: "task_assigned",
|
||||
payload: {
|
||||
subject: { ref: obj.id },
|
||||
assignee: { ref: data.assigneeId },
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// 4. If status is not default (backlog), emit task_status_changed
|
||||
const status = data.status ?? "backlog"
|
||||
if (status !== "backlog") {
|
||||
await this.request<OGraphEvent>("/events", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: "task_status_changed",
|
||||
payload: {
|
||||
subject: { ref: obj.id },
|
||||
status,
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// Return reconstructed task
|
||||
return {
|
||||
id: obj.id,
|
||||
title: data.title,
|
||||
description: data.description ?? "",
|
||||
status,
|
||||
priority: (data.priority ?? "p2") as TaskPriority,
|
||||
assigneeId: data.assigneeId ?? null,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
async updateTask(id: number, data: Partial<Task>): Promise<Task> {
|
||||
// Emit events for each changed field
|
||||
// We don't know what the old values are, so we emit events for everything provided
|
||||
const promises: Promise<unknown>[] = []
|
||||
|
||||
if (data.status !== undefined) {
|
||||
promises.push(
|
||||
this.request("/events", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: "task_status_changed",
|
||||
payload: {
|
||||
subject: { ref: id },
|
||||
status: data.status,
|
||||
},
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
if (data.assigneeId !== undefined) {
|
||||
promises.push(
|
||||
this.request("/events", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: "task_assigned",
|
||||
payload: {
|
||||
subject: { ref: id },
|
||||
assignee: data.assigneeId ? { ref: data.assigneeId } : null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
// Note: OGraph doesn't have a "task_title_changed" or "task_priority_changed" event type
|
||||
// For title/priority/description changes, we would need new event types.
|
||||
// For now, we emit task_created again with updated info (not ideal but works for MVP)
|
||||
// Actually, let's just handle status and assignee which have proper event types.
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
// Re-fetch this task's events to rebuild state
|
||||
const eventsRes = await this.request<{ events: OGraphEvent[] }>(`/events?ref=${id}&limit=200`)
|
||||
const events = eventsRes.events ?? []
|
||||
const task = this.replayEvents(id, events)
|
||||
if (!task) throw new Error(`Failed to rebuild task ${id} after update`)
|
||||
return task
|
||||
}
|
||||
|
||||
async deleteTask(_id: number): Promise<void> {
|
||||
// OGraph is event-sourced and doesn't support deletion.
|
||||
// We could mark it as "archived" via a status change.
|
||||
// For now, move to "done" as a soft-delete.
|
||||
await this.request("/events", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: "task_status_changed",
|
||||
payload: {
|
||||
subject: { ref: _id },
|
||||
status: "done",
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async moveTask(id: number, status: TaskStatus): Promise<Task> {
|
||||
await this.request("/events", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: "task_status_changed",
|
||||
payload: {
|
||||
subject: { ref: id },
|
||||
status,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
// Re-fetch this task's events to rebuild state
|
||||
const eventsRes = await this.request<{ events: OGraphEvent[] }>(`/events?ref=${id}&limit=200`)
|
||||
const events = eventsRes.events ?? []
|
||||
const task = this.replayEvents(id, events)
|
||||
if (!task) throw new Error(`Failed to rebuild task ${id} after move`)
|
||||
return task
|
||||
}
|
||||
|
||||
async getAgentIds(): Promise<number[]> {
|
||||
const res = await this.request<{ objects: OGraphObject[] }>("/objects?type=agent")
|
||||
return (res.objects ?? []).map((o) => o.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load agent profiles from OGraph agent_profile_updated events.
|
||||
* For each agent object, fetches its event stream and picks the latest
|
||||
* agent_profile_updated event (LWW). Falls back to FALLBACK_AGENT_MAP.
|
||||
*/
|
||||
async loadAgentProfiles(): Promise<Record<number, { name: string; emoji: string }>> {
|
||||
const map: Record<number, { name: string; emoji: string }> = { ...FALLBACK_AGENT_MAP }
|
||||
try {
|
||||
// 1. Get all agent objects
|
||||
const agentIds = await this.getAgentIds()
|
||||
if (agentIds.length === 0) return map
|
||||
|
||||
// 2. For each agent, fetch events and find latest agent_profile_updated
|
||||
for (const agentId of agentIds) {
|
||||
try {
|
||||
const eventsRes = await this.request<{ events: OGraphEvent[] }>(`/events?ref=${agentId}&limit=200`)
|
||||
const events = eventsRes.events ?? []
|
||||
const profileEvents = events.filter(e => e.type_name === "agent_profile_updated")
|
||||
if (profileEvents.length > 0) {
|
||||
// Sort by id (chronological) and take the latest
|
||||
const latest = profileEvents.sort((a, b) => a.id - b.id).at(-1)!
|
||||
const p = latest.payload as { name?: string; emoji?: string }
|
||||
if (p.name) {
|
||||
map[agentId] = { name: p.name, emoji: p.emoji ?? "👤" }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Failed to fetch events for this agent, keep fallback
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Failed to fetch agent objects, return fallback map
|
||||
}
|
||||
|
||||
// Update the global map
|
||||
AGENT_NAME_MAP = map
|
||||
return map
|
||||
}
|
||||
|
||||
// ---- event replay ----
|
||||
|
||||
private replayEvents(taskId: number, events: OGraphEvent[]): Task | null {
|
||||
// Sort events by id (chronological)
|
||||
const sorted = [...events].sort((a, b) => a.id - b.id)
|
||||
|
||||
let title = `Task #${taskId}`
|
||||
let description = ""
|
||||
let priority: TaskPriority = "p2"
|
||||
let status: TaskStatus = "backlog"
|
||||
let assigneeId: number | null = null
|
||||
let createdAt = Date.now()
|
||||
let updatedAt = Date.now()
|
||||
let hasCreatedEvent = false
|
||||
|
||||
for (const evt of sorted) {
|
||||
const ts = evt.created_at
|
||||
|
||||
switch (evt.type_name) {
|
||||
case "task_created":
|
||||
hasCreatedEvent = true
|
||||
title = evt.payload.title ?? title
|
||||
priority = (evt.payload.priority as TaskPriority) ?? priority
|
||||
description = evt.payload.description ?? description
|
||||
createdAt = ts
|
||||
updatedAt = ts
|
||||
break
|
||||
|
||||
case "task_assigned":
|
||||
assigneeId = refId(evt.payload.assignee) ?? null
|
||||
updatedAt = ts
|
||||
break
|
||||
|
||||
case "assigned":
|
||||
// legacy format
|
||||
assigneeId = refId(evt.payload.participant) ?? null
|
||||
updatedAt = ts
|
||||
break
|
||||
|
||||
case "task_status_changed":
|
||||
status = (evt.payload.status as TaskStatus) ?? status
|
||||
updatedAt = ts
|
||||
break
|
||||
|
||||
case "task_commented":
|
||||
case "commented":
|
||||
// Comments don't change task state, but update timestamp
|
||||
updatedAt = ts
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If no task_created event was found, it might still be a valid task object
|
||||
// (created but with no events yet). We still return it.
|
||||
if (!hasCreatedEvent && sorted.length === 0) {
|
||||
// Task object exists but has no events — still show it
|
||||
}
|
||||
|
||||
return {
|
||||
id: taskId,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
priority,
|
||||
assigneeId,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- localStorage config ----
|
||||
|
||||
const STORAGE_KEY_ENDPOINT = "ograph_endpoint"
|
||||
const STORAGE_KEY_TOKEN = "ograph_token"
|
||||
|
||||
export function getStoredConfig(): { endpoint: string; token: string } | null {
|
||||
const endpoint = localStorage.getItem(STORAGE_KEY_ENDPOINT)
|
||||
const token = localStorage.getItem(STORAGE_KEY_TOKEN)
|
||||
if (!endpoint || !token) return null
|
||||
return { endpoint, token }
|
||||
}
|
||||
|
||||
export function setStoredConfig(endpoint: string, token: string): void {
|
||||
localStorage.setItem(STORAGE_KEY_ENDPOINT, endpoint)
|
||||
localStorage.setItem(STORAGE_KEY_TOKEN, token)
|
||||
}
|
||||
|
||||
export function clearStoredConfig(): void {
|
||||
localStorage.removeItem(STORAGE_KEY_ENDPOINT)
|
||||
localStorage.removeItem(STORAGE_KEY_TOKEN)
|
||||
}
|
||||
|
||||
export function createClientFromStorage(): OGraphClient | null {
|
||||
const config = getStoredConfig()
|
||||
if (!config) return null
|
||||
return new OGraphClient(config.endpoint, config.token)
|
||||
}
|
||||
BIN
packages/board/src/assets/hero.png
Normal file
BIN
packages/board/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
48
packages/board/src/components/Header.tsx
Normal file
48
packages/board/src/components/Header.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { Plus, LayoutDashboard, Settings } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface HeaderProps {
|
||||
onNewTask: () => void
|
||||
onOpenSettings: () => void
|
||||
taskCount: number
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export function Header({ onNewTask, onOpenSettings, taskCount, connected }: HeaderProps) {
|
||||
return (
|
||||
<header className="border-b border-zinc-800 bg-zinc-950 px-6 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center h-8 w-8 rounded-lg bg-zinc-800 text-zinc-300">
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-zinc-100 leading-tight">OGraph Task Board</h1>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{connected
|
||||
? `${taskCount} task${taskCount !== 1 ? "s" : ""}`
|
||||
: "Not connected — configure in Settings"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={onOpenSettings}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-zinc-500 hover:text-zinc-200"
|
||||
title="Settings"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button onClick={onNewTask} size="sm" className="gap-1.5" disabled={!connected}>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
37
packages/board/src/components/KanbanBoard.tsx
Normal file
37
packages/board/src/components/KanbanBoard.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { ALL_STATUSES, type Task, type TaskStatus } from "@/types"
|
||||
import { KanbanColumn } from "@/components/KanbanColumn"
|
||||
|
||||
interface KanbanBoardProps {
|
||||
tasks: Task[]
|
||||
onTaskClick: (task: Task) => void
|
||||
onAddTask: (status: TaskStatus) => void
|
||||
onMoveTask: (taskId: number, status: TaskStatus) => void
|
||||
}
|
||||
|
||||
export function KanbanBoard({ tasks, onTaskClick, onAddTask, onMoveTask }: KanbanBoardProps) {
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 px-6 pt-4">
|
||||
{ALL_STATUSES.map((status) => {
|
||||
const columnTasks = tasks
|
||||
.filter((t) => t.status === status)
|
||||
.sort((a, b) => {
|
||||
// Sort by priority first (p0 first), then by updatedAt descending
|
||||
const prioOrder = a.priority.localeCompare(b.priority)
|
||||
if (prioOrder !== 0) return prioOrder
|
||||
return b.updatedAt - a.updatedAt
|
||||
})
|
||||
|
||||
return (
|
||||
<KanbanColumn
|
||||
key={status}
|
||||
status={status}
|
||||
tasks={columnTasks}
|
||||
onTaskClick={onTaskClick}
|
||||
onAddTask={onAddTask}
|
||||
onDrop={onMoveTask}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
95
packages/board/src/components/KanbanColumn.tsx
Normal file
95
packages/board/src/components/KanbanColumn.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { Plus } from "lucide-react"
|
||||
import { STATUS_CONFIG, type Task, type TaskStatus } from "@/types"
|
||||
import { TaskCard } from "@/components/TaskCard"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface KanbanColumnProps {
|
||||
status: TaskStatus
|
||||
tasks: Task[]
|
||||
onTaskClick: (task: Task) => void
|
||||
onAddTask: (status: TaskStatus) => void
|
||||
onDrop: (taskId: number, status: TaskStatus) => void
|
||||
}
|
||||
|
||||
const borderColors: Record<string, string> = {
|
||||
zinc: "border-t-zinc-500",
|
||||
blue: "border-t-blue-500",
|
||||
amber: "border-t-amber-500",
|
||||
purple: "border-t-purple-500",
|
||||
emerald: "border-t-emerald-500",
|
||||
}
|
||||
|
||||
const countBgColors: Record<string, string> = {
|
||||
zinc: "bg-zinc-700/50 text-zinc-300",
|
||||
blue: "bg-blue-500/15 text-blue-400",
|
||||
amber: "bg-amber-500/15 text-amber-400",
|
||||
purple: "bg-purple-500/15 text-purple-400",
|
||||
emerald: "bg-emerald-500/15 text-emerald-400",
|
||||
}
|
||||
|
||||
export function KanbanColumn({ status, tasks, onTaskClick, onAddTask, onDrop }: KanbanColumnProps) {
|
||||
const config = STATUS_CONFIG[status]
|
||||
|
||||
function handleDragOver(e: React.DragEvent) {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = "move"
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault()
|
||||
const taskId = parseInt(e.dataTransfer.getData("text/plain"), 10)
|
||||
if (!isNaN(taskId)) {
|
||||
onDrop(taskId, status)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col min-w-[272px] w-[272px] shrink-0"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className={`rounded-t-lg border-t-2 ${borderColors[config.color]} bg-zinc-900/50 border-x border-b border-zinc-800 px-3 py-2.5`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-zinc-200">{config.label}</span>
|
||||
<span className={`text-xs rounded-full px-1.5 py-0.5 font-medium ${countBgColors[config.color]}`}>
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-zinc-500 hover:text-zinc-200"
|
||||
onClick={() => onAddTask(status)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task list */}
|
||||
<div className="flex-1 overflow-y-auto space-y-2 py-2 min-h-[120px] max-h-[calc(100vh-200px)]">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData("text/plain", String(task.id))
|
||||
e.dataTransfer.effectAllowed = "move"
|
||||
}}
|
||||
>
|
||||
<TaskCard task={task} onClick={onTaskClick} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
{tasks.length === 0 && (
|
||||
<div className="flex items-center justify-center h-20 text-xs text-zinc-600 border border-dashed border-zinc-800 rounded-lg mx-1">
|
||||
No tasks
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
packages/board/src/components/SettingsDialog.tsx
Normal file
147
packages/board/src/components/SettingsDialog.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { getStoredConfig, setStoredConfig } from "@/api/ograph-client"
|
||||
import { resetClient } from "@/api"
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
export function SettingsDialog({ open, onOpenChange, onSaved }: SettingsDialogProps) {
|
||||
const [endpoint, setEndpoint] = useState("https://ograph.shazhou.workers.dev")
|
||||
const [token, setToken] = useState("")
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const stored = getStoredConfig()
|
||||
if (stored) {
|
||||
setEndpoint(stored.endpoint)
|
||||
setToken(stored.token)
|
||||
}
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
try {
|
||||
const url = endpoint.replace(/\/+$/, "")
|
||||
const res = await fetch(`${url}/objects?type=task`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${await res.text()}`)
|
||||
}
|
||||
const data = await res.json()
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Unexpected response format")
|
||||
}
|
||||
setSuccess(true)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Connection failed")
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (!endpoint.trim() || !token.trim()) {
|
||||
setError("Both endpoint and token are required")
|
||||
return
|
||||
}
|
||||
setStoredConfig(endpoint.trim(), token.trim())
|
||||
resetClient()
|
||||
onSaved()
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>OGraph Settings</DialogTitle>
|
||||
<DialogDescription className="text-zinc-500">
|
||||
Configure your OGraph API endpoint and authentication token.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-endpoint" className="text-sm font-medium text-zinc-300">
|
||||
API Endpoint
|
||||
</label>
|
||||
<Input
|
||||
id="settings-endpoint"
|
||||
placeholder="https://ograph.shazhou.workers.dev"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="settings-token" className="text-sm font-medium text-zinc-300">
|
||||
API Token
|
||||
</label>
|
||||
<Input
|
||||
id="settings-token"
|
||||
type="password"
|
||||
placeholder="Enter your API token..."
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-500/10 border border-red-500/30 px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="rounded-md bg-emerald-500/10 border border-emerald-500/30 px-3 py-2 text-sm text-emerald-400">
|
||||
Connection successful! Found tasks in OGraph.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !endpoint.trim() || !token.trim()}
|
||||
>
|
||||
{testing ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!endpoint.trim() || !token.trim()}
|
||||
>
|
||||
Save & Connect
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
55
packages/board/src/components/TaskCard.tsx
Normal file
55
packages/board/src/components/TaskCard.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import { PRIORITY_CONFIG, resolveAgent, type Task } from "@/types"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task
|
||||
onClick: (task: Task) => void
|
||||
}
|
||||
|
||||
const priorityClasses: Record<string, string> = {
|
||||
red: "bg-red-500/15 text-red-400 border-red-500/30",
|
||||
orange: "bg-orange-500/15 text-orange-400 border-orange-500/30",
|
||||
yellow: "bg-amber-500/15 text-amber-400 border-amber-500/30",
|
||||
green: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||||
}
|
||||
|
||||
export function TaskCard({ task, onClick }: TaskCardProps) {
|
||||
const priorityCfg = PRIORITY_CONFIG[task.priority]
|
||||
const assignee = task.assigneeId ? resolveAgent(task.assigneeId) : null
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(task)}
|
||||
className="w-full text-left rounded-lg border border-zinc-800 bg-zinc-900 p-3 hover:border-zinc-700 hover:bg-zinc-800/80 transition-all duration-150 cursor-pointer group focus:outline-none focus:ring-1 focus:ring-zinc-600"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-zinc-100 leading-snug line-clamp-2 group-hover:text-white">
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] px-1.5 py-0 font-semibold ${priorityClasses[priorityCfg.color] ?? ""}`}
|
||||
>
|
||||
{priorityCfg.label}
|
||||
</Badge>
|
||||
|
||||
{assignee && (
|
||||
<div className="flex items-center gap-1 text-xs text-zinc-400" title={assignee.name}>
|
||||
<span className="text-sm">{assignee.emoji}</span>
|
||||
<span className="hidden sm:inline">{assignee.name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="mt-2 text-xs text-zinc-500 line-clamp-2 leading-relaxed">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
199
packages/board/src/components/TaskDialog.tsx
Normal file
199
packages/board/src/components/TaskDialog.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
ALL_STATUSES,
|
||||
ALL_PRIORITIES,
|
||||
STATUS_CONFIG,
|
||||
PRIORITY_CONFIG,
|
||||
type Agent,
|
||||
type Task,
|
||||
type TaskStatus,
|
||||
type TaskPriority,
|
||||
type CreateTaskInput,
|
||||
} from "@/types"
|
||||
|
||||
interface TaskDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
task: Task | null // null = creating new task
|
||||
defaultStatus?: TaskStatus
|
||||
agents: Agent[]
|
||||
onSave: (data: CreateTaskInput & { id?: number; status?: TaskStatus }) => void
|
||||
onDelete?: (id: number) => void
|
||||
}
|
||||
|
||||
export function TaskDialog({ open, onOpenChange, task, defaultStatus, agents, onSave, onDelete }: TaskDialogProps) {
|
||||
const [title, setTitle] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [priority, setPriority] = useState<TaskPriority>("p2")
|
||||
const [status, setStatus] = useState<TaskStatus>(defaultStatus ?? "backlog")
|
||||
const [assigneeId, setAssigneeId] = useState<string>("unassigned")
|
||||
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
setTitle(task.title)
|
||||
setDescription(task.description)
|
||||
setPriority(task.priority)
|
||||
setStatus(task.status)
|
||||
setAssigneeId(task.assigneeId ? String(task.assigneeId) : "unassigned")
|
||||
} else {
|
||||
setTitle("")
|
||||
setDescription("")
|
||||
setPriority("p2")
|
||||
setStatus(defaultStatus ?? "backlog")
|
||||
setAssigneeId("unassigned")
|
||||
}
|
||||
}, [task, defaultStatus, open])
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
|
||||
onSave({
|
||||
id: task?.id,
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
priority,
|
||||
status,
|
||||
assigneeId: assigneeId === "unassigned" ? null : Number(assigneeId),
|
||||
})
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const isEditing = task !== null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? "Edit Task" : "New Task"}</DialogTitle>
|
||||
<DialogDescription className="text-zinc-500">
|
||||
{isEditing ? "Update the task details below." : "Fill in the details to create a new task."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Title */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="task-title" className="text-sm font-medium text-zinc-300">
|
||||
Title
|
||||
</label>
|
||||
<Input
|
||||
id="task-title"
|
||||
placeholder="Task title..."
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="task-desc" className="text-sm font-medium text-zinc-300">
|
||||
Description
|
||||
</label>
|
||||
<Textarea
|
||||
id="task-desc"
|
||||
placeholder="Add a description..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority & Status row */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-zinc-300">Priority</label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ALL_PRIORITIES.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{PRIORITY_CONFIG[p].icon} {PRIORITY_CONFIG[p].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-zinc-300">Status</label>
|
||||
<Select value={status} onValueChange={(v) => setStatus(v as TaskStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{STATUS_CONFIG[s].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignee */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-zinc-300">Assignee</label>
|
||||
<Select value={assigneeId} onValueChange={setAssigneeId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="unassigned">Unassigned</SelectItem>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.id} value={String(agent.id)}>
|
||||
{agent.emoji} {agent.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
{isEditing && onDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
className="mr-auto"
|
||||
onClick={() => {
|
||||
onDelete(task.id)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!title.trim()}>
|
||||
{isEditing ? "Save Changes" : "Create Task"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
36
packages/board/src/components/ui/badge.tsx
Normal file
36
packages/board/src/components/ui/badge.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
57
packages/board/src/components/ui/button.tsx
Normal file
57
packages/board/src/components/ui/button.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-border bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
76
packages/board/src/components/ui/card.tsx
Normal file
76
packages/board/src/components/ui/card.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
120
packages/board/src/components/ui/dialog.tsx
Normal file
120
packages/board/src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-zinc-800 bg-zinc-900 text-zinc-50 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
22
packages/board/src/components/ui/input.tsx
Normal file
22
packages/board/src/components/ui/input.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-zinc-800 bg-transparent px-3 py-1 text-sm text-zinc-100 shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-zinc-100 placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-600 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
157
packages/board/src/components/ui/select.tsx
Normal file
157
packages/board/src/components/ui/select.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-zinc-800 bg-transparent px-3 py-2 text-sm text-zinc-100 shadow-sm ring-offset-background placeholder:text-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-600 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-zinc-800 bg-zinc-900 text-zinc-50 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm text-zinc-100 outline-none focus:bg-zinc-800 focus:text-zinc-50 data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
22
packages/board/src/components/ui/textarea.tsx
Normal file
22
packages/board/src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-zinc-800 bg-transparent px-3 py-2 text-sm text-zinc-100 shadow-sm placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-600 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
35
packages/board/src/index.css
Normal file
35
packages/board/src/index.css
Normal file
@ -0,0 +1,35 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-background: var(--color-zinc-950);
|
||||
--color-foreground: var(--color-zinc-50);
|
||||
--color-muted: var(--color-zinc-800);
|
||||
--color-muted-foreground: var(--color-zinc-400);
|
||||
--color-border: var(--color-zinc-800);
|
||||
--color-input: var(--color-zinc-800);
|
||||
--color-ring: var(--color-zinc-700);
|
||||
--color-primary: var(--color-zinc-50);
|
||||
--color-primary-foreground: var(--color-zinc-900);
|
||||
--color-secondary: var(--color-zinc-800);
|
||||
--color-secondary-foreground: var(--color-zinc-50);
|
||||
--color-destructive: var(--color-red-500);
|
||||
--color-destructive-foreground: var(--color-zinc-50);
|
||||
--color-accent: var(--color-zinc-800);
|
||||
--color-accent-foreground: var(--color-zinc-50);
|
||||
--color-card: var(--color-zinc-900);
|
||||
--color-card-foreground: var(--color-zinc-50);
|
||||
--color-popover: var(--color-zinc-900);
|
||||
--color-popover-foreground: var(--color-zinc-50);
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
6
packages/board/src/lib/utils.ts
Normal file
6
packages/board/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
10
packages/board/src/main.tsx
Normal file
10
packages/board/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
70
packages/board/src/types/index.ts
Normal file
70
packages/board/src/types/index.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { AGENT_NAME_MAP } from "@/api/ograph-client"
|
||||
|
||||
export type TaskStatus = "backlog" | "todo" | "in_progress" | "in_review" | "done"
|
||||
export type TaskPriority = "p0" | "p1" | "p2" | "p3"
|
||||
|
||||
export interface Agent {
|
||||
id: number
|
||||
name: string
|
||||
emoji: string
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
status: TaskStatus
|
||||
priority: TaskPriority
|
||||
assigneeId: number | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface CreateTaskInput {
|
||||
title: string
|
||||
description?: string
|
||||
status?: TaskStatus
|
||||
priority: TaskPriority
|
||||
assigneeId?: number | null
|
||||
}
|
||||
|
||||
export interface TaskEvent {
|
||||
id: number
|
||||
type: string
|
||||
payload: Record<string, unknown>
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
/** Resolve an agent by ID using the dynamically-loaded name map */
|
||||
export function resolveAgent(id: number): Agent {
|
||||
const known = AGENT_NAME_MAP[id]
|
||||
if (known) return { id, name: known.name, emoji: known.emoji }
|
||||
return { id, name: `Agent #${id}`, emoji: "👤" }
|
||||
}
|
||||
|
||||
/** Get all known agents from the dynamically-loaded map */
|
||||
export function getAgents(): Agent[] {
|
||||
return Object.entries(AGENT_NAME_MAP).map(([idStr, info]) => ({
|
||||
id: Number(idStr),
|
||||
name: info.name,
|
||||
emoji: info.emoji,
|
||||
}))
|
||||
}
|
||||
|
||||
export const STATUS_CONFIG: Record<TaskStatus, { label: string; color: string }> = {
|
||||
backlog: { label: "Backlog", color: "zinc" },
|
||||
todo: { label: "To Do", color: "blue" },
|
||||
in_progress: { label: "In Progress", color: "amber" },
|
||||
in_review: { label: "In Review", color: "purple" },
|
||||
done: { label: "Done", color: "emerald" },
|
||||
}
|
||||
|
||||
export const PRIORITY_CONFIG: Record<TaskPriority, { label: string; color: string; icon: string }> = {
|
||||
p0: { label: "P0 Critical", color: "red", icon: "🔴" },
|
||||
p1: { label: "P1 High", color: "orange", icon: "🟠" },
|
||||
p2: { label: "P2 Medium", color: "yellow", icon: "🟡" },
|
||||
p3: { label: "P3 Low", color: "green", icon: "🟢" },
|
||||
}
|
||||
|
||||
export const ALL_STATUSES: TaskStatus[] = ["backlog", "todo", "in_progress", "in_review", "done"]
|
||||
export const ALL_PRIORITIES: TaskPriority[] = ["p0", "p1", "p2", "p3"]
|
||||
32
packages/board/tsconfig.app.json
Normal file
32
packages/board/tsconfig.app.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"ignoreDeprecations": "6.0"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
packages/board/tsconfig.json
Normal file
7
packages/board/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
packages/board/tsconfig.node.json
Normal file
24
packages/board/tsconfig.node.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
14
packages/board/vite.config.ts
Normal file
14
packages/board/vite.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
3
packages/board/wrangler.toml
Normal file
3
packages/board/wrangler.toml
Normal file
@ -0,0 +1,3 @@
|
||||
name = "og-tasks"
|
||||
compatibility_date = "2024-09-23"
|
||||
assets = { directory = "dist" }
|
||||
Loading…
x
Reference in New Issue
Block a user