feature: Add projects homepage and support for adding new projects

This commit is contained in:
Keith Solomon
2026-04-05 16:58:51 -05:00
parent 239a7eff64
commit 6d923b98b9
10 changed files with 492 additions and 38 deletions
+34
View File
@@ -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
View File
@@ -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));
}
+59 -24
View File
@@ -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', '<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;
@@ -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());
+96 -8
View File
@@ -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 !== '') {
<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>
<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">
<div class="section-heading">
<h2>Projects</h2>
@@ -55,13 +69,14 @@ if ($activeProjectId !== '') {
</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); ?>">
<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>
<span><?php echo e($project->id); ?></span>
</a>
<?php endforeach; ?>
<?php endif; ?>
</section>
<section class="project-root">
<div class="section-heading">
<h2>Storage</h2>
@@ -70,11 +85,83 @@ if ($activeProjectId !== '') {
</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>
<main class="workspace" id="app" data-project-id="<?php echo e($showDashboard ? '' : $activeProjectId); ?>" data-view="<?php echo e($showDashboard ? 'dashboard' : 'board'); ?>">
<?php if ($showDashboard) : ?>
<section class="dashboard-intro hero">
<div>
<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>
<?php else : ?>
<header class="workspace-header">
@@ -84,6 +171,7 @@ if ($activeProjectId !== '') {
<p class="project-description"><?php echo nl2br(e((string) $initialState['project']['body'])); ?></p>
</div>
<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 primary" data-action="new-task">New Task</button>
</div>