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