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:
+175
-108
@@ -1,44 +1,71 @@
|
||||
<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, onclose, ondelete }: Props = $props();
|
||||
let { card, boardId, columnId, canEdit, boardMembers, currentUserId, onclose, ondelete, onupdate }: Props = $props();
|
||||
|
||||
let editingTitle = $state(false);
|
||||
let title = $state(card.title);
|
||||
let description = $state(card.description || '');
|
||||
let editingDescription = $state(false);
|
||||
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;
|
||||
await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
|
||||
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title })
|
||||
});
|
||||
card.title = title;
|
||||
if (res.ok) {
|
||||
card.title = title;
|
||||
onupdate(card);
|
||||
}
|
||||
editingTitle = false;
|
||||
saving = false;
|
||||
}
|
||||
|
||||
async function saveDescription() {
|
||||
saving = true;
|
||||
await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${card.id}`, {
|
||||
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 })
|
||||
body: JSON.stringify({ description: value })
|
||||
});
|
||||
card.description = description;
|
||||
editingDescription = false;
|
||||
saving = false;
|
||||
if (res.ok) {
|
||||
card.description = value;
|
||||
if (fullCard) fullCard.description = value;
|
||||
onupdate(card);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
@@ -48,16 +75,55 @@
|
||||
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-16 overflow-y-auto"
|
||||
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-2xl bg-white rounded-xl shadow-2xl">
|
||||
<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">
|
||||
@@ -86,107 +152,108 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Labels -->
|
||||
{#if card.labels && card.labels.length > 0}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Labels</h3>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each card.labels as label}
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs text-white"
|
||||
style="background-color: {label.tag.color}"
|
||||
>
|
||||
{label.tag.name}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 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>
|
||||
{#if editingDescription && canEdit}
|
||||
<div>
|
||||
<textarea
|
||||
bind:value={description}
|
||||
rows="6"
|
||||
class="w-full rounded-lg border border-gray-300 p-3 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||
placeholder="Add a more detailed description..."
|
||||
></textarea>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button
|
||||
onclick={saveDescription}
|
||||
disabled={saving}
|
||||
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { editingDescription = false; description = card.description || ''; }}
|
||||
class="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="{canEdit ? 'cursor-pointer hover:bg-gray-50 rounded' : ''} p-2 -mx-2 min-h-[40px]"
|
||||
onclick={() => canEdit && (editingDescription = true)}
|
||||
role={canEdit ? 'button' : undefined}
|
||||
tabindex={canEdit ? 0 : undefined}
|
||||
onkeydown={(e) => { if (canEdit && (e.key === 'Enter' || e.key === ' ')) editingDescription = true; }}
|
||||
>
|
||||
{#if card.description}
|
||||
<p class="text-sm text-gray-700 whitespace-pre-wrap">{card.description}</p>
|
||||
{:else if canEdit}
|
||||
<p class="text-sm text-gray-400">Add a more detailed description...</p>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-400 italic">No description</p>
|
||||
{/if}
|
||||
<!-- 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>
|
||||
|
||||
<!-- Due date -->
|
||||
{#if card.dueDate}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Due Date</h3>
|
||||
<p class="text-sm text-gray-700">{new Date(card.dueDate).toLocaleDateString()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 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}
|
||||
/>
|
||||
|
||||
<!-- Assignees -->
|
||||
{#if card.assignees && card.assignees.length > 0}
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Assignees</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each card.assignees as assignee}
|
||||
<div class="flex items-center gap-1.5 rounded-full bg-gray-100 pl-1 pr-2.5 py-0.5">
|
||||
<div class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center">
|
||||
{assignee.user.name[0].toUpperCase()}
|
||||
</div>
|
||||
<span class="text-xs text-gray-700">{assignee.user.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
{#if canEdit}
|
||||
<div class="flex justify-end gap-2 px-4 py-3 border-t border-gray-100">
|
||||
<button
|
||||
onclick={() => ondelete(card.id)}
|
||||
class="rounded px-3 py-1.5 text-sm text-[var(--color-danger)] hover:bg-red-50 transition"
|
||||
>
|
||||
Delete card
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user