✨feature: Add projects homepage and support for adding new projects
This commit is contained in:
@@ -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}}
|
{"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}}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new project.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
Http::json(
|
||||||
|
[
|
||||||
|
'success' => 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);
|
||||||
|
}
|
||||||
+166
-3
@@ -34,6 +34,9 @@ select {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
code {
|
code {
|
||||||
font-family: "Cascadia Code", Consolas, monospace;
|
font-family: "Cascadia Code", Consolas, monospace;
|
||||||
}
|
}
|
||||||
@@ -59,12 +62,17 @@ code {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-stack {
|
.workspace-stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
@@ -214,10 +222,13 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
padding: 11px 18px;
|
padding: 11px 18px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.button.primary {
|
.button.primary {
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
background: linear-gradient(135deg, var(--accent) 0%, #70e0c6 100%);
|
background: linear-gradient(135deg, var(--accent) 0%, #70e0c6 100%);
|
||||||
@@ -235,6 +246,135 @@ p {
|
|||||||
padding-bottom: 8px;
|
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 {
|
.trash-columns-wrap {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
@@ -501,6 +641,19 @@ p {
|
|||||||
.notes-list {
|
.notes-list {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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) {
|
@media (max-width: 720px) {
|
||||||
@@ -529,6 +682,12 @@ p {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-actions,
|
||||||
|
.project-create-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
.header-actions .button {
|
.header-actions .button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
@@ -537,10 +696,14 @@ p {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trash-panel .column {
|
.sidebar-actions .button,
|
||||||
|
.project-create-actions .button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.trash-panel .column {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.column {
|
.column {
|
||||||
width: min(320px, calc(100vw - 52px));
|
width: min(320px, calc(100vw - 52px));
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-24
@@ -1,9 +1,67 @@
|
|||||||
const initialStateNode = document.getElementById('initial-state');
|
const initialStateNode = document.getElementById('initial-state');
|
||||||
const appRoot = document.getElementById('app');
|
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') {
|
if (initialStateNode && appRoot && initialStateNode.textContent !== 'null') {
|
||||||
const state = JSON.parse(initialStateNode.textContent);
|
const state = JSON.parse(initialStateNode.textContent);
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
||||||
const boardColumnsEl = document.getElementById('board-columns');
|
const boardColumnsEl = document.getElementById('board-columns');
|
||||||
const trashColumnsEl = document.getElementById('trash-columns');
|
const trashColumnsEl = document.getElementById('trash-columns');
|
||||||
const trashDropzoneEl = document.getElementById('trash-dropzone');
|
const trashDropzoneEl = document.getElementById('trash-dropzone');
|
||||||
@@ -39,25 +97,6 @@ if (initialStateNode && appRoot && initialStateNode.textContent !== 'null') {
|
|||||||
|
|
||||||
const markdownPreview = (value = '') => escapeHtml(value).replaceAll('\n', '<br>');
|
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) => {
|
const updateState = (nextState) => {
|
||||||
state.project = nextState.project;
|
state.project = nextState.project;
|
||||||
state.board = nextState.board;
|
state.board = nextState.board;
|
||||||
@@ -394,10 +433,6 @@ if (initialStateNode && appRoot && initialStateNode.textContent !== 'null') {
|
|||||||
noteDialog.showModal();
|
noteDialog.showModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleError = (error) => {
|
|
||||||
window.alert(error.message || 'Something went wrong.');
|
|
||||||
};
|
|
||||||
|
|
||||||
const reloadBoard = async () => {
|
const reloadBoard = async () => {
|
||||||
const response = await fetch(`/api/board-state.php?project=${encodeURIComponent(projectId)}`);
|
const response = await fetch(`/api/board-state.php?project=${encodeURIComponent(projectId)}`);
|
||||||
updateState(await response.json());
|
updateState(await response.json());
|
||||||
|
|||||||
+96
-8
@@ -15,15 +15,19 @@ use IronKanban\Service\BoardService;
|
|||||||
$projectRepository = new ProjectRepository();
|
$projectRepository = new ProjectRepository();
|
||||||
$projects = $projectRepository->getAll();
|
$projects = $projectRepository->getAll();
|
||||||
$requestedProject = isset($_GET['project']) ? trim((string) $_GET['project']) : '';
|
$requestedProject = isset($_GET['project']) ? trim((string) $_GET['project']) : '';
|
||||||
$activeProjectId = $requestedProject !== '' ? $requestedProject : ($projects[0]->id ?? '');
|
$activeProjectId = $requestedProject;
|
||||||
$boardService = new BoardService();
|
$boardService = new BoardService();
|
||||||
$initialState = null;
|
$initialState = null;
|
||||||
|
$loadError = null;
|
||||||
|
$showDashboard = $requestedProject === '';
|
||||||
|
|
||||||
if ($activeProjectId !== '') {
|
if ($activeProjectId !== '') {
|
||||||
try {
|
try {
|
||||||
$initialState = $boardService->getBoardState($activeProjectId);
|
$initialState = $boardService->getBoardState($activeProjectId);
|
||||||
|
$showDashboard = false;
|
||||||
} catch (Throwable $exception) {
|
} 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 !== '') {
|
|||||||
<h1>IronKanban</h1>
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-actions">
|
||||||
|
<a class="button ghost" href="/">All Projects</a>
|
||||||
|
<?php if ($showDashboard) : ?>
|
||||||
|
<a class="button primary" href="#project-create-form">New Project</a>
|
||||||
|
<?php elseif ($initialState !== null) : ?>
|
||||||
|
<a class="button primary" href="/?project=<?php echo rawurlencode((string) $initialState['project']['id']); ?>">Open Current Board</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section class="project-list">
|
<section class="project-list">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2>Projects</h2>
|
<h2>Projects</h2>
|
||||||
@@ -55,13 +69,14 @@ if ($activeProjectId !== '') {
|
|||||||
</div>
|
</div>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<?php foreach ($projects as $project) : ?>
|
<?php foreach ($projects as $project) : ?>
|
||||||
<a class="project-link<?php echo $project->id === $activeProjectId ? ' active' : ''; ?>" href="/?project=<?php echo rawurlencode($project->id); ?>">
|
<a class="project-link<?php echo $project->id === $activeProjectId && !$showDashboard ? ' active' : ''; ?>" href="/?project=<?php echo rawurlencode($project->id); ?>">
|
||||||
<strong><?php echo e($project->title); ?></strong>
|
<strong><?php echo e($project->title); ?></strong>
|
||||||
<span><?php echo e($project->id); ?></span>
|
<span><?php echo e($project->id); ?></span>
|
||||||
</a>
|
</a>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="project-root">
|
<section class="project-root">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<h2>Storage</h2>
|
<h2>Storage</h2>
|
||||||
@@ -70,11 +85,83 @@ if ($activeProjectId !== '') {
|
|||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="workspace" id="app" data-project-id="<?php echo e($activeProjectId); ?>">
|
<main class="workspace" id="app" data-project-id="<?php echo e($showDashboard ? '' : $activeProjectId); ?>" data-view="<?php echo e($showDashboard ? 'dashboard' : 'board'); ?>">
|
||||||
<?php if ($initialState === null) : ?>
|
<?php if ($showDashboard) : ?>
|
||||||
<section class="hero empty-state">
|
<section class="dashboard-intro hero">
|
||||||
<h2>No project selected</h2>
|
<div>
|
||||||
<p>Create or copy a project folder into <code><?php echo e(project_root()); ?></code> using the markdown layout from the notes.</p>
|
<p class="eyebrow">Project Dashboard</p>
|
||||||
|
<h2>Choose a project or start a fresh one</h2>
|
||||||
|
<p class="project-description">Each project stays as markdown on disk, with its own notes, tasks, board definition, and revision tracking.</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-intro-meta">
|
||||||
|
<span><?php echo count($projects); ?> project<?php echo count($projects) === 1 ? '' : 's'; ?></span>
|
||||||
|
<span>Stored in <code><?php echo e(project_root()); ?></code></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<?php if ($loadError !== null) : ?>
|
||||||
|
<section class="hero error-state">
|
||||||
|
<h2>Project unavailable</h2>
|
||||||
|
<p><?php echo e($loadError); ?></p>
|
||||||
|
</section>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<section class="dashboard-grid">
|
||||||
|
<section class="project-create-panel">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Create Project</h2>
|
||||||
|
</div>
|
||||||
|
<form class="project-create-form" id="project-create-form" data-project-form>
|
||||||
|
<label>
|
||||||
|
<span>Title</span>
|
||||||
|
<input type="text" name="title" maxlength="200" required placeholder="Release Planning">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Slug</span>
|
||||||
|
<input type="text" name="slug" maxlength="200" placeholder="release-planning">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description</span>
|
||||||
|
<textarea name="body" rows="7" placeholder="Describe the project, goals, and context."></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="project-create-actions">
|
||||||
|
<p class="form-hint">Leave the slug blank to generate it automatically.</p>
|
||||||
|
<button type="submit" class="button primary">Create Project</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="projects-panel">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Projects</h2>
|
||||||
|
<span><?php echo count($projects); ?></span>
|
||||||
|
</div>
|
||||||
|
<?php if ($projects === []) : ?>
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No projects yet</h3>
|
||||||
|
<p>Create your first project to generate a board with default kanban columns.</p>
|
||||||
|
</div>
|
||||||
|
<?php else : ?>
|
||||||
|
<div class="project-card-grid">
|
||||||
|
<?php foreach ($projects as $project) : ?>
|
||||||
|
<?php
|
||||||
|
$preview = trim(preg_replace('/\s+/', ' ', $project->body) ?? '');
|
||||||
|
$preview = $preview !== '' ? $preview : 'No project description yet.';
|
||||||
|
$preview = strlen($preview) > 180 ? substr($preview, 0, 177) . '...' : $preview;
|
||||||
|
?>
|
||||||
|
<a class="project-card" href="/?project=<?php echo rawurlencode($project->id); ?>">
|
||||||
|
<p class="eyebrow">Project</p>
|
||||||
|
<h3><?php echo e($project->title); ?></h3>
|
||||||
|
<p class="project-card-body"><?php echo e($preview); ?></p>
|
||||||
|
<div class="project-card-footer">
|
||||||
|
<span class="project-card-slug"><?php echo e($project->id); ?></span>
|
||||||
|
<span class="project-card-cta">Open Board</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
<?php else : ?>
|
<?php else : ?>
|
||||||
<header class="workspace-header">
|
<header class="workspace-header">
|
||||||
@@ -84,6 +171,7 @@ if ($activeProjectId !== '') {
|
|||||||
<p class="project-description"><?php echo nl2br(e((string) $initialState['project']['body'])); ?></p>
|
<p class="project-description"><?php echo nl2br(e((string) $initialState['project']['body'])); ?></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<a class="button ghost" href="/">All Projects</a>
|
||||||
<button type="button" class="button ghost" data-action="new-column">New Column</button>
|
<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>
|
<button type="button" class="button primary" data-action="new-task">New Task</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ declare(strict_types=1);
|
|||||||
namespace IronKanban\Repository;
|
namespace IronKanban\Repository;
|
||||||
|
|
||||||
use IronKanban\Domain\Project;
|
use IronKanban\Domain\Project;
|
||||||
|
use IronKanban\Support\AtomicWriter;
|
||||||
use IronKanban\Support\FrontMatter;
|
use IronKanban\Support\FrontMatter;
|
||||||
use IronKanban\Support\Path;
|
use IronKanban\Support\Path;
|
||||||
|
|
||||||
@@ -18,6 +19,43 @@ use IronKanban\Support\Path;
|
|||||||
*/
|
*/
|
||||||
final class ProjectRepository
|
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.
|
* Load all available projects.
|
||||||
*
|
*
|
||||||
@@ -72,4 +110,24 @@ final class ProjectRepository
|
|||||||
$document->meta
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, mixed>
|
||||||
|
*/
|
||||||
|
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.
|
* Move a task to a new column and index.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ final class BoardApiTest extends IntegrationTestCase
|
|||||||
public function testBoardStateEndpointReturnsProjectState(): void
|
public function testBoardStateEndpointReturnsProjectState(): void
|
||||||
{
|
{
|
||||||
$projectId = $this->createProject();
|
$projectId = $this->createProject();
|
||||||
|
|
||||||
$response = $this->callApi(
|
$response = $this->callApi(
|
||||||
'public/api/board-state.php',
|
'public/api/board-state.php',
|
||||||
'GET',
|
'GET',
|
||||||
@@ -37,6 +36,36 @@ final class BoardApiTest extends IntegrationTestCase
|
|||||||
self::assertContains('trash', array_column($response['payload']['columns'], 'id'));
|
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.
|
* Ensure state-changing endpoints reject missing CSRF tokens.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ final class BoardServiceTest extends IntegrationTestCase
|
|||||||
{
|
{
|
||||||
$projectId = $this->createProject();
|
$projectId = $this->createProject();
|
||||||
$service = new BoardService();
|
$service = new BoardService();
|
||||||
|
|
||||||
$state = $service->getBoardState($projectId);
|
$state = $service->getBoardState($projectId);
|
||||||
|
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
@@ -38,6 +37,27 @@ final class BoardServiceTest extends IntegrationTestCase
|
|||||||
self::assertSame('Demo Project', $state['project']['title']);
|
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.
|
* Ensure a created task is persisted and returned in board state.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
const { test, expect } = require('@playwright/test');
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
test.describe('IronKanban smoke', () => {
|
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 }) => {
|
test('loads the demo board and core UI sections', async ({ page }) => {
|
||||||
await page.goto('/?project=demo-project');
|
await page.goto('/?project=demo-project');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user