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,385 @@
|
||||
<script lang="ts">
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||
import CardModal from '$lib/components/CardModal.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
// Local mutable state derived from server data
|
||||
let columns = $state(
|
||||
data.board.columns.map((col: any) => ({
|
||||
...col,
|
||||
cards: col.cards.map((card: any) => ({ ...card, id: card.id }))
|
||||
}))
|
||||
);
|
||||
|
||||
let addingColumnTitle = $state('');
|
||||
let showAddColumn = $state(false);
|
||||
let addingCardColumnId = $state<string | null>(null);
|
||||
let newCardTitle = $state('');
|
||||
let selectedCard = $state<any>(null);
|
||||
let editingColumnId = $state<string | null>(null);
|
||||
let editingColumnTitle = $state('');
|
||||
|
||||
const flipDurationMs = 200;
|
||||
|
||||
// ─── Column DnD ──────────────────────────────────
|
||||
function handleColumnSort(e: CustomEvent<any>) {
|
||||
columns = e.detail.items;
|
||||
if (e.detail.info.trigger === 'droppedIntoZone') {
|
||||
// Update positions based on new order
|
||||
columns.forEach((col: any, idx: number) => {
|
||||
const before = idx > 0 ? columns[idx - 1].position : null;
|
||||
const after = idx < columns.length - 1 ? columns[idx + 1].position : null;
|
||||
if (col.id === e.detail.info.id) {
|
||||
const newPos = generatePosition(before, after);
|
||||
col.position = newPos;
|
||||
fetch(`/api/boards/${data.board.id}/columns/${col.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ position: newPos })
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Card DnD ────────────────────────────────────
|
||||
function handleCardSort(columnId: string, e: CustomEvent<any>) {
|
||||
const col = columns.find((c: any) => c.id === columnId);
|
||||
if (!col) return;
|
||||
col.cards = e.detail.items;
|
||||
|
||||
if (e.detail.info.trigger === 'droppedIntoZone') {
|
||||
const cardId = e.detail.info.id;
|
||||
const cardIdx = col.cards.findIndex((c: any) => c.id === cardId);
|
||||
if (cardIdx === -1) return;
|
||||
|
||||
const before = cardIdx > 0 ? col.cards[cardIdx - 1].position : null;
|
||||
const after = cardIdx < col.cards.length - 1 ? col.cards[cardIdx + 1].position : null;
|
||||
const newPos = generatePosition(before, after);
|
||||
col.cards[cardIdx].position = newPos;
|
||||
|
||||
fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ columnId, position: newPos })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Add Column ──────────────────────────────────
|
||||
async function addColumn() {
|
||||
if (!addingColumnTitle.trim()) return;
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: addingColumnTitle })
|
||||
});
|
||||
if (res.ok) {
|
||||
const { column } = await res.json();
|
||||
columns = [...columns, { ...column, cards: [] }];
|
||||
addingColumnTitle = '';
|
||||
showAddColumn = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Add Card ────────────────────────────────────
|
||||
async function addCard(columnId: string) {
|
||||
if (!newCardTitle.trim()) return;
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: newCardTitle })
|
||||
});
|
||||
if (res.ok) {
|
||||
const { card } = await res.json();
|
||||
const col = columns.find((c: any) => c.id === columnId);
|
||||
if (col) col.cards = [...col.cards, card];
|
||||
newCardTitle = '';
|
||||
addingCardColumnId = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Delete Column ───────────────────────────────
|
||||
async function deleteColumn(columnId: string) {
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
columns = columns.filter((c: any) => c.id !== columnId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Rename Column ───────────────────────────────
|
||||
async function renameColumn(columnId: string) {
|
||||
if (!editingColumnTitle.trim()) return;
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: editingColumnTitle })
|
||||
});
|
||||
if (res.ok) {
|
||||
const col = columns.find((c: any) => c.id === columnId);
|
||||
if (col) col.title = editingColumnTitle;
|
||||
}
|
||||
editingColumnId = null;
|
||||
}
|
||||
|
||||
// ─── Delete Card ─────────────────────────────────
|
||||
async function deleteCard(columnId: string, cardId: string) {
|
||||
const res = await fetch(`/api/boards/${data.board.id}/columns/${columnId}/cards/${cardId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
const col = columns.find((c: any) => c.id === columnId);
|
||||
if (col) col.cards = col.cards.filter((c: any) => c.id !== cardId);
|
||||
if (selectedCard?.id === cardId) selectedCard = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.board.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if selectedCard}
|
||||
<CardModal
|
||||
card={selectedCard}
|
||||
boardId={data.board.id}
|
||||
columnId={columns.find((c: any) => c.cards.some((card: any) => card.id === selectedCard.id))?.id}
|
||||
canEdit={data.canEdit}
|
||||
onclose={() => (selectedCard = null)}
|
||||
ondelete={(cardId) => {
|
||||
const col = columns.find((c: any) => c.cards.some((card: any) => card.id === cardId));
|
||||
if (col) deleteCard(col.id, cardId);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col h-[calc(100vh-3.5rem)]">
|
||||
<!-- Board header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 bg-white/80 border-b border-gray-200">
|
||||
<a href="/boards" class="text-gray-400 hover:text-gray-600 transition" aria-label="Back to boards">
|
||||
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
<h1 class="text-lg font-bold text-gray-900">{data.board.title}</h1>
|
||||
<span class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
|
||||
{data.board.visibility === 'PUBLIC' ? 'Public' : 'Private'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Columns container -->
|
||||
<div class="flex-1 overflow-x-auto p-4">
|
||||
<div
|
||||
class="flex gap-4 h-full items-start"
|
||||
use:dndzone={{
|
||||
items: columns,
|
||||
flipDurationMs,
|
||||
type: 'columns',
|
||||
dragDisabled: !data.canEdit
|
||||
}}
|
||||
onconsider={handleColumnSort}
|
||||
onfinalize={handleColumnSort}
|
||||
>
|
||||
{#each columns as column (column.id)}
|
||||
<div
|
||||
class="flex-shrink-0 w-72 bg-[var(--color-column-bg)] rounded-xl flex flex-col max-h-full"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
>
|
||||
<!-- Column header -->
|
||||
<div class="flex items-center justify-between px-3 py-2.5">
|
||||
{#if editingColumnId === column.id}
|
||||
<form
|
||||
onsubmit={(e) => { e.preventDefault(); renameColumn(column.id); }}
|
||||
class="flex-1 flex gap-1"
|
||||
>
|
||||
<input
|
||||
bind:value={editingColumnTitle}
|
||||
class="flex-1 rounded border border-gray-300 px-2 py-1 text-sm font-semibold"
|
||||
autofocus
|
||||
onblur={() => renameColumn(column.id)}
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<button
|
||||
class="text-sm font-semibold text-gray-700 hover:text-gray-900 text-left flex-1"
|
||||
ondblclick={() => {
|
||||
if (data.canEdit) {
|
||||
editingColumnId = column.id;
|
||||
editingColumnTitle = column.title;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{column.title}
|
||||
<span class="ml-1 text-xs text-gray-400 font-normal">{column.cards.length}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if data.canEdit}
|
||||
<button
|
||||
onclick={() => deleteColumn(column.id)}
|
||||
class="text-gray-400 hover:text-[var(--color-danger)] transition p-1"
|
||||
title="Delete column"
|
||||
>
|
||||
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Cards zone -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto px-2 pb-2 min-h-[60px]"
|
||||
use:dndzone={{
|
||||
items: column.cards,
|
||||
flipDurationMs,
|
||||
type: 'cards',
|
||||
dragDisabled: !data.canEdit
|
||||
}}
|
||||
onconsider={(e) => handleCardSort(column.id, e)}
|
||||
onfinalize={(e) => handleCardSort(column.id, e)}
|
||||
>
|
||||
{#each column.cards as card (card.id)}
|
||||
<button
|
||||
class="w-full text-left mb-2 rounded-lg bg-[var(--color-card-bg)] p-3 shadow-sm hover:shadow-md border border-gray-200 hover:border-gray-300 transition cursor-pointer"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
onclick={() => (selectedCard = card)}
|
||||
>
|
||||
{#if card.labels && card.labels.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mb-1.5">
|
||||
{#each card.labels as label}
|
||||
<span
|
||||
class="inline-block h-2 w-8 rounded-full"
|
||||
style="background-color: {label.tag.color}"
|
||||
title={label.tag.name}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<span class="text-sm text-gray-800">{card.title}</span>
|
||||
{#if card.dueDate || card._count?.comments > 0 || card._count?.checklists > 0}
|
||||
<div class="flex items-center gap-2 mt-2 text-xs text-gray-400">
|
||||
{#if card.dueDate}
|
||||
<span>Due {new Date(card.dueDate).toLocaleDateString()}</span>
|
||||
{/if}
|
||||
{#if card._count?.comments > 0}
|
||||
<span>💬 {card._count.comments}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if card.assignees && card.assignees.length > 0}
|
||||
<div class="flex -space-x-1 mt-2">
|
||||
{#each card.assignees.slice(0, 3) as assignee}
|
||||
<div
|
||||
class="w-5 h-5 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center border border-white"
|
||||
title={assignee.user.name}
|
||||
>
|
||||
{assignee.user.name[0].toUpperCase()}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Add card button -->
|
||||
{#if data.canEdit}
|
||||
{#if addingCardColumnId === column.id}
|
||||
<div class="px-2 pb-2">
|
||||
<form
|
||||
onsubmit={(e) => { e.preventDefault(); addCard(column.id); }}
|
||||
>
|
||||
<textarea
|
||||
bind:value={newCardTitle}
|
||||
placeholder="Enter a title for this card..."
|
||||
class="w-full rounded-lg border border-gray-300 p-2 text-sm resize-none focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
|
||||
rows="2"
|
||||
autofocus
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
addCard(column.id);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
addingCardColumnId = null;
|
||||
newCardTitle = '';
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition"
|
||||
>
|
||||
Add card
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { addingCardColumnId = null; newCardTitle = ''; }}
|
||||
class="text-gray-500 hover:text-gray-700 transition text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => { addingCardColumnId = column.id; newCardTitle = ''; }}
|
||||
class="mx-2 mb-2 rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-200/50 transition text-left"
|
||||
>
|
||||
+ Add a card
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Add column -->
|
||||
{#if data.canEdit}
|
||||
<div class="flex-shrink-0 w-72">
|
||||
{#if showAddColumn}
|
||||
<div class="bg-[var(--color-column-bg)] rounded-xl p-3">
|
||||
<form onsubmit={(e) => { e.preventDefault(); addColumn(); }}>
|
||||
<input
|
||||
bind:value={addingColumnTitle}
|
||||
placeholder="Enter list title..."
|
||||
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)]"
|
||||
autofocus
|
||||
onkeydown={(e) => { if (e.key === 'Escape') { showAddColumn = false; addingColumnTitle = ''; } }}
|
||||
/>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded bg-[var(--color-primary)] px-3 py-1.5 text-sm text-white hover:bg-[var(--color-primary-hover)] transition"
|
||||
>
|
||||
Add list
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showAddColumn = false; addingColumnTitle = ''; }}
|
||||
class="text-gray-500 hover:text-gray-700 transition text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (showAddColumn = true)}
|
||||
class="w-full rounded-xl bg-white/60 hover:bg-white/80 border border-dashed border-gray-300 px-4 py-3 text-sm text-gray-500 transition text-left"
|
||||
>
|
||||
+ Add another list
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user