264 lines
9.9 KiB
JavaScript
264 lines
9.9 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
|
|
// (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;
|
|
}
|
|
});
|
|
|
|
})();
|