095d9756a8
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>
229 lines
6.4 KiB
Svelte
229 lines
6.4 KiB
Svelte
<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>
|