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>
|
||||
@@ -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({
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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