✨feature: Initial MVP
This commit is contained in:
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user