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
+67 -8
View File
@@ -2,6 +2,7 @@
import { dndzone } from 'svelte-dnd-action';
import { flip } from 'svelte/animate';
import { generatePosition } from '$lib/utils/fractional-index.js';
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
import CardModal from '$lib/components/CardModal.svelte';
let { data } = $props();
@@ -24,11 +25,22 @@
const flipDurationMs = 200;
function getChecklistProgress(card: any): { done: number; total: number } | null {
if (!card.checklists || card.checklists.length === 0) return null;
let done = 0, total = 0;
for (const cl of card.checklists) {
for (const item of cl.items) {
total++;
if (item.completed) done++;
}
}
return total > 0 ? { done, total } : null;
}
// ─── Column DnD ──────────────────────────────────
function handleColumnSort(e: CustomEvent<any>) {
columns = e.detail.items;
if (e.detail.info.trigger === 'droppedIntoZone') {
// Update positions based on new order
columns.forEach((col: any, idx: number) => {
const before = idx > 0 ? columns[idx - 1].position : null;
const after = idx < columns.length - 1 ? columns[idx + 1].position : null;
@@ -138,6 +150,17 @@
if (selectedCard?.id === cardId) selectedCard = null;
}
}
// ─── Card updated from modal ─────────────────────
function handleCardUpdate(updatedCard: any) {
for (const col of columns) {
const idx = col.cards.findIndex((c: any) => c.id === updatedCard.id);
if (idx !== -1) {
col.cards[idx] = { ...col.cards[idx], ...updatedCard };
break;
}
}
}
</script>
<svelte:head>
@@ -150,11 +173,14 @@
boardId={data.board.id}
columnId={columns.find((c: any) => c.cards.some((card: any) => card.id === selectedCard.id))?.id}
canEdit={data.canEdit}
boardMembers={data.board.members}
currentUserId={data.user?.id || ''}
onclose={() => (selectedCard = null)}
ondelete={(cardId) => {
const col = columns.find((c: any) => c.cards.some((card: any) => card.id === cardId));
if (col) deleteCard(col.id, cardId);
}}
onupdate={handleCardUpdate}
/>
{/if}
@@ -250,6 +276,8 @@
onfinalize={(e) => handleCardSort(column.id, e)}
>
{#each column.cards as card (card.id)}
{@const progress = getChecklistProgress(card)}
{@const dueUrgency = getDueUrgency(card.dueDate)}
<button
class="w-full text-left mb-2 rounded-lg bg-[var(--color-card-bg)] p-3 shadow-sm hover:shadow-md border border-gray-200 hover:border-gray-300 transition cursor-pointer"
animate:flip={{ duration: flipDurationMs }}
@@ -259,21 +287,45 @@
<div class="flex flex-wrap gap-1 mb-1.5">
{#each card.labels as label}
<span
class="inline-block h-2 w-8 rounded-full"
class="rounded px-1.5 py-0.5 text-[10px] text-white leading-none"
style="background-color: {label.tag.color}"
title={label.tag.name}
></span>
>
{label.tag.name}
</span>
{/each}
</div>
{/if}
<span class="text-sm text-gray-800">{card.title}</span>
{#if card.dueDate || card._count?.comments > 0 || card._count?.checklists > 0}
<div class="flex items-center gap-2 mt-2 text-xs text-gray-400">
{#if card.dueDate || card._count?.comments > 0 || card._count?.attachments > 0 || progress}
<div class="flex items-center gap-2 mt-2 text-xs text-gray-400 flex-wrap">
{#if card.dueDate}
<span>Due {new Date(card.dueDate).toLocaleDateString()}</span>
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-medium {getUrgencyClasses(dueUrgency)}">
{formatDueDate(card.dueDate)}
</span>
{/if}
{#if progress}
<span class="inline-flex items-center gap-1 {progress.done === progress.total ? 'text-[var(--color-success)]' : ''}">
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</svg>
{progress.done}/{progress.total}
</span>
{/if}
{#if card._count?.comments > 0}
<span>💬 {card._count.comments}</span>
<span class="inline-flex items-center gap-0.5">
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M2 5a2 2 0 012-2h12a2 2 0 012 2v6a2 2 0 01-2 2H7.414l-3.207 3.207A1 1 0 012 15.5V5z" clip-rule="evenodd" />
</svg>
{card._count.comments}
</span>
{/if}
{#if card._count?.attachments > 0}
<span class="inline-flex items-center gap-0.5">
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M15.621 4.379a3 3 0 00-4.242 0l-7 7a3 3 0 004.241 4.243h.001l.497-.5a.75.75 0 011.064 1.057l-.498.501a4.5 4.5 0 01-6.364-6.364l7-7a4.5 4.5 0 016.368 6.36l-3.455 3.553A2.625 2.625 0 119.52 9.52l3.45-3.451a.75.75 0 111.061 1.06l-3.45 3.451a1.125 1.125 0 001.587 1.595l3.454-3.553a3 3 0 000-4.242z" clip-rule="evenodd" />
</svg>
{card._count.attachments}
</span>
{/if}
</div>
{/if}
@@ -287,6 +339,13 @@
{assignee.user.name[0].toUpperCase()}
</div>
{/each}
{#if card.assignees.length > 3}
<div
class="w-5 h-5 rounded-full bg-gray-300 text-gray-600 text-[10px] flex items-center justify-center border border-white"
>
+{card.assignees.length - 3}
</div>
{/if}
</div>
{/if}
</button>