Files
IronKanban/public/assets/app.js
T
Keith Solomon 812e5c2f2a feature: Initial MVP
2026-04-05 16:20:39 -05:00

620 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const initialStateNode = document.getElementById('initial-state');
const appRoot = document.getElementById('app');
if (initialStateNode && appRoot && initialStateNode.textContent !== 'null') {
const state = JSON.parse(initialStateNode.textContent);
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const boardColumnsEl = document.getElementById('board-columns');
const trashColumnsEl = document.getElementById('trash-columns');
const trashDropzoneEl = document.getElementById('trash-dropzone');
const trashDropzoneMetaEl = document.getElementById('trash-dropzone-meta');
const notesPanelEl = document.getElementById('notes-panel');
const notesListEl = document.getElementById('notes-list');
const notesToggleEl = document.querySelector('[data-action="toggle-notes"]');
const trashWrapEl = document.getElementById('trash-columns-wrap');
const trashToggleEl = document.querySelector('[data-action="toggle-trash"]');
const trashPanelEl = document.getElementById('trash-panel');
const taskDialog = document.getElementById('task-dialog');
const noteDialog = document.getElementById('note-dialog');
const taskForm = document.getElementById('task-form');
const noteForm = document.getElementById('note-form');
const projectId = appRoot.dataset.projectId;
const uiState = {
notesExpanded: false,
trashExpanded: false,
trashPinned: false,
};
let columnSortable = null;
const taskSortables = [];
let trashAutoCollapseTimer = null;
const escapeHtml = (value = '') =>
value
.replaceAll('&', '&')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
const markdownPreview = (value = '') => escapeHtml(value).replaceAll('\n', '<br>');
const fetchJson = async (url, options = {}) => {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
...(options.headers || {}),
},
...options,
});
const payload = await response.json();
if (!response.ok || payload.success === false) {
throw new Error(payload.error || 'Request failed');
}
return payload;
};
const updateState = (nextState) => {
state.project = nextState.project;
state.board = nextState.board;
state.columns = nextState.columns;
state.tasks = nextState.tasks;
state.notes = nextState.notes;
state.revision = nextState.revision;
render();
};
const taskForId = (taskId) => state.tasks.find((task) => task.id === taskId);
const noteForId = (noteId) => state.notes.find((note) => note.id === noteId);
const priorityClass = (priority) => `priority-${priority || 'normal'}`;
const notesToggleLabel = (count, isExpanded) =>
isExpanded ? `Hide Notes (${count})` : `Show Notes (${count})`;
const trashToggleLabel = (count, isExpanded) =>
isExpanded ? `Hide Trash (${count})` : `Show Trash (${count})`;
const sortedColumns = () => state.columns.slice().sort((a, b) => a.order - b.order);
const boardColumns = () => sortedColumns().filter((column) => column.id !== 'trash');
const trashColumn = () => state.columns.find((column) => column.id === 'trash') || null;
const tasksByColumn = () =>
state.tasks.reduce((acc, task) => {
if (!acc[task.column]) {
acc[task.column] = [];
}
acc[task.column].push(task);
return acc;
}, {});
const renderColumn = (column, tasks) => `
<section class="column" data-column-id="${escapeHtml(column.id)}">
<header class="column-header">
<div class="column-title-wrap">
<h3>${escapeHtml(column.label)}</h3>
<p class="column-meta">${tasks.length} task${tasks.length === 1 ? '' : 's'}</p>
</div>
<div class="column-actions">
<button type="button" data-action="rename-column" data-column-id="${escapeHtml(column.id)}" title="Rename column">✎</button>
<button type="button" data-action="delete-column" data-column-id="${escapeHtml(column.id)}" title="Delete column">×</button>
</div>
</header>
<div class="task-list" data-column-id="${escapeHtml(column.id)}">
${tasks
.map(
(task) => `
<article class="task-card ${task.completed ? 'is-completed' : ''}" data-task-id="${escapeHtml(task.id)}">
<h4>${escapeHtml(task.title)}</h4>
<p>${markdownPreview(task.body.slice(0, 160))}</p>
<div class="task-card-footer">
<div class="task-tags">
<span class="pill ${priorityClass(task.priority)}">${escapeHtml(task.priority)}</span>
${task.completed ? '<span class="pill">done</span>' : ''}
${task.is_active ? '' : '<span class="pill">paused</span>'}
</div>
<div class="task-mini-actions">
<button type="button" data-action="edit-task" data-task-id="${escapeHtml(task.id)}" title="Edit task">✎</button>
</div>
</div>
</article>
`
)
.join('')}
</div>
</section>
`;
const renderBoard = () => {
const tasksByColumnMap = tasksByColumn();
boardColumnsEl.innerHTML = boardColumns()
.map((column) => {
const tasks = (tasksByColumnMap[column.id] || []).slice().sort((a, b) => a.order - b.order);
return renderColumn(column, tasks);
})
.join('');
const trash = trashColumn();
const trashTasks = trash
? (tasksByColumnMap[trash.id] || []).slice().sort((a, b) => a.order - b.order)
: [];
if (trashDropzoneEl) {
trashDropzoneEl.querySelectorAll('.task-card').forEach((taskCard) => taskCard.remove());
}
if (trashPanelEl && trashWrapEl && trashToggleEl && trashColumnsEl) {
trashPanelEl.hidden = trash === null;
if (trash === null) {
trashColumnsEl.innerHTML = '';
if (trashDropzoneMetaEl) {
trashDropzoneMetaEl.textContent = 'Trash is unavailable.';
}
return;
}
trashColumnsEl.innerHTML = renderColumn(trash, trashTasks);
if (trashDropzoneMetaEl) {
trashDropzoneMetaEl.textContent =
trashTasks.length === 0
? 'Trash is empty.'
: `${trashTasks.length} task${trashTasks.length === 1 ? '' : 's'} in trash.`;
}
setTrashExpanded(uiState.trashExpanded, trashTasks.length);
}
};
const renderNotes = () => {
notesListEl.innerHTML =
state.notes.length === 0
? '<div class="empty-state compact"><p>No notes yet.</p></div>'
: state.notes
.map(
(note) => `
<article class="note-card" data-note-id="${escapeHtml(note.id)}">
<h4>${escapeHtml(note.title)}</h4>
<p class="note-preview">${markdownPreview((note.body || '').slice(0, 200))}</p>
</article>
`
)
.join('');
};
const setNotesExpanded = (isExpanded) => {
if (!notesListEl || !notesToggleEl || !notesPanelEl) {
return;
}
const noteCount = state.notes.length;
uiState.notesExpanded = isExpanded;
notesListEl.hidden = !isExpanded;
notesListEl.setAttribute('aria-hidden', String(!isExpanded));
notesPanelEl.classList.toggle('is-expanded', isExpanded);
notesPanelEl.classList.toggle('is-collapsed', !isExpanded);
notesToggleEl.setAttribute('aria-expanded', String(isExpanded));
notesToggleEl.textContent = notesToggleLabel(noteCount, isExpanded);
};
const setTrashExpanded = (isExpanded, count = 0, pinned = uiState.trashPinned) => {
if (!trashWrapEl || !trashToggleEl) {
return;
}
uiState.trashPinned = pinned;
uiState.trashExpanded = isExpanded;
trashWrapEl.hidden = !isExpanded;
trashToggleEl.setAttribute('aria-expanded', String(isExpanded));
trashToggleEl.textContent = trashToggleLabel(count, isExpanded);
};
const revealTrashTemporarily = () => {
const trashTaskCount = state.tasks.filter((task) => task.column === 'trash').length;
if (!trashPanelEl || trashTaskCount === 0) {
return;
}
if (trashAutoCollapseTimer !== null) {
window.clearTimeout(trashAutoCollapseTimer);
trashAutoCollapseTimer = null;
}
setTrashExpanded(true, trashTaskCount, false);
trashPanelEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
trashAutoCollapseTimer = window.setTimeout(() => {
if (!uiState.trashPinned) {
setTrashExpanded(false, state.tasks.filter((task) => task.column === 'trash').length, false);
}
trashAutoCollapseTimer = null;
}, 4000);
};
const destroySortables = () => {
if (columnSortable) {
columnSortable.destroy();
columnSortable = null;
}
while (taskSortables.length > 0) {
taskSortables.pop().destroy();
}
};
const saveColumns = async (deletedColumnId = null) => {
const columns = [...boardColumnsEl.querySelectorAll('.column')].map((columnEl, index) => ({
id: columnEl.dataset.columnId,
label: columnEl.querySelector('h3')?.textContent || columnEl.dataset.columnId,
order: (index + 1) * 100,
}));
const trash = trashColumn();
if (trash) {
columns.push({ ...trash, order: (columns.length + 1) * 100 });
}
const payload = await fetchJson('/api/update-board.php', {
method: 'POST',
body: JSON.stringify({ projectId, revision: state.revision, columns, deletedColumnId }),
});
updateState(payload.state);
};
const mountSortables = () => {
destroySortables();
columnSortable = new Sortable(boardColumnsEl, {
animation: 180,
draggable: '.column',
ghostClass: 'sortable-ghost',
onEnd: () => {
saveColumns().catch(handleError);
},
});
document.querySelectorAll('.task-list').forEach((listEl) => {
const sortable = new Sortable(listEl, {
group: 'tasks',
animation: 180,
draggable: '.task-card',
ghostClass: 'sortable-ghost',
onEnd: async (event) => {
try {
const payload = await fetchJson('/api/move-task.php', {
method: 'POST',
body: JSON.stringify({
projectId,
taskId: event.item.dataset.taskId,
column: event.to.dataset.columnId,
index: event.newIndex ?? 0,
revision: state.revision,
}),
});
updateState(payload.state);
if (event.to.dataset.columnId === 'trash') {
revealTrashTemporarily();
}
} catch (error) {
handleError(error);
reloadBoard();
}
},
});
taskSortables.push(sortable);
});
if (trashDropzoneEl) {
const trashDropzoneSortable = new Sortable(trashDropzoneEl, {
group: 'tasks',
animation: 180,
draggable: '.task-card',
ghostClass: 'sortable-ghost',
onStart: () => {
trashDropzoneEl.classList.add('is-active-dropzone');
},
onEnd: async (event) => {
trashDropzoneEl.classList.remove('is-active-dropzone');
if (event.to !== trashDropzoneEl) {
return;
}
try {
const payload = await fetchJson('/api/move-task.php', {
method: 'POST',
body: JSON.stringify({
projectId,
taskId: event.item.dataset.taskId,
column: 'trash',
index: 0,
revision: state.revision,
}),
});
updateState(payload.state);
revealTrashTemporarily();
} catch (error) {
handleError(error);
reloadBoard();
}
},
});
taskSortables.push(trashDropzoneSortable);
}
};
const render = () => {
renderBoard();
renderNotes();
mountSortables();
setNotesExpanded(uiState.notesExpanded);
};
const populateTaskDialog = (task = null) => {
taskForm.reset();
taskForm.elements.id.value = task?.id || '';
taskForm.elements.title.value = task?.title || '';
taskForm.elements.priority.value = task?.priority || 'normal';
taskForm.elements.body.value = task?.body || '';
taskForm.elements.completed.checked = Boolean(task?.completed);
taskForm.elements.is_active.checked = task ? Boolean(task.is_active) : true;
taskForm.querySelector('[data-action="trash-task"]').hidden = !task;
taskForm.elements.column.innerHTML = state.columns
.map(
(column) =>
`<option value="${escapeHtml(column.id)}"${column.id === (task?.column || state.columns[0]?.id) ? ' selected' : ''}>${escapeHtml(column.label)}</option>`
)
.join('');
};
const populateNoteDialog = (note = null) => {
noteForm.reset();
noteForm.elements.id.value = note?.id || '';
noteForm.elements.title.value = note?.title || '';
noteForm.elements.body.value = note?.body || '';
};
const openTaskDialog = (taskId = null) => {
populateTaskDialog(taskId ? taskForId(taskId) : null);
taskDialog.showModal();
};
const openNoteDialog = (noteId = null) => {
populateNoteDialog(noteId ? noteForId(noteId) : null);
noteDialog.showModal();
};
const handleError = (error) => {
window.alert(error.message || 'Something went wrong.');
};
const reloadBoard = async () => {
const response = await fetch(`/api/board-state.php?project=${encodeURIComponent(projectId)}`);
updateState(await response.json());
};
appRoot.addEventListener('click', async (event) => {
const button = event.target.closest('[data-action]');
const taskCard = event.target.closest('.task-card');
const noteCard = event.target.closest('.note-card');
if (taskCard && !button) {
openTaskDialog(taskCard.dataset.taskId);
return;
}
if (noteCard && !button) {
openNoteDialog(noteCard.dataset.noteId);
return;
}
if (!button) {
return;
}
try {
if (button.dataset.action === 'new-task') {
openTaskDialog();
}
if (button.dataset.action === 'new-note') {
openNoteDialog();
setNotesExpanded(true);
}
if (button.dataset.action === 'toggle-notes') {
setNotesExpanded(!uiState.notesExpanded);
}
if (button.dataset.action === 'toggle-trash') {
const trashTaskCount = state.tasks.filter((task) => task.column === 'trash').length;
const nextExpanded = !uiState.trashExpanded;
if (trashAutoCollapseTimer !== null) {
window.clearTimeout(trashAutoCollapseTimer);
trashAutoCollapseTimer = null;
}
setTrashExpanded(nextExpanded, trashTaskCount, nextExpanded);
}
if (button.dataset.action === 'new-column') {
const label = window.prompt('Column name');
if (label) {
updateState(
(
await fetchJson('/api/create-column.php', {
method: 'POST',
body: JSON.stringify({ projectId, label, revision: state.revision }),
})
).state
);
}
}
if (button.dataset.action === 'edit-task') {
openTaskDialog(button.dataset.taskId);
}
if (button.dataset.action === 'rename-column') {
const column = state.columns.find((item) => item.id === button.dataset.columnId);
const label = window.prompt('Rename column', column?.label || button.dataset.columnId);
if (label) {
const columns = state.columns.map((item) =>
item.id === button.dataset.columnId ? { ...item, label } : item
);
updateState(
(
await fetchJson('/api/update-board.php', {
method: 'POST',
body: JSON.stringify({ projectId, revision: state.revision, columns }),
})
).state
);
}
}
if (button.dataset.action === 'delete-column') {
if (button.dataset.columnId === 'trash') {
window.alert('Trash cannot be removed.');
return;
}
if (window.confirm('Delete this column and move its tasks to Trash?')) {
updateState(
(
await fetchJson('/api/update-board.php', {
method: 'POST',
body: JSON.stringify({
projectId,
revision: state.revision,
columns: state.columns,
deletedColumnId: button.dataset.columnId,
}),
})
).state
);
}
}
} catch (error) {
handleError(error);
}
});
taskForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(taskForm);
const taskId = formData.get('id');
const payload = {
projectId,
revision: state.revision,
title: formData.get('title'),
column: formData.get('column'),
body: formData.get('body'),
priority: formData.get('priority'),
completed: taskForm.elements.completed.checked,
is_active: taskForm.elements.is_active.checked,
meta: {
priority: formData.get('priority'),
completed: taskForm.elements.completed.checked,
is_active: taskForm.elements.is_active.checked,
},
};
try {
updateState(
(
await fetchJson(taskId ? '/api/save-task.php' : '/api/create-task.php', {
method: 'POST',
body: JSON.stringify(taskId ? { ...payload, taskId } : payload),
})
).state
);
taskDialog.close();
} catch (error) {
handleError(error);
}
});
noteForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(noteForm);
try {
updateState(
(
await fetchJson('/api/save-note.php', {
method: 'POST',
body: JSON.stringify({
projectId,
revision: state.revision,
id: formData.get('id') || undefined,
title: formData.get('title'),
body: formData.get('body'),
}),
})
).state
);
setNotesExpanded(true);
noteDialog.close();
} catch (error) {
handleError(error);
}
});
taskForm.querySelector('[data-action="trash-task"]').addEventListener('click', async () => {
const taskId = taskForm.elements.id.value;
if (!taskId) {
taskDialog.close();
return;
}
try {
updateState(
(
await fetchJson('/api/delete-task.php', {
method: 'POST',
body: JSON.stringify({ projectId, taskId, revision: state.revision }),
})
).state
);
revealTrashTemporarily();
taskDialog.close();
} catch (error) {
handleError(error);
}
});
window.setInterval(async () => {
try {
const response = await fetch(`/api/board-state.php?project=${encodeURIComponent(projectId)}`);
const payload = await response.json();
if (payload.revision !== state.revision) {
updateState(payload);
}
} catch (error) {
console.error(error);
}
}, 5000);
render();
setNotesExpanded(false);
setTrashExpanded(false, state.tasks.filter((task) => task.column === 'trash').length);
}