From 6d923b98b9a959cc5b6db69b8694d306c2d8a2b2 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Sun, 5 Apr 2026 16:58:51 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8feature:=20Add=20projects=20homepage?= =?UTF-8?q?=20and=20support=20for=20adding=20new=20projects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .phpunit.cache/test-results | 2 +- public/api/create-project.php | 34 ++++++ public/assets/app.css | 169 ++++++++++++++++++++++++++- public/assets/app.js | 83 +++++++++---- public/index.php | 104 +++++++++++++++-- src/Repository/ProjectRepository.php | 58 +++++++++ src/Service/BoardService.php | 18 +++ tests/Api/BoardApiTest.php | 31 ++++- tests/Service/BoardServiceTest.php | 22 +++- tests/e2e/app.spec.js | 9 ++ 10 files changed, 492 insertions(+), 38 deletions(-) create mode 100644 public/api/create-project.php diff --git a/.phpunit.cache/test-results b/.phpunit.cache/test-results index 1329119..719ba7f 100644 --- a/.phpunit.cache/test-results +++ b/.phpunit.cache/test-results @@ -1 +1 @@ -{"version":2,"defects":{"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":7,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":7,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":7},"times":{"Tests\\Domain\\BoardTest::testToArrayIncludesSerializedColumnsAndMeta":0,"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":0,"Tests\\Support\\FrontMatterTest::testParseWithoutFrontMatterReturnsBodyOnly":0,"Tests\\Service\\BoardServiceTest::testGetBoardStateBuildsDefaultColumnsForNewProject":0.015,"Tests\\Service\\BoardServiceTest::testCreateTaskPersistsTaskInRequestedColumn":0.075,"Tests\\Service\\BoardServiceTest::testDeleteTaskMovesTaskToTrashByDefault":0.132,"Tests\\Service\\BoardServiceTest::testUpdateBoardMovesDeletedColumnTasksIntoTrash":0.153,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":0.036,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":0.004,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":0.07,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":0.073,"Tests\\Api\\BoardApiTest::testMoveTaskEndpointMovesTaskToRequestedColumn":0.121,"Tests\\Api\\BoardApiTest::testDeleteTaskEndpointMovesTaskToTrash":0.107,"Tests\\Api\\BoardApiTest::testSaveNoteEndpointCreatesNote":0.061,"Tests\\Api\\BoardApiTest::testCreateColumnEndpointCreatesNewColumn":0.072,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":0.139,"Tests\\Api\\BoardApiTest::testUpdateBoardEndpointReplacesColumnDefinitions":0.074}} \ No newline at end of file +{"version":2,"defects":{"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":7,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":7,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":7,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":7},"times":{"Tests\\Domain\\BoardTest::testToArrayIncludesSerializedColumnsAndMeta":0,"Tests\\Support\\FrontMatterTest::testDumpAndParseRoundTrip":0,"Tests\\Support\\FrontMatterTest::testParseWithoutFrontMatterReturnsBodyOnly":0,"Tests\\Service\\BoardServiceTest::testGetBoardStateBuildsDefaultColumnsForNewProject":0.009,"Tests\\Service\\BoardServiceTest::testCreateTaskPersistsTaskInRequestedColumn":0.076,"Tests\\Service\\BoardServiceTest::testDeleteTaskMovesTaskToTrashByDefault":0.103,"Tests\\Service\\BoardServiceTest::testUpdateBoardMovesDeletedColumnTasksIntoTrash":0.107,"Tests\\Api\\BoardApiTest::testBoardStateEndpointReturnsProjectState":0.03,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointRejectsMissingCsrfToken":0.004,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointCreatesTaskWithValidCsrfToken":0.068,"Tests\\Api\\BoardApiTest::testCreateTaskEndpointReturnsConflictForStaleRevision":0.059,"Tests\\Api\\BoardApiTest::testMoveTaskEndpointMovesTaskToRequestedColumn":0.106,"Tests\\Api\\BoardApiTest::testDeleteTaskEndpointMovesTaskToTrash":0.106,"Tests\\Api\\BoardApiTest::testSaveNoteEndpointCreatesNote":0.06,"Tests\\Api\\BoardApiTest::testCreateColumnEndpointCreatesNewColumn":0.059,"Tests\\Api\\BoardApiTest::testSaveTaskEndpointUpdatesTaskFields":0.109,"Tests\\Api\\BoardApiTest::testUpdateBoardEndpointReplacesColumnDefinitions":0.073,"Tests\\Api\\BoardApiTest::testCreateProjectEndpointCreatesProject":0.066,"Tests\\Service\\BoardServiceTest::testCreateProjectBuildsInitialBoardState":0.065}} \ No newline at end of file diff --git a/public/api/create-project.php b/public/api/create-project.php new file mode 100644 index 0000000..042fcb0 --- /dev/null +++ b/public/api/create-project.php @@ -0,0 +1,34 @@ + true, + 'state' => $boardService->createProject( + trim((string) ($input['title'] ?? 'Untitled Project')), + trim((string) ($input['body'] ?? '')), + isset($input['slug']) ? trim((string) $input['slug']) : null + ), + ] + ); +} catch (HttpHalt $halt) { + return; +} catch (Throwable $exception) { + Http::error($exception->getMessage(), 500); +} diff --git a/public/assets/app.css b/public/assets/app.css index e475aa0..c8dc4a1 100644 --- a/public/assets/app.css +++ b/public/assets/app.css @@ -34,6 +34,9 @@ select { font: inherit; } +a { + color: inherit; +} code { font-family: "Cascadia Code", Consolas, monospace; } @@ -59,12 +62,17 @@ code { min-width: 0; } +.sidebar-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + .workspace-stack { display: grid; gap: 20px; min-width: 0; } - .eyebrow { margin: 0 0 8px; color: var(--accent); @@ -214,10 +222,13 @@ p { } .button { + display: inline-flex; + align-items: center; + justify-content: center; padding: 11px 18px; border-radius: 999px; + text-decoration: none; } - .button.primary { border-color: transparent; background: linear-gradient(135deg, var(--accent) 0%, #70e0c6 100%); @@ -235,6 +246,135 @@ p { padding-bottom: 8px; } +.dashboard-intro, +.project-create-panel, +.projects-panel { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); +} + +.dashboard-intro, +.project-create-panel, +.projects-panel { + padding: 22px; +} + +.dashboard-intro { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + margin-bottom: 20px; +} + +.dashboard-intro-meta { + display: grid; + gap: 8px; + color: var(--muted); + text-align: right; +} + +.dashboard-grid { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +.project-create-form { + display: grid; + gap: 16px; +} + +.project-create-form label { + display: grid; + gap: 8px; +} + +.project-create-form input, +.project-create-form textarea { + width: 100%; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 14px; + background: rgba(255, 255, 255, 0.03); + color: var(--text); +} + +.project-create-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.form-hint { + margin-bottom: 0; + color: var(--muted); + font-size: 0.92rem; +} + +.project-card-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; +} + +.project-card { + display: flex; + flex-direction: column; + gap: 14px; + min-height: 220px; + padding: 18px; + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 20px; + background: + linear-gradient(180deg, rgba(27, 39, 54, 0.94), rgba(14, 21, 31, 0.96)), + radial-gradient(circle at top right, rgba(125, 211, 167, 0.14), transparent 38%); + box-shadow: var(--shadow); + text-decoration: none; + transition: + transform 160ms ease, + border-color 160ms ease, + background 160ms ease; +} + +.project-card:hover { + transform: translateY(-2px); + border-color: rgba(125, 211, 167, 0.38); + background: + linear-gradient(180deg, rgba(31, 45, 62, 0.98), rgba(16, 23, 35, 0.98)), + radial-gradient(circle at top right, rgba(125, 211, 167, 0.18), transparent 40%); +} + +.project-card-body { + flex: 1; + color: var(--muted); +} + +.project-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: auto; +} + +.project-card-slug { + color: var(--muted); + font-size: 0.9rem; +} + +.project-card-cta { + color: var(--accent); + font-weight: 700; +} + +.error-state { + margin-bottom: 20px; +} .trash-columns-wrap { margin-top: 18px; } @@ -501,6 +641,19 @@ p { .notes-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .dashboard-intro { + flex-direction: column; + align-items: flex-start; + } + + .dashboard-intro-meta { + text-align: left; + } } @media (max-width: 720px) { @@ -529,6 +682,12 @@ p { width: 100%; } + .sidebar-actions, + .project-create-actions { + flex-direction: column; + align-items: stretch; + } + .header-actions .button { flex: 1; } @@ -537,10 +696,14 @@ p { flex: 1; } - .trash-panel .column { + .sidebar-actions .button, + .project-create-actions .button { width: 100%; } + .trash-panel .column { + width: 100%; + } .column { width: min(320px, calc(100vw - 52px)); } diff --git a/public/assets/app.js b/public/assets/app.js index 0c0e9e9..613067b 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -1,9 +1,67 @@ const initialStateNode = document.getElementById('initial-state'); const appRoot = document.getElementById('app'); +const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; +const projectCreateForm = document.getElementById('project-create-form'); + +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 handleError = (error) => { + window.alert(error.message || 'Something went wrong.'); +}; + +if (projectCreateForm) { + projectCreateForm.addEventListener('submit', async (event) => { + event.preventDefault(); + + const formData = new FormData(projectCreateForm); + const submitButton = projectCreateForm.querySelector('button[type="submit"]'); + + if (submitButton) { + submitButton.disabled = true; + } + + try { + const payload = await fetchJson('/api/create-project.php', { + method: 'POST', + body: JSON.stringify({ + title: formData.get('title'), + slug: formData.get('slug'), + body: formData.get('body'), + }), + }); + + window.location.assign( + payload.state.projectUrl || `/?project=${encodeURIComponent(payload.state.project.id)}` + ); + } catch (error) { + handleError(error); + + if (submitButton) { + submitButton.disabled = false; + } + } + }); +} 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'); @@ -39,25 +97,6 @@ if (initialStateNode && appRoot && initialStateNode.textContent !== 'null') { const markdownPreview = (value = '') => escapeHtml(value).replaceAll('\n', '
'); - 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; @@ -394,10 +433,6 @@ if (initialStateNode && appRoot && initialStateNode.textContent !== '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()); diff --git a/public/index.php b/public/index.php index 9a20857..447a45b 100644 --- a/public/index.php +++ b/public/index.php @@ -15,15 +15,19 @@ use IronKanban\Service\BoardService; $projectRepository = new ProjectRepository(); $projects = $projectRepository->getAll(); $requestedProject = isset($_GET['project']) ? trim((string) $_GET['project']) : ''; -$activeProjectId = $requestedProject !== '' ? $requestedProject : ($projects[0]->id ?? ''); +$activeProjectId = $requestedProject; $boardService = new BoardService(); $initialState = null; +$loadError = null; +$showDashboard = $requestedProject === ''; if ($activeProjectId !== '') { try { $initialState = $boardService->getBoardState($activeProjectId); + $showDashboard = false; } catch (Throwable $exception) { - $initialState = null; + $showDashboard = true; + $loadError = 'The requested project could not be loaded. Pick another project or create a new one.'; } } ?> @@ -44,6 +48,16 @@ if ($activeProjectId !== '') {

IronKanban

+ + +

Projects

@@ -55,13 +69,14 @@ if ($activeProjectId !== '') {
- + title); ?> id); ?>
+

Storage

@@ -70,11 +85,83 @@ if ($activeProjectId !== '') {
-
- -
-

No project selected

-

Create or copy a project folder into using the markdown layout from the notes.

+
+ +
+
+

Project Dashboard

+

Choose a project or start a fresh one

+

Each project stays as markdown on disk, with its own notes, tasks, board definition, and revision tracking.

+
+
+ project + Stored in +
+
+ + +
+

Project unavailable

+

+
+ + +
+
+
+

Create Project

+
+
+ + + +
+

Leave the slug blank to generate it automatically.

+ +
+
+
+ +
+
+

Projects

+ +
+ +
+

No projects yet

+

Create your first project to generate a board with default kanban columns.

+
+ +
+ + body) ?? ''); + $preview = $preview !== '' ? $preview : 'No project description yet.'; + $preview = strlen($preview) > 180 ? substr($preview, 0, 177) . '...' : $preview; + ?> + +

Project

+

title); ?>

