095d9756a8
Add full CRUD + UI for all card sub-resources (tags/labels, assignees, checklists with items, comments with markdown, file attachments). Restructure CardModal into a two-column layout with lazy-loaded data. Upgrade card thumbnails with named label pills, urgency-colored due date badges, checklist progress indicators, and SVG icons. Add board categories with filtering on the listing page. Include markdown rendering with DOMPurify sanitization and .prose CSS styles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
208 lines
6.6 KiB
Svelte
208 lines
6.6 KiB
Svelte
<script lang="ts">
|
|
import { invalidateAll } from '$app/navigation';
|
|
|
|
let { data } = $props();
|
|
|
|
let showCreate = $state(false);
|
|
let newTitle = $state('');
|
|
let newVisibility = $state<'PRIVATE' | 'PUBLIC'>('PRIVATE');
|
|
let newCategoryId = $state('');
|
|
let creating = $state(false);
|
|
let error = $state('');
|
|
let filterCategoryId = $state('');
|
|
|
|
const filteredBoards = $derived(
|
|
filterCategoryId
|
|
? data.boards.filter((b: any) => b.categoryId === filterCategoryId)
|
|
: data.boards
|
|
);
|
|
|
|
async function createBoard(e: Event) {
|
|
e.preventDefault();
|
|
if (!newTitle.trim()) return;
|
|
creating = true;
|
|
error = '';
|
|
|
|
try {
|
|
const res = await fetch('/api/boards', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
title: newTitle,
|
|
visibility: newVisibility,
|
|
...(newCategoryId ? { categoryId: newCategoryId } : {})
|
|
})
|
|
});
|
|
const result = await res.json();
|
|
if (!res.ok) {
|
|
error = result.error || 'Failed to create board';
|
|
return;
|
|
}
|
|
newTitle = '';
|
|
newCategoryId = '';
|
|
showCreate = false;
|
|
invalidateAll();
|
|
} catch {
|
|
error = 'Network error';
|
|
} finally {
|
|
creating = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>Boards</title>
|
|
</svelte:head>
|
|
|
|
<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>
|
|
|
|
{#if showCreate}
|
|
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
|
|
{#if error}
|
|
<div class="mb-3 rounded bg-red-50 border border-red-200 p-2 text-sm text-red-700">{error}</div>
|
|
{/if}
|
|
<form onsubmit={createBoard} class="flex gap-3 items-end flex-wrap">
|
|
<div class="flex-1 min-w-[200px]">
|
|
<label for="board-title" class="block text-sm font-medium text-gray-700 mb-1">Board title</label>
|
|
<input
|
|
id="board-title"
|
|
type="text"
|
|
bind:value={newTitle}
|
|
required
|
|
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
|
placeholder="My new board"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label for="board-vis" class="block text-sm font-medium text-gray-700 mb-1">Visibility</label>
|
|
<select
|
|
id="board-vis"
|
|
bind:value={newVisibility}
|
|
class="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
|
>
|
|
<option value="PRIVATE">Private</option>
|
|
<option value="PUBLIC">Public</option>
|
|
</select>
|
|
</div>
|
|
{#if data.categories.length > 0}
|
|
<div>
|
|
<label for="board-cat" class="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
|
<select
|
|
id="board-cat"
|
|
bind:value={newCategoryId}
|
|
class="rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
|
>
|
|
<option value="">None</option>
|
|
{#each data.categories as cat}
|
|
<option value={cat.id}>{cat.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
{/if}
|
|
<button
|
|
type="submit"
|
|
disabled={creating}
|
|
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-primary-hover)] transition disabled:opacity-50"
|
|
>
|
|
{creating ? 'Creating...' : 'Create'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => (showCreate = false)}
|
|
class="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-600 hover:bg-gray-50 transition"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</form>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Category filter -->
|
|
{#if data.categories.length > 0}
|
|
<div class="flex items-center gap-2 mb-4">
|
|
<span class="text-sm text-gray-500">Filter:</span>
|
|
<button
|
|
class="rounded-full px-3 py-1 text-xs {filterCategoryId === '' ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'} transition"
|
|
onclick={() => (filterCategoryId = '')}
|
|
>
|
|
All
|
|
</button>
|
|
{#each data.categories as cat}
|
|
<button
|
|
class="rounded-full px-3 py-1 text-xs {filterCategoryId === cat.id ? 'bg-[var(--color-primary)] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'} transition"
|
|
onclick={() => (filterCategoryId = cat.id)}
|
|
>
|
|
{cat.name}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if filteredBoards.length === 0}
|
|
<div class="text-center py-16">
|
|
<svg class="mx-auto w-16 h-16 text-gray-300 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
<rect x="14" y="3" width="7" height="4" rx="1" />
|
|
<rect x="14" y="10" width="7" height="7" rx="1" />
|
|
<rect x="3" y="13" width="7" height="8" rx="1" />
|
|
</svg>
|
|
<p class="text-gray-500">{filterCategoryId ? 'No boards in this category.' : 'No boards yet.'}</p>
|
|
{#if data.user && !filterCategoryId}
|
|
<p class="text-gray-400 text-sm mt-1">Create your first board to get started.</p>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
{#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"
|
|
>
|
|
<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>
|
|
<div class="mt-2 flex items-center gap-3 text-xs text-gray-500">
|
|
<span>{board._count.columns} columns</span>
|
|
<span>{board.visibility === 'PUBLIC' ? 'Public' : 'Private'}</span>
|
|
</div>
|
|
{#if board.members.length > 0}
|
|
<div class="mt-3 flex -space-x-1">
|
|
{#each board.members.slice(0, 5) as member}
|
|
<div
|
|
class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-xs flex items-center justify-center border-2 border-white"
|
|
title={member.user.name}
|
|
>
|
|
{member.user.name[0].toUpperCase()}
|
|
</div>
|
|
{/each}
|
|
{#if board.members.length > 5}
|
|
<div class="w-6 h-6 rounded-full bg-gray-300 text-gray-600 text-xs flex items-center justify-center border-2 border-white">
|
|
+{board.members.length - 5}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|