Private
Public Access
1
0

Add 17 features: My Work view, card copy/numbering, calendar, bulk ops, custom fields, automations, webhooks, invites, import/export, and more

Features implemented across 6 batches:
- Batch A: Fix ColorPicker gradient sync, tenant custom logo, unarchive cards, board backgrounds
- Batch B: My Work view, card copy/duplicate, card numbering (BOARD-123)
- Batch C: Due date reminders (cron), card watching with notifications
- Batch D: Bulk card operations, column sorting, calendar view
- Batch E: Invite by link, board import/export (Pounce + Trello JSON)
- Batch F: Webhooks with HMAC signing, custom fields, automation rules engine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-27 08:50:15 -05:00
parent 3a11ed01e9
commit 8c41b33313
54 changed files with 3656 additions and 82 deletions
+154 -9
View File
@@ -10,6 +10,9 @@
import CardModal from '$lib/components/CardModal.svelte';
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
import BoardFilters from '$lib/components/BoardFilters.svelte';
import BoardBackgroundPicker from '$lib/components/BoardBackgroundPicker.svelte';
import BulkToolbar from '$lib/components/BulkToolbar.svelte';
import ColumnSortMenu from '$lib/components/ColumnSortMenu.svelte';
import Avatar from '$lib/components/Avatar.svelte';
import type { FilterState } from '$lib/components/BoardFilters.svelte';
@@ -35,6 +38,9 @@
let showArchivedCards = $state(false);
let archivedCards = $state<any[]>([]);
let activeFilters = $state<FilterState>({ labelIds: [], assigneeIds: [], due: null });
let selectMode = $state(false);
let selectedCardIds = $state(new Set<string>());
let columnSorts = $state<Record<string, string | null>>({});
const flipDurationMs = 200;
@@ -74,6 +80,36 @@
});
}
// ─── Card Sorting ────────────────────────────────────
function sortCards(cards: any[], sort: string | null): any[] {
if (!sort) return cards;
const sorted = [...cards];
switch (sort) {
case 'due-date':
sorted.sort((a, b) => {
if (!a.dueDate && !b.dueDate) return 0;
if (!a.dueDate) return 1;
if (!b.dueDate) return -1;
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime();
});
break;
case 'created':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'title':
sorted.sort((a, b) => a.title.localeCompare(b.title));
break;
case 'assignee':
sorted.sort((a, b) => {
const aName = a.assignees?.[0]?.user?.name || '';
const bName = b.assignees?.[0]?.user?.name || '';
return aName.localeCompare(bName);
});
break;
}
return sorted;
}
// ─── Archived cards ─────────────────────────────────
async function toggleArchivedCards() {
showArchivedCards = !showArchivedCards;
@@ -346,6 +382,32 @@
// ─── Card updated from modal ─────────────────────
function handleCardUpdate(updatedCard: any) {
// Handle unarchive: add card back to column's active cards
if (updatedCard.archived === false) {
const wasArchived = archivedCards.find((c: any) => c.id === updatedCard.id);
if (wasArchived) {
archivedCards = archivedCards.filter((c: any) => c.id !== updatedCard.id);
const col = columns.find((c: any) => c.id === updatedCard.columnId);
if (col) {
col.cards = [...col.cards, updatedCard];
}
return;
}
}
// Handle archive: remove card from column's active cards
if (updatedCard.archived === true) {
for (const col of columns) {
const idx = col.cards.findIndex((c: any) => c.id === updatedCard.id);
if (idx !== -1) {
col.cards = col.cards.filter((c: any) => c.id !== updatedCard.id);
archivedCards = [...archivedCards, updatedCard];
break;
}
}
return;
}
for (const col of columns) {
const idx = col.cards.findIndex((c: any) => c.id === updatedCard.id);
if (idx !== -1) {
@@ -364,16 +426,22 @@
<CardModal
card={selectedCard}
boardId={data.board.id}
columnId={columns.find((c: any) => c.cards.some((card: any) => card.id === selectedCard.id))?.id}
columnId={columns.find((c: any) => c.cards.some((card: any) => card.id === selectedCard.id))?.id ?? archivedCards.find((c: any) => c.id === selectedCard.id)?.columnId}
canEdit={data.canEdit}
boardMembers={data.board.members}
currentUserId={data.user?.id || ''}
cardPrefix={data.board.cardPrefix}
onclose={() => (selectedCard = null)}
ondelete={(cardId) => {
const col = columns.find((c: any) => c.cards.some((card: any) => card.id === cardId));
if (col) deleteCard(col.id, cardId);
}}
onupdate={handleCardUpdate}
oncopied={(newCard) => {
const col = columns.find((c: any) => c.id === newCard.columnId);
if (col) col.cards = [...col.cards, newCard];
toast.success('Card copied');
}}
/>
{/if}
@@ -388,6 +456,17 @@
</svg>
</a>
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">{data.board.title}</h1>
<div class="flex gap-1 bg-gray-100 dark:bg-gray-800 rounded-lg p-0.5">
<span class="px-3 py-1 text-xs rounded-md bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100">
Board
</span>
<a
href="/boards/{data.board.id}/calendar"
class="px-3 py-1 text-xs rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Calendar
</a>
</div>
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-400">
{data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
</span>
@@ -409,6 +488,21 @@
</div>
{/if}
{#if data.canEdit}
<button
onclick={() => { selectMode = !selectMode; if (!selectMode) selectedCardIds = new Set(); }}
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
class:bg-gray-200={selectMode}
title="Select cards for bulk actions"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
Select
</button>
<BoardBackgroundPicker boardId={data.board.id} currentBackground={data.board.background || null} />
{/if}
<button
onclick={() => toggleArchivedCards()}
class="ml-auto rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
@@ -423,6 +517,19 @@
Archived
</button>
<a
href="/api/boards/{data.board.id}/export"
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
title="Export board"
aria-label="Export board"
download
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
Export
</a>
<button
onclick={() => (showActivity = !showActivity)}
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300 transition flex items-center gap-1"
@@ -447,7 +554,7 @@
</div>
<!-- Main area: columns + optional activity sidebar -->
<div class="flex-1 flex overflow-hidden">
<div class="flex-1 flex overflow-hidden" style="{data.board.background ? `background: ${data.board.background}; background-size: cover; background-position: center;` : ''}">
<!-- Columns container -->
<div class="flex-1 overflow-x-auto p-4">
<div
@@ -462,7 +569,8 @@
onfinalize={handleColumnSort}
>
{#each columns as column (column.id)}
{@const visibleCards = filterCards(column.cards)}
{@const colSort = columnSorts[column.id] ?? null}
{@const visibleCards = sortCards(filterCards(column.cards), colSort)}
<div
class="flex-shrink-0 w-64 sm:w-72 rounded-xl flex flex-col max-h-full"
style="background: var(--color-column-bg); border: 1px solid var(--color-column-border); color: var(--color-column-text); backdrop-filter: var(--column-backdrop-blur, none);"
@@ -503,7 +611,11 @@
<span class="ml-1 text-xs text-gray-400 dark:text-gray-500 font-normal">{visibleCards.length}{#if hasActiveFilters}/{column.cards.length}{/if}</span>
</button>
{/if}
{#if data.canEdit}
<ColumnSortMenu
currentSort={colSort}
onsort={(sort) => (columnSorts[column.id] = sort)}
/>
{#if data.canEdit}
<button
onclick={() => deleteColumn(column.id)}
class="text-gray-400 dark:text-gray-500 hover:text-[var(--color-danger)] transition p-1"
@@ -524,7 +636,7 @@
items: visibleCards,
flipDurationMs,
type: 'cards',
dragDisabled: !data.canEdit || hasActiveFilters
dragDisabled: !data.canEdit || hasActiveFilters || selectMode || !!colSort
}}
onconsider={(e) => handleCardSort(column.id, e)}
onfinalize={(e) => handleCardSort(column.id, e)}
@@ -533,10 +645,19 @@
{@const progress = getChecklistProgress(card)}
{@const dueUrgency = getDueUrgency(card.dueDate)}
<button
class="w-full text-left mb-2 p-3 shadow-sm hover:shadow-md transition cursor-pointer"
class="w-full text-left mb-2 p-3 shadow-sm hover:shadow-md transition cursor-pointer {selectMode && selectedCardIds.has(card.id) ? 'ring-2 ring-[var(--color-primary)]' : ''}"
style="background: var(--color-card-bg); border: 1px solid var(--color-card-border); color: var(--color-card-text); border-radius: var(--color-card-radius); backdrop-filter: var(--card-backdrop-blur, none);"
animate:flip={{ duration: flipDurationMs }}
onclick={() => (selectedCard = card)}
onclick={() => {
if (selectMode) {
const next = new Set(selectedCardIds);
if (next.has(card.id)) next.delete(card.id);
else next.add(card.id);
selectedCardIds = next;
} else {
selectedCard = card;
}
}}
>
{#if card.labels && card.labels.length > 0}
<div class="flex flex-wrap gap-1 mb-1.5">
@@ -550,6 +671,9 @@
{/each}
</div>
{/if}
{#if data.board.cardPrefix && card.cardNumber}
<span class="text-[10px] font-mono text-gray-400 dark:text-gray-500">{data.board.cardPrefix}-{card.cardNumber}</span>
{/if}
<span class="text-sm text-gray-800 dark:text-gray-200">{card.title}</span>
{#if card.dueDate || card._count?.comments > 0 || card._count?.attachments > 0 || progress}
<div class="flex items-center gap-2 mt-2 text-xs text-gray-400 dark:text-gray-500 flex-wrap">
@@ -609,9 +733,12 @@
<div class="px-2 pb-2">
<div class="text-[10px] text-gray-400 dark:text-gray-500 uppercase tracking-wide mb-1 px-1">Archived</div>
{#each archivedInCol as card}
<div class="mb-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 p-2.5 border border-gray-200 dark:border-gray-700 opacity-60">
<button
onclick={() => (selectedCard = card)}
class="w-full text-left mb-1.5 rounded-lg bg-gray-100 dark:bg-gray-800 p-2.5 border border-gray-200 dark:border-gray-700 opacity-60 hover:opacity-80 transition cursor-pointer"
>
<span class="text-sm text-gray-500 dark:text-gray-400">{card.title}</span>
</div>
</button>
{/each}
</div>
{/if}
@@ -729,3 +856,21 @@
{/if}
</div>
</div>
{#if selectMode && selectedCardIds.size > 0}
<BulkToolbar
boardId={data.board.id}
{selectedCardIds}
{columns}
onaction={() => {
selectMode = false;
selectedCardIds = new Set();
// Reload page data
window.location.reload();
}}
oncancel={() => {
selectMode = false;
selectedCardIds = new Set();
}}
/>
{/if}