Private
Public Access
1
0
Files
Kanban/src/lib/components/CardModal.svelte
T
Catherine Renelle 095d9756a8 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>
2026-02-25 23:38:31 -05:00

260 lines
7.0 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import MarkdownEditor from './MarkdownEditor.svelte';
import DueDatePicker from './DueDatePicker.svelte';
import TagPicker from './TagPicker.svelte';
import AssigneePicker from './AssigneePicker.svelte';
import ChecklistSection from './ChecklistSection.svelte';
import CommentList from './CommentList.svelte';
import AttachmentList from './AttachmentList.svelte';
type Props = {
card: any;
boardId: string;
columnId: string | undefined;
canEdit: boolean;
boardMembers: any[];
currentUserId: string;
onclose: () => void;
ondelete: (cardId: string) => void;
onupdate: (card: any) => void;
};
let { card, boardId, columnId, canEdit, boardMembers, currentUserId, onclose, ondelete, onupdate }: Props = $props();
let editingTitle = $state(false);
let title = $state(card.title);
let saving = $state(false);
// Full card data (lazy-loaded)
let fullCard = $state<any>(null);
let loadingFull = $state(true);
onMount(async () => {
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`);
if (res.ok) {
const data = await res.json();
fullCard = data.card;
}
loadingFull = false;
});
async function saveTitle() {
if (!title.trim()) return;
saving = true;
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (res.ok) {
card.title = title;
onupdate(card);
}
editingTitle = false;
saving = false;
}
async function saveDescription(value: string) {
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: value })
});
if (res.ok) {
card.description = value;
if (fullCard) fullCard.description = value;
onupdate(card);
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) onclose();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
function updateLabels(labels: any[]) {
card.labels = labels;
if (fullCard) fullCard.labels = labels;
onupdate(card);
}
function updateAssignees(assignees: any[]) {
card.assignees = assignees;
if (fullCard) fullCard.assignees = assignees;
onupdate(card);
}
function updateDueDate(dueDate: string | null) {
card.dueDate = dueDate;
if (fullCard) fullCard.dueDate = dueDate;
onupdate(card);
}
function updateChecklists(checklists: any[]) {
if (fullCard) fullCard.checklists = checklists;
// Update lightweight checklist data on the card for thumbnail progress
card.checklists = checklists.map((c: any) => ({
id: c.id,
items: c.items.map((i: any) => ({ completed: i.completed }))
}));
onupdate(card);
}
async function archiveCard() {
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ archived: true })
});
if (res.ok) {
ondelete(card.id);
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-12 overflow-y-auto"
onclick={handleBackdropClick}
>
<div class="w-full max-w-4xl bg-white rounded-xl shadow-2xl">
<!-- Header -->
<div class="flex items-start justify-between p-4 border-b border-gray-100">
<div class="flex-1">
{#if editingTitle && canEdit}
<form onsubmit={(e) => { e.preventDefault(); saveTitle(); }}>
<input
bind:value={title}
class="text-lg font-semibold w-full rounded border border-gray-300 px-2 py-1"
autofocus
onblur={saveTitle}
/>
</form>
{:else}
<h2
class="text-lg font-semibold text-gray-900 {canEdit ? 'cursor-pointer hover:bg-gray-50 rounded px-2 py-1 -mx-2 -my-1' : ''}"
ondblclick={() => canEdit && (editingTitle = true)}
>
{card.title}
</h2>
{/if}
</div>
<button onclick={onclose} class="text-gray-400 hover:text-gray-600 transition p-1 ml-2">
<svg class="w-5 h-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>
</div>
<!-- Body: Two-column layout -->
<div class="flex flex-col md:flex-row p-4 gap-4">
<!-- Main content -->
<div class="flex-1 space-y-5 min-w-0">
<!-- Labels -->
<TagPicker
cardId={card.id}
{boardId}
{columnId}
currentLabels={card.labels || []}
{canEdit}
onupdate={updateLabels}
/>
<!-- Description -->
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Description</h3>
<MarkdownEditor
value={card.description || ''}
readonly={!canEdit}
onsave={saveDescription}
/>
</div>
<!-- Checklists (lazy-loaded) -->
{#if loadingFull}
<div class="animate-pulse space-y-2">
<div class="h-4 bg-gray-200 rounded w-24"></div>
<div class="h-2 bg-gray-100 rounded w-full"></div>
<div class="h-3 bg-gray-100 rounded w-3/4"></div>
</div>
{:else if fullCard}
<ChecklistSection
cardId={card.id}
{boardId}
{columnId}
checklists={fullCard.checklists || []}
{canEdit}
onupdate={updateChecklists}
/>
<AttachmentList
cardId={card.id}
{boardId}
{columnId}
attachments={fullCard.attachments || []}
{canEdit}
/>
<CommentList
cardId={card.id}
{boardId}
{columnId}
comments={fullCard.comments || []}
{currentUserId}
{canEdit}
/>
{/if}
</div>
<!-- Sidebar -->
<div class="w-full md:w-48 space-y-4 flex-shrink-0">
<DueDatePicker
value={card.dueDate || null}
{canEdit}
{boardId}
{columnId}
cardId={card.id}
onupdate={updateDueDate}
/>
<AssigneePicker
cardId={card.id}
{boardId}
{columnId}
currentAssignees={card.assignees || []}
{boardMembers}
{canEdit}
onupdate={updateAssignees}
/>
<!-- Actions -->
{#if canEdit}
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Actions</h3>
<div class="space-y-1">
<button
onclick={archiveCard}
class="w-full text-left rounded px-2 py-1.5 text-sm text-gray-600 hover:bg-gray-100 transition"
>
Archive
</button>
<button
onclick={() => ondelete(card.id)}
class="w-full text-left rounded px-2 py-1.5 text-sm text-[var(--color-danger)] hover:bg-red-50 transition"
>
Delete
</button>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>