Private
Public Access
1
0

Phase 4: Rich card features — labels, checklists, comments, attachments, categories

Add full CRUD + UI for all card sub-resources (tags/labels, assignees, checklists
with items, comments with markdown, file attachments). Restructure CardModal into
a two-column layout with lazy-loaded data. Upgrade card thumbnails with named label
pills, urgency-colored due date badges, checklist progress indicators, and SVG icons.
Add board categories with filtering on the listing page. Include markdown rendering
with DOMPurify sanitization and .prose CSS styles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-25 23:38:31 -05:00
parent 9b5708af64
commit 095d9756a8
33 changed files with 2226 additions and 143 deletions
+66 -10
View File
@@ -6,8 +6,16 @@
let showCreate = $state(false);
let newTitle = $state('');
let newVisibility = $state<'PRIVATE' | 'PUBLIC'>('PRIVATE');
let newCategoryId = $state('');
let creating = $state(false);
let error = $state('');
let filterCategoryId = $state('');
const filteredBoards = $derived(
filterCategoryId
? data.boards.filter((b: any) => b.categoryId === filterCategoryId)
: data.boards
);
async function createBoard(e: Event) {
e.preventDefault();
@@ -19,7 +27,11 @@
const res = await fetch('/api/boards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle, visibility: newVisibility })
body: JSON.stringify({
title: newTitle,
visibility: newVisibility,
...(newCategoryId ? { categoryId: newCategoryId } : {})
})
});
const result = await res.json();
if (!res.ok) {
@@ -27,6 +39,7 @@
return;
}
newTitle = '';
newCategoryId = '';
showCreate = false;
invalidateAll();
} catch {
@@ -59,8 +72,8 @@
{#if error}
<div class="mb-3 rounded bg-red-50 border border-red-200 p-2 text-sm text-red-700">{error}</div>
{/if}
<form onsubmit={createBoard} class="flex gap-3 items-end">
<div class="flex-1">
<form onsubmit={createBoard} class="flex gap-3 items-end flex-wrap">
<div class="flex-1 min-w-[200px]">
<label for="board-title" class="block text-sm font-medium text-gray-700 mb-1">Board title</label>
<input
id="board-title"
@@ -82,6 +95,21 @@
<option value="PUBLIC">Public</option>
</select>
</div>
{#if data.categories.length > 0}
<div>
<label for="board-cat" class="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select
id="board-cat"
bind:value={newCategoryId}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="">None</option>
{#each data.categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
{/if}
<button
type="submit"
disabled={creating}
@@ -100,7 +128,28 @@
</div>
{/if}
{#if data.boards.length === 0}
<!-- Category filter -->
{#if data.categories.length > 0}
<div class="flex items-center gap-2 mb-4">
<span class="text-sm text-gray-500">Filter:</span>
<button
class="rounded-full px-3 py-1 text-xs {filterCategoryId === '' ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'} transition"
onclick={() => (filterCategoryId = '')}
>
All
</button>
{#each data.categories as cat}
<button
class="rounded-full px-3 py-1 text-xs {filterCategoryId === cat.id ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'} transition"
onclick={() => (filterCategoryId = cat.id)}
>
{cat.name}
</button>
{/each}
</div>
{/if}
{#if filteredBoards.length === 0}
<div class="text-center py-16">
<svg class="mx-auto w-16 h-16 text-gray-300 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="7" height="7" rx="1" />
@@ -108,21 +157,28 @@
<rect x="14" y="10" width="7" height="7" rx="1" />
<rect x="3" y="13" width="7" height="8" rx="1" />
</svg>
<p class="text-gray-500">No boards yet.</p>
{#if data.user}
<p class="text-gray-500">{filterCategoryId ? 'No boards in this category.' : 'No boards yet.'}</p>
{#if data.user && !filterCategoryId}
<p class="text-gray-400 text-sm mt-1">Create your first board to get started.</p>
{/if}
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each data.boards as board}
{#each filteredBoards as board}
<a
href="/boards/{board.id}"
class="group block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md hover:border-gray-300 transition"
>
<h2 class="font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition">
{board.title}
</h2>
<div class="flex items-start justify-between gap-2">
<h2 class="font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition">
{board.title}
</h2>
{#if board.category}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500 flex-shrink-0">
{board.category.name}
</span>
{/if}
</div>
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
<span>{board._count.columns} columns</span>
<span>{board.visibility === 'PUBLIC' ? 'Public' : 'Private'}</span>