// 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 // (Search modal Escape is handled separately with stopPropagation) document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { // Don't close detail panel if search modal handled Escape var searchModal = document.getElementById('search-modal'); if (searchModal && searchModal.classList.contains('search-backdrop--open')) return; var panel = document.querySelector('.detail-panel--open'); if (panel) { closeDetailPanel(); e.preventDefault(); } } }); // ======================================== // Search Modal (Ctrl+K) // ======================================== var searchSelectedIndex = 0; window.openSearch = function() { var modal = document.getElementById('search-modal'); if (!modal) return; modal.classList.add('search-backdrop--open'); var input = document.getElementById('search-input'); if (input) { input.value = ''; input.focus(); // Trigger initial load (show recent tasks when empty) htmx.trigger(input, 'search'); } searchSelectedIndex = 0; }; window.closeSearch = function() { var modal = document.getElementById('search-modal'); if (modal) modal.classList.remove('search-backdrop--open'); }; window.selectSearchResult = function(taskId) { closeSearch(); // Load the task detail panel htmx.ajax('GET', '/board?handler=TaskDetail&id=' + taskId, { target: '#detail-panel', swap: 'innerHTML' }); }; window.highlightResult = function(el) { var results = document.querySelectorAll('.search-result'); results.forEach(function(r) { r.classList.remove('search-result--selected'); }); el.classList.add('search-result--selected'); // Update index searchSelectedIndex = Array.from(results).indexOf(el); }; // Keyboard navigation in search document.addEventListener('keydown', function(e) { var modal = document.getElementById('search-modal'); if (!modal || !modal.classList.contains('search-backdrop--open')) { // Ctrl+K / Cmd+K to open if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); openSearch(); } return; } // Modal is open if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeSearch(); return; } var results = document.querySelectorAll('.search-result'); if (results.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); searchSelectedIndex = Math.min(searchSelectedIndex + 1, results.length - 1); updateSearchSelection(results); } else if (e.key === 'ArrowUp') { e.preventDefault(); searchSelectedIndex = Math.max(searchSelectedIndex - 1, 0); updateSearchSelection(results); } else if (e.key === 'Enter') { e.preventDefault(); var selected = results[searchSelectedIndex]; if (selected) { var taskId = selected.dataset.taskId; selectSearchResult(taskId); } } }); function updateSearchSelection(results) { results.forEach(function(r, i) { if (i === searchSelectedIndex) { r.classList.add('search-result--selected'); r.scrollIntoView({ block: 'nearest' }); } else { r.classList.remove('search-result--selected'); } }); } // Reset selection when search results are updated document.addEventListener('htmx:afterSwap', function(evt) { if (evt.detail.target && evt.detail.target.id === 'search-results') { searchSelectedIndex = 0; } }); })();