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
+7
View File
@@ -27,5 +27,12 @@ else
echo "WARNING: Failed to apply RLS policies. Continuing anyway..."
fi
echo "Applying full-text search indexes..."
if psql "$DATABASE_URL" -f ./scripts/setup-search.sql; then
echo "Search indexes applied successfully."
else
echo "WARNING: Failed to apply search indexes. Continuing anyway..."
fi
echo "Starting server..."
exec node server.js
+53
View File
@@ -36,6 +36,59 @@ async function main() {
}
});
// Seed system templates (tenantId: null)
const systemTemplates = [
{
name: 'Basic Kanban',
description: 'Simple three-column workflow',
template: { columns: [{ title: 'To Do' }, { title: 'In Progress' }, { title: 'Done' }] }
},
{
name: 'Scrum Sprint',
description: 'Sprint-based agile workflow',
template: {
columns: [
{ title: 'Backlog' },
{ title: 'Sprint' },
{ title: 'In Review' },
{ title: 'Done' }
]
}
},
{
name: 'Bug Tracker',
description: 'Issue lifecycle tracking',
template: {
columns: [
{ title: 'New' },
{ title: 'Confirmed' },
{ title: 'In Progress' },
{ title: 'Resolved' },
{ title: 'Closed' }
]
}
}
];
for (const tpl of systemTemplates) {
const existing = await prisma.boardTemplate.findFirst({
where: { tenantId: null, name: tpl.name }
});
if (!existing) {
await prisma.boardTemplate.create({
data: {
tenantId: null,
name: tpl.name,
description: tpl.description,
template: tpl.template
}
});
console.log(` Created system template: ${tpl.name}`);
} else {
console.log(` System template exists: ${tpl.name}`);
}
}
console.log('Seed complete.');
console.log(' Admin: admin@example.com / admin123');
}
+9
View File
@@ -0,0 +1,9 @@
-- Full-text search on cards
-- Generated tsvector column + GIN index for fast searching
ALTER TABLE cards ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, ''))
) STORED;
CREATE INDEX IF NOT EXISTS cards_search_idx ON cards USING gin(search_vector);
+167
View File
@@ -0,0 +1,167 @@
<script lang="ts">
import { replaceState } from '$app/navigation';
import { page } from '$app/stores';
type Props = {
columns: any[];
members: any[];
onfilter: (filters: FilterState) => void;
};
export type FilterState = {
labelIds: string[];
assigneeIds: string[];
due: string | null; // 'overdue' | 'due-soon' | 'no-date' | null
};
let { columns, members, onfilter }: Props = $props();
// Collect all unique tags from all cards across all columns
let allTags = $derived(() => {
const tagMap = new Map<string, { id: string; name: string; color: string }>();
for (const col of columns) {
for (const card of col.cards) {
for (const label of card.labels || []) {
if (!tagMap.has(label.tag.id)) {
tagMap.set(label.tag.id, label.tag);
}
}
}
}
return Array.from(tagMap.values());
});
let selectedLabels = $state<string[]>([]);
let selectedAssignees = $state<string[]>([]);
let selectedDue = $state<string | null>(null);
let expanded = $state(false);
// Parse URL params on init
$effect(() => {
const params = $page.url.searchParams;
const labelParam = params.getAll('label');
const assigneeParam = params.getAll('assignee');
const dueParam = params.get('due');
if (labelParam.length) selectedLabels = labelParam;
if (assigneeParam.length) selectedAssignees = assigneeParam;
if (dueParam) selectedDue = dueParam;
});
let hasFilters = $derived(selectedLabels.length > 0 || selectedAssignees.length > 0 || selectedDue !== null);
function emitFilters() {
const filters: FilterState = {
labelIds: selectedLabels,
assigneeIds: selectedAssignees,
due: selectedDue
};
onfilter(filters);
// Update URL params
const url = new URL($page.url);
url.searchParams.delete('label');
url.searchParams.delete('assignee');
url.searchParams.delete('due');
for (const id of selectedLabels) url.searchParams.append('label', id);
for (const id of selectedAssignees) url.searchParams.append('assignee', id);
if (selectedDue) url.searchParams.set('due', selectedDue);
replaceState(url, {});
}
function toggleLabel(id: string) {
if (selectedLabels.includes(id)) {
selectedLabels = selectedLabels.filter((l) => l !== id);
} else {
selectedLabels = [...selectedLabels, id];
}
emitFilters();
}
function toggleAssignee(id: string) {
if (selectedAssignees.includes(id)) {
selectedAssignees = selectedAssignees.filter((a) => a !== id);
} else {
selectedAssignees = [...selectedAssignees, id];
}
emitFilters();
}
function setDue(val: string | null) {
selectedDue = selectedDue === val ? null : val;
emitFilters();
}
function clearAll() {
selectedLabels = [];
selectedAssignees = [];
selectedDue = null;
emitFilters();
}
</script>
<div class="flex items-center gap-2 flex-wrap">
<button
onclick={() => (expanded = !expanded)}
class="inline-flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition {hasFilters ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z" clip-rule="evenodd" />
</svg>
Filter
{#if hasFilters}
<span class="rounded-full bg-white/30 px-1.5 text-[10px]">
{selectedLabels.length + selectedAssignees.length + (selectedDue ? 1 : 0)}
</span>
{/if}
</button>
{#if hasFilters}
<button onclick={clearAll} class="text-xs text-gray-400 hover:text-gray-600 transition">Clear</button>
{/if}
{#if expanded}
<!-- Labels -->
{#if allTags().length > 0}
<div class="flex items-center gap-1">
<span class="text-[10px] text-gray-400 uppercase tracking-wide">Labels:</span>
{#each allTags() as tag}
<button
onclick={() => toggleLabel(tag.id)}
class="rounded px-1.5 py-0.5 text-[10px] transition border {selectedLabels.includes(tag.id) ? 'ring-2 ring-offset-1 ring-gray-400' : 'border-transparent'}"
style="background-color: {tag.color}; color: white;"
>
{tag.name}
</button>
{/each}
</div>
{/if}
<!-- Assignees -->
{#if members.length > 0}
<div class="flex items-center gap-1">
<span class="text-[10px] text-gray-400 uppercase tracking-wide">Assignee:</span>
{#each members as member}
<button
onclick={() => toggleAssignee(member.user.id)}
class="rounded-full px-2 py-0.5 text-[10px] transition {selectedAssignees.includes(member.user.id) ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
{member.user.name}
</button>
{/each}
</div>
{/if}
<!-- Due date -->
<div class="flex items-center gap-1">
<span class="text-[10px] text-gray-400 uppercase tracking-wide">Due:</span>
{#each [{ val: 'overdue', label: 'Overdue' }, { val: 'due-soon', label: 'Due soon' }, { val: 'no-date', label: 'No date' }] as opt}
<button
onclick={() => setDue(opt.val)}
class="rounded-full px-2 py-0.5 text-[10px] transition {selectedDue === opt.val ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
>
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
+95
View File
@@ -0,0 +1,95 @@
<script lang="ts">
import { goto } from '$app/navigation';
let query = $state('');
let results = $state<any[]>([]);
let open = $state(false);
let loading = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
let containerEl: HTMLDivElement;
function debounceSearch(q: string) {
clearTimeout(debounceTimer);
if (!q.trim()) {
results = [];
open = false;
return;
}
loading = true;
debounceTimer = setTimeout(() => doSearch(q), 300);
}
async function doSearch(q: string) {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&limit=10`);
if (res.ok) {
const data = await res.json();
results = data.results;
open = results.length > 0;
}
} finally {
loading = false;
}
}
function navigateToCard(result: any) {
open = false;
query = '';
results = [];
goto(`/boards/${result.boardId}?card=${result.id}`);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
query = '';
results = [];
}
}
function handleClickOutside(e: MouseEvent) {
if (containerEl && !containerEl.contains(e.target as Node)) {
open = false;
}
}
</script>
<svelte:document onclick={handleClickOutside} />
<div class="relative" bind:this={containerEl}>
<div class="relative">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50 pointer-events-none" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
</svg>
<input
type="text"
placeholder="Search cards..."
bind:value={query}
oninput={() => debounceSearch(query)}
onkeydown={handleKeydown}
onfocus={() => { if (results.length > 0) open = true; }}
class="w-56 rounded-lg bg-white/20 pl-8 pr-3 py-1.5 text-sm text-white placeholder-white/50 focus:bg-white/30 focus:outline-none focus:ring-1 focus:ring-white/40 transition"
/>
{#if loading}
<div class="absolute right-2.5 top-1/2 -translate-y-1/2">
<div class="w-3.5 h-3.5 border-2 border-white/30 border-t-white/80 rounded-full animate-spin"></div>
</div>
{/if}
</div>
{#if open}
<div class="absolute top-full mt-1 left-0 w-80 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-96 overflow-y-auto">
{#each results as result}
<button
class="w-full text-left px-3 py-2.5 hover:bg-gray-50 transition border-b border-gray-100 last:border-0"
onclick={() => navigateToCard(result)}
>
<div class="text-sm font-medium text-gray-800 truncate">{result.title}</div>
<div class="text-xs text-gray-500 mt-0.5 truncate">
{result.boardTitle} &middot; {result.columnTitle}
</div>
</button>
{/each}
</div>
{/if}
</div>
+2 -1
View File
@@ -15,7 +15,8 @@ export const boardSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(2000).optional(),
visibility: z.enum(['PUBLIC', 'PRIVATE']).default('PRIVATE'),
categoryId: z.string().uuid().optional()
categoryId: z.string().uuid().optional(),
templateId: z.string().uuid().optional()
});
export const columnSchema = z.object({
+5
View File
@@ -4,6 +4,7 @@
import { onMount } from 'svelte';
import { connectSocket, disconnectSocket } from '$lib/stores/socket.js';
import NotificationBell from '$lib/components/NotificationBell.svelte';
import SearchBar from '$lib/components/SearchBar.svelte';
let { children, data }: { children: Snippet; data: { user: any; tenant: any } } = $props();
@@ -29,6 +30,10 @@
{data.tenant?.name ?? 'Pounce'}
</a>
{#if data.user}
<SearchBar />
{/if}
<div class="flex items-center gap-3">
{#if data.user}
{#if data.user.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'}
+36 -2
View File
@@ -3,6 +3,8 @@ import type { RequestHandler } from './$types.js';
import { boardSchema } from '$lib/server/validation.js';
import { withTenant } from '$lib/server/rls.js';
import { requireAuth } from '$lib/server/guards.js';
import { prisma } from '$lib/server/db.js';
import { generatePositions } from '$lib/utils/fractional-index.js';
// List boards for current tenant
export const GET: RequestHandler = async ({ locals }) => {
@@ -50,10 +52,26 @@ export const POST: RequestHandler = async ({ request, locals }) => {
return json({ error: result.error.issues[0].message }, { status: 400 });
}
const { title, description, visibility, categoryId } = result.data;
const { title, description, visibility, categoryId, templateId } = result.data;
// Load template columns if templateId provided
let templateColumns: { title: string }[] = [];
if (templateId) {
// System templates have null tenantId, so query without RLS
const template = await prisma.boardTemplate.findFirst({
where: {
id: templateId,
OR: [{ tenantId: null }, { tenantId: tenant.id }]
}
});
if (template) {
const tpl = template.template as { columns?: { title: string }[] };
templateColumns = tpl.columns || [];
}
}
const board = await withTenant(tenant.id, async (tx) => {
return tx.board.create({
const newBoard = await tx.board.create({
data: {
tenantId: tenant.id,
title,
@@ -73,6 +91,22 @@ export const POST: RequestHandler = async ({ request, locals }) => {
}
}
});
// Create columns from template
if (templateColumns.length > 0) {
const positions = generatePositions(templateColumns.length);
for (let i = 0; i < templateColumns.length; i++) {
await tx.column.create({
data: {
boardId: newBoard.id,
title: templateColumns[i].title,
position: positions[i]
}
});
}
}
return newBoard;
});
return json({ board }, { status: 201 });
@@ -0,0 +1,31 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireAuth, requireBoardView } from '$lib/server/guards.js';
export const GET: RequestHandler = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireAuth(locals.user);
const cards = await withTenant(tenant.id, async (tx) => {
// Verify user has access to this board
await requireBoardView(tx, locals.user, params.boardId, tenant.id);
return tx.card.findMany({
where: {
archived: true,
column: { board: { id: params.boardId, tenantId: tenant.id } }
},
include: {
labels: { include: { tag: true } },
assignees: {
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
}
},
orderBy: { updatedAt: 'desc' }
});
});
return json({ cards });
};
+43
View File
@@ -0,0 +1,43 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireAuth } from '$lib/server/guards.js';
import { prisma } from '$lib/server/db.js';
export const GET: RequestHandler = async ({ url, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireAuth(locals.user);
const q = url.searchParams.get('q')?.trim();
if (!q) return json({ results: [] });
const limit = Math.min(parseInt(url.searchParams.get('limit') || '10'), 50);
const results = await withTenant(tenant.id, async (tx) => {
const cards: any[] = await tx.$queryRaw`
SELECT
c.id,
c.title,
c.description,
c.column_id AS "columnId",
col.title AS "columnTitle",
b.id AS "boardId",
b.title AS "boardTitle"
FROM cards c
JOIN columns col ON col.id = c.column_id
JOIN boards b ON b.id = col.board_id
LEFT JOIN board_members bm ON bm.board_id = b.id AND bm.user_id = ${locals.user!.id}::uuid
WHERE c.search_vector @@ plainto_tsquery('english', ${q})
AND b.tenant_id = ${tenant.id}::uuid
AND c.archived = false
AND b.archived = false
AND (b.visibility = 'PUBLIC' OR bm.user_id IS NOT NULL)
ORDER BY ts_rank(c.search_vector, plainto_tsquery('english', ${q})) DESC
LIMIT ${limit}
`;
return cards;
});
return json({ results });
};
+72
View File
@@ -0,0 +1,72 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireAuth } from '$lib/server/guards.js';
import { prisma } from '$lib/server/db.js';
// List system + tenant templates
export const GET: RequestHandler = async ({ locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireAuth(locals.user);
// System templates bypass RLS (tenantId is null)
// Tenant templates go through RLS
const [systemTemplates, tenantTemplates] = await Promise.all([
prisma.boardTemplate.findMany({
where: { tenantId: null },
orderBy: { name: 'asc' }
}),
withTenant(tenant.id, async (tx) => {
return tx.boardTemplate.findMany({
where: { tenantId: tenant.id },
orderBy: { name: 'asc' }
});
})
]);
return json({ templates: [...systemTemplates, ...tenantTemplates] });
};
// Save a board as a tenant template
export const POST: RequestHandler = async ({ request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireAuth(locals.user);
const body = await request.json();
const { name, description, boardId } = body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return json({ error: 'Name is required' }, { status: 400 });
}
if (!boardId || typeof boardId !== 'string') {
return json({ error: 'Board ID is required' }, { status: 400 });
}
const template = await withTenant(tenant.id, async (tx) => {
// Fetch the board's columns
const board = await tx.board.findFirst({
where: { id: boardId, tenantId: tenant.id },
include: {
columns: { orderBy: { position: 'asc' }, select: { title: true } }
}
});
if (!board) throw error(404, 'Board not found');
const templateJson = {
columns: board.columns.map((c) => ({ title: c.title }))
};
return tx.boardTemplate.create({
data: {
tenantId: tenant.id,
name: name.trim(),
description: description?.trim() || null,
template: templateJson
}
});
});
return json({ template }, { status: 201 });
};
+28 -4
View File
@@ -1,12 +1,18 @@
import type { PageServerLoad } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { prisma } from '$lib/server/db.js';
export const load: PageServerLoad = async ({ locals }) => {
export const load: PageServerLoad = async ({ locals, url }) => {
const tenant = locals.tenant;
if (!tenant) return { boards: [], categories: [] };
if (!tenant) return { boards: [], categories: [], templates: [] };
const showArchived = url.searchParams.get('archived') === 'true';
const result = await withTenant(tenant.id, async (tx) => {
const where: any = { tenantId: tenant.id, archived: false };
const where: any = { tenantId: tenant.id };
if (!showArchived) {
where.archived = false;
}
if (!locals.user) {
where.visibility = 'PUBLIC';
@@ -40,5 +46,23 @@ export const load: PageServerLoad = async ({ locals }) => {
return { boards, categories };
});
return result;
// Fetch templates (system + tenant) — only if logged in
let templates: any[] = [];
if (locals.user) {
const [systemTemplates, tenantTemplates] = await Promise.all([
prisma.boardTemplate.findMany({
where: { tenantId: null },
orderBy: { name: 'asc' }
}),
withTenant(tenant.id, async (tx) => {
return tx.boardTemplate.findMany({
where: { tenantId: tenant.id },
orderBy: { name: 'asc' }
});
})
]);
templates = [...systemTemplates, ...tenantTemplates];
}
return { ...result, templates };
};
+67 -15
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import { replaceState } from '$app/navigation';
let { data } = $props();
@@ -7,9 +9,11 @@
let newTitle = $state('');
let newVisibility = $state<'PRIVATE' | 'PUBLIC'>('PRIVATE');
let newCategoryId = $state('');
let newTemplateId = $state('');
let creating = $state(false);
let error = $state('');
let filterCategoryId = $state('');
let showArchived = $state($page.url.searchParams.get('archived') === 'true');
const filteredBoards = $derived(
filterCategoryId
@@ -17,6 +21,18 @@
: data.boards
);
function toggleArchived() {
showArchived = !showArchived;
const url = new URL($page.url);
if (showArchived) {
url.searchParams.set('archived', 'true');
} else {
url.searchParams.delete('archived');
}
replaceState(url, {});
invalidateAll();
}
async function createBoard(e: Event) {
e.preventDefault();
if (!newTitle.trim()) return;
@@ -30,7 +46,8 @@
body: JSON.stringify({
title: newTitle,
visibility: newVisibility,
...(newCategoryId ? { categoryId: newCategoryId } : {})
...(newCategoryId ? { categoryId: newCategoryId } : {}),
...(newTemplateId ? { templateId: newTemplateId } : {})
})
});
const result = await res.json();
@@ -40,6 +57,7 @@
}
newTitle = '';
newCategoryId = '';
newTemplateId = '';
showCreate = false;
invalidateAll();
} catch {
@@ -57,14 +75,26 @@
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Boards</h1>
{#if data.user}
<button
onclick={() => (showCreate = !showCreate)}
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition"
>
+ New Board
</button>
{/if}
<div class="flex items-center gap-3">
{#if data.user}
<button
onclick={toggleArchived}
class="rounded-lg px-3 py-2 text-sm transition flex items-center gap-1.5 {showArchived ? 'bg-gray-200 text-gray-700' : 'text-gray-500 hover:bg-gray-100'}"
>
<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>
{showArchived ? 'Showing archived' : 'Show archived'}
</button>
<button
onclick={() => (showCreate = !showCreate)}
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition"
>
+ New Board
</button>
{/if}
</div>
</div>
{#if showCreate}
@@ -110,6 +140,21 @@
</select>
</div>
{/if}
{#if data.templates.length > 0}
<div>
<label for="board-tpl" class="block text-sm font-medium text-gray-700 mb-1">Template</label>
<select
id="board-tpl"
bind:value={newTemplateId}
class="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="">No template</option>
{#each data.templates as tpl}
<option value={tpl.id}>{tpl.name}{tpl.tenantId ? '' : ' (System)'}</option>
{/each}
</select>
</div>
{/if}
<button
type="submit"
disabled={creating}
@@ -167,17 +212,24 @@
{#each filteredBoards as board}
<a
href="/boards/{board.id}"
class="group block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md hover:border-gray-300 transition"
class="group block rounded-lg border border-gray-200 bg-white p-4 shadow-sm hover:shadow-md hover:border-gray-300 transition {board.archived ? 'opacity-50' : ''}"
>
<div class="flex items-start justify-between gap-2">
<h2 class="font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition">
{board.title}
</h2>
{#if board.category}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500 flex-shrink-0">
{board.category.name}
</span>
{/if}
<div class="flex items-center gap-1 flex-shrink-0">
{#if board.archived}
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-[10px] text-gray-500">
Archived
</span>
{/if}
{#if board.category}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500">
{board.category.name}
</span>
{/if}
</div>
</div>
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
<span>{board._count.columns} columns</span>
+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}