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>
158 lines
6.2 KiB
JavaScript
158 lines
6.2 KiB
JavaScript
// TaskTracker app.js — drag-and-drop, detail panel, keyboard shortcuts
|
|
|
|
(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();
|
|
}
|
|
}
|
|
});
|
|
|
|
})();
|