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:
parent
3e163b8d1c
commit
a93ffc6de5
13 changed files with 1086 additions and 12 deletions
63
migrations/0002_kanban.sql
Normal file
63
migrations/0002_kanban.sql
Normal 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;
|
||||
|
|
@ -34,6 +34,84 @@ INSERT INTO nodes (id, workspace_id, node_type) VALUES
|
|||
INSERT INTO channels (id, parent_id, name) VALUES
|
||||
('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
|
||||
UPDATE workspaces SET settings = jsonb_set(
|
||||
COALESCE(settings, '{}'::jsonb),
|
||||
|
|
@ -46,7 +124,7 @@ UPDATE workspaces SET settings = jsonb_set(
|
|||
"layout": "2-1",
|
||||
"blocks": [
|
||||
{"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"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,25 +1,552 @@
|
|||
<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();
|
||||
|
||||
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>
|
||||
|
||||
<div class="placeholder">
|
||||
<span class="icon">📋</span>
|
||||
<p class="label">Kanban</p>
|
||||
<p class="hint">Kommer snart</p>
|
||||
</div>
|
||||
{#if !boardId}
|
||||
<div class="no-board">
|
||||
<p>Ingen kanban-brett konfigurert for denne blokken.</p>
|
||||
</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>
|
||||
.placeholder {
|
||||
.kanban-wrapper {
|
||||
display: flex;
|
||||
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;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
<li>
|
||||
<a
|
||||
href="/?switch_workspace={ws.id}"
|
||||
data-sveltekit-reload
|
||||
onclick={() => (open = false)}
|
||||
>
|
||||
{ws.name}
|
||||
|
|
|
|||
10
web/src/lib/kanban/create.svelte.ts
Normal file
10
web/src/lib/kanban/create.svelte.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import type { KanbanConnection } from './types';
|
||||
import { createPgKanban } from './pg.svelte';
|
||||
|
||||
/**
|
||||
* Factory for kanban-adapter.
|
||||
* PG-adapter for nå, SpacetimeDB hybrid-adapter senere.
|
||||
*/
|
||||
export function createKanban(boardId: string): KanbanConnection {
|
||||
return createPgKanban(boardId);
|
||||
}
|
||||
2
web/src/lib/kanban/index.ts
Normal file
2
web/src/lib/kanban/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export type { KanbanBoard, KanbanColumn, KanbanCard, KanbanConnection } from './types';
|
||||
export { createKanban } from './create.svelte';
|
||||
135
web/src/lib/kanban/pg.svelte.ts
Normal file
135
web/src/lib/kanban/pg.svelte.ts
Normal 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
|
||||
};
|
||||
}
|
||||
36
web/src/lib/kanban/types.ts
Normal file
36
web/src/lib/kanban/types.ts
Normal 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;
|
||||
}
|
||||
51
web/src/routes/api/kanban/[boardId]/+server.ts
Normal file
51
web/src/routes/api/kanban/[boardId]/+server.ts
Normal 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] ?? []
|
||||
}))
|
||||
});
|
||||
};
|
||||
46
web/src/routes/api/kanban/[boardId]/cards/+server.ts
Normal file
46
web/src/routes/api/kanban/[boardId]/cards/+server.ts
Normal 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 });
|
||||
};
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
37
web/src/routes/api/kanban/[boardId]/columns/+server.ts
Normal file
37
web/src/routes/api/kanban/[boardId]/columns/+server.ts
Normal 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 });
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue