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:
parent
5775b0d1e8
commit
c57209a5fc
|
|
@ -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">
|
||||
|
|
@ -413,19 +480,138 @@
|
|||
`${projects.length} 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() {
|
||||
const filter = document.getElementById('filter').value.toLowerCase();
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue