mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-08-28 02:44:59 +00:00
feat: initial project scaffolding
- Dev container with Node.js 22 - pnpm workspace monorepo structure - Express + WebSocket server - React + Vite + shadcn/ui frontend - Shared TypeScript types package - Kanban board with drag-and-drop - Task CRUD with file-based persistence - Dark mode styling Sprint 1 - US-101: Project scaffolding with dev container
This commit is contained in:
commit
a489c5358f
43 changed files with 1961 additions and 0 deletions
32
.devcontainer/devcontainer.json
Normal file
32
.devcontainer/devcontainer.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "Veritas Kanban",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:22",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/git:1": {},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"formulahendry.auto-rename-tag",
|
||||
"christian-kohler.path-intellisense",
|
||||
"ms-vscode.vscode-typescript-next"
|
||||
],
|
||||
"settings": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"typescript.updateImportsOnFileMove.enabled": "always"
|
||||
}
|
||||
}
|
||||
},
|
||||
"forwardPorts": [3000, 3001],
|
||||
"postCreateCommand": "corepack enable && pnpm install",
|
||||
"remoteUser": "node"
|
||||
}
|
||||
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
.next/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Task data (track structure, not content)
|
||||
tasks/active/*.md
|
||||
tasks/archive/*.md
|
||||
.veritas-kanban/logs/
|
||||
.veritas-kanban/config.json
|
||||
|
||||
# Test
|
||||
coverage/
|
||||
.nyc_output/
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
0
.veritas-kanban/.gitkeep
Normal file
0
.veritas-kanban/.gitkeep
Normal file
101
README.md
Normal file
101
README.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# Veritas Kanban
|
||||
|
||||
A local-first task management and AI agent orchestration platform built for Brad Groux and Veritas.
|
||||
|
||||
## Features
|
||||
|
||||
- 📋 **Kanban Board** - Visual task management with drag-and-drop
|
||||
- 🤖 **AI Agent Orchestration** - Spawn Claude Code, Amp, Copilot, Gemini
|
||||
- 🌳 **Git Worktrees** - Isolated branches for each coding task
|
||||
- 📝 **Markdown Storage** - Human-readable task files with frontmatter
|
||||
- 🔍 **Code Review** - Diff viewing and inline comments
|
||||
- 🌙 **Dark Mode** - Easy on the eyes
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/dm-bradgroux/veritas-kanban.git
|
||||
cd veritas-kanban
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Start development
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Runtime | Node.js 22+ |
|
||||
| Language | TypeScript (strict) |
|
||||
| Server | Express + WebSocket |
|
||||
| Frontend | React 19 + Vite + shadcn/ui |
|
||||
| Persistence | Markdown files (gray-matter) |
|
||||
| Git | simple-git |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
veritas-kanban/
|
||||
├── .devcontainer/ # Dev container config
|
||||
├── server/ # Express API + WebSocket
|
||||
├── web/ # React frontend
|
||||
├── shared/ # Shared TypeScript types
|
||||
├── tasks/ # Task storage (markdown files)
|
||||
│ ├── active/ # Current tasks
|
||||
│ └── archive/ # Completed tasks
|
||||
└── .veritas-kanban/ # Config and runtime data
|
||||
```
|
||||
|
||||
## Repositories
|
||||
|
||||
- **Work**: https://github.com/dm-bradgroux/veritas-kanban
|
||||
- **Personal**: https://github.com/BradGroux/veritas-kanban
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 22+
|
||||
- pnpm 9+
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
pnpm dev # Start dev server (frontend + backend)
|
||||
pnpm build # Build for production
|
||||
pnpm typecheck # Run TypeScript checks
|
||||
pnpm lint # Run ESLint
|
||||
```
|
||||
|
||||
### Dev Container
|
||||
|
||||
This project includes a VS Code Dev Container configuration. Open in VS Code and select "Reopen in Container" for a consistent development environment.
|
||||
|
||||
## Task File Format
|
||||
|
||||
Tasks are stored as markdown files with YAML frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
id: "task_20260126_abc123"
|
||||
title: "Implement feature X"
|
||||
type: "code"
|
||||
status: "in-progress"
|
||||
priority: "high"
|
||||
project: "rubicon"
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Details about the task...
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
28
package.json
Normal file
28
package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "veritas-kanban",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Local-first task management and AI agent orchestration platform",
|
||||
"author": "Brad Groux <brad@digitalmeld.io>",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.4",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,web -c blue,green \"pnpm --filter server dev\" \"pnpm --filter web dev\"",
|
||||
"build": "pnpm --filter server build && pnpm --filter web build",
|
||||
"lint": "pnpm -r lint",
|
||||
"typecheck": "pnpm -r typecheck",
|
||||
"test": "pnpm -r test",
|
||||
"clean": "pnpm -r clean && rm -rf node_modules"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"concurrently": "^9.1.0",
|
||||
"prettier": "^3.4.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
packages:
|
||||
- "server"
|
||||
- "web"
|
||||
- "shared"
|
||||
34
server/package.json
Normal file
34
server/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@veritas-kanban/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@veritas-kanban/shared": "workspace:*",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"nanoid": "^5.0.9",
|
||||
"simple-git": "^3.27.0",
|
||||
"slugify": "^1.6.6",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/ws": "^8.5.13",
|
||||
"eslint": "^9.17.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
50
server/src/index.ts
Normal file
50
server/src/index.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { createServer } from 'http';
|
||||
import { taskRoutes } from './routes/tasks.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.use('/api/tasks', taskRoutes);
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
// WebSocket server for real-time updates
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('WebSocket client connected');
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('WebSocket client disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
export { wss };
|
||||
|
||||
// Start server
|
||||
server.listen(PORT, () => {
|
||||
console.log(`
|
||||
╔═══════════════════════════════════════════════╗
|
||||
║ Veritas Kanban Server ║
|
||||
╠═══════════════════════════════════════════════╣
|
||||
║ API: http://localhost:${PORT} ║
|
||||
║ WebSocket: ws://localhost:${PORT}/ws ║
|
||||
║ Health: http://localhost:${PORT}/health ║
|
||||
╚═══════════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
115
server/src/routes/tasks.ts
Normal file
115
server/src/routes/tasks.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { TaskService } from '../services/task-service.js';
|
||||
import type { CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared';
|
||||
|
||||
const router = Router();
|
||||
const taskService = new TaskService();
|
||||
|
||||
// Validation schemas
|
||||
const createTaskSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().optional().default(''),
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).optional().default('code'),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional().default('medium'),
|
||||
project: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const updateTaskSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(['code', 'research', 'content', 'automation']).optional(),
|
||||
status: z.enum(['todo', 'in-progress', 'review', 'done']).optional(),
|
||||
priority: z.enum(['low', 'medium', 'high']).optional(),
|
||||
project: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// GET /api/tasks - List all tasks
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const tasks = await taskService.listTasks();
|
||||
res.json(tasks);
|
||||
} catch (error) {
|
||||
console.error('Error listing tasks:', error);
|
||||
res.status(500).json({ error: 'Failed to list tasks' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks/:id - Get single task
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const task = await taskService.getTask(req.params.id);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
res.json(task);
|
||||
} catch (error) {
|
||||
console.error('Error getting task:', error);
|
||||
res.status(500).json({ error: 'Failed to get task' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks - Create task
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const input = createTaskSchema.parse(req.body) as CreateTaskInput;
|
||||
const task = await taskService.createTask(input);
|
||||
res.status(201).json(task);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: 'Validation failed', details: error.errors });
|
||||
}
|
||||
console.error('Error creating task:', error);
|
||||
res.status(500).json({ error: 'Failed to create task' });
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/tasks/:id - Update task
|
||||
router.patch('/:id', async (req, res) => {
|
||||
try {
|
||||
const input = updateTaskSchema.parse(req.body) as UpdateTaskInput;
|
||||
const task = await taskService.updateTask(req.params.id, input);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
res.json(task);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({ error: 'Validation failed', details: error.errors });
|
||||
}
|
||||
console.error('Error updating task:', error);
|
||||
res.status(500).json({ error: 'Failed to update task' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/tasks/:id - Delete task (move to archive)
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const success = await taskService.deleteTask(req.params.id);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
res.status(500).json({ error: 'Failed to delete task' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/archive - Archive task
|
||||
router.post('/:id/archive', async (req, res) => {
|
||||
try {
|
||||
const success = await taskService.archiveTask(req.params.id);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Task not found' });
|
||||
}
|
||||
res.json({ archived: true });
|
||||
} catch (error) {
|
||||
console.error('Error archiving task:', error);
|
||||
res.status(500).json({ error: 'Failed to archive task' });
|
||||
}
|
||||
});
|
||||
|
||||
export { router as taskRoutes };
|
||||
175
server/src/services/task-service.ts
Normal file
175
server/src/services/task-service.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import matter from 'gray-matter';
|
||||
import { nanoid } from 'nanoid';
|
||||
import slugify from 'slugify';
|
||||
import type { Task, CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared';
|
||||
|
||||
const TASKS_DIR = path.join(process.cwd(), 'tasks', 'active');
|
||||
const ARCHIVE_DIR = path.join(process.cwd(), 'tasks', 'archive');
|
||||
|
||||
export class TaskService {
|
||||
constructor() {
|
||||
this.ensureDirectories();
|
||||
}
|
||||
|
||||
private async ensureDirectories(): Promise<void> {
|
||||
await fs.mkdir(TASKS_DIR, { recursive: true });
|
||||
await fs.mkdir(ARCHIVE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
const date = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
||||
return `task_${date}_${nanoid(6)}`;
|
||||
}
|
||||
|
||||
private taskToFilename(task: Task): string {
|
||||
const slug = slugify(task.title, { lower: true, strict: true }).slice(0, 50);
|
||||
return `${task.id}-${slug}.md`;
|
||||
}
|
||||
|
||||
private taskToMarkdown(task: Task): string {
|
||||
const { description, reviewComments, ...frontmatter } = task;
|
||||
|
||||
const content = matter.stringify(description || '', frontmatter);
|
||||
|
||||
// Add review comments section if present
|
||||
if (reviewComments && reviewComments.length > 0) {
|
||||
const commentsSection = reviewComments
|
||||
.map(c => `- **${c.file}:${c.line}** - ${c.content}`)
|
||||
.join('\n');
|
||||
return content + '\n\n## Review Comments\n\n' + commentsSection;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private parseTaskFile(content: string, filename: string): Task {
|
||||
const { data, content: description } = matter(content);
|
||||
|
||||
// Extract review comments from description if present
|
||||
let cleanDescription = description;
|
||||
const reviewComments: Task['reviewComments'] = [];
|
||||
|
||||
const reviewSection = description.indexOf('## Review Comments');
|
||||
if (reviewSection !== -1) {
|
||||
cleanDescription = description.slice(0, reviewSection).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id || filename.split('-')[0],
|
||||
title: data.title || 'Untitled',
|
||||
description: cleanDescription.trim(),
|
||||
type: data.type || 'code',
|
||||
status: data.status || 'todo',
|
||||
priority: data.priority || 'medium',
|
||||
project: data.project,
|
||||
tags: data.tags,
|
||||
created: data.created || new Date().toISOString(),
|
||||
updated: data.updated || new Date().toISOString(),
|
||||
git: data.git,
|
||||
attempt: data.attempt,
|
||||
attempts: data.attempts,
|
||||
reviewComments,
|
||||
};
|
||||
}
|
||||
|
||||
async listTasks(): Promise<Task[]> {
|
||||
await this.ensureDirectories();
|
||||
|
||||
const files = await fs.readdir(TASKS_DIR);
|
||||
const mdFiles = files.filter(f => f.endsWith('.md'));
|
||||
|
||||
const tasks = await Promise.all(
|
||||
mdFiles.map(async (filename) => {
|
||||
const filepath = path.join(TASKS_DIR, filename);
|
||||
const content = await fs.readFile(filepath, 'utf-8');
|
||||
return this.parseTaskFile(content, filename);
|
||||
})
|
||||
);
|
||||
|
||||
// Sort by updated date, newest first
|
||||
return tasks.sort((a, b) =>
|
||||
new Date(b.updated).getTime() - new Date(a.updated).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
async getTask(id: string): Promise<Task | null> {
|
||||
const tasks = await this.listTasks();
|
||||
return tasks.find(t => t.id === id) || null;
|
||||
}
|
||||
|
||||
async createTask(input: CreateTaskInput): Promise<Task> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const task: Task = {
|
||||
id: this.generateId(),
|
||||
title: input.title,
|
||||
description: input.description || '',
|
||||
type: input.type || 'code',
|
||||
status: 'todo',
|
||||
priority: input.priority || 'medium',
|
||||
project: input.project,
|
||||
tags: input.tags,
|
||||
created: now,
|
||||
updated: now,
|
||||
};
|
||||
|
||||
const filename = this.taskToFilename(task);
|
||||
const filepath = path.join(TASKS_DIR, filename);
|
||||
const content = this.taskToMarkdown(task);
|
||||
|
||||
await fs.writeFile(filepath, content, 'utf-8');
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
async updateTask(id: string, input: UpdateTaskInput): Promise<Task | null> {
|
||||
const task = await this.getTask(id);
|
||||
if (!task) return null;
|
||||
|
||||
const updatedTask: Task = {
|
||||
...task,
|
||||
...input,
|
||||
updated: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Remove old file if title changed (filename changes)
|
||||
const oldFilename = this.taskToFilename(task);
|
||||
const newFilename = this.taskToFilename(updatedTask);
|
||||
|
||||
if (oldFilename !== newFilename) {
|
||||
await fs.unlink(path.join(TASKS_DIR, oldFilename)).catch(() => {});
|
||||
}
|
||||
|
||||
const filepath = path.join(TASKS_DIR, newFilename);
|
||||
const content = this.taskToMarkdown(updatedTask);
|
||||
|
||||
await fs.writeFile(filepath, content, 'utf-8');
|
||||
|
||||
return updatedTask;
|
||||
}
|
||||
|
||||
async deleteTask(id: string): Promise<boolean> {
|
||||
const task = await this.getTask(id);
|
||||
if (!task) return false;
|
||||
|
||||
const filename = this.taskToFilename(task);
|
||||
await fs.unlink(path.join(TASKS_DIR, filename));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async archiveTask(id: string): Promise<boolean> {
|
||||
const task = await this.getTask(id);
|
||||
if (!task) return false;
|
||||
|
||||
const filename = this.taskToFilename(task);
|
||||
const sourcePath = path.join(TASKS_DIR, filename);
|
||||
const destPath = path.join(ARCHIVE_DIR, filename);
|
||||
|
||||
await fs.rename(sourcePath, destPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
19
server/tsconfig.json
Normal file
19
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
22
shared/package.json
Normal file
22
shared/package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "@veritas-kanban/shared",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
3
shared/src/index.ts
Normal file
3
shared/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Veritas Kanban - Shared Types
|
||||
|
||||
export * from './types.js';
|
||||
139
shared/src/types.ts
Normal file
139
shared/src/types.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Task Types
|
||||
|
||||
export type TaskType = 'code' | 'research' | 'content' | 'automation';
|
||||
export type TaskStatus = 'todo' | 'in-progress' | 'review' | 'done';
|
||||
export type TaskPriority = 'low' | 'medium' | 'high';
|
||||
export type AgentType = 'claude-code' | 'amp' | 'copilot' | 'gemini';
|
||||
export type AttemptStatus = 'pending' | 'running' | 'complete' | 'failed';
|
||||
|
||||
export interface TaskGit {
|
||||
repo: string;
|
||||
branch: string;
|
||||
baseBranch: string;
|
||||
worktreePath?: string;
|
||||
}
|
||||
|
||||
export interface TaskAttempt {
|
||||
id: string;
|
||||
agent: AgentType;
|
||||
status: AttemptStatus;
|
||||
started?: string;
|
||||
ended?: string;
|
||||
}
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: TaskType;
|
||||
status: TaskStatus;
|
||||
priority: TaskPriority;
|
||||
project?: string;
|
||||
tags?: string[];
|
||||
created: string;
|
||||
updated: string;
|
||||
|
||||
// Code task specific
|
||||
git?: TaskGit;
|
||||
|
||||
// Current attempt
|
||||
attempt?: TaskAttempt;
|
||||
|
||||
// Attempt history
|
||||
attempts?: TaskAttempt[];
|
||||
|
||||
// Review comments (for code tasks)
|
||||
reviewComments?: ReviewComment[];
|
||||
}
|
||||
|
||||
export interface ReviewComment {
|
||||
id: string;
|
||||
file: string;
|
||||
line: number;
|
||||
content: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
// API Types
|
||||
|
||||
export interface CreateTaskInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
type?: TaskType;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateTaskInput {
|
||||
title?: string;
|
||||
description?: string;
|
||||
type?: TaskType;
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
project?: string;
|
||||
tags?: string[];
|
||||
git?: Partial<TaskGit>;
|
||||
}
|
||||
|
||||
export interface TaskFilters {
|
||||
status?: TaskStatus | TaskStatus[];
|
||||
type?: TaskType | TaskType[];
|
||||
project?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// Config Types
|
||||
|
||||
export interface RepoConfig {
|
||||
name: string;
|
||||
path: string;
|
||||
defaultBranch: string;
|
||||
}
|
||||
|
||||
export interface AgentConfig {
|
||||
type: AgentType;
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
repos: RepoConfig[];
|
||||
agents: AgentConfig[];
|
||||
defaultAgent: AgentType;
|
||||
}
|
||||
|
||||
// WebSocket Message Types
|
||||
|
||||
export type WSMessageType =
|
||||
| 'agent:output'
|
||||
| 'agent:status'
|
||||
| 'agent:complete'
|
||||
| 'task:updated'
|
||||
| 'error';
|
||||
|
||||
export interface WSMessage {
|
||||
type: WSMessageType;
|
||||
taskId?: string;
|
||||
attemptId?: string;
|
||||
data: unknown;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface AgentOutputMessage extends WSMessage {
|
||||
type: 'agent:output';
|
||||
data: {
|
||||
stream: 'stdout' | 'stderr';
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AgentStatusMessage extends WSMessage {
|
||||
type: 'agent:status';
|
||||
data: {
|
||||
status: AttemptStatus;
|
||||
exitCode?: number;
|
||||
};
|
||||
}
|
||||
19
shared/tsconfig.json
Normal file
19
shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
0
tasks/active/.gitkeep
Normal file
0
tasks/active/.gitkeep
Normal file
0
tasks/archive/.gitkeep
Normal file
0
tasks/archive/.gitkeep
Normal file
13
web/index.html
Normal file
13
web/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="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Veritas Kanban</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
45
web/package.json
Normal file
45
web/package.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "@veritas-kanban/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@veritas-kanban/shared": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.17.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
17
web/src/App.tsx
Normal file
17
web/src/App.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { KanbanBoard } from './components/board/KanbanBoard';
|
||||
import { Header } from './components/layout/Header';
|
||||
import { Toaster } from './components/ui/toaster';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-6">
|
||||
<KanbanBoard />
|
||||
</main>
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
107
web/src/components/board/KanbanBoard.tsx
Normal file
107
web/src/components/board/KanbanBoard.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { useTasks, useTasksByStatus, useUpdateTask } from '@/hooks/useTasks';
|
||||
import { KanbanColumn } from './KanbanColumn';
|
||||
import type { TaskStatus } from '@veritas-kanban/shared';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { useState } from 'react';
|
||||
import { TaskCard } from '@/components/task/TaskCard';
|
||||
import type { Task } from '@veritas-kanban/shared';
|
||||
|
||||
const COLUMNS: { id: TaskStatus; title: string }[] = [
|
||||
{ id: 'todo', title: 'To Do' },
|
||||
{ id: 'in-progress', title: 'In Progress' },
|
||||
{ id: 'review', title: 'Review' },
|
||||
{ id: 'done', title: 'Done' },
|
||||
];
|
||||
|
||||
export function KanbanBoard() {
|
||||
const { data: tasks, isLoading, error } = useTasks();
|
||||
const tasksByStatus = useTasksByStatus(tasks);
|
||||
const updateTask = useUpdateTask();
|
||||
const [activeTask, setActiveTask] = useState<Task | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const task = tasks?.find(t => t.id === event.active.id);
|
||||
if (task) {
|
||||
setActiveTask(task);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveTask(null);
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const taskId = active.id as string;
|
||||
const newStatus = over.id as TaskStatus;
|
||||
|
||||
const task = tasks?.find(t => t.id === taskId);
|
||||
if (task && task.status !== newStatus) {
|
||||
updateTask.mutate({
|
||||
id: taskId,
|
||||
input: { status: newStatus },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-muted-foreground">Loading tasks...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="text-destructive">
|
||||
Error loading tasks: {error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{COLUMNS.map(column => (
|
||||
<KanbanColumn
|
||||
key={column.id}
|
||||
id={column.id}
|
||||
title={column.title}
|
||||
tasks={tasksByStatus[column.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeTask ? (
|
||||
<TaskCard task={activeTask} isDragging />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
53
web/src/components/board/KanbanColumn.tsx
Normal file
53
web/src/components/board/KanbanColumn.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TaskCard } from '@/components/task/TaskCard';
|
||||
import type { Task, TaskStatus } from '@veritas-kanban/shared';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: TaskStatus;
|
||||
title: string;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
const columnColors: Record<TaskStatus, string> = {
|
||||
'todo': 'border-t-slate-500',
|
||||
'in-progress': 'border-t-blue-500',
|
||||
'review': 'border-t-amber-500',
|
||||
'done': 'border-t-green-500',
|
||||
};
|
||||
|
||||
export function KanbanColumn({ id, title, tasks }: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex flex-col rounded-lg bg-muted/50 border-t-2',
|
||||
columnColors[id],
|
||||
isOver && 'ring-2 ring-primary/50'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 py-2">
|
||||
<h2 className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-2 py-0.5 rounded-full">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-2 space-y-2 min-h-[calc(100vh-200px)] overflow-y-auto">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
|
||||
No tasks
|
||||
</div>
|
||||
) : (
|
||||
tasks.map(task => (
|
||||
<TaskCard key={task.id} task={task} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
web/src/components/layout/Header.tsx
Normal file
39
web/src/components/layout/Header.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Plus, Settings } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CreateTaskDialog } from '@/components/task/CreateTaskDialog';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Header() {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<header className="border-b border-border bg-card">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex h-14 items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">⚖️</span>
|
||||
<h1 className="text-lg font-semibold">Veritas Kanban</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Task
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateTaskDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
144
web/src/components/task/CreateTaskDialog.tsx
Normal file
144
web/src/components/task/CreateTaskDialog.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useCreateTask } from '@/hooks/useTasks';
|
||||
import type { TaskType, TaskPriority } from '@veritas-kanban/shared';
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateTaskDialog({ open, onOpenChange }: CreateTaskDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [type, setType] = useState<TaskType>('code');
|
||||
const [priority, setPriority] = useState<TaskPriority>('medium');
|
||||
const [project, setProject] = useState('');
|
||||
|
||||
const createTask = useCreateTask();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!title.trim()) return;
|
||||
|
||||
await createTask.mutateAsync({
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
type,
|
||||
priority,
|
||||
project: project.trim() || undefined,
|
||||
});
|
||||
|
||||
// Reset form
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setType('code');
|
||||
setPriority('medium');
|
||||
setProject('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Enter task title..."
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe the task..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select value={type} onValueChange={(v) => setType(v as TaskType)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="code">Code</SelectItem>
|
||||
<SelectItem value="research">Research</SelectItem>
|
||||
<SelectItem value="content">Content</SelectItem>
|
||||
<SelectItem value="automation">Automation</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="priority">Priority</Label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as TaskPriority)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="project">Project (optional)</Label>
|
||||
<Input
|
||||
id="project"
|
||||
value={project}
|
||||
onChange={(e) => setProject(e.target.value)}
|
||||
placeholder="e.g., rubicon"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!title.trim() || createTask.isPending}>
|
||||
{createTask.isPending ? 'Creating...' : 'Create Task'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
87
web/src/components/task/TaskCard.tsx
Normal file
87
web/src/components/task/TaskCard.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Task, TaskType, TaskPriority } from '@veritas-kanban/shared';
|
||||
import { Code, Search, FileText, Zap } from 'lucide-react';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
const typeIcons: Record<TaskType, React.ReactNode> = {
|
||||
code: <Code className="h-3.5 w-3.5" />,
|
||||
research: <Search className="h-3.5 w-3.5" />,
|
||||
content: <FileText className="h-3.5 w-3.5" />,
|
||||
automation: <Zap className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
const typeColors: Record<TaskType, string> = {
|
||||
code: 'border-l-violet-500',
|
||||
research: 'border-l-cyan-500',
|
||||
content: 'border-l-orange-500',
|
||||
automation: 'border-l-emerald-500',
|
||||
};
|
||||
|
||||
const priorityColors: Record<TaskPriority, string> = {
|
||||
high: 'bg-red-500/20 text-red-400',
|
||||
medium: 'bg-amber-500/20 text-amber-400',
|
||||
low: 'bg-slate-500/20 text-slate-400',
|
||||
};
|
||||
|
||||
export function TaskCard({ task, isDragging }: TaskCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: task.id,
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={cn(
|
||||
'group bg-card border border-border rounded-md p-3 cursor-grab active:cursor-grabbing',
|
||||
'hover:border-muted-foreground/50 transition-colors',
|
||||
'border-l-2',
|
||||
typeColors[task.type],
|
||||
isDragging && 'opacity-50 shadow-lg'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground mt-0.5">
|
||||
{typeIcons[task.type]}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium leading-tight truncate">
|
||||
{task.title}
|
||||
</h3>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{task.project && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
{task.project}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
'text-xs px-1.5 py-0.5 rounded capitalize',
|
||||
priorityColors[task.priority]
|
||||
)}>
|
||||
{task.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
web/src/components/ui/button.tsx
Normal file
52
web/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
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 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
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 };
|
||||
107
web/src/components/ui/dialog.tsx
Normal file
107
web/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
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.ElementRef<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.ElementRef<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 bg-background 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.ElementRef<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.ElementRef<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,
|
||||
};
|
||||
23
web/src/components/ui/input.tsx
Normal file
23
web/src/components/ui/input.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export { Input };
|
||||
23
web/src/components/ui/label.tsx
Normal file
23
web/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const labelVariants = cva(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
89
web/src/components/ui/select.tsx
Normal file
89
web/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
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.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 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 SelectContent = React.forwardRef<
|
||||
React.ElementRef<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 bg-popover text-popover-foreground 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}
|
||||
>
|
||||
<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>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<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-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-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;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
};
|
||||
22
web/src/components/ui/textarea.tsx
Normal file
22
web/src/components/ui/textarea.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = 'Textarea';
|
||||
|
||||
export { Textarea };
|
||||
4
web/src/components/ui/toaster.tsx
Normal file
4
web/src/components/ui/toaster.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Placeholder toaster - we'll implement this properly later
|
||||
export function Toaster() {
|
||||
return null;
|
||||
}
|
||||
76
web/src/globals.css
Normal file
76
web/src/globals.css
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for dark mode */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(var(--muted));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(var(--muted-foreground) / 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.5);
|
||||
}
|
||||
71
web/src/hooks/useTasks.ts
Normal file
71
web/src/hooks/useTasks.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Task, CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared';
|
||||
|
||||
export function useTasks() {
|
||||
return useQuery({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: api.tasks.list,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTask(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', id],
|
||||
queryFn: () => api.tasks.get(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTask() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: CreateTaskInput) => api.tasks.create(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTask() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: UpdateTaskInput }) =>
|
||||
api.tasks.update(id, input),
|
||||
onSuccess: (task) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
queryClient.setQueryData(['tasks', task.id], task);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTask() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.tasks.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTasksByStatus(tasks: Task[] | undefined) {
|
||||
if (!tasks) {
|
||||
return {
|
||||
todo: [],
|
||||
'in-progress': [],
|
||||
review: [],
|
||||
done: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
todo: tasks.filter(t => t.status === 'todo'),
|
||||
'in-progress': tasks.filter(t => t.status === 'in-progress'),
|
||||
review: tasks.filter(t => t.status === 'review'),
|
||||
done: tasks.filter(t => t.status === 'done'),
|
||||
};
|
||||
}
|
||||
60
web/src/lib/api.ts
Normal file
60
web/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { Task, CreateTaskInput, UpdateTaskInput } from '@veritas-kanban/shared';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
tasks: {
|
||||
list: async (): Promise<Task[]> => {
|
||||
const response = await fetch(`${API_BASE}/tasks`);
|
||||
return handleResponse<Task[]>(response);
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${id}`);
|
||||
return handleResponse<Task>(response);
|
||||
},
|
||||
|
||||
create: async (input: CreateTaskInput): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return handleResponse<Task>(response);
|
||||
},
|
||||
|
||||
update: async (id: string, input: UpdateTaskInput): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return handleResponse<Task>(response);
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return handleResponse<void>(response);
|
||||
},
|
||||
|
||||
archive: async (id: string): Promise<void> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${id}/archive`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return handleResponse<void>(response);
|
||||
},
|
||||
},
|
||||
};
|
||||
6
web/src/lib/utils.ts
Normal file
6
web/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));
|
||||
}
|
||||
22
web/src/main.tsx
Normal file
22
web/src/main.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './globals.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60, // 1 minute
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
49
web/tailwind.config.js
Normal file
49
web/tailwind.config.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
25
web/tsconfig.json
Normal file
25
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
web/tsconfig.node.json
Normal file
11
web/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
25
web/vite.config.ts
Normal file
25
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: 'ws://localhost:3001',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue