feat(web): add drag-and-drop between Kanban columns via SortableJS

Wire up SortableJS in app.js to enable dragging task cards between
Kanban columns. On drop, fires htmx PUT requests to the appropriate
Board handler (Start/Pause/Resume/Complete) based on the column
transition. Invalid transitions are reverted. Sortable instances are
destroyed and recreated after htmx swaps to prevent memory leaks.
Also centralizes detail panel open/close/edit helpers and Escape key
handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 22:25:26 -05:00
parent 1d1b2a153e
commit 1fafae9705

View File

@@ -1,2 +1,157 @@
// TaskTracker app.js — command palette, keyboard shortcuts, drag-and-drop wiring // TaskTracker app.js — drag-and-drop, detail panel, keyboard shortcuts
// Will be populated in later tasks
(function() {
'use strict';
// ========================================
// Drag-and-Drop (SortableJS)
// ========================================
function initKanban() {
document.querySelectorAll('.kanban-column-body').forEach(function(el) {
// Destroy existing Sortable instance if re-initializing
if (el._sortable) {
el._sortable.destroy();
}
el._sortable = new Sortable(el, {
group: 'kanban',
animation: 150,
ghostClass: 'task-card--ghost',
dragClass: 'task-card--dragging',
handle: '.task-card', // Only drag by the card itself
filter: '.create-task-form', // Don't drag the create form
onEnd: function(evt) {
var taskId = evt.item.dataset.taskId;
var fromStatus = evt.from.dataset.status;
var toStatus = evt.to.dataset.status;
// No-op if dropped in same column
if (fromStatus === toStatus) return;
// Determine the handler based on transition
var handler = null;
if (toStatus === 'Active' && fromStatus === 'Paused') {
handler = 'Resume';
} else if (toStatus === 'Active') {
handler = 'Start';
} else if (toStatus === 'Paused' && fromStatus === 'Active') {
handler = 'Pause';
} else if (toStatus === 'Completed') {
handler = 'Complete';
} else {
// Invalid transition — revert by moving the item back
evt.from.appendChild(evt.item);
return;
}
// Fire htmx request to update the task status
htmx.ajax('PUT', '/board?handler=' + handler + '&id=' + taskId, {
target: '#kanban-board',
swap: 'innerHTML'
});
}
});
});
}
// ========================================
// Detail Panel helpers
// ========================================
// Defined on window so inline onclick handlers and partial scripts can call them.
// The _TaskDetail partial also defines these inline; whichever loads last wins,
// but the behavior is identical.
window.closeDetailPanel = function() {
var overlay = document.querySelector('.detail-overlay');
var panel = document.querySelector('.detail-panel');
if (overlay) overlay.classList.remove('detail-overlay--open');
if (panel) panel.classList.remove('detail-panel--open');
// Clear content after CSS transition completes
setTimeout(function() {
var container = document.getElementById('detail-panel');
if (container && !document.querySelector('.detail-panel--open')) {
container.innerHTML = '';
}
}, 300);
};
window.startEdit = function(fieldId) {
var container = document.getElementById('edit-' + fieldId);
if (!container) return;
var display = container.querySelector('.inline-edit-display');
var form = container.querySelector('.inline-edit-form');
if (display) display.style.display = 'none';
if (form) {
form.style.display = '';
var input = form.querySelector('input, textarea');
if (input) {
input.focus();
if (input.type === 'text' || input.tagName === 'TEXTAREA') {
input.setSelectionRange(input.value.length, input.value.length);
}
}
}
};
window.cancelEdit = function(fieldId) {
var container = document.getElementById('edit-' + fieldId);
if (!container) return;
var display = container.querySelector('.inline-edit-display');
var form = container.querySelector('.inline-edit-form');
if (display) display.style.display = '';
if (form) form.style.display = 'none';
};
// ========================================
// Initialization
// ========================================
// Initialize Kanban drag-and-drop on page load
document.addEventListener('DOMContentLoaded', function() {
initKanban();
});
// Re-initialize after htmx swaps that affect the kanban board.
// This is critical because htmx replaces DOM nodes, destroying Sortable instances.
document.addEventListener('htmx:afterSwap', function(evt) {
var target = evt.detail.target;
if (target && (target.id === 'kanban-board' || target.id === 'board-content' || (target.closest && target.closest('#kanban-board')))) {
initKanban();
}
});
// Also handle htmx:afterSettle for cases where the DOM isn't fully ready on afterSwap
document.addEventListener('htmx:afterSettle', function(evt) {
var target = evt.detail.target;
if (target && (target.id === 'kanban-board' || target.id === 'board-content')) {
initKanban();
}
});
// Open detail panel when its content is loaded via htmx
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'detail-panel') {
// Panel content was just loaded — trigger open animation
requestAnimationFrame(function() {
var overlay = document.querySelector('.detail-overlay');
var panel = document.querySelector('.detail-panel');
if (overlay) overlay.classList.add('detail-overlay--open');
if (panel) panel.classList.add('detail-panel--open');
});
}
});
// Global Escape key handler for closing the detail panel
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
var panel = document.querySelector('.detail-panel--open');
if (panel) {
closeDetailPanel();
e.preventDefault();
}
}
});
})();