Phase 7: Search, filters, templates, and archive UI
Add full-text card search (PostgreSQL tsvector + GIN index) with navbar SearchBar, client-side board filters (label/assignee/due date), board templates (3 system templates + save-as-template API), and archive toggle for both board listing and board view. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
import { joinBoard, leaveBoard, onBoardEvent, offBoardEvent, getSocketId } from '$lib/stores/socket.js';
|
||||
import CardModal from '$lib/components/CardModal.svelte';
|
||||
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
|
||||
import BoardFilters from '$lib/components/BoardFilters.svelte';
|
||||
import type { FilterState } from '$lib/components/BoardFilters.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -27,9 +29,64 @@
|
||||
let editingColumnTitle = $state('');
|
||||
let viewingUsers = $state<any[]>([]);
|
||||
let showActivity = $state(false);
|
||||
let showArchivedCards = $state(false);
|
||||
let archivedCards = $state<any[]>([]);
|
||||
let activeFilters = $state<FilterState>({ labelIds: [], assigneeIds: [], due: null });
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
// ─── Filtering ──────────────────────────────────────
|
||||
let hasActiveFilters = $derived(
|
||||
activeFilters.labelIds.length > 0 || activeFilters.assigneeIds.length > 0 || activeFilters.due !== null
|
||||
);
|
||||
|
||||
function filterCards(cards: any[]): any[] {
|
||||
if (!hasActiveFilters) return cards;
|
||||
return cards.filter((card: any) => {
|
||||
// Label filter
|
||||
if (activeFilters.labelIds.length > 0) {
|
||||
const cardTagIds = (card.labels || []).map((l: any) => l.tag.id);
|
||||
if (!activeFilters.labelIds.some((id) => cardTagIds.includes(id))) return false;
|
||||
}
|
||||
// Assignee filter
|
||||
if (activeFilters.assigneeIds.length > 0) {
|
||||
const cardUserIds = (card.assignees || []).map((a: any) => a.user.id);
|
||||
if (!activeFilters.assigneeIds.some((id) => cardUserIds.includes(id))) return false;
|
||||
}
|
||||
// Due date filter
|
||||
if (activeFilters.due) {
|
||||
const now = new Date();
|
||||
const soon = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
if (activeFilters.due === 'overdue') {
|
||||
if (!card.dueDate || new Date(card.dueDate) >= now) return false;
|
||||
} else if (activeFilters.due === 'due-soon') {
|
||||
if (!card.dueDate) return false;
|
||||
const d = new Date(card.dueDate);
|
||||
if (d < now || d > soon) return false;
|
||||
} else if (activeFilters.due === 'no-date') {
|
||||
if (card.dueDate) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Archived cards ─────────────────────────────────
|
||||
async function toggleArchivedCards() {
|
||||
showArchivedCards = !showArchivedCards;
|
||||
if (showArchivedCards && archivedCards.length === 0) {
|
||||
const res = await fetch(`/api/boards/${data.board.id}/archived-cards`);
|
||||
if (res.ok) {
|
||||
const result = await res.json();
|
||||
archivedCards = result.cards;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getArchivedCardsForColumn(columnId: string): any[] {
|
||||
return archivedCards.filter((c: any) => c.columnId === columnId);
|
||||
}
|
||||
|
||||
// ─── Socket.IO header for self-dedup ─────────────
|
||||
function socketHeaders(): Record<string, string> {
|
||||
const sid = getSocketId();
|
||||
@@ -142,6 +199,18 @@
|
||||
onBoardEvent('board:deleted', onBoardDeleted);
|
||||
onBoardEvent('presence:update', onPresenceUpdate);
|
||||
|
||||
// Check for ?card= param to auto-open a card
|
||||
const cardParam = new URL(window.location.href).searchParams.get('card');
|
||||
if (cardParam) {
|
||||
for (const col of columns) {
|
||||
const found = col.cards.find((c: any) => c.id === cardParam);
|
||||
if (found) {
|
||||
selectedCard = found;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
leaveBoard(data.board.id);
|
||||
offBoardEvent('column:created', onColumnCreated);
|
||||
@@ -353,8 +422,21 @@
|
||||
{/if}
|
||||
|
||||
<button
|
||||
onclick={() => (showActivity = !showActivity)}
|
||||
onclick={() => toggleArchivedCards()}
|
||||
class="ml-auto rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 transition flex items-center gap-1"
|
||||
class:bg-gray-200={showArchivedCards}
|
||||
title="Toggle archived cards"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2 3a1 1 0 00-1 1v1a1 1 0 001 1h16a1 1 0 001-1V4a1 1 0 00-1-1H2z" />
|
||||
<path fill-rule="evenodd" d="M2 7.5h16l-.811 7.71a2 2 0 01-1.99 1.79H4.802a2 2 0 01-1.99-1.79L2 7.5zM7 11a1 1 0 011-1h4a1 1 0 110 2H8a1 1 0 01-1-1z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Archived
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => (showActivity = !showActivity)}
|
||||
class="rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 transition flex items-center gap-1"
|
||||
class:bg-gray-200={showActivity}
|
||||
title="Toggle activity feed"
|
||||
>
|
||||
@@ -365,6 +447,15 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters bar -->
|
||||
<div class="px-4 py-2 bg-white/60 border-b border-gray-100">
|
||||
<BoardFilters
|
||||
{columns}
|
||||
members={data.board.members}
|
||||
onfilter={(f) => (activeFilters = f)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Main area: columns + optional activity sidebar -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Columns container -->
|
||||
@@ -381,6 +472,7 @@
|
||||
onfinalize={handleColumnSort}
|
||||
>
|
||||
{#each columns as column (column.id)}
|
||||
{@const visibleCards = filterCards(column.cards)}
|
||||
<div
|
||||
class="flex-shrink-0 w-72 bg-[var(--color-column-bg)] rounded-xl flex flex-col max-h-full"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
@@ -410,7 +502,7 @@
|
||||
}}
|
||||
>
|
||||
{column.title}
|
||||
<span class="ml-1 text-xs text-gray-400 font-normal">{column.cards.length}</span>
|
||||
<span class="ml-1 text-xs text-gray-400 font-normal">{visibleCards.length}{#if hasActiveFilters}/{column.cards.length}{/if}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if data.canEdit}
|
||||
@@ -430,15 +522,15 @@
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-2 pb-2 min-h-[60px]"
|
||||
use:dndzone={{
|
||||
items: column.cards,
|
||||
items: visibleCards,
|
||||
flipDurationMs,
|
||||
type: 'cards',
|
||||
dragDisabled: !data.canEdit
|
||||
dragDisabled: !data.canEdit || hasActiveFilters
|
||||
}}
|
||||
onconsider={(e) => handleCardSort(column.id, e)}
|
||||
onfinalize={(e) => handleCardSort(column.id, e)}
|
||||
>
|
||||
{#each column.cards as card (card.id)}
|
||||
{#each visibleCards as card (card.id)}
|
||||
{@const progress = getChecklistProgress(card)}
|
||||
{@const dueUrgency = getDueUrgency(card.dueDate)}
|
||||
<button
|
||||
@@ -515,6 +607,21 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Archived cards for this column -->
|
||||
{#if showArchivedCards}
|
||||
{@const archivedInCol = getArchivedCardsForColumn(column.id)}
|
||||
{#if archivedInCol.length > 0}
|
||||
<div class="px-2 pb-2">
|
||||
<div class="text-[10px] text-gray-400 uppercase tracking-wide mb-1 px-1">Archived</div>
|
||||
{#each archivedInCol as card}
|
||||
<div class="mb-1.5 rounded-lg bg-gray-100 p-2.5 border border-gray-200 opacity-60">
|
||||
<span class="text-sm text-gray-500">{card.title}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Add card button -->
|
||||
{#if data.canEdit}
|
||||
{#if addingCardColumnId === column.id}
|
||||
|
||||
Reference in New Issue
Block a user