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
+14
View File
@@ -0,0 +1,14 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Shared API bootstrap.
*/
declare(strict_types=1);
require_once dirname(__DIR__, 2) . '/bootstrap.php';
use IronKanban\Service\BoardService;
return new BoardService();
+27
View File
@@ -0,0 +1,27 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Return the full board state for a project.
*/
declare(strict_types=1);
$boardService = include __DIR__ . '/_bootstrap.php';
use IronKanban\Support\Http;
use IronKanban\Support\HttpHalt;
try {
$projectId = trim((string) ($_GET['project'] ?? ''));
if ($projectId === '') {
Http::error('project_required', 422);
}
Http::json($boardService->getBoardState($projectId));
} catch (HttpHalt $halt) {
return;
} catch (Throwable $exception) {
Http::error($exception->getMessage(), 500);
}
+35
View File
@@ -0,0 +1,35 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Create a new board column.
*/
declare(strict_types=1);
$boardService = include __DIR__ . '/_bootstrap.php';
use IronKanban\Support\Http;
use IronKanban\Support\HttpHalt;
try {
Http::requireMethod('POST');
Http::verifyCsrf();
$input = Http::input();
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
Http::json(
[
'success' => true,
'state' => $boardService->createColumn(
(string) $input['projectId'],
trim((string) ($input['label'] ?? 'Untitled Column'))
),
]
);
} catch (HttpHalt $halt) {
return;
} catch (Throwable $exception) {
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
}
+37
View File
@@ -0,0 +1,37 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Create a new task.
*/
declare(strict_types=1);
$boardService = include __DIR__ . '/_bootstrap.php';
use IronKanban\Support\Http;
use IronKanban\Support\HttpHalt;
try {
Http::requireMethod('POST');
Http::verifyCsrf();
$input = Http::input();
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
Http::json(
[
'success' => true,
'state' => $boardService->createTask(
(string) $input['projectId'],
trim((string) ($input['title'] ?? 'Untitled Task')),
(string) ($input['column'] ?? 'backlog'),
(string) ($input['body'] ?? '')
),
]
);
} catch (HttpHalt $halt) {
return;
} catch (Throwable $exception) {
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
}
+36
View File
@@ -0,0 +1,36 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Delete or trash a task.
*/
declare(strict_types=1);
$boardService = include __DIR__ . '/_bootstrap.php';
use IronKanban\Support\Http;
use IronKanban\Support\HttpHalt;
try {
Http::requireMethod('POST');
Http::verifyCsrf();
$input = Http::input();
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
Http::json(
[
'success' => true,
'state' => $boardService->deleteTask(
(string) $input['projectId'],
(string) $input['taskId'],
(bool) ($input['permanent'] ?? false)
),
]
);
} catch (HttpHalt $halt) {
return;
} catch (Throwable $exception) {
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
}
+37
View File
@@ -0,0 +1,37 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Move a task between columns.
*/
declare(strict_types=1);
$boardService = include __DIR__ . '/_bootstrap.php';
use IronKanban\Support\Http;
use IronKanban\Support\HttpHalt;
try {
Http::requireMethod('POST');
Http::verifyCsrf();
$input = Http::input();
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
Http::json(
[
'success' => true,
'state' => $boardService->moveTask(
(string) $input['projectId'],
(string) $input['taskId'],
(string) $input['column'],
max(0, (int) ($input['index'] ?? 0))
),
]
);
} catch (HttpHalt $halt) {
return;
} catch (Throwable $exception) {
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
}
+35
View File
@@ -0,0 +1,35 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Create or update a note.
*/
declare(strict_types=1);
$boardService = include __DIR__ . '/_bootstrap.php';
use IronKanban\Support\Http;
use IronKanban\Support\HttpHalt;
try {
Http::requireMethod('POST');
Http::verifyCsrf();
$input = Http::input();
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
Http::json(
[
'success' => true,
'state' => $boardService->saveNote(
(string) $input['projectId'],
$input
),
]
);
} catch (HttpHalt $halt) {
return;
} catch (Throwable $exception) {
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
}
+36
View File
@@ -0,0 +1,36 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Update an existing task.
*/
declare(strict_types=1);
$boardService = include __DIR__ . '/_bootstrap.php';
use IronKanban\Support\Http;
use IronKanban\Support\HttpHalt;
try {
Http::requireMethod('POST');
Http::verifyCsrf();
$input = Http::input();
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
Http::json(
[
'success' => true,
'state' => $boardService->saveTask(
(string) $input['projectId'],
(string) $input['taskId'],
$input
),
]
);
} catch (HttpHalt $halt) {
return;
} catch (Throwable $exception) {
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
}
+36
View File
@@ -0,0 +1,36 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Update board column definitions.
*/
declare(strict_types=1);
$boardService = include __DIR__ . '/_bootstrap.php';
use IronKanban\Support\Http;
use IronKanban\Support\HttpHalt;
try {
Http::requireMethod('POST');
Http::verifyCsrf();
$input = Http::input();
$boardService->assertRevision((string) $input['projectId'], isset($input['revision']) ? (string) $input['revision'] : null);
Http::json(
[
'success' => true,
'state' => $boardService->updateBoard(
(string) $input['projectId'],
is_array($input['columns'] ?? null) ? $input['columns'] : [],
isset($input['deletedColumnId']) ? (string) $input['deletedColumnId'] : null
),
]
);
} catch (HttpHalt $halt) {
return;
} catch (Throwable $exception) {
Http::error($exception->getMessage(), $exception->getMessage() === 'conflict' ? 409 : 500);
}
+551
View File
@@ -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;
}
}
+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('&', '&amp;')
.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);
}
+228
View File
@@ -0,0 +1,228 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Main application entrypoint.
*/
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
use IronKanban\Repository\ProjectRepository;
use IronKanban\Service\BoardService;
$projectRepository = new ProjectRepository();
$projects = $projectRepository->getAll();
$requestedProject = isset($_GET['project']) ? trim((string) $_GET['project']) : '';
$activeProjectId = $requestedProject !== '' ? $requestedProject : ($projects[0]->id ?? '');
$boardService = new BoardService();
$initialState = null;
if ($activeProjectId !== '') {
try {
$initialState = $boardService->getBoardState($activeProjectId);
} catch (Throwable $exception) {
$initialState = null;
}
}
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="<?php echo e(csrf_token()); ?>">
<title><?php echo e((string) config('app_name')); ?></title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div>
<p class="eyebrow">Git-backed markdown kanban</p>
<h1>IronKanban</h1>
<p class="sidebar-copy">Projects stay as flat files, while the UI gives you drag-and-drop columns, quick edits, notes, and polling-based refreshes.</p>
</div>
<section class="project-list">
<div class="section-heading">
<h2>Projects</h2>
<span><?php echo count($projects); ?></span>
</div>
<?php if ($projects === []) : ?>
<div class="empty-state compact">
<p>No projects found in <code><?php echo e(project_root()); ?></code>.</p>
</div>
<?php else : ?>
<?php foreach ($projects as $project) : ?>
<a class="project-link<?php echo $project->id === $activeProjectId ? ' active' : ''; ?>" href="/?project=<?php echo rawurlencode($project->id); ?>">
<strong><?php echo e($project->title); ?></strong>
<span><?php echo e($project->id); ?></span>
</a>
<?php endforeach; ?>
<?php endif; ?>
</section>
<section class="project-root">
<div class="section-heading">
<h2>Storage</h2>
</div>
<p><code><?php echo e(project_root()); ?></code></p>
</section>
</aside>
<main class="workspace" id="app" data-project-id="<?php echo e($activeProjectId); ?>">
<?php if ($initialState === null) : ?>
<section class="hero empty-state">
<h2>No project selected</h2>
<p>Create or copy a project folder into <code><?php echo e(project_root()); ?></code> using the markdown layout from the notes.</p>
</section>
<?php else : ?>
<header class="workspace-header">
<div>
<p class="eyebrow">Project Board</p>
<h2><?php echo e((string) $initialState['project']['title']); ?></h2>
<p class="project-description"><?php echo nl2br(e((string) $initialState['project']['body'])); ?></p>
</div>
<div class="header-actions">
<button type="button" class="button ghost" data-action="new-column">New Column</button>
<button type="button" class="button primary" data-action="new-task">New Task</button>
</div>
</header>
<section class="workspace-stack">
<section class="notes-panel is-collapsible is-collapsed" id="notes-panel" data-collapsible>
<div class="notes-panel-header">
<div>
<p class="eyebrow">Project Notes</p>
<h2>Notes</h2>
</div>
<div class="notes-panel-actions">
<button type="button" class="button ghost compact" data-action="new-note">Add Note</button>
<button
type="button"
class="button ghost compact collapse-toggle"
data-action="toggle-notes"
data-target="notes-list"
aria-expanded="false"
aria-controls="notes-list"
>
Show Notes
</button>
</div>
</div>
<div id="notes-list" class="notes-list" hidden></div>
</section>
<section class="board-panel">
<div class="board-scroll">
<div class="board-columns" id="board-columns"></div>
</div>
</section>
<section class="trash-panel" id="trash-panel">
<div class="trash-panel-header">
<div>
<p class="eyebrow">Discarded Tasks</p>
<h2>Trash</h2>
</div>
<button
type="button"
class="button ghost compact collapse-toggle"
data-action="toggle-trash"
aria-expanded="false"
aria-controls="trash-columns-wrap"
>
Show Trash
</button>
</div>
<div class="trash-dropzone" id="trash-dropzone" data-column-id="trash">
<p class="trash-dropzone-title">Drop tasks here to discard them</p>
<p class="trash-dropzone-meta" id="trash-dropzone-meta">Trash is empty.</p>
</div>
<div id="trash-columns-wrap" class="trash-columns-wrap" hidden>
<div class="board-scroll">
<div class="board-columns board-columns-trash" id="trash-columns"></div>
</div>
</div>
</section>
</section>
<?php endif; ?>
</main>
</div>
<dialog id="task-dialog" class="dialog">
<form method="dialog" class="dialog-card" id="task-form">
<div class="dialog-header">
<h3 id="task-dialog-title">Task</h3>
<button type="submit" class="icon-button" aria-label="Close">×</button>
</div>
<input type="hidden" name="id">
<label>
<span>Title</span>
<input type="text" name="title" maxlength="200" required>
</label>
<label>
<span>Column</span>
<select name="column"></select>
</label>
<label>
<span>Priority</span>
<select name="priority">
<option value="low">Low</option>
<option value="normal">Normal</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</label>
<label class="checkbox-row">
<input type="checkbox" name="completed">
<span>Completed</span>
</label>
<label class="checkbox-row">
<input type="checkbox" name="is_active" checked>
<span>Active</span>
</label>
<label>
<span>Markdown</span>
<textarea name="body" rows="12"></textarea>
</label>
<div class="dialog-actions">
<button type="button" class="button ghost" data-action="trash-task">Move To Trash</button>
<button type="submit" class="button primary">Save Task</button>
</div>
</form>
</dialog>
<dialog id="note-dialog" class="dialog">
<form method="dialog" class="dialog-card" id="note-form">
<div class="dialog-header">
<h3 id="note-dialog-title">Note</h3>
<button type="submit" class="icon-button" aria-label="Close">×</button>
</div>
<input type="hidden" name="id">
<label>
<span>Title</span>
<input type="text" name="title" maxlength="200" required>
</label>
<label>
<span>Markdown</span>
<textarea name="body" rows="14"></textarea>
</label>
<div class="dialog-actions">
<button type="submit" class="button primary">Save Note</button>
</div>
</form>
</dialog>
<script id="initial-state" type="application/json"><?php echo
$initialState !== null
? (string) json_encode(
$initialState,
JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
)
: 'null';
?></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.6/Sortable.min.js"></script>
<script src="/assets/app.js"></script>
</body>
</html>