Add interactive drag-and-drop task management to aggregator
- Add drag-and-drop to move tasks between kanban columns (To Do, In Progress, Done) - Add "New Task" button with modal form to create tasks - Add /api/tasks/update PATCH endpoint for status changes - Add /api/tasks/create POST endpoint for new tasks - Write changes directly to task markdown files on disk - Enable read-write mounts in docker-compose for task editing - Add toast notifications for user feedback - Optimistic UI updates with error handling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3230156ada
commit
261c2695ae
|
|
@ -11,10 +11,11 @@ services:
|
|||
volumes:
|
||||
# Mount all project directories that contain backlog folders
|
||||
# The aggregator scans these paths for backlog/ subdirectories
|
||||
- /opt/websites:/projects/websites:ro
|
||||
- /opt/apps:/projects/apps:ro
|
||||
# NOTE: Using rw (read-write) to allow task creation/updates from web UI
|
||||
- /opt/websites:/projects/websites:rw
|
||||
- /opt/apps:/projects/apps:rw
|
||||
# If you have repos in other locations, add them here:
|
||||
# - /home/user/projects:/projects/home:ro
|
||||
# - /home/user/projects:/projects/home:rw
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.backlog.rule=Host(`backlog.jeffemmett.com`)"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
import { type Server, type ServerWebSocket, $ } from "bun";
|
||||
import { watch, type FSWatcher } from "node:fs";
|
||||
import { readdir, stat, readFile } from "node:fs/promises";
|
||||
import { readdir, stat, readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { join, basename, dirname, relative } from "node:path";
|
||||
import { parseTask } from "../markdown/parser.ts";
|
||||
import type { Task } from "../types/index.ts";
|
||||
|
|
@ -108,6 +108,12 @@ export class BacklogAggregator {
|
|||
"/api/health": {
|
||||
GET: async () => Response.json({ status: "ok", projects: this.projects.size, tasks: this.tasks.size }),
|
||||
},
|
||||
"/api/tasks/update": {
|
||||
PATCH: async (req: Request) => this.handleUpdateTask(req),
|
||||
},
|
||||
"/api/tasks/create": {
|
||||
POST: async (req: Request) => this.handleCreateTask(req),
|
||||
},
|
||||
},
|
||||
fetch: async (req: Request, server: Server) => {
|
||||
const url = new URL(req.url);
|
||||
|
|
@ -499,6 +505,193 @@ export class BacklogAggregator {
|
|||
for (const s of common) statuses.add(s);
|
||||
return Response.json(Array.from(statuses));
|
||||
}
|
||||
|
||||
// Task modification handlers
|
||||
private async handleUpdateTask(req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { projectPath, taskId, status } = body as {
|
||||
projectPath: string;
|
||||
taskId: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
if (!projectPath || !taskId || !status) {
|
||||
return Response.json({ error: "Missing required fields: projectPath, taskId, status" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Find the task
|
||||
const taskKey = `${projectPath}:${taskId}`;
|
||||
const task = this.tasks.get(taskKey);
|
||||
if (!task) {
|
||||
return Response.json({ error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Find the task file path
|
||||
const tasksDir = join(projectPath, "backlog", "tasks");
|
||||
const taskFileName = `${taskId}.md`;
|
||||
const taskFilePath = join(tasksDir, taskFileName);
|
||||
|
||||
// Read the current task file
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(taskFilePath, "utf-8");
|
||||
} catch {
|
||||
return Response.json({ error: "Task file not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Update the status in the frontmatter
|
||||
const updatedContent = this.updateTaskStatus(content, status);
|
||||
|
||||
// Write the updated content back
|
||||
await writeFile(taskFilePath, updatedContent, "utf-8");
|
||||
|
||||
console.log(`Task ${taskId} status updated to "${status}" in ${this.projects.get(projectPath)?.name}`);
|
||||
|
||||
// The file watcher will pick up the change and broadcast update
|
||||
// But we can also force an immediate update
|
||||
await this.loadTask(projectPath, taskFilePath);
|
||||
this.broadcastUpdate();
|
||||
|
||||
return Response.json({ success: true, taskId, status });
|
||||
} catch (error) {
|
||||
console.error("Error updating task:", error);
|
||||
return Response.json({ error: "Failed to update task" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCreateTask(req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { projectPath, title, description, priority, status, labels } = body as {
|
||||
projectPath: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: string;
|
||||
status?: string;
|
||||
labels?: string[];
|
||||
};
|
||||
|
||||
if (!projectPath || !title) {
|
||||
return Response.json({ error: "Missing required fields: projectPath, title" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify project exists
|
||||
if (!this.projects.has(projectPath)) {
|
||||
return Response.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Generate next task ID
|
||||
const taskId = await this.getNextTaskId(projectPath);
|
||||
|
||||
// Generate the task markdown content
|
||||
const now = new Date();
|
||||
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
|
||||
let frontmatter = `---
|
||||
id: ${taskId}
|
||||
title: ${title}
|
||||
status: ${status || "To Do"}
|
||||
assignee: []
|
||||
created_date: '${dateStr}'
|
||||
`;
|
||||
|
||||
if (labels && labels.length > 0) {
|
||||
frontmatter += `labels: [${labels.join(", ")}]\n`;
|
||||
} else {
|
||||
frontmatter += `labels: []\n`;
|
||||
}
|
||||
|
||||
if (priority) {
|
||||
frontmatter += `priority: ${priority}\n`;
|
||||
}
|
||||
|
||||
frontmatter += `dependencies: []\n---\n\n`;
|
||||
|
||||
let content = frontmatter;
|
||||
content += `## Description\n\n${description || "No description provided."}\n`;
|
||||
|
||||
// Ensure tasks directory exists
|
||||
const tasksDir = join(projectPath, "backlog", "tasks");
|
||||
try {
|
||||
await mkdir(tasksDir, { recursive: true });
|
||||
} catch {
|
||||
// Directory might already exist
|
||||
}
|
||||
|
||||
// Write the task file
|
||||
const taskFilePath = join(tasksDir, `${taskId}.md`);
|
||||
await writeFile(taskFilePath, content, "utf-8");
|
||||
|
||||
console.log(`Created task ${taskId} in ${this.projects.get(projectPath)?.name}`);
|
||||
|
||||
// Load the new task and broadcast
|
||||
await this.loadTask(projectPath, taskFilePath);
|
||||
this.broadcastUpdate();
|
||||
|
||||
return Response.json({ success: true, taskId, projectPath });
|
||||
} catch (error) {
|
||||
console.error("Error creating task:", error);
|
||||
return Response.json({ error: "Failed to create task" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
private updateTaskStatus(content: string, newStatus: string): string {
|
||||
// Parse frontmatter and update status
|
||||
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!frontmatterMatch) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const frontmatter = frontmatterMatch[1];
|
||||
const updatedFrontmatter = frontmatter.replace(
|
||||
/^status:\s*.+$/m,
|
||||
`status: ${newStatus}`,
|
||||
);
|
||||
|
||||
// Add updated_date
|
||||
const now = new Date();
|
||||
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
|
||||
let finalFrontmatter = updatedFrontmatter;
|
||||
if (finalFrontmatter.includes("updated_date:")) {
|
||||
finalFrontmatter = finalFrontmatter.replace(
|
||||
/^updated_date:\s*.+$/m,
|
||||
`updated_date: '${dateStr}'`,
|
||||
);
|
||||
} else {
|
||||
// Add updated_date before the closing ---
|
||||
finalFrontmatter += `\nupdated_date: '${dateStr}'`;
|
||||
}
|
||||
|
||||
return content.replace(
|
||||
/^---\n[\s\S]*?\n---/,
|
||||
`---\n${finalFrontmatter}\n---`,
|
||||
);
|
||||
}
|
||||
|
||||
private async getNextTaskId(projectPath: string): Promise<string> {
|
||||
// Find the highest task ID in the project
|
||||
const tasksPath = join(projectPath, "backlog", "tasks");
|
||||
let maxId = 0;
|
||||
|
||||
try {
|
||||
const entries = await readdir(tasksPath, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
||||
const match = entry.name.match(/^task-(\d+)\.md$/);
|
||||
if (match) {
|
||||
const id = Number.parseInt(match[1], 10);
|
||||
if (id > maxId) maxId = id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory might not exist yet
|
||||
}
|
||||
|
||||
// Return next ID with zero-padding
|
||||
return `task-${String(maxId + 1).padStart(3, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
// CLI entry point
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@
|
|||
color: #e2e8f0;
|
||||
min-width: 200px;
|
||||
}
|
||||
.project-btn {
|
||||
.project-btn, .action-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #334155;
|
||||
|
|
@ -58,8 +58,11 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.project-btn.active { border-width: 2px; border-color: #3b82f6; background: #1e40af; }
|
||||
.action-btn { background: #22c55e; border-color: #16a34a; font-weight: 500; }
|
||||
.action-btn:hover { background: #16a34a; }
|
||||
.project-dot {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
|
|
@ -78,6 +81,11 @@
|
|||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.column.drag-over {
|
||||
background: #334155;
|
||||
box-shadow: inset 0 0 0 2px #3b82f6;
|
||||
}
|
||||
.column-header {
|
||||
display: flex;
|
||||
|
|
@ -94,19 +102,28 @@
|
|||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.tasks { display: flex; flex-direction: column; gap: 0.75rem; flex: 1; overflow-y: auto; }
|
||||
.tasks { display: flex; flex-direction: column; gap: 0.75rem; flex: 1; overflow-y: auto; min-height: 100px; }
|
||||
.task-card {
|
||||
background: #0f172a;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
border-left: 4px solid #3b82f6;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s, box-shadow 0.1s;
|
||||
cursor: grab;
|
||||
transition: transform 0.1s, box-shadow 0.1s, opacity 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
.task-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.task-card.dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
}
|
||||
.task-card.drag-preview {
|
||||
transform: rotate(3deg);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.task-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem; }
|
||||
.task-title { font-size: 0.875rem; font-weight: 500; margin-bottom: 0.25rem; }
|
||||
.task-meta { font-size: 0.75rem; color: #64748b; }
|
||||
|
|
@ -140,6 +157,91 @@
|
|||
display: none;
|
||||
}
|
||||
.task-card.expanded .task-description { display: block; }
|
||||
|
||||
/* Modal styles */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal h2 { margin-bottom: 1rem; font-size: 1.25rem; }
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.form-group textarea { min-height: 100px; resize: vertical; }
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-primary:hover { background: #2563eb; }
|
||||
.btn-secondary { background: #334155; color: #e2e8f0; }
|
||||
.btn-secondary:hover { background: #475569; }
|
||||
|
||||
/* Toast notifications */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.toast {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
background: #334155;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.875rem;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
.toast.success { background: #166534; }
|
||||
.toast.error { background: #991b1b; }
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -154,6 +256,7 @@
|
|||
<div class="filter-bar" id="filter-bar">
|
||||
<input type="text" id="filter" class="filter-input" placeholder="Filter tasks...">
|
||||
<button class="project-btn active" id="all-projects">All Projects</button>
|
||||
<button class="action-btn" id="new-task-btn" style="margin-left: auto;">+ New Task</button>
|
||||
</div>
|
||||
|
||||
<div class="board" id="board">
|
||||
|
|
@ -180,11 +283,72 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Task Modal -->
|
||||
<div class="modal-overlay" id="new-task-modal">
|
||||
<div class="modal">
|
||||
<h2>Create New Task</h2>
|
||||
<form id="new-task-form">
|
||||
<div class="form-group">
|
||||
<label for="task-project">Project *</label>
|
||||
<select id="task-project" required>
|
||||
<option value="">Select a project...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="task-title">Title *</label>
|
||||
<input type="text" id="task-title" required placeholder="Task title...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="task-description">Description</label>
|
||||
<textarea id="task-description" placeholder="Describe the task..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="task-priority">Priority</label>
|
||||
<select id="task-priority">
|
||||
<option value="">None</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="task-status">Status</label>
|
||||
<select id="task-status">
|
||||
<option value="To Do">To Do</option>
|
||||
<option value="In Progress">In Progress</option>
|
||||
<option value="Done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="task-labels">Labels (comma-separated)</label>
|
||||
<input type="text" id="task-labels" placeholder="feature, backend, urgent">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancel-task">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Task</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast container -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
|
||||
<script>
|
||||
let projects = [];
|
||||
let tasks = [];
|
||||
let selectedProject = null;
|
||||
let ws = null;
|
||||
let draggedTask = null;
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container');
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
|
@ -209,17 +373,29 @@
|
|||
tasks = data.tasks || [];
|
||||
renderProjects();
|
||||
renderTasks();
|
||||
updateProjectDropdown();
|
||||
}
|
||||
} catch (e) { console.error('Parse error:', e); }
|
||||
};
|
||||
}
|
||||
|
||||
function updateProjectDropdown() {
|
||||
const select = document.getElementById('task-project');
|
||||
select.innerHTML = '<option value="">Select a project...</option>';
|
||||
projects.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.path;
|
||||
opt.textContent = p.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function renderProjects() {
|
||||
const filterBar = document.getElementById('filter-bar');
|
||||
// Remove old project buttons except filter and all-projects
|
||||
const oldBtns = filterBar.querySelectorAll('.project-btn:not(#all-projects)');
|
||||
oldBtns.forEach(btn => btn.remove());
|
||||
|
||||
const newTaskBtn = document.getElementById('new-task-btn');
|
||||
projects.forEach(p => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'project-btn';
|
||||
|
|
@ -230,7 +406,7 @@
|
|||
btn.classList.add('active');
|
||||
renderTasks();
|
||||
};
|
||||
filterBar.appendChild(btn);
|
||||
filterBar.insertBefore(btn, newTaskBtn);
|
||||
});
|
||||
|
||||
document.getElementById('stats').textContent =
|
||||
|
|
@ -245,7 +421,6 @@
|
|||
'done': 'done'
|
||||
};
|
||||
|
||||
// Clear all columns
|
||||
['todo', 'progress', 'done'].forEach(id => {
|
||||
document.getElementById(`tasks-${id}`).innerHTML = '';
|
||||
});
|
||||
|
|
@ -263,7 +438,19 @@
|
|||
const card = document.createElement('div');
|
||||
card.className = 'task-card';
|
||||
card.style.borderLeftColor = task.projectColor;
|
||||
card.onclick = () => card.classList.toggle('expanded');
|
||||
card.draggable = true;
|
||||
card.dataset.taskId = task.id;
|
||||
card.dataset.projectPath = task.projectPath;
|
||||
card.dataset.currentStatus = task.status;
|
||||
|
||||
// Drag events
|
||||
card.addEventListener('dragstart', handleDragStart);
|
||||
card.addEventListener('dragend', handleDragEnd);
|
||||
card.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.task-card').classList.contains('dragging')) {
|
||||
card.classList.toggle('expanded');
|
||||
}
|
||||
});
|
||||
|
||||
let priorityClass = '';
|
||||
if (task.priority) {
|
||||
|
|
@ -299,6 +486,149 @@
|
|||
document.getElementById('count-done').textContent = counts.done;
|
||||
}
|
||||
|
||||
// Drag and Drop handlers
|
||||
function handleDragStart(e) {
|
||||
draggedTask = e.target;
|
||||
e.target.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({
|
||||
taskId: e.target.dataset.taskId,
|
||||
projectPath: e.target.dataset.projectPath,
|
||||
currentStatus: e.target.dataset.currentStatus
|
||||
}));
|
||||
}
|
||||
|
||||
function handleDragEnd(e) {
|
||||
e.target.classList.remove('dragging');
|
||||
draggedTask = null;
|
||||
document.querySelectorAll('.column').forEach(col => col.classList.remove('drag-over'));
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
function handleDragEnter(e) {
|
||||
e.preventDefault();
|
||||
const column = e.target.closest('.column');
|
||||
if (column) column.classList.add('drag-over');
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
const column = e.target.closest('.column');
|
||||
if (column && !column.contains(e.relatedTarget)) {
|
||||
column.classList.remove('drag-over');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
const column = e.target.closest('.column');
|
||||
if (!column) return;
|
||||
|
||||
column.classList.remove('drag-over');
|
||||
const newStatus = column.dataset.status;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('text/plain'));
|
||||
if (data.currentStatus === newStatus) return;
|
||||
|
||||
// Optimistic update
|
||||
const taskIndex = tasks.findIndex(t => t.id === data.taskId && t.projectPath === data.projectPath);
|
||||
if (taskIndex !== -1) {
|
||||
tasks[taskIndex].status = newStatus;
|
||||
renderTasks();
|
||||
}
|
||||
|
||||
// Send to server
|
||||
const response = await fetch('/api/tasks/update', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath: data.projectPath,
|
||||
taskId: data.taskId,
|
||||
status: newStatus
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update task');
|
||||
}
|
||||
|
||||
showToast(`Task moved to ${newStatus}`, 'success');
|
||||
} catch (err) {
|
||||
console.error('Drop error:', err);
|
||||
showToast('Failed to update task', 'error');
|
||||
// Revert on error - will be fixed on next WebSocket update
|
||||
}
|
||||
}
|
||||
|
||||
// Set up column drop zones
|
||||
document.querySelectorAll('.column').forEach(column => {
|
||||
column.addEventListener('dragover', handleDragOver);
|
||||
column.addEventListener('dragenter', handleDragEnter);
|
||||
column.addEventListener('dragleave', handleDragLeave);
|
||||
column.addEventListener('drop', handleDrop);
|
||||
});
|
||||
|
||||
// New Task Modal
|
||||
document.getElementById('new-task-btn').onclick = () => {
|
||||
document.getElementById('new-task-modal').classList.add('active');
|
||||
};
|
||||
|
||||
document.getElementById('cancel-task').onclick = () => {
|
||||
document.getElementById('new-task-modal').classList.remove('active');
|
||||
document.getElementById('new-task-form').reset();
|
||||
};
|
||||
|
||||
document.getElementById('new-task-modal').onclick = (e) => {
|
||||
if (e.target.classList.contains('modal-overlay')) {
|
||||
document.getElementById('new-task-modal').classList.remove('active');
|
||||
document.getElementById('new-task-form').reset();
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('new-task-form').onsubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const projectPath = document.getElementById('task-project').value;
|
||||
const title = document.getElementById('task-title').value;
|
||||
const description = document.getElementById('task-description').value;
|
||||
const priority = document.getElementById('task-priority').value;
|
||||
const status = document.getElementById('task-status').value;
|
||||
const labelsStr = document.getElementById('task-labels').value;
|
||||
const labels = labelsStr ? labelsStr.split(',').map(l => l.trim()).filter(l => l) : [];
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/tasks/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
projectPath,
|
||||
title,
|
||||
description,
|
||||
priority: priority || undefined,
|
||||
status,
|
||||
labels
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || 'Failed to create task');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
showToast(`Task ${result.taskId} created!`, 'success');
|
||||
document.getElementById('new-task-modal').classList.remove('active');
|
||||
document.getElementById('new-task-form').reset();
|
||||
} catch (err) {
|
||||
console.error('Create error:', err);
|
||||
showToast(err.message || 'Failed to create task', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
|
|
|
|||
Loading…
Reference in New Issue