✨feature: Initial MVP
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: rgba(18, 27, 38, 0.94);
|
||||
--border: rgba(160, 190, 220, 0.16);
|
||||
--text: #f3f7fb;
|
||||
--muted: #9fb2c7;
|
||||
--accent: #7dd3a7;
|
||||
--warning: #f6c76a;
|
||||
--shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
|
||||
--radius: 22px;
|
||||
--font-sans: "Segoe UI", "Aptos", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(125, 211, 167, 0.18), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(98, 160, 255, 0.16), transparent 24%),
|
||||
linear-gradient(180deg, #071018 0%, #0b0f14 100%);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Cascadia Code", Consolas, monospace;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 304px) 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 28px;
|
||||
border-right: 1px solid var(--border);
|
||||
background: rgba(10, 16, 24, 0.75);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
padding: 28px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-stack {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--accent);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.sidebar-copy,
|
||||
.project-description,
|
||||
.project-root p,
|
||||
.note-preview,
|
||||
.column-meta,
|
||||
.task-card p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.project-list,
|
||||
.project-root,
|
||||
.board-panel,
|
||||
.notes-panel,
|
||||
.trash-panel,
|
||||
.hero {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.project-list,
|
||||
.project-root,
|
||||
.board-panel,
|
||||
.notes-panel,
|
||||
.trash-panel,
|
||||
.hero {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.board-panel,
|
||||
.notes-panel,
|
||||
.trash-panel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.project-link:hover,
|
||||
.project-link.active {
|
||||
border-color: rgba(125, 211, 167, 0.45);
|
||||
background: rgba(125, 211, 167, 0.1);
|
||||
}
|
||||
|
||||
.project-link span {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.workspace-header > div,
|
||||
.notes-panel-header > div,
|
||||
.column-title-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notes-panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.trash-panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.notes-panel-header h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.trash-panel-header h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.notes-panel-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.button,
|
||||
.icon-button,
|
||||
.column-actions button,
|
||||
.task-mini-actions button {
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 11px 18px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
border-color: transparent;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, #70e0c6 100%);
|
||||
color: #0b1410;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.button.compact {
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.board-scroll {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.trash-columns-wrap {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.trash-dropzone {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
border: 1px dashed rgba(246, 199, 106, 0.42);
|
||||
border-radius: 18px;
|
||||
background: rgba(246, 199, 106, 0.08);
|
||||
color: var(--muted);
|
||||
min-height: 92px;
|
||||
align-content: center;
|
||||
transition:
|
||||
border-color 160ms ease,
|
||||
background 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.trash-dropzone-title,
|
||||
.trash-dropzone-meta {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.trash-dropzone.sortable-ghost,
|
||||
.trash-dropzone.sortable-chosen,
|
||||
.trash-dropzone.is-active-dropzone {
|
||||
border-color: rgba(246, 199, 106, 0.78);
|
||||
background: rgba(246, 199, 106, 0.16);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.board-columns {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-start;
|
||||
min-height: 60vh;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(320px, calc(100vw - 72px));
|
||||
min-width: 280px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(28, 39, 54, 0.96), rgba(18, 25, 36, 0.96));
|
||||
}
|
||||
|
||||
.trash-panel .column {
|
||||
width: min(360px, 100%);
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.column-title-wrap h3 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.column-actions,
|
||||
.task-mini-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.column-actions button,
|
||||
.task-mini-actions button,
|
||||
.icon-button {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
min-height: 140px;
|
||||
}
|
||||
|
||||
.task-card,
|
||||
.note-card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
background: rgba(8, 12, 18, 0.55);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-card.is-completed {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(125, 211, 167, 0.12);
|
||||
color: var(--accent);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.pill.priority-high,
|
||||
.pill.priority-urgent {
|
||||
background: rgba(246, 199, 106, 0.14);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.pill.priority-low {
|
||||
background: rgba(98, 160, 255, 0.14);
|
||||
color: #8ec1ff;
|
||||
}
|
||||
|
||||
.notes-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notes-panel.is-collapsed .notes-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.notes-panel.is-expanded .notes-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.note-card {
|
||||
cursor: pointer;
|
||||
min-height: 160px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.note-card h4,
|
||||
.task-card h4,
|
||||
.column-title-wrap h3,
|
||||
.project-description,
|
||||
.note-preview,
|
||||
.task-card p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.note-preview {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.collapse-toggle {
|
||||
min-width: 122px;
|
||||
}
|
||||
|
||||
.collapse-toggle::after {
|
||||
content: "▾";
|
||||
margin-left: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.collapse-toggle[aria-expanded="true"]::after {
|
||||
content: "▴";
|
||||
}
|
||||
|
||||
.dialog {
|
||||
width: min(720px, calc(100vw - 24px));
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dialog::backdrop {
|
||||
background: rgba(2, 5, 10, 0.72);
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.dialog-card {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 24px;
|
||||
background: #0e1620;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dialog label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog input[type="text"],
|
||||
.dialog textarea,
|
||||
.dialog select {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state.compact {
|
||||
padding: 12px 0 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.sortable-ghost {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.task-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.task-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.notes-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.workspace,
|
||||
.sidebar {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notes-panel-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.trash-panel-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notes-panel-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions .button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notes-panel-actions .button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trash-panel .column {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.column {
|
||||
width: min(320px, calc(100vw - 52px));
|
||||
}
|
||||
|
||||
.notes-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -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('<', '<')
|
||||
.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);
|
||||
}
|
||||
Reference in New Issue
Block a user