Kanban-brett: PG-adapter, API-ruter, drag & drop UI + multi-workspace seed

- Migrering 0002: kanban_boards, kanban_columns, kanban_cards (REAL-posisjon)
- REST API: CRUD for kolonner, kort, flytt-kort
- PG polling-adapter med optimistisk UI-oppdatering
- KanbanBlock: drag & drop, redigeringsmodal, enkelt kort-input
- WorkspaceSwitcher: data-sveltekit-reload for korrekt workspace-bytte
- Seed: Foreningen Liberalistene som andre test-workspace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-15 02:29:46 +01:00
parent 3e163b8d1c
commit a93ffc6de5
13 changed files with 1086 additions and 12 deletions

View file

@ -0,0 +1,63 @@
-- Kanban: brett, kolonner og kort
-- Kort er nodes i kunnskapsgrafen (kan kobles til temaer/aktører senere).
-- Kolonner er IKKE nodes — de er intern struktur for brettet.
-- Posisjon bruker REAL for midtpunkts-innsetting uten re-nummerering.
BEGIN;
-- ============================================================
-- Kanban boards (er nodes → arver workspace via nodes-tabellen)
-- ============================================================
CREATE TABLE kanban_boards (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
parent_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Kanban'
);
CREATE INDEX idx_kanban_boards_parent ON kanban_boards(parent_id);
-- ============================================================
-- Kolonner
-- ============================================================
CREATE TABLE kanban_columns (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
board_id UUID NOT NULL REFERENCES kanban_boards(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT, -- valgfri hex-fargekode (#ff6b6b)
position REAL NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_kanban_columns_board ON kanban_columns(board_id, position);
-- ============================================================
-- Kort (er nodes → kan kobles i grafen, arver workspace)
-- ============================================================
CREATE TABLE kanban_cards (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
column_id UUID NOT NULL REFERENCES kanban_columns(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
assignee_id TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
position REAL NOT NULL DEFAULT 0,
created_by TEXT REFERENCES users(authentik_id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_kanban_cards_column ON kanban_cards(column_id, position);
CREATE TRIGGER trg_kanban_cards_updated_at BEFORE UPDATE ON kanban_cards
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ============================================================
-- Utvid node_type enum med 'kanban_board' og 'kanban_card'
-- ============================================================
ALTER TYPE node_type ADD VALUE IF NOT EXISTS 'kanban_board';
ALTER TYPE node_type ADD VALUE IF NOT EXISTS 'kanban_card';
COMMIT;

View file

@ -34,6 +34,84 @@ INSERT INTO nodes (id, workspace_id, node_type) VALUES
INSERT INTO channels (id, parent_id, name) VALUES INSERT INTO channels (id, parent_id, name) VALUES
('a0000000-0000-0000-0000-000000000012', 'a0000000-0000-0000-0000-000000000010', 'Redaksjonen'); ('a0000000-0000-0000-0000-000000000012', 'a0000000-0000-0000-0000-000000000010', 'Redaksjonen');
-- =============================================
-- Workspace 2: Foreningen Liberalistene
-- =============================================
INSERT INTO workspaces (id, name, slug) VALUES
('b0000000-0000-0000-0000-000000000001', 'Foreningen Liberalistene', 'forlib');
-- Vegard er medlem av begge workspaces
INSERT INTO workspace_members (workspace_id, user_id, role) VALUES
('b0000000-0000-0000-0000-000000000001', '6af61f43c6647a237cbb381ee7788376a9bc20299c2c06281d9954d763e854f0', 'owner'),
('b0000000-0000-0000-0000-000000000001', 'dev-user-1', 'owner');
-- Rot-node for Liberalistene
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('b0000000-0000-0000-0000-000000000010', 'b0000000-0000-0000-0000-000000000001', 'channel');
-- Chat-kanaler
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('b0000000-0000-0000-0000-000000000011', 'b0000000-0000-0000-0000-000000000001', 'channel');
INSERT INTO channels (id, parent_id, name) VALUES
('b0000000-0000-0000-0000-000000000011', 'b0000000-0000-0000-0000-000000000010', 'Generelt');
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('b0000000-0000-0000-0000-000000000012', 'b0000000-0000-0000-0000-000000000001', 'channel');
INSERT INTO channels (id, parent_id, name) VALUES
('b0000000-0000-0000-0000-000000000012', 'b0000000-0000-0000-0000-000000000010', 'Styret');
-- Kanban-brett for Liberalistene
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('b0000000-0000-0000-0000-000000000020', 'b0000000-0000-0000-0000-000000000001', 'kanban_board');
INSERT INTO kanban_boards (id, parent_id, name) VALUES
('b0000000-0000-0000-0000-000000000020', 'b0000000-0000-0000-0000-000000000010', 'Oppgaver');
INSERT INTO kanban_columns (id, board_id, name, color, position) VALUES
('b0000000-0000-0000-0000-000000000021', 'b0000000-0000-0000-0000-000000000020', 'Å gjøre', '#8b92a5', 1),
('b0000000-0000-0000-0000-000000000022', 'b0000000-0000-0000-0000-000000000020', 'Pågår', '#f59e0b', 2),
('b0000000-0000-0000-0000-000000000023', 'b0000000-0000-0000-0000-000000000020', 'Ferdig', '#10b981', 3);
-- Sider for Liberalistene
UPDATE workspaces SET settings = jsonb_set(
COALESCE(settings, '{}'::jsonb),
'{pages}',
'[
{
"slug": "styrearbeid",
"title": "Styrearbeid",
"icon": "🏛️",
"layout": "2-1",
"blocks": [
{"id": "chat-lib-1", "type": "chat", "title": "Styrechat", "props": {"channelId": "b0000000-0000-0000-0000-000000000012"}},
{"id": "kanban-lib-1", "type": "kanban", "title": "Oppgaver", "props": {"boardId": "b0000000-0000-0000-0000-000000000020"}}
]
},
{
"slug": "generelt",
"title": "Generelt",
"icon": "💬",
"layout": "single",
"blocks": [
{"id": "chat-lib-2", "type": "chat", "title": "Generell diskusjon", "props": {"channelId": "b0000000-0000-0000-0000-000000000011"}}
]
}
]'::jsonb
) WHERE slug = 'forlib';
-- =============================================
-- Sidelinja: Kanban-brett for redaksjonen
-- =============================================
-- Kanban-brett for redaksjonen
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('a0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000001', 'kanban_board');
INSERT INTO kanban_boards (id, parent_id, name) VALUES
('a0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000010', 'Episodeplanlegging');
INSERT INTO kanban_columns (id, board_id, name, color, position) VALUES
('a0000000-0000-0000-0000-000000000021', 'a0000000-0000-0000-0000-000000000020', 'Ideer', '#8b92a5', 1),
('a0000000-0000-0000-0000-000000000022', 'a0000000-0000-0000-0000-000000000020', 'Planlagt', '#f59e0b', 2),
('a0000000-0000-0000-0000-000000000023', 'a0000000-0000-0000-0000-000000000020', 'Innspilt', '#3b82f6', 3),
('a0000000-0000-0000-0000-000000000024', 'a0000000-0000-0000-0000-000000000020', 'Publisert', '#10b981', 4);
-- Default-sider for workspace -- Default-sider for workspace
UPDATE workspaces SET settings = jsonb_set( UPDATE workspaces SET settings = jsonb_set(
COALESCE(settings, '{}'::jsonb), COALESCE(settings, '{}'::jsonb),
@ -46,7 +124,7 @@ UPDATE workspaces SET settings = jsonb_set(
"layout": "2-1", "layout": "2-1",
"blocks": [ "blocks": [
{"id": "chat-1", "type": "chat", "title": "Redaksjonschat", "props": {"channelId": "a0000000-0000-0000-0000-000000000012"}}, {"id": "chat-1", "type": "chat", "title": "Redaksjonschat", "props": {"channelId": "a0000000-0000-0000-0000-000000000012"}},
{"id": "kanban-1", "type": "kanban", "title": "Planlegging"} {"id": "kanban-1", "type": "kanban", "title": "Planlegging", "props": {"boardId": "a0000000-0000-0000-0000-000000000020"}}
] ]
}, },
{ {

View file

@ -1,25 +1,552 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { createKanban } from '$lib/kanban/create.svelte';
import type { KanbanConnection, KanbanColumn, KanbanCard } from '$lib/kanban/types';
let { props = {} }: { props?: Record<string, unknown> } = $props(); let { props = {} }: { props?: Record<string, unknown> } = $props();
const boardId = props.boardId as string | undefined;
let kanban = $state<KanbanConnection | null>(null);
let newCardTitle = $state('');
let addingColumn = $state(false);
let newColumnName = $state('');
let dragCardId = $state<string | null>(null);
let dragOverColumnId = $state<string | null>(null);
// Redigering
let editingCard = $state<KanbanCard | null>(null);
let editTitle = $state('');
let editDescription = $state('');
let board = $derived(kanban?.board ?? null);
let columns = $derived(board?.columns ?? []);
let firstColumnId = $derived(columns[0]?.id ?? null);
function handleDragStart(e: DragEvent, card: KanbanCard) {
dragCardId = card.id;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', card.id);
}
}
function handleDragOver(e: DragEvent, columnId: string) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
dragOverColumnId = columnId;
}
function handleDragLeave() {
dragOverColumnId = null;
}
function handleDrop(e: DragEvent, column: KanbanColumn) {
e.preventDefault();
dragOverColumnId = null;
if (!kanban || !dragCardId) return;
const lastCard = column.cards[column.cards.length - 1];
const position = lastCard ? lastCard.position + 1 : 1;
kanban.moveCard(dragCardId, column.id, position);
dragCardId = null;
}
function handleAddCard() {
const title = newCardTitle.trim();
if (!title || !kanban || !firstColumnId) return;
kanban.addCard(firstColumnId, title);
newCardTitle = '';
}
function handleAddCardKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAddCard();
}
}
function handleAddColumn() {
const name = newColumnName.trim();
if (!name || !kanban) return;
kanban.addColumn(name);
newColumnName = '';
addingColumn = false;
}
function handleColumnKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
handleAddColumn();
}
if (e.key === 'Escape') {
addingColumn = false;
}
}
function openEdit(card: KanbanCard) {
editingCard = card;
editTitle = card.title;
editDescription = card.description ?? '';
}
function closeEdit() {
editingCard = null;
}
function saveEdit() {
if (!kanban || !editingCard) return;
const titleChanged = editTitle.trim() !== editingCard.title;
const descChanged = (editDescription.trim() || null) !== (editingCard.description ?? null);
if (titleChanged || descChanged) {
kanban.updateCard(editingCard.id, {
title: editTitle.trim() || editingCard.title,
description: editDescription.trim() || null
});
}
closeEdit();
}
function handleDeleteCard() {
if (!kanban || !editingCard) return;
kanban.deleteCard(editingCard.id);
closeEdit();
}
function handleEditKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeEdit();
}
$effect(() => {
if (editingCard) {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeEdit();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}
});
onMount(() => {
if (boardId) {
kanban = createKanban(boardId);
}
return () => kanban?.destroy();
});
</script> </script>
<div class="placeholder"> {#if !boardId}
<span class="icon">📋</span> <div class="no-board">
<p class="label">Kanban</p> <p>Ingen kanban-brett konfigurert for denne blokken.</p>
<p class="hint">Kommer snart</p> </div>
</div> {:else if kanban?.loading}
<div class="no-board"><p>Laster...</p></div>
{:else}
<div class="kanban-wrapper">
<div class="kanban">
{#each columns as column (column.id)}
<div
class="column"
class:drag-over={dragOverColumnId === column.id}
ondragover={(e) => handleDragOver(e, column.id)}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, column)}
role="list"
>
<div class="column-header">
<span class="column-name" style:border-bottom-color={column.color ?? 'transparent'}>
{column.name}
</span>
<span class="card-count">{column.cards.length}</span>
</div>
<div class="cards">
{#each column.cards as card (card.id)}
<button
type="button"
class="card"
class:dragging={dragCardId === card.id}
draggable="true"
ondragstart={(e) => handleDragStart(e, card)}
onclick={() => openEdit(card)}
>
<div class="card-title">{card.title}</div>
{#if card.description}
<div class="card-desc">{card.description}</div>
{/if}
</button>
{/each}
</div>
</div>
{/each}
<div class="add-column">
{#if addingColumn}
<input
type="text"
placeholder="Kolonnenavn..."
bind:value={newColumnName}
onkeydown={handleColumnKeydown}
/>
<div class="add-column-actions">
<button type="button" onclick={handleAddColumn} disabled={!newColumnName.trim()}>Opprett</button>
<button type="button" class="cancel" onclick={() => { addingColumn = false; }}>Avbryt</button>
</div>
{:else}
<button type="button" class="add-column-btn" onclick={() => { addingColumn = true; }}>
+ Kolonne
</button>
{/if}
</div>
</div>
<div class="add-card-row">
<input
type="text"
placeholder="Nytt kort..."
bind:value={newCardTitle}
onkeydown={handleAddCardKeydown}
/>
<button
type="button"
onclick={handleAddCard}
disabled={!newCardTitle.trim() || !firstColumnId}
>+</button>
</div>
</div>
{#if editingCard}
<div class="modal-backdrop" onclick={closeEdit} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} onkeydown={handleEditKeydown} role="dialog">
<input
class="edit-title"
type="text"
bind:value={editTitle}
placeholder="Tittel"
/>
<textarea
class="edit-desc"
bind:value={editDescription}
placeholder="Beskrivelse (valgfritt)"
rows="3"
></textarea>
<div class="modal-actions">
<button type="button" onclick={saveEdit}>Lagre</button>
<button type="button" class="delete" onclick={handleDeleteCard}>Slett</button>
<button type="button" class="cancel" onclick={closeEdit}>Avbryt</button>
</div>
</div>
</div>
{/if}
{#if kanban?.error}
<div class="error">{kanban.error}</div>
{/if}
{/if}
<style> <style>
.placeholder { .kanban-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
min-height: 0;
}
.kanban {
display: flex;
gap: 0.75rem;
flex: 1;
overflow-x: auto;
min-height: 0;
padding-bottom: 0.5rem;
}
.column {
flex: 0 0 200px;
display: flex;
flex-direction: column;
background: #161822;
border-radius: 8px;
padding: 0.5rem;
max-height: 100%;
transition: background 0.15s;
}
.column.drag-over {
background: #1c1f33;
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.25rem 0.5rem;
}
.column-name {
font-size: 0.8rem;
font-weight: 600;
color: #e1e4e8;
border-bottom: 2px solid transparent;
padding-bottom: 2px;
}
.card-count {
font-size: 0.7rem;
color: #8b92a5;
background: #0f1117;
padding: 0.1rem 0.4rem;
border-radius: 10px;
}
.cards {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.card {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
padding: 0.5rem;
cursor: grab;
transition: opacity 0.15s, border-color 0.15s;
text-align: left;
width: 100%;
font-family: inherit;
}
.card:hover {
border-color: #3b82f6;
}
.card.dragging {
opacity: 0.4;
}
.card-title {
font-size: 0.8rem;
color: #e1e4e8;
line-height: 1.3;
}
.card-desc {
font-size: 0.7rem;
color: #8b92a5;
margin-top: 0.25rem;
line-height: 1.3;
}
.add-card-row {
display: flex;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid #2d3148;
}
.add-card-row input {
flex: 1;
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e1e4e8;
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
font-family: inherit;
}
.add-card-row input:focus {
outline: none;
border-color: #3b82f6;
}
.add-card-row input::placeholder {
color: #8b92a5;
}
.add-card-row button {
background: #1e2235;
border: 1px solid #2d3148;
border-radius: 6px;
color: #8b92a5;
width: 32px;
height: 32px;
cursor: pointer;
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.add-card-row button:not(:disabled):hover {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.add-column {
flex: 0 0 160px;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.5rem;
}
.add-column input {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e1e4e8;
padding: 0.4rem;
font-size: 0.8rem;
font-family: inherit;
}
.add-column input:focus {
outline: none;
border-color: #3b82f6;
}
.add-column-actions {
display: flex;
gap: 0.25rem;
}
.add-column-actions button {
flex: 1;
padding: 0.3rem;
border-radius: 4px;
border: none;
font-size: 0.75rem;
cursor: pointer;
background: #3b82f6;
color: white;
}
.add-column-actions button.cancel {
background: #1e2235;
color: #8b92a5;
}
.add-column-btn {
background: none;
border: 1px dashed #2d3148;
border-radius: 8px;
color: #8b92a5;
padding: 0.75rem;
cursor: pointer;
font-size: 0.8rem;
font-family: inherit;
}
.add-column-btn:hover {
border-color: #3b82f6;
color: #3b82f6;
}
/* Modal for redigering */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: #161822;
border: 1px solid #2d3148;
border-radius: 10px;
padding: 1rem;
width: 340px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.edit-title {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e1e4e8;
padding: 0.5rem;
font-size: 0.9rem;
font-weight: 600;
font-family: inherit;
}
.edit-title:focus {
outline: none;
border-color: #3b82f6;
}
.edit-desc {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e1e4e8;
padding: 0.5rem;
font-size: 0.8rem;
font-family: inherit;
resize: vertical;
line-height: 1.4;
}
.edit-desc:focus {
outline: none;
border-color: #3b82f6;
}
.edit-desc::placeholder, .edit-title::placeholder {
color: #8b92a5;
}
.modal-actions {
display: flex;
gap: 0.5rem;
}
.modal-actions button {
flex: 1;
padding: 0.4rem;
border-radius: 6px;
border: none;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
background: #3b82f6;
color: white;
}
.modal-actions button.delete {
background: #dc2626;
}
.modal-actions button.cancel {
background: #1e2235;
color: #8b92a5;
}
.no-board {
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; height: 100%;
min-height: 200px;
color: #8b92a5; color: #8b92a5;
gap: 0.5rem; font-size: 0.85rem;
}
.error {
font-size: 0.75rem;
color: #f87171;
padding: 0.25rem 0.5rem;
} }
.icon { font-size: 2rem; }
.label { font-weight: 600; color: #e1e4e8; }
.hint { font-size: 0.8rem; }
</style> </style>

View file

@ -19,6 +19,7 @@
<li> <li>
<a <a
href="/?switch_workspace={ws.id}" href="/?switch_workspace={ws.id}"
data-sveltekit-reload
onclick={() => (open = false)} onclick={() => (open = false)}
> >
{ws.name} {ws.name}

View file

@ -0,0 +1,10 @@
import type { KanbanConnection } from './types';
import { createPgKanban } from './pg.svelte';
/**
* Factory for kanban-adapter.
* PG-adapter for , SpacetimeDB hybrid-adapter senere.
*/
export function createKanban(boardId: string): KanbanConnection {
return createPgKanban(boardId);
}

View file

@ -0,0 +1,2 @@
export type { KanbanBoard, KanbanColumn, KanbanCard, KanbanConnection } from './types';
export { createKanban } from './create.svelte';

View file

@ -0,0 +1,135 @@
import type { KanbanBoard, KanbanConnection } from './types';
/**
* Kanban PG-adapter.
* Henter brett med kolonner og kort via REST, poller for oppdateringer.
*/
export function createPgKanban(boardId: string): KanbanConnection {
let board = $state<KanbanBoard | null>(null);
let error = $state('');
let loading = $state(true);
let timer: ReturnType<typeof setInterval> | null = null;
let destroyed = false;
async function refresh() {
if (destroyed) return;
try {
const res = await fetch(`/api/kanban/${boardId}`);
if (!res.ok) throw new Error('Feil ved lasting');
board = await res.json();
error = '';
} catch {
error = 'Kunne ikke laste kanban-brett';
} finally {
loading = false;
}
}
async function addColumn(name: string) {
try {
const res = await fetch(`/api/kanban/${boardId}/columns`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
if (!res.ok) throw new Error('Feil ved opprettelse');
await refresh();
} catch {
error = 'Kunne ikke opprette kolonne';
}
}
async function addCard(columnId: string, title: string) {
try {
const res = await fetch(`/api/kanban/${boardId}/cards`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ columnId, title })
});
if (!res.ok) throw new Error('Feil ved opprettelse');
await refresh();
} catch {
error = 'Kunne ikke opprette kort';
}
}
async function moveCard(cardId: string, toColumnId: string, position: number) {
// Optimistisk oppdatering — flytt kortet lokalt først
if (board) {
const updatedColumns = board.columns.map(col => ({
...col,
cards: col.cards.filter(c => c.id !== cardId)
}));
const card = board.columns.flatMap(c => c.cards).find(c => c.id === cardId);
if (card) {
const targetCol = updatedColumns.find(c => c.id === toColumnId);
if (targetCol) {
const movedCard = { ...card, column_id: toColumnId, position };
targetCol.cards.push(movedCard);
targetCol.cards.sort((a, b) => a.position - b.position);
}
}
board = { ...board, columns: updatedColumns };
}
try {
const res = await fetch(`/api/kanban/${boardId}/cards/${cardId}/move`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ columnId: toColumnId, position })
});
if (!res.ok) throw new Error('Feil ved flytting');
await refresh();
} catch {
error = 'Kunne ikke flytte kort';
await refresh(); // Reverser optimistisk oppdatering
}
}
async function updateCard(cardId: string, updates: { title?: string; description?: string | null }) {
try {
const res = await fetch(`/api/kanban/${boardId}/cards/${cardId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!res.ok) throw new Error('Feil ved oppdatering');
await refresh();
} catch {
error = 'Kunne ikke oppdatere kort';
}
}
async function deleteCard(cardId: string) {
try {
const res = await fetch(`/api/kanban/${boardId}/cards/${cardId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Feil ved sletting');
await refresh();
} catch {
error = 'Kunne ikke slette kort';
}
}
function destroy() {
destroyed = true;
if (timer) clearInterval(timer);
}
// Start polling
refresh();
timer = setInterval(refresh, 5000);
return {
get board() { return board; },
get error() { return error; },
get loading() { return loading; },
addColumn,
addCard,
moveCard,
updateCard,
deleteCard,
destroy
};
}

View file

@ -0,0 +1,36 @@
export interface KanbanCard {
id: string;
column_id: string;
title: string;
description: string | null;
assignee_id: string | null;
position: number;
created_by: string | null;
created_at: string;
}
export interface KanbanColumn {
id: string;
name: string;
color: string | null;
position: number;
cards: KanbanCard[];
}
export interface KanbanBoard {
id: string;
name: string;
columns: KanbanColumn[];
}
export interface KanbanConnection {
readonly board: KanbanBoard | null;
readonly error: string;
readonly loading: boolean;
addColumn(name: string): Promise<void>;
addCard(columnId: string, title: string): Promise<void>;
moveCard(cardId: string, toColumnId: string, position: number): Promise<void>;
updateCard(cardId: string, updates: { title?: string; description?: string | null }): Promise<void>;
deleteCard(cardId: string): Promise<void>;
destroy(): void;
}

View file

@ -0,0 +1,51 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** GET /api/kanban/:boardId — Hent brett med kolonner og kort */
export const GET: RequestHandler = async ({ params, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const boardId = params.boardId;
// Verifiser at brettet tilhører workspace
const [board] = await sql`
SELECT b.id, b.name FROM kanban_boards b
JOIN nodes n ON n.id = b.id
WHERE b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!board) error(404, 'Brett ikke funnet');
// Hent kolonner
const columns = await sql`
SELECT id, name, color, position
FROM kanban_columns
WHERE board_id = ${boardId}
ORDER BY position ASC
`;
// Hent alle kort for brettet
const cards = await sql`
SELECT c.id, c.column_id, c.title, c.description,
c.assignee_id, c.position, c.created_by, c.created_at
FROM kanban_cards c
JOIN kanban_columns col ON col.id = c.column_id
WHERE col.board_id = ${boardId}
ORDER BY c.position ASC
`;
// Grupper kort per kolonne
const cardsByColumn: Record<string, (typeof cards[number])[]> = {};
for (const card of cards) {
(cardsByColumn[card.column_id] ??= []).push(card);
}
return json({
id: board.id,
name: board.name,
columns: columns.map(col => ({
...col,
cards: cardsByColumn[col.id] ?? []
}))
});
};

View file

@ -0,0 +1,46 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** POST /api/kanban/:boardId/cards — Opprett nytt kort */
export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const boardId = params.boardId;
const { columnId, title } = await request.json();
if (!title || typeof title !== 'string' || title.trim().length === 0) {
error(400, 'Korttittel kan ikke være tom');
}
if (!columnId) error(400, 'columnId er påkrevd');
// Verifiser at kolonnen tilhører dette brettet og workspace
const [col] = await sql`
SELECT c.id FROM kanban_columns c
JOIN kanban_boards b ON b.id = c.board_id
JOIN nodes n ON n.id = b.id
WHERE c.id = ${columnId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!col) error(404, 'Kolonne ikke funnet');
// Finn høyeste posisjon i kolonnen
const [maxPos] = await sql`
SELECT COALESCE(MAX(position), 0) + 1 as next_pos
FROM kanban_cards WHERE column_id = ${columnId}
`;
// Opprett node + kort i én transaksjon
const [card] = await sql`
WITH new_node AS (
INSERT INTO nodes (workspace_id, node_type)
VALUES (${locals.workspace.id}, 'kanban_card')
RETURNING id
)
INSERT INTO kanban_cards (id, column_id, title, position, created_by)
SELECT new_node.id, ${columnId}, ${title.trim()}, ${maxPos.next_pos}, ${locals.user.id}
FROM new_node
RETURNING id, column_id, title, description, assignee_id, position, created_by, created_at
`;
return json(card, { status: 201 });
};

View file

@ -0,0 +1,53 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** PATCH /api/kanban/:boardId/cards/:cardId — Oppdater kort */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const { boardId, cardId } = params;
const updates = await request.json();
// Verifiser tilgang
const [card] = await sql`
SELECT c.id FROM kanban_cards c
JOIN kanban_columns col ON col.id = c.column_id
JOIN kanban_boards b ON b.id = col.board_id
JOIN nodes n ON n.id = b.id
WHERE c.id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!card) error(404, 'Kort ikke funnet');
const [updated] = await sql`
UPDATE kanban_cards SET
title = COALESCE(${updates.title ?? null}, title),
description = CASE WHEN ${updates.description !== undefined} THEN ${updates.description ?? null} ELSE description END
WHERE id = ${cardId}
RETURNING id, column_id, title, description, assignee_id, position, created_by, created_at
`;
return json(updated);
};
/** DELETE /api/kanban/:boardId/cards/:cardId — Slett kort */
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const { boardId, cardId } = params;
// Verifiser tilgang
const [card] = await sql`
SELECT c.id FROM kanban_cards c
JOIN kanban_columns col ON col.id = c.column_id
JOIN kanban_boards b ON b.id = col.board_id
JOIN nodes n ON n.id = b.id
WHERE c.id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!card) error(404, 'Kort ikke funnet');
// Slett node (cascader til kanban_cards)
await sql`DELETE FROM nodes WHERE id = ${cardId}`;
return new Response(null, { status: 204 });
};

View file

@ -0,0 +1,35 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** PATCH /api/kanban/:boardId/cards/:cardId/move — Flytt kort til kolonne/posisjon */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const { boardId, cardId } = params;
const { columnId, position } = await request.json();
if (!columnId || typeof position !== 'number') {
error(400, 'columnId og position er påkrevd');
}
// Verifiser at kort og målkolonne tilhører dette brettet og workspace
const [valid] = await sql`
SELECT 1 FROM kanban_cards c
JOIN kanban_columns src_col ON src_col.id = c.column_id
JOIN kanban_boards b ON b.id = src_col.board_id
JOIN nodes n ON n.id = b.id
JOIN kanban_columns dst_col ON dst_col.board_id = b.id AND dst_col.id = ${columnId}
WHERE c.id = ${cardId} AND b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!valid) error(404, 'Kort eller kolonne ikke funnet');
const [updated] = await sql`
UPDATE kanban_cards
SET column_id = ${columnId}, position = ${position}
WHERE id = ${cardId}
RETURNING id, column_id, title, position
`;
return json(updated);
};

View file

@ -0,0 +1,37 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** POST /api/kanban/:boardId/columns — Opprett ny kolonne */
export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const boardId = params.boardId;
const { name, color } = await request.json();
if (!name || typeof name !== 'string' || name.trim().length === 0) {
error(400, 'Kolonnenavn kan ikke være tomt');
}
// Verifiser at brettet tilhører workspace
const [board] = await sql`
SELECT b.id FROM kanban_boards b
JOIN nodes n ON n.id = b.id
WHERE b.id = ${boardId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!board) error(404, 'Brett ikke funnet');
// Finn høyeste posisjon og legg til etter
const [maxPos] = await sql`
SELECT COALESCE(MAX(position), 0) + 1 as next_pos
FROM kanban_columns WHERE board_id = ${boardId}
`;
const [column] = await sql`
INSERT INTO kanban_columns (board_id, name, color, position)
VALUES (${boardId}, ${name.trim()}, ${color ?? null}, ${maxPos.next_pos})
RETURNING id, name, color, position
`;
return json(column, { status: 201 });
};