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:
@@ -27,5 +27,12 @@ else
|
|||||||
echo "WARNING: Failed to apply RLS policies. Continuing anyway..."
|
echo "WARNING: Failed to apply RLS policies. Continuing anyway..."
|
||||||
fi
|
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..."
|
echo "Starting server..."
|
||||||
exec node server.js
|
exec node server.js
|
||||||
|
|||||||
@@ -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('Seed complete.');
|
||||||
console.log(' Admin: admin@example.com / admin123');
|
console.log(' Admin: admin@example.com / admin123');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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),
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
visibility: z.enum(['PUBLIC', 'PRIVATE']).default('PRIVATE'),
|
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({
|
export const columnSchema = z.object({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { connectSocket, disconnectSocket } from '$lib/stores/socket.js';
|
import { connectSocket, disconnectSocket } from '$lib/stores/socket.js';
|
||||||
import NotificationBell from '$lib/components/NotificationBell.svelte';
|
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();
|
let { children, data }: { children: Snippet; data: { user: any; tenant: any } } = $props();
|
||||||
|
|
||||||
@@ -29,6 +30,10 @@
|
|||||||
{data.tenant?.name ?? 'Pounce'}
|
{data.tenant?.name ?? 'Pounce'}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{#if data.user}
|
||||||
|
<SearchBar />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
{#if data.user}
|
{#if data.user}
|
||||||
{#if data.user.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'}
|
{#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 { boardSchema } from '$lib/server/validation.js';
|
||||||
import { withTenant } from '$lib/server/rls.js';
|
import { withTenant } from '$lib/server/rls.js';
|
||||||
import { requireAuth } from '$lib/server/guards.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
|
// List boards for current tenant
|
||||||
export const GET: RequestHandler = async ({ locals }) => {
|
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 });
|
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) => {
|
const board = await withTenant(tenant.id, async (tx) => {
|
||||||
return tx.board.create({
|
const newBoard = await tx.board.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
title,
|
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 });
|
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 type { PageServerLoad } from './$types.js';
|
||||||
import { withTenant } from '$lib/server/rls.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;
|
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 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) {
|
if (!locals.user) {
|
||||||
where.visibility = 'PUBLIC';
|
where.visibility = 'PUBLIC';
|
||||||
@@ -40,5 +46,23 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
return { boards, categories };
|
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">
|
<script lang="ts">
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { replaceState } from '$app/navigation';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -7,9 +9,11 @@
|
|||||||
let newTitle = $state('');
|
let newTitle = $state('');
|
||||||
let newVisibility = $state<'PRIVATE' | 'PUBLIC'>('PRIVATE');
|
let newVisibility = $state<'PRIVATE' | 'PUBLIC'>('PRIVATE');
|
||||||
let newCategoryId = $state('');
|
let newCategoryId = $state('');
|
||||||
|
let newTemplateId = $state('');
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let filterCategoryId = $state('');
|
let filterCategoryId = $state('');
|
||||||
|
let showArchived = $state($page.url.searchParams.get('archived') === 'true');
|
||||||
|
|
||||||
const filteredBoards = $derived(
|
const filteredBoards = $derived(
|
||||||
filterCategoryId
|
filterCategoryId
|
||||||
@@ -17,6 +21,18 @@
|
|||||||
: data.boards
|
: 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) {
|
async function createBoard(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!newTitle.trim()) return;
|
if (!newTitle.trim()) return;
|
||||||
@@ -30,7 +46,8 @@
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title: newTitle,
|
title: newTitle,
|
||||||
visibility: newVisibility,
|
visibility: newVisibility,
|
||||||
...(newCategoryId ? { categoryId: newCategoryId } : {})
|
...(newCategoryId ? { categoryId: newCategoryId } : {}),
|
||||||
|
...(newTemplateId ? { templateId: newTemplateId } : {})
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
@@ -40,6 +57,7 @@
|
|||||||
}
|
}
|
||||||
newTitle = '';
|
newTitle = '';
|
||||||
newCategoryId = '';
|
newCategoryId = '';
|
||||||
|
newTemplateId = '';
|
||||||
showCreate = false;
|
showCreate = false;
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -57,7 +75,18 @@
|
|||||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
<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">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Boards</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Boards</h1>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
{#if data.user}
|
{#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
|
<button
|
||||||
onclick={() => (showCreate = !showCreate)}
|
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"
|
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition"
|
||||||
@@ -66,6 +95,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if showCreate}
|
{#if showCreate}
|
||||||
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
||||||
@@ -110,6 +140,21 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={creating}
|
disabled={creating}
|
||||||
@@ -167,18 +212,25 @@
|
|||||||
{#each filteredBoards as board}
|
{#each filteredBoards as board}
|
||||||
<a
|
<a
|
||||||
href="/boards/{board.id}"
|
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">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<h2 class="font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition">
|
<h2 class="font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition">
|
||||||
{board.title}
|
{board.title}
|
||||||
</h2>
|
</h2>
|
||||||
|
<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}
|
{#if board.category}
|
||||||
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500 flex-shrink-0">
|
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-[10px] text-gray-500">
|
||||||
{board.category.name}
|
{board.category.name}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
|
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
|
||||||
<span>{board._count.columns} columns</span>
|
<span>{board._count.columns} columns</span>
|
||||||
<span>{board.visibility === 'PUBLIC' ? 'Public' : 'Private'}</span>
|
<span>{board.visibility === 'PUBLIC' ? 'Public' : 'Private'}</span>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import { joinBoard, leaveBoard, onBoardEvent, offBoardEvent, getSocketId } from '$lib/stores/socket.js';
|
import { joinBoard, leaveBoard, onBoardEvent, offBoardEvent, getSocketId } from '$lib/stores/socket.js';
|
||||||
import CardModal from '$lib/components/CardModal.svelte';
|
import CardModal from '$lib/components/CardModal.svelte';
|
||||||
import ActivityFeed from '$lib/components/ActivityFeed.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();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -27,9 +29,64 @@
|
|||||||
let editingColumnTitle = $state('');
|
let editingColumnTitle = $state('');
|
||||||
let viewingUsers = $state<any[]>([]);
|
let viewingUsers = $state<any[]>([]);
|
||||||
let showActivity = $state(false);
|
let showActivity = $state(false);
|
||||||
|
let showArchivedCards = $state(false);
|
||||||
|
let archivedCards = $state<any[]>([]);
|
||||||
|
let activeFilters = $state<FilterState>({ labelIds: [], assigneeIds: [], due: null });
|
||||||
|
|
||||||
const flipDurationMs = 200;
|
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 ─────────────
|
// ─── Socket.IO header for self-dedup ─────────────
|
||||||
function socketHeaders(): Record<string, string> {
|
function socketHeaders(): Record<string, string> {
|
||||||
const sid = getSocketId();
|
const sid = getSocketId();
|
||||||
@@ -142,6 +199,18 @@
|
|||||||
onBoardEvent('board:deleted', onBoardDeleted);
|
onBoardEvent('board:deleted', onBoardDeleted);
|
||||||
onBoardEvent('presence:update', onPresenceUpdate);
|
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 () => {
|
return () => {
|
||||||
leaveBoard(data.board.id);
|
leaveBoard(data.board.id);
|
||||||
offBoardEvent('column:created', onColumnCreated);
|
offBoardEvent('column:created', onColumnCreated);
|
||||||
@@ -353,8 +422,21 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<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="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}
|
class:bg-gray-200={showActivity}
|
||||||
title="Toggle activity feed"
|
title="Toggle activity feed"
|
||||||
>
|
>
|
||||||
@@ -365,6 +447,15 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Main area: columns + optional activity sidebar -->
|
||||||
<div class="flex-1 flex overflow-hidden">
|
<div class="flex-1 flex overflow-hidden">
|
||||||
<!-- Columns container -->
|
<!-- Columns container -->
|
||||||
@@ -381,6 +472,7 @@
|
|||||||
onfinalize={handleColumnSort}
|
onfinalize={handleColumnSort}
|
||||||
>
|
>
|
||||||
{#each columns as column (column.id)}
|
{#each columns as column (column.id)}
|
||||||
|
{@const visibleCards = filterCards(column.cards)}
|
||||||
<div
|
<div
|
||||||
class="flex-shrink-0 w-72 bg-[var(--color-column-bg)] rounded-xl flex flex-col max-h-full"
|
class="flex-shrink-0 w-72 bg-[var(--color-column-bg)] rounded-xl flex flex-col max-h-full"
|
||||||
animate:flip={{ duration: flipDurationMs }}
|
animate:flip={{ duration: flipDurationMs }}
|
||||||
@@ -410,7 +502,7 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{column.title}
|
{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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.canEdit}
|
{#if data.canEdit}
|
||||||
@@ -430,15 +522,15 @@
|
|||||||
<div
|
<div
|
||||||
class="flex-1 overflow-y-auto px-2 pb-2 min-h-[60px]"
|
class="flex-1 overflow-y-auto px-2 pb-2 min-h-[60px]"
|
||||||
use:dndzone={{
|
use:dndzone={{
|
||||||
items: column.cards,
|
items: visibleCards,
|
||||||
flipDurationMs,
|
flipDurationMs,
|
||||||
type: 'cards',
|
type: 'cards',
|
||||||
dragDisabled: !data.canEdit
|
dragDisabled: !data.canEdit || hasActiveFilters
|
||||||
}}
|
}}
|
||||||
onconsider={(e) => handleCardSort(column.id, e)}
|
onconsider={(e) => handleCardSort(column.id, e)}
|
||||||
onfinalize={(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 progress = getChecklistProgress(card)}
|
||||||
{@const dueUrgency = getDueUrgency(card.dueDate)}
|
{@const dueUrgency = getDueUrgency(card.dueDate)}
|
||||||
<button
|
<button
|
||||||
@@ -515,6 +607,21 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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 -->
|
<!-- Add card button -->
|
||||||
{#if data.canEdit}
|
{#if data.canEdit}
|
||||||
{#if addingCardColumnId === column.id}
|
{#if addingCardColumnId === column.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user