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