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
+228
View File
@@ -0,0 +1,228 @@
<script lang="ts">
type ChecklistItem = { id: string; title: string; completed: boolean; position: string };
type Checklist = { id: string; title: string; position: string; items: ChecklistItem[] };
type Props = {
cardId: string;
boardId: string;
columnId: string | undefined;
checklists: Checklist[];
canEdit: boolean;
onupdate: (checklists: Checklist[]) => void;
};
let { cardId, boardId, columnId, checklists, canEdit, onupdate }: Props = $props();
let newChecklistTitle = $state('');
let addingChecklist = $state(false);
let newItemTitles = $state<Record<string, string>>({});
const basePath = $derived(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/checklists`);
function progress(cl: Checklist): { done: number; total: number } {
const total = cl.items.length;
const done = cl.items.filter((i) => i.completed).length;
return { done, total };
}
async function addChecklist() {
if (!newChecklistTitle.trim()) return;
const res = await fetch(basePath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newChecklistTitle.trim() })
});
if (res.ok) {
const data = await res.json();
onupdate([...checklists, data.checklist]);
newChecklistTitle = '';
addingChecklist = false;
}
}
async function deleteChecklist(clId: string) {
const res = await fetch(`${basePath}/${clId}`, { method: 'DELETE' });
if (res.ok) {
onupdate(checklists.filter((c) => c.id !== clId));
}
}
async function addItem(clId: string) {
const title = newItemTitles[clId]?.trim();
if (!title) return;
const res = await fetch(`${basePath}/${clId}/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (res.ok) {
const data = await res.json();
onupdate(
checklists.map((c) =>
c.id === clId ? { ...c, items: [...c.items, data.item] } : c
)
);
newItemTitles[clId] = '';
}
}
async function toggleItem(clId: string, item: ChecklistItem) {
const res = await fetch(`${basePath}/${clId}/items/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !item.completed })
});
if (res.ok) {
onupdate(
checklists.map((c) =>
c.id === clId
? {
...c,
items: c.items.map((i) =>
i.id === item.id ? { ...i, completed: !i.completed } : i
)
}
: c
)
);
}
}
async function deleteItem(clId: string, itemId: string) {
const res = await fetch(`${basePath}/${clId}/items/${itemId}`, { method: 'DELETE' });
if (res.ok) {
onupdate(
checklists.map((c) =>
c.id === clId ? { ...c, items: c.items.filter((i) => i.id !== itemId) } : c
)
);
}
}
</script>
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Checklists</h3>
{#each checklists as cl}
{@const p = progress(cl)}
<div class="mb-4 last:mb-0">
<div class="flex items-center justify-between mb-1">
<span class="text-sm font-medium text-gray-700">{cl.title}</span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">
{p.done}/{p.total}
</span>
{#if canEdit}
<button
onclick={() => deleteChecklist(cl.id)}
class="text-xs text-gray-400 hover:text-red-500"
>
Delete
</button>
{/if}
</div>
</div>
<!-- Progress bar -->
{#if p.total > 0}
<div class="w-full bg-gray-200 rounded-full h-1.5 mb-2">
<div
class="h-1.5 rounded-full transition-all {p.done === p.total ? 'bg-[var(--color-success)]' : 'bg-[var(--color-primary)]'}"
style="width: {(p.done / p.total) * 100}%"
></div>
</div>
{/if}
<!-- Items -->
<div class="space-y-1">
{#each cl.items as item}
<div class="flex items-center gap-2 group">
{#if canEdit}
<input
type="checkbox"
checked={item.completed}
onchange={() => toggleItem(cl.id, item)}
class="rounded border-gray-300 text-[var(--color-primary)]"
/>
{:else}
<input
type="checkbox"
checked={item.completed}
disabled
class="rounded border-gray-300"
/>
{/if}
<span class="flex-1 text-sm {item.completed ? 'text-gray-400 line-through' : 'text-gray-700'}">
{item.title}
</span>
{#if canEdit}
<button
onclick={() => deleteItem(cl.id, item.id)}
class="text-gray-300 hover:text-red-500 opacity-0 group-hover:opacity-100 transition"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
{/each}
</div>
<!-- Add item -->
{#if canEdit}
<form
onsubmit={(e) => { e.preventDefault(); addItem(cl.id); }}
class="flex gap-1 mt-2"
>
<input
bind:value={newItemTitles[cl.id]}
placeholder="Add an item..."
class="flex-1 rounded border border-gray-200 px-2 py-1 text-sm focus:border-[var(--color-primary)] focus:outline-none"
/>
<button
type="submit"
disabled={!newItemTitles[cl.id]?.trim()}
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200 disabled:opacity-50"
>
Add
</button>
</form>
{/if}
</div>
{/each}
{#if canEdit}
{#if addingChecklist}
<form onsubmit={(e) => { e.preventDefault(); addChecklist(); }} class="flex gap-1 mt-2">
<input
bind:value={newChecklistTitle}
placeholder="Checklist title..."
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm"
autofocus
/>
<button
type="submit"
disabled={!newChecklistTitle.trim()}
class="rounded bg-[var(--color-primary)] px-2 py-1 text-xs text-white disabled:opacity-50"
>
Add
</button>
<button
type="button"
onclick={() => { addingChecklist = false; newChecklistTitle = ''; }}
class="text-xs text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</form>
{:else}
<button
onclick={() => (addingChecklist = true)}
class="text-xs text-gray-400 hover:text-gray-600 mt-1"
>
+ Add checklist
</button>
{/if}
{/if}
</div>