Port features from app.tsx to vanilla JS index.html

- Add Won't Do as 4th kanban column
- Add delete (×) and archive (↑) hover buttons on task cards
- Add inline title editing (click to edit, Enter/Escape)
- Add status dropdown for quick status changes
- Add priority dropdown with color-coded display
- Add expandable description editing (Ctrl+Enter to save)

This ports the React features to vanilla JS so the aggregator
actually uses them (server serves index.html, not app.tsx).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2025-12-04 02:38:56 -08:00
parent 5775b0d1e8
commit 235fe7c11c
1 changed files with 258 additions and 13 deletions

View File

@ -71,7 +71,7 @@
.board {
padding: 2rem;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
min-height: calc(100vh - 180px);
}
@ -157,6 +157,65 @@
display: none;
}
.task-card.expanded .task-description { display: block; }
.task-card { position: relative; }
.task-actions {
position: absolute;
top: -8px;
right: -8px;
display: none;
gap: 4px;
z-index: 10;
}
.task-card:hover .task-actions { display: flex; }
.task-action-btn {
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.task-action-btn.archive { background: #3b82f6; font-size: 12px; }
.task-action-btn.delete { background: #ef4444; }
.task-action-btn:hover { transform: scale(1.1); }
/* Inline editing styles */
.edit-input {
background: #1e293b;
border: 1px solid #3b82f6;
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
color: #e2e8f0;
font-size: inherit;
width: 100%;
outline: none;
}
.inline-select {
background: #1e293b;
border: 1px solid #334155;
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
color: #e2e8f0;
font-size: 0.625rem;
cursor: pointer;
outline: none;
}
.task-title.editable:hover {
background: rgba(59, 130, 246, 0.1);
border-radius: 0.25rem;
cursor: text;
}
.task-controls {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
/* Modal styles */
.modal-overlay {
@ -281,6 +340,13 @@
</div>
<div class="tasks" id="tasks-done"></div>
</div>
<div class="column" data-status="Won't Do">
<div class="column-header">
<h2>Won't Do</h2>
<span class="task-count" id="count-wontdo">0</span>
</div>
<div class="tasks" id="tasks-wontdo"></div>
</div>
</div>
<!-- New Task Modal -->
@ -317,6 +383,7 @@
<option value="To Do">To Do</option>
<option value="In Progress">In Progress</option>
<option value="Done">Done</option>
<option value="Won't Do">Won't Do</option>
</select>
</div>
<div class="form-group">
@ -396,7 +463,7 @@
oldBtns.forEach(btn => btn.remove());
const newTaskBtn = document.getElementById('new-task-btn');
projects.forEach(p => {
projects.filter(p => tasks.some(t => t.projectName === p.name)).forEach(p => {
const btn = document.createElement('button');
btn.className = 'project-btn';
btn.innerHTML = `<span class="project-dot" style="background:${p.color}"></span>${p.name}`;
@ -410,7 +477,125 @@
});
document.getElementById('stats').textContent =
`${projects.length} projects | ${tasks.length} tasks | Updated ${new Date().toLocaleTimeString()}`;
`${projects.filter(p => tasks.some(t => t.projectName === p.name)).length} active projects | ${tasks.length} tasks | Updated ${new Date().toLocaleTimeString()}`;
}
async function archiveTask(projectPath, taskId) {
try {
const response = await fetch('/api/tasks/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectPath, taskId })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to archive task');
}
showToast('Task archived', 'success');
} catch (err) {
console.error('Archive error:', err);
showToast(err.message || 'Failed to archive task', 'error');
}
}
async function deleteTask(projectPath, taskId, title) {
if (!confirm(`Are you sure you want to delete "${title}"? This cannot be undone.`)) return;
try {
const response = await fetch('/api/tasks/delete', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectPath, taskId })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to delete task');
}
showToast('Task deleted', 'success');
} catch (err) {
console.error('Delete error:', err);
showToast(err.message || 'Failed to delete task', 'error');
}
}
async function updateTask(projectPath, taskId, updates) {
try {
const response = await fetch('/api/tasks/update', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectPath, taskId, ...updates })
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || 'Failed to update task');
}
showToast('Task updated', 'success');
} catch (err) {
console.error('Update error:', err);
showToast(err.message || 'Failed to update task', 'error');
}
}
function startInlineEdit(card, task, field) {
const element = card.querySelector(`[data-field="${field}"]`);
const currentValue = field === 'title' ? task.title : (task.description || '');
const isTextarea = field === 'description';
if (isTextarea) {
element.innerHTML = `
<textarea class="edit-input" style="min-height:80px;resize:vertical;font-size:0.8rem;">${escapeHtml(currentValue)}</textarea>
<div style="display:flex;gap:0.25rem;justify-content:flex-end;margin-top:0.5rem;">
<span style="font-size:0.625rem;color:#64748b;margin-right:auto;">Ctrl+Enter to save</span>
<button class="btn btn-primary" style="padding:0.25rem 0.5rem;font-size:0.75rem;" data-save>Save</button>
<button class="btn btn-secondary" style="padding:0.25rem 0.5rem;font-size:0.75rem;" data-cancel>Cancel</button>
</div>
`;
} else {
element.innerHTML = `
<div style="display:flex;gap:0.25rem;align-items:center;">
<input type="text" class="edit-input" style="font-size:0.875rem;font-weight:500;" value="${escapeHtml(currentValue)}">
<button class="btn btn-primary" style="padding:0.25rem 0.5rem;font-size:0.75rem;" data-save></button>
<button class="btn btn-secondary" style="padding:0.25rem 0.5rem;font-size:0.75rem;" data-cancel></button>
</div>
`;
}
const input = element.querySelector(isTextarea ? 'textarea' : 'input');
input.focus();
if (!isTextarea) input.select();
const saveEdit = async () => {
const newValue = input.value;
if (newValue !== currentValue) {
await updateTask(task.projectPath, task.id, { [field]: newValue });
}
// Re-render will happen via WebSocket update
};
const cancelEdit = () => {
renderTasks(); // Re-render to restore original
};
element.querySelector('[data-save]').addEventListener('click', (e) => {
e.stopPropagation();
saveEdit();
});
element.querySelector('[data-cancel]').addEventListener('click', (e) => {
e.stopPropagation();
cancelEdit();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
cancelEdit();
} else if (e.key === 'Enter' && (!isTextarea || e.ctrlKey)) {
e.stopPropagation();
e.preventDefault();
saveEdit();
}
});
input.addEventListener('click', (e) => e.stopPropagation());
}
function renderTasks() {
@ -418,14 +603,15 @@
const statusMap = {
'to do': 'todo',
'in progress': 'progress',
'done': 'done'
'done': 'done',
"won't do": 'wontdo'
};
['todo', 'progress', 'done'].forEach(id => {
['todo', 'progress', 'done', 'wontdo'].forEach(id => {
document.getElementById(`tasks-${id}`).innerHTML = '';
});
let counts = { todo: 0, progress: 0, done: 0 };
let counts = { todo: 0, progress: 0, done: 0, wontdo: 0 };
tasks.forEach(task => {
if (selectedProject && task.projectName !== selectedProject) return;
@ -457,13 +643,35 @@
priorityClass = `priority-${task.priority.toLowerCase()}`;
}
const priorityBgColors = { high: 'rgba(239,68,68,0.2)', medium: 'rgba(245,158,11,0.2)', low: 'rgba(34,197,94,0.2)' };
const priorityTextColors = { high: '#ef4444', medium: '#f59e0b', low: '#22c55e' };
const currentPriority = task.priority ? task.priority.toLowerCase() : '';
card.innerHTML = `
<div class="task-actions">
<button class="task-action-btn archive" title="Archive task" data-action="archive"></button>
<button class="task-action-btn delete" title="Delete task" data-action="delete">×</button>
</div>
<div class="task-header">
<div>
<div class="task-title">${escapeHtml(task.title)}</div>
<div class="task-meta">${task.id} | ${task.projectName}</div>
<div style="flex:1">
<div class="task-title editable" data-field="title">${escapeHtml(task.title)}</div>
<div class="task-controls">
<span class="task-meta">${task.id} | ${task.projectName}</span>
<select class="inline-select" data-field="status" title="Change status">
<option value="To Do" ${task.status === 'To Do' ? 'selected' : ''}>To Do</option>
<option value="In Progress" ${task.status === 'In Progress' ? 'selected' : ''}>In Progress</option>
<option value="Done" ${task.status === 'Done' ? 'selected' : ''}>Done</option>
<option value="Won't Do" ${task.status === "Won't Do" ? 'selected' : ''}>Won't Do</option>
</select>
</div>
</div>
${task.priority ? `<span class="priority-badge ${priorityClass}">${task.priority}</span>` : ''}
<select class="inline-select" data-field="priority" title="Change priority"
style="background:${currentPriority ? priorityBgColors[currentPriority] : '#334155'};color:${currentPriority ? priorityTextColors[currentPriority] : '#94a3b8'};font-weight:500;text-transform:uppercase;">
<option value="" ${!task.priority ? 'selected' : ''}>-</option>
<option value="low" ${task.priority?.toLowerCase() === 'low' ? 'selected' : ''}>Low</option>
<option value="medium" ${task.priority?.toLowerCase() === 'medium' ? 'selected' : ''}>Med</option>
<option value="high" ${task.priority?.toLowerCase() === 'high' ? 'selected' : ''}>High</option>
</select>
</div>
${task.labels && task.labels.length > 0 ? `
<div class="labels">
@ -473,17 +681,54 @@
${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>
` : ''}
<div class="task-description" data-field="description">${task.description ? escapeHtml(task.description.slice(0, 500)) + (task.description.length > 500 ? '...' : '') : '<em style="color:#64748b">Click to add description...</em>'}</div>
`;
// Add event listeners for action buttons
card.querySelector('[data-action="archive"]').addEventListener('click', (e) => {
e.stopPropagation();
archiveTask(task.projectPath, task.id);
});
card.querySelector('[data-action="delete"]').addEventListener('click', (e) => {
e.stopPropagation();
deleteTask(task.projectPath, task.id, task.title);
});
// Status dropdown
card.querySelector('[data-field="status"]').addEventListener('change', (e) => {
e.stopPropagation();
updateTask(task.projectPath, task.id, { status: e.target.value });
});
card.querySelector('[data-field="status"]').addEventListener('click', (e) => e.stopPropagation());
// Priority dropdown
card.querySelector('[data-field="priority"]').addEventListener('change', (e) => {
e.stopPropagation();
updateTask(task.projectPath, task.id, { priority: e.target.value || undefined });
});
card.querySelector('[data-field="priority"]').addEventListener('click', (e) => e.stopPropagation());
// Inline title editing
card.querySelector('[data-field="title"]').addEventListener('click', (e) => {
e.stopPropagation();
startInlineEdit(card, task, 'title');
});
// Description editing (only when expanded)
card.querySelector('[data-field="description"]').addEventListener('click', (e) => {
e.stopPropagation();
if (card.classList.contains('expanded')) {
startInlineEdit(card, task, 'description');
}
});
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;
document.getElementById('count-wontdo').textContent = counts.wontdo;
}
// Drag and Drop handlers