backlog-md/src/aggregator/web/index.html

665 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Backlog Aggregator - Real-time Task View</title>
<link rel="icon" type="image/png" href="/favicon.png">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f172a;
color: #e2e8f0;
min-height: 100vh;
}
.header {
padding: 1rem 2rem;
background: #1e293b;
border-bottom: 1px solid #334155;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 1.5rem; font-weight: 600; }
.header-left { display: flex; align-items: center; gap: 1rem; }
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
background: #22c55e;
color: white;
}
.status-badge.offline { background: #ef4444; }
.stats { font-size: 0.875rem; color: #94a3b8; }
.filter-bar {
padding: 1rem 2rem;
background: #1e293b;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.filter-input {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
min-width: 200px;
}
.project-btn, .action-btn {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: 1px solid #334155;
background: #0f172a;
color: #e2e8f0;
cursor: pointer;
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;
border-radius: 50%;
}
.board {
padding: 2rem;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
min-height: calc(100vh - 180px);
}
.column {
background: #1e293b;
border-radius: 0.5rem;
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;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #334155;
}
.column-header h2 { font-size: 1rem; font-weight: 600; }
.task-count {
background: #334155;
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
}
.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: 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; }
.priority-badge {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
font-weight: 500;
text-transform: uppercase;
}
.priority-high { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.priority-medium { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
.priority-low { background: rgba(34, 197, 94, 0.2); color: #22c55e; }
.labels { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-top: 0.5rem; }
.label {
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.625rem;
background: #334155;
color: #94a3b8;
}
.assignees { margin-top: 0.5rem; font-size: 0.75rem; color: #64748b; }
.empty-state { color: #64748b; text-align: center; padding: 2rem; font-size: 0.875rem; }
.task-description {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid #334155;
font-size: 0.8rem;
color: #94a3b8;
white-space: pre-wrap;
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>
<header class="header">
<div class="header-left">
<h1>Backlog Aggregator</h1>
<span id="status-badge" class="status-badge">Connecting...</span>
</div>
<div class="stats" id="stats">Loading...</div>
</header>
<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">
<div class="column" data-status="To Do">
<div class="column-header">
<h2>To Do</h2>
<span class="task-count" id="count-todo">0</span>
</div>
<div class="tasks" id="tasks-todo"></div>
</div>
<div class="column" data-status="In Progress">
<div class="column-header">
<h2>In Progress</h2>
<span class="task-count" id="count-progress">0</span>
</div>
<div class="tasks" id="tasks-progress"></div>
</div>
<div class="column" data-status="Done">
<div class="column-header">
<h2>Done</h2>
<span class="task-count" id="count-done">0</span>
</div>
<div class="tasks" id="tasks-done"></div>
</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:';
ws = new WebSocket(`${protocol}//${location.host}`);
ws.onopen = () => {
document.getElementById('status-badge').textContent = 'Live';
document.getElementById('status-badge').classList.remove('offline');
};
ws.onclose = () => {
document.getElementById('status-badge').textContent = 'Reconnecting...';
document.getElementById('status-badge').classList.add('offline');
setTimeout(connectWebSocket, 3000);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'init' || data.type === 'update') {
projects = data.projects || [];
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');
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';
btn.innerHTML = `<span class="project-dot" style="background:${p.color}"></span>${p.name}`;
btn.onclick = () => {
selectedProject = p.name;
document.querySelectorAll('.project-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderTasks();
};
filterBar.insertBefore(btn, newTaskBtn);
});
document.getElementById('stats').textContent =
`${projects.length} projects | ${tasks.length} tasks | Updated ${new Date().toLocaleTimeString()}`;
}
function renderTasks() {
const filter = document.getElementById('filter').value.toLowerCase();
const statusMap = {
'to do': 'todo',
'in progress': 'progress',
'done': 'done'
};
['todo', 'progress', 'done'].forEach(id => {
document.getElementById(`tasks-${id}`).innerHTML = '';
});
let counts = { todo: 0, progress: 0, done: 0 };
tasks.forEach(task => {
if (selectedProject && task.projectName !== selectedProject) return;
if (filter && !task.title.toLowerCase().includes(filter) &&
!task.projectName.toLowerCase().includes(filter)) return;
const statusKey = statusMap[task.status.toLowerCase()] || 'todo';
counts[statusKey]++;
const card = document.createElement('div');
card.className = 'task-card';
card.style.borderLeftColor = task.projectColor;
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) {
priorityClass = `priority-${task.priority.toLowerCase()}`;
}
card.innerHTML = `
<div class="task-header">
<div>
<div class="task-title">${escapeHtml(task.title)}</div>
<div class="task-meta">${task.id} | ${task.projectName}</div>
</div>
${task.priority ? `<span class="priority-badge ${priorityClass}">${task.priority}</span>` : ''}
</div>
${task.labels && task.labels.length > 0 ? `
<div class="labels">
${task.labels.map(l => `<span class="label">${escapeHtml(l)}</span>`).join('')}
</div>
` : ''}
${task.assignee && task.assignee.length > 0 ? `
<div class="assignees">${task.assignee.join(', ')}</div>
` : ''}
${task.description ? `
<div class="task-description">${escapeHtml(task.description.slice(0, 500))}${task.description.length > 500 ? '...' : ''}</div>
` : ''}
`;
document.getElementById(`tasks-${statusKey}`).appendChild(card);
});
document.getElementById('count-todo').textContent = counts.todo;
document.getElementById('count-progress').textContent = counts.progress;
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();
}
};
let isSubmitting = false;
document.getElementById('new-task-form').onsubmit = async (e) => {
e.preventDefault();
// Prevent double submission
if (isSubmitting) return;
isSubmitting = true;
const submitBtn = e.target.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
submitBtn.textContent = 'Creating...';
submitBtn.disabled = true;
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');
} finally {
isSubmitting = false;
submitBtn.textContent = originalText;
submitBtn.disabled = false;
}
};
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.getElementById('filter').addEventListener('input', renderTasks);
document.getElementById('all-projects').onclick = () => {
selectedProject = null;
document.querySelectorAll('.project-btn').forEach(b => b.classList.remove('active'));
document.getElementById('all-projects').classList.add('active');
renderTasks();
};
// Initial connection
connectWebSocket();
</script>
</body>
</html>