+

+ +
+ +
+ +
@@ -84,6 +171,7 @@ if ($activeProjectId !== '') {

+ All Projects
diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 4e96634..8487120 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace IronKanban\Repository; use IronKanban\Domain\Project; +use IronKanban\Support\AtomicWriter; use IronKanban\Support\FrontMatter; use IronKanban\Support\Path; @@ -18,6 +19,43 @@ use IronKanban\Support\Path; */ final class ProjectRepository { + /** + * Create a new project in markdown storage. + * + * @param string $title Project title. + * @param string $body Project description body. + * @param string|null $slug Optional requested project slug. + * + * @return Project + */ + public function create(string $title, string $body = '', ?string $slug = null): Project + { + ensure_directory(project_root()); + + $normalizedTitle = trim($title) !== '' ? trim($title) : 'Untitled Project'; + $baseSlug = slugify($slug !== null && trim($slug) !== '' ? $slug : $normalizedTitle); + $projectId = $this->_nextAvailableSlug($baseSlug); + $projectPath = Path::project($projectId); + + ensure_directory($projectPath . DIRECTORY_SEPARATOR . 'notes'); + ensure_directory($projectPath . DIRECTORY_SEPARATOR . 'tasks'); + + AtomicWriter::write( + $projectPath . DIRECTORY_SEPARATOR . 'index.md', + FrontMatter::dump( + [ + 'type' => 'project', + 'id' => $projectId, + 'title' => $normalizedTitle, + 'created' => now_iso8601(), + ], + trim($body) + ) + ); + + return $this->get($projectId); + } + /** * Load all available projects. * @@ -72,4 +110,24 @@ final class ProjectRepository $document->meta ); } + + /** + * Find the next available project slug. + * + * @param string $baseSlug Base slug candidate. + * + * @return string + */ + private function _nextAvailableSlug(string $baseSlug): string + { + $slug = $baseSlug; + $suffix = 2; + + while (is_dir(project_root() . DIRECTORY_SEPARATOR . $slug)) { + $slug = $baseSlug . '-' . $suffix; + $suffix++; + } + + return $slug; + } } diff --git a/src/Service/BoardService.php b/src/Service/BoardService.php index f2d3ae2..d6cc71c 100644 --- a/src/Service/BoardService.php +++ b/src/Service/BoardService.php @@ -70,6 +70,24 @@ final class BoardService ]; } + /** + * Create a new project and return its initial board state. + * + * @param string $title Project title. + * @param string $body Project description body. + * @param string|null $slug Optional requested slug. + * + * @return array + */ + public function createProject(string $title, string $body = '', ?string $slug = null): array + { + $project = $this->projectRepository->create($title, $body, $slug); + $state = $this->touchProject($project->id, sprintf('feat(project): create %s', $project->id)); + $state['projectUrl'] = '/?project=' . rawurlencode($project->id); + + return $state; + } + /** * Move a task to a new column and index. * diff --git a/tests/Api/BoardApiTest.php b/tests/Api/BoardApiTest.php index 2506102..a9a4010 100644 --- a/tests/Api/BoardApiTest.php +++ b/tests/Api/BoardApiTest.php @@ -25,7 +25,6 @@ final class BoardApiTest extends IntegrationTestCase public function testBoardStateEndpointReturnsProjectState(): void { $projectId = $this->createProject(); - $response = $this->callApi( 'public/api/board-state.php', 'GET', @@ -37,6 +36,36 @@ final class BoardApiTest extends IntegrationTestCase self::assertContains('trash', array_column($response['payload']['columns'], 'id')); } + /** + * Ensure create-project creates markdown storage and returns the new project state. + * + * @return void + */ + public function testCreateProjectEndpointCreatesProject(): void + { + $token = csrf_token(); + + $response = $this->callApi( + 'public/api/create-project.php', + 'POST', + [], + [ + 'title' => 'Release Planning', + 'slug' => 'release-planning', + 'body' => 'Track launch work.', + '_token' => $token, + ], + ['HTTP_X_CSRF_TOKEN' => $token] + ); + + self::assertSame(200, $response['status']); + self::assertTrue($response['payload']['success']); + self::assertSame('release-planning', $response['payload']['state']['project']['id']); + self::assertSame('/?project=release-planning', $response['payload']['state']['projectUrl']); + self::assertContains('trash', array_column($response['payload']['state']['columns'], 'id')); + self::assertFileExists($this->projectRoot . DIRECTORY_SEPARATOR . 'release-planning' . DIRECTORY_SEPARATOR . 'index.md'); + } + /** * Ensure state-changing endpoints reject missing CSRF tokens. * diff --git a/tests/Service/BoardServiceTest.php b/tests/Service/BoardServiceTest.php index ce5327d..acf564d 100644 --- a/tests/Service/BoardServiceTest.php +++ b/tests/Service/BoardServiceTest.php @@ -26,7 +26,6 @@ final class BoardServiceTest extends IntegrationTestCase { $projectId = $this->createProject(); $service = new BoardService(); - $state = $service->getBoardState($projectId); self::assertSame( @@ -38,6 +37,27 @@ final class BoardServiceTest extends IntegrationTestCase self::assertSame('Demo Project', $state['project']['title']); } + /** + * Ensure creating a project initializes markdown storage and default board state. + * + * @return void + */ + public function testCreateProjectBuildsInitialBoardState(): void + { + $service = new BoardService(); + + $state = $service->createProject('Release Planning', 'Track launch work.', 'release-planning'); + + self::assertSame('release-planning', $state['project']['id']); + self::assertSame('Release Planning', $state['project']['title']); + self::assertSame('Track launch work.', trim((string) $state['project']['body'])); + self::assertSame( + ['backlog', 'ready', 'in-progress', 'review', 'done', 'trash'], + array_column($state['columns'], 'id') + ); + self::assertFileExists($this->projectRoot . DIRECTORY_SEPARATOR . 'release-planning' . DIRECTORY_SEPARATOR . 'index.md'); + } + /** * Ensure a created task is persisted and returned in board state. * diff --git a/tests/e2e/app.spec.js b/tests/e2e/app.spec.js index ab5db57..d78eef9 100644 --- a/tests/e2e/app.spec.js +++ b/tests/e2e/app.spec.js @@ -1,6 +1,15 @@ const { test, expect } = require('@playwright/test'); test.describe('IronKanban smoke', () => { + test('loads the project dashboard with cards and create form', async ({ page }) => { + await page.goto('/'); + + await expect(page.getByRole('heading', { name: 'Choose a project or start a fresh one' })).toBeVisible(); + await expect(page.locator('#project-create-form')).toBeVisible(); + await expect(page.locator('.project-card')).toHaveCount(1); + await expect(page.locator('.project-card h3')).toHaveText(['Demo Project']); + }); + test('loads the demo board and core UI sections', async ({ page }) => { await page.goto('/?project=demo-project');