Initial implementation: multi-tenant Kanban board (Phase 1)
Complete Phase 1 foundation with working board, column, and card CRUD: - SvelteKit + TypeScript + Tailwind CSS v4 + adapter-node - Full Prisma schema with 18 tables (tenants, users, boards, columns, cards, etc.) - Multi-tenant architecture with hostname-based tenant resolution - Email/password auth with bcrypt + session cookies - Board/Column/Card API routes with full CRUD - Fractional indexing for drag-and-drop ordering - Board view with svelte-dnd-action for column and card drag-and-drop - Card modal with inline editing - Auth pages (login, register) - Board listing with create form - RLS policies SQL script for tenant isolation - Prisma RLS client extensions (forTenant/bypassRLS) - Permission helpers (canEditBoard, canManageMembers, etc.) - Socket.IO server setup (dev Vite plugin + production Express server) - Client-side socket store for real-time updates - Docker Compose (app + PostgreSQL 16) + multi-stage Dockerfile - Database seed script (default tenant + admin user) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
<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 creating = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
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 })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (!res.ok) {
|
||||
error = result.error || 'Failed to create board';
|
||||
return;
|
||||
}
|
||||
newTitle = '';
|
||||
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">
|
||||
<div class="flex-1">
|
||||
<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>
|
||||
<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}
|
||||
|
||||
{#if data.boards.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">No boards yet.</p>
|
||||
{#if data.user}
|
||||
<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 data.boards 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"
|
||||
>
|
||||
<h2 class="font-semibold text-gray-900 group-hover:text-[var(--color-primary)] transition">
|
||||
{board.title}
|
||||
</h2>
|
||||
<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>
|
||||
Reference in New Issue
Block a user