const initialStateNode = document.getElementById('initial-state'); const appRoot = document.getElementById('app'); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; const projectCreateForm = document.getElementById('project-create-form'); 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 handleError = (error) => { window.alert(error.message || 'Something went wrong.'); }; if (projectCreateForm) { projectCreateForm.addEventListener('submit', async (event) => { event.preventDefault(); const formData = new FormData(projectCreateForm); const submitButton = projectCreateForm.querySelector('button[type="submit"]'); if (submitButton) { submitButton.disabled = true; } try { const payload = await fetchJson('/api/create-project.php', { method: 'POST', body: JSON.stringify({ title: formData.get('title'), slug: formData.get('slug'), body: formData.get('body'), }), }); window.location.assign( payload.state.projectUrl || `/?project=${encodeURIComponent(payload.state.project.id)}` ); } catch (error) { handleError(error); if (submitButton) { submitButton.disabled = false; } } }); } if (initialStateNode && appRoot && initialStateNode.textContent !== 'null') { const state = JSON.parse(initialStateNode.textContent); 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('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); const markdownPreview = (value = '') => escapeHtml(value).replaceAll('\n', '
'); 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) => `

${escapeHtml(column.label)}

${tasks.length} task${tasks.length === 1 ? '' : 's'}

${tasks .map( (task) => `

${escapeHtml(task.title)}

${markdownPreview(task.body.slice(0, 160))}

` ) .join('')}
`; 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 ? '

No notes yet.

' : state.notes .map( (note) => `

${escapeHtml(note.title)}

${markdownPreview((note.body || '').slice(0, 200))}

` ) .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) => `` ) .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 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); }