620 lines
19 KiB
JavaScript
620 lines
19 KiB
JavaScript
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('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
|
||
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);
|
||
}
|