Files
TaskTracker/TaskTracker.Api/wwwroot/js/app.js
2026-03-01 22:28:02 -05:00

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;
}
});
})();