Private
Public Access
1
0

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:
Catherine Renelle
2026-02-25 23:38:31 -05:00
parent 9b5708af64
commit 095d9756a8
33 changed files with 2226 additions and 143 deletions
+113
View File
@@ -0,0 +1,113 @@
<script lang="ts">
type Assignee = { id: string; userId: string; user: { id: string; name: string; email?: string; avatarUrl: string | null } };
type Member = { id: string; userId: string; role: string; user: { id: string; name: string; email?: string; avatarUrl: string | null } };
type Props = {
cardId: string;
boardId: string;
columnId: string | undefined;
currentAssignees: Assignee[];
boardMembers: Member[];
canEdit: boolean;
onupdate: (assignees: Assignee[]) => void;
};
let { cardId, boardId, columnId, currentAssignees, boardMembers, canEdit, onupdate }: Props = $props();
let showDropdown = $state(false);
function isAssigned(userId: string): boolean {
return currentAssignees.some((a) => a.userId === userId);
}
async function toggleAssignee(member: Member) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/assignees`;
if (isAssigned(member.userId)) {
const res = await fetch(base, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: member.userId })
});
if (res.ok) {
onupdate(currentAssignees.filter((a) => a.userId !== member.userId));
}
} else {
const res = await fetch(base, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: member.userId })
});
if (res.ok) {
const data = await res.json();
onupdate([...currentAssignees, data.assignee]);
}
}
}
async function removeAssignee(userId: string) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/assignees`;
const res = await fetch(base, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId })
});
if (res.ok) {
onupdate(currentAssignees.filter((a) => a.userId !== userId));
}
}
</script>
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Assignees</h3>
<div class="flex flex-wrap gap-1.5">
{#each currentAssignees as assignee}
<div class="flex items-center gap-1 rounded-full bg-gray-100 pl-1 pr-2 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>
{#if canEdit}
<button onclick={() => removeAssignee(assignee.userId)} class="text-gray-400 hover:text-red-500 ml-0.5">
<svg class="w-3 h-3" 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}
{#if canEdit}
<div class="relative">
<button
onclick={() => (showDropdown = !showDropdown)}
class="inline-flex items-center rounded-full bg-gray-100 w-6 h-6 text-xs text-gray-500 hover:bg-gray-200 justify-center"
>
+
</button>
{#if showDropdown}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-10" onclick={() => (showDropdown = false)}></div>
<div class="absolute right-0 top-full mt-1 z-20 w-52 rounded-lg bg-white shadow-lg border border-gray-200 p-1">
<div class="max-h-48 overflow-y-auto">
{#each boardMembers as member}
<button
onclick={() => toggleAssignee(member)}
class="w-full flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-50 {isAssigned(member.userId) ? 'bg-blue-50' : ''}"
>
<div class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center">
{member.user.name[0].toUpperCase()}
</div>
<span class="flex-1 text-left">{member.user.name}</span>
{#if isAssigned(member.userId)}
<svg class="w-4 h-4 text-blue-500" 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>
{/if}
</button>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
type Attachment = {
id: string;
filename: string;
mimetype: string;
size: number;
createdAt: string;
};
type Props = {
cardId: string;
boardId: string;
columnId: string | undefined;
attachments: Attachment[];
canEdit: boolean;
};
let { cardId, boardId, columnId, attachments = [], canEdit }: Props = $props();
let localAttachments = $state<Attachment[]>(attachments);
let uploading = $state(false);
let uploadProgress = $state(0);
$effect(() => {
localAttachments = attachments;
});
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function getIcon(mimetype: string): string {
if (mimetype.startsWith('image/')) return 'img';
if (mimetype.startsWith('video/')) return 'vid';
if (mimetype.includes('pdf')) return 'pdf';
return 'file';
}
async function handleUpload(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
alert('File too large (max 10MB)');
input.value = '';
return;
}
uploading = true;
uploadProgress = 0;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(
`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/attachments`,
{ method: 'POST', body: formData }
);
if (res.ok) {
const data = await res.json();
localAttachments = [data.attachment, ...localAttachments];
}
} finally {
uploading = false;
input.value = '';
}
}
async function deleteAttachment(attId: string) {
const res = await fetch(`/api/attachments/${attId}`, { method: 'DELETE' });
if (res.ok) {
localAttachments = localAttachments.filter((a) => a.id !== attId);
}
}
</script>
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Attachments</h3>
{#if canEdit}
<div class="mb-3">
<label class="inline-flex items-center gap-1.5 rounded bg-gray-100 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-200 cursor-pointer transition">
<svg class="w-4 h-4" 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>
{uploading ? 'Uploading...' : 'Attach file'}
<input type="file" class="hidden" onchange={handleUpload} disabled={uploading} />
</label>
</div>
{/if}
{#if localAttachments.length > 0}
<div class="space-y-1.5">
{#each localAttachments as att}
<div class="flex items-center gap-2 rounded border border-gray-100 px-2 py-1.5 hover:bg-gray-50">
<span class="text-xs font-mono text-gray-400 w-6 text-center uppercase">
{getIcon(att.mimetype)}
</span>
<a
href="/api/attachments/{att.id}"
class="flex-1 text-sm text-gray-700 hover:text-[var(--color-primary)] truncate"
download
>
{att.filename}
</a>
<span class="text-xs text-gray-400 flex-shrink-0">{formatSize(att.size)}</span>
{#if canEdit}
<button
onclick={() => deleteAttachment(att.id)}
class="text-gray-300 hover:text-red-500 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>
{:else}
<p class="text-xs text-gray-400 italic">No attachments</p>
{/if}
</div>
+175 -108
View File
@@ -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>
+228
View File
@@ -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>
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js';
import { formatDistanceToNow } from 'date-fns';
type Comment = {
id: string;
content: string;
createdAt: string;
updatedAt: string;
userId: string;
user: { id: string; name: string; avatarUrl: string | null };
};
type Props = {
cardId: string;
boardId: string;
columnId: string | undefined;
comments: Comment[];
currentUserId: string;
canEdit: boolean;
};
let { cardId, boardId, columnId, comments = [], currentUserId, canEdit }: Props = $props();
let localComments = $state<Comment[]>(comments);
let newContent = $state('');
let submitting = $state(false);
let editingId = $state<string | null>(null);
let editContent = $state('');
let ready = $state(false);
onMount(async () => {
await initDOMPurify();
ready = true;
});
$effect(() => {
localComments = comments;
});
const basePath = $derived(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/comments`);
async function addComment() {
if (!newContent.trim()) return;
submitting = true;
const res = await fetch(basePath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newContent.trim() })
});
if (res.ok) {
const data = await res.json();
localComments = [data.comment, ...localComments];
newContent = '';
}
submitting = false;
}
async function updateComment(commentId: string) {
if (!editContent.trim()) return;
const res = await fetch(`${basePath}/${commentId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editContent.trim() })
});
if (res.ok) {
const data = await res.json();
localComments = localComments.map((c) => (c.id === commentId ? data.comment : c));
editingId = null;
}
}
async function deleteComment(commentId: string) {
const res = await fetch(`${basePath}/${commentId}`, { method: 'DELETE' });
if (res.ok) {
localComments = localComments.filter((c) => c.id !== commentId);
}
}
function startEdit(comment: Comment) {
editingId = comment.id;
editContent = comment.content;
}
</script>
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Comments</h3>
<!-- Add comment -->
{#if canEdit}
<div class="mb-4">
<textarea
bind:value={newContent}
rows="3"
placeholder="Write a comment... (Markdown supported)"
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)]"
></textarea>
<div class="flex justify-end mt-1">
<button
onclick={addComment}
disabled={submitting || !newContent.trim()}
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"
>
{submitting ? 'Posting...' : 'Comment'}
</button>
</div>
</div>
{/if}
<!-- Comment list -->
<div class="space-y-3">
{#each localComments as comment}
<div class="flex gap-2">
<div class="w-7 h-7 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center flex-shrink-0 mt-0.5">
{comment.user.name[0].toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-sm font-medium text-gray-800">{comment.user.name}</span>
<span class="text-xs text-gray-400">
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
</span>
</div>
{#if editingId === comment.id}
<div>
<textarea
bind:value={editContent}
rows="3"
class="w-full rounded border border-gray-300 p-2 text-sm resize-y focus:border-[var(--color-primary)] focus:outline-none"
></textarea>
<div class="flex gap-2 mt-1">
<button
onclick={() => updateComment(comment.id)}
class="rounded bg-[var(--color-primary)] px-2 py-1 text-xs text-white"
>
Save
</button>
<button
onclick={() => (editingId = null)}
class="text-xs text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
</div>
{:else}
<div class="prose text-sm text-gray-700">
{#if ready}
{@html renderMarkdown(comment.content)}
{:else}
<p class="whitespace-pre-wrap">{comment.content}</p>
{/if}
</div>
<div class="flex gap-2 mt-1">
{#if comment.userId === currentUserId}
<button
onclick={() => startEdit(comment)}
class="text-xs text-gray-400 hover:text-gray-600"
>
Edit
</button>
{/if}
{#if comment.userId === currentUserId || canEdit}
<button
onclick={() => deleteComment(comment.id)}
class="text-xs text-gray-400 hover:text-red-500"
>
Delete
</button>
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>
{#if localComments.length === 0}
<p class="text-xs text-gray-400 italic">No comments yet</p>
{/if}
</div>
+97
View File
@@ -0,0 +1,97 @@
<script lang="ts">
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
type Props = {
value: string | null;
canEdit: boolean;
boardId: string;
columnId: string | undefined;
cardId: string;
onupdate: (dueDate: string | null) => void;
};
let { value, canEdit, boardId, columnId, cardId, onupdate }: Props = $props();
let picking = $state(false);
let saving = $state(false);
const urgency = $derived(getDueUrgency(value));
const urgencyClasses = $derived(getUrgencyClasses(urgency));
const formatted = $derived(formatDueDate(value));
const dateValue = $derived(value ? new Date(value).toISOString().split('T')[0] : '');
async function setDate(dateStr: string) {
saving = true;
const dueDate = dateStr || null;
const res = await fetch(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dueDate })
});
if (res.ok) {
onupdate(dueDate);
}
picking = false;
saving = false;
}
async function clearDate() {
await setDate('');
}
</script>
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Due Date</h3>
{#if picking && canEdit}
<div class="flex items-center gap-2">
<input
type="date"
value={dateValue}
onchange={(e) => setDate(e.currentTarget.value)}
class="rounded border border-gray-300 px-2 py-1 text-sm"
disabled={saving}
/>
<button
onclick={() => (picking = false)}
class="text-xs text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
{:else if value}
<div class="flex items-center gap-2">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium {urgencyClasses}">
{formatted}
</span>
{#if canEdit}
<button
onclick={() => (picking = true)}
class="text-xs text-gray-400 hover:text-gray-600"
title="Change date"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path d="M2.695 14.763l-1.262 3.154a.5.5 0 00.65.65l3.155-1.262a4 4 0 001.343-.885L17.5 5.5a2.121 2.121 0 00-3-3L3.58 13.42a4 4 0 00-.885 1.343z" />
</svg>
</button>
<button
onclick={clearDate}
class="text-xs text-gray-400 hover:text-red-500"
title="Remove date"
>
<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>
{:else if canEdit}
<button
onclick={() => (picking = true)}
class="text-xs text-gray-400 hover:text-gray-600"
>
+ Set due date
</button>
{:else}
<p class="text-xs text-gray-400 italic">No due date</p>
{/if}
</div>
+108
View File
@@ -0,0 +1,108 @@
<script lang="ts">
import { onMount } from 'svelte';
import { renderMarkdown, initDOMPurify } from '$lib/utils/markdown.js';
type Props = {
value: string;
readonly?: boolean;
onsave?: (value: string) => void;
};
let { value, readonly = false, onsave }: Props = $props();
let editing = $state(false);
let draft = $state(value);
let previewing = $state(false);
let ready = $state(false);
onMount(async () => {
await initDOMPurify();
ready = true;
});
function save() {
onsave?.(draft);
editing = false;
previewing = false;
}
function cancel() {
draft = value;
editing = false;
previewing = false;
}
// Keep draft in sync when value prop changes externally
$effect(() => {
if (!editing) draft = value;
});
</script>
{#if editing && !readonly}
<div>
<div class="flex gap-2 mb-2">
<button
class="text-xs px-2 py-1 rounded {!previewing ? 'bg-gray-200 text-gray-700' : 'text-gray-500 hover:bg-gray-100'}"
onclick={() => (previewing = false)}
>
Write
</button>
<button
class="text-xs px-2 py-1 rounded {previewing ? 'bg-gray-200 text-gray-700' : 'text-gray-500 hover:bg-gray-100'}"
onclick={() => (previewing = true)}
>
Preview
</button>
</div>
{#if previewing}
<div class="prose rounded-lg border border-gray-200 p-3 min-h-[120px] text-sm">
{#if ready}
{@html renderMarkdown(draft)}
{:else}
<p class="text-gray-400">{draft}</p>
{/if}
</div>
{:else}
<textarea
bind:value={draft}
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... (Markdown supported)"
></textarea>
{/if}
<div class="flex gap-2 mt-2">
<button
onclick={save}
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition"
>
Save
</button>
<button
onclick={cancel}
class="text-sm text-gray-500 hover:text-gray-700"
>
Cancel
</button>
</div>
</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="{!readonly ? 'cursor-pointer hover:bg-gray-50 rounded' : ''} p-2 -mx-2 min-h-[40px]"
onclick={() => { if (!readonly) editing = true; }}
>
{#if value}
<div class="prose text-sm">
{#if ready}
{@html renderMarkdown(value)}
{:else}
<p class="text-gray-700 whitespace-pre-wrap">{value}</p>
{/if}
</div>
{:else if !readonly}
<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}
</div>
{/if}
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
type Label = { id: string; tagId: string; tag: { id: string; name: string; color: string } };
type Tag = { id: string; name: string; color: string };
type Props = {
cardId: string;
boardId: string;
columnId: string | undefined;
currentLabels: Label[];
canEdit: boolean;
onupdate: (labels: Label[]) => void;
};
let { cardId, boardId, columnId, currentLabels, canEdit, onupdate }: Props = $props();
let showDropdown = $state(false);
let allTags = $state<Tag[]>([]);
let loading = $state(false);
let creating = $state(false);
let newTagName = $state('');
let newTagColor = $state('#0079bf');
const presetColors = ['#0079bf', '#61bd4f', '#f2d600', '#ff9f1a', '#eb5a46', '#c377e0', '#00c2e0', '#51e898'];
async function fetchTags() {
loading = true;
const res = await fetch('/api/tags');
if (res.ok) {
const data = await res.json();
allTags = data.tags;
}
loading = false;
}
function openDropdown() {
showDropdown = true;
fetchTags();
}
function isApplied(tagId: string): boolean {
return currentLabels.some((l) => l.tagId === tagId);
}
async function toggleTag(tag: Tag) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/labels`;
if (isApplied(tag.id)) {
const res = await fetch(base, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagId: tag.id })
});
if (res.ok) {
const updated = currentLabels.filter((l) => l.tagId !== tag.id);
onupdate(updated);
}
} else {
const res = await fetch(base, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagId: tag.id })
});
if (res.ok) {
const data = await res.json();
onupdate([...currentLabels, data.label]);
}
}
}
async function createTag() {
if (!newTagName.trim()) return;
creating = true;
const res = await fetch('/api/tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newTagName.trim(), color: newTagColor })
});
if (res.ok) {
const data = await res.json();
allTags = [...allTags, data.tag];
newTagName = '';
}
creating = false;
}
async function removeLabel(tagId: string) {
const base = `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/labels`;
const res = await fetch(base, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tagId })
});
if (res.ok) {
onupdate(currentLabels.filter((l) => l.tagId !== tagId));
}
}
</script>
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Labels</h3>
<div class="flex flex-wrap gap-1">
{#each currentLabels as label}
<span
class="inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs text-white"
style="background-color: {label.tag.color}"
>
{label.tag.name}
{#if canEdit}
<button onclick={() => removeLabel(label.tagId)} class="hover:opacity-70" title="Remove label">
<svg class="w-3 h-3" 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}
</span>
{/each}
{#if canEdit}
<div class="relative">
<button
onclick={openDropdown}
class="inline-flex items-center rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 hover:bg-gray-200"
>
+
</button>
{#if showDropdown}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-10"
onclick={() => (showDropdown = false)}
></div>
<div class="absolute left-0 top-full mt-1 z-20 w-56 rounded-lg bg-white shadow-lg border border-gray-200 p-2">
{#if loading}
<p class="text-xs text-gray-400 p-2">Loading...</p>
{:else}
<div class="max-h-40 overflow-y-auto space-y-1">
{#each allTags as tag}
<button
onclick={() => toggleTag(tag)}
class="w-full flex items-center gap-2 rounded px-2 py-1 text-sm hover:bg-gray-50 {isApplied(tag.id) ? 'bg-blue-50' : ''}"
>
<span class="w-4 h-4 rounded" style="background-color: {tag.color}"></span>
<span class="flex-1 text-left">{tag.name}</span>
{#if isApplied(tag.id)}
<svg class="w-4 h-4 text-blue-500" 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>
{/if}
</button>
{/each}
</div>
<div class="border-t border-gray-100 mt-2 pt-2">
<p class="text-xs text-gray-500 mb-1">Create tag</p>
<div class="flex gap-1 mb-1.5">
{#each presetColors as color}
<button
class="w-5 h-5 rounded-full border-2 {newTagColor === color ? 'border-gray-600' : 'border-transparent'}"
style="background-color: {color}"
onclick={() => (newTagColor = color)}
></button>
{/each}
</div>
<form onsubmit={(e) => { e.preventDefault(); createTag(); }} class="flex gap-1">
<input
bind:value={newTagName}
placeholder="Tag name"
class="flex-1 rounded border border-gray-300 px-2 py-1 text-xs"
/>
<button
type="submit"
disabled={creating || !newTagName.trim()}
class="rounded bg-[var(--color-primary)] px-2 py-1 text-xs text-white disabled:opacity-50"
>
Add
</button>
</form>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>