feature: Initial MVP

This commit is contained in:
Keith Solomon
2026-04-05 16:20:39 -05:00
parent 3af0b9cd0f
commit 812e5c2f2a
60 changed files with 5917 additions and 5 deletions
+619
View File
@@ -0,0 +1,619 @@
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);
}