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:
@@ -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>
|
||||
@@ -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} · {result.columnTitle}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user