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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user