Private
Public Access
1
0

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:
Catherine Renelle
2026-02-26 00:42:20 -05:00
parent e5f2c64e14
commit 6ed0349507
14 changed files with 727 additions and 27 deletions
+112 -5
View File
@@ -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}