Gjør KanbanTrait til fullverdig BlockShell-panel med inline brett (oppgave 20.6)

KanbanTrait var tidligere bare en lenke til /board/[id]. Nå viser den
kolonner og kort direkte i BlockShell-panelet, med full funksjonalitet:

- Inline kanban-brett med kolonner (todo/in_progress/done) fra metadata
- Intern drag-and-drop mellom kolonner (status-edge oppdatering)
- Cross-panel drag: kort bruker setDragPayload for drag til andre paneler
- BlockReceiver: aksepterer drops fra chat/editor (innholdstransfer-modus)
- Opprett kort direkte i kolonne (inline input)
- Responsivt: kolonner stables vertikalt på smale paneler (@container + @media)
- accessToken-prop lagt til for mutasjoner

/board/[id]-ruten beholdes som frittstående visning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 08:34:43 +00:00
parent bc876eeb88
commit ccc7d59b7f
4 changed files with 639 additions and 19 deletions

View file

@ -22,6 +22,17 @@ action points fra Møterommet.
- Opprett kort direkte i kolonne (tittel-input)
- Oppretting av nye brett fra mottak-siden
### KanbanTrait panel (oppgave 20.6, mars 2026)
- **Inline panel:** KanbanTrait er nå et fullverdig BlockShell-panel som viser kolonner og kort direkte i panelet — ikke bare lenke til `/board/[id]`.
- **Kolonner:** Henter kolonner fra `metadata.traits.kanban.columns`, fallback til `['todo', 'in_progress', 'done']`.
- **Intern drag-and-drop:** Kort kan dras mellom kolonner for statusendring (bruker status-edge).
- **BlockReceiver:** Aksepterer drops fra andre paneler (`innholdstransfer`-modus). Noder fra chat/editor opprettes som nye kort med `source_material`-edge.
- **Drag-out:** Kort er draggable med `setDragPayload` — kan dras til andre paneler (chat, editor, kalender).
- **Opprett kort:** Inline-knapp per kolonne for å opprette nye kort (tittel → content-node + belongs_to + status edges).
- **Responsivt:** Kolonner stables vertikalt på smale paneler/mobil via `@container` og `@media` queries.
- **Fullskjerm-toggle:** Via BlockShell-wrapperen (forelder-side wrapper KanbanTrait i BlockShell).
- **`/board/[id]`-ruten beholdes** som frittstående fullside-visning for direktelenker.
### Gjenstår
- Reposisjonering ved dra innad i kolonne (sortert rekkefølge)
- Redigeringsmodal for kort (tittel/beskrivelse)

View file

@ -1,18 +1,20 @@
<script lang="ts">
import type { Node } from '$lib/spacetime';
import { checkKanbanCompat, type DragPayload } from '$lib/transfer';
import type { Node, Edge } from '$lib/spacetime';
import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime';
import { createNode, createEdge, updateEdge } from '$lib/api';
import { setDragPayload, checkKanbanCompat, type DragPayload } from '$lib/transfer';
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
import TraitPanel from './TraitPanel.svelte';
interface Props {
collection: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
/** Called when a drop is received on this panel */
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
}
let { collection, config, userId, onReceiveDrop }: Props = $props();
let { collection, config, userId, accessToken, onReceiveDrop }: Props = $props();
/**
* BlockReceiver implementation for Kanban.
@ -29,21 +31,629 @@
mode: 'innholdstransfer',
contextId: collection?.id ?? '',
contextType: 'kanban',
position: { column_id: 'inbox', position: 0 },
position: { column_id: 'todo', position: 0 },
};
onReceiveDrop?.(payload, intent);
return intent;
}
};
// =========================================================================
// Column definitions from collection metadata
// =========================================================================
const columns = $derived.by(() => {
try {
const meta = JSON.parse(collection.metadata ?? '{}');
const traitConf = meta.traits?.kanban;
if (traitConf && Array.isArray(traitConf.columns) && traitConf.columns.length > 0) {
return traitConf.columns as string[];
}
} catch { /* ignore */ }
return ['todo', 'in_progress', 'done'];
});
const columnLabels: Record<string, string> = {
todo: 'Å gjøre',
in_progress: 'I gang',
done: 'Ferdig',
};
function columnLabel(col: string): string {
return columnLabels[col] ?? col;
}
// =========================================================================
// Cards: nodes with belongs_to edge to this collection
// =========================================================================
interface CardData {
node: Node;
status: string;
position: number;
belongsToEdge: Edge;
statusEdge: Edge | undefined;
}
const cards = $derived.by((): CardData[] => {
if (!collection?.id) return [];
const result: CardData[] = [];
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);
if (!node || nodeVisibility(node, userId) === 'hidden') continue;
// Skip non-content nodes (communication channels, etc.)
if (node.nodeKind !== 'content') continue;
let statusEdge: Edge | undefined;
let status = columns[0] ?? 'todo';
for (const e of edgeStore.bySource(node.id)) {
if (e.edgeType === 'status' && e.targetId === collection.id) {
statusEdge = e;
try {
const meta = JSON.parse(e.metadata ?? '{}');
if (meta.value) status = meta.value;
} catch { /* ignore */ }
break;
}
}
let position = 0;
try {
const meta = JSON.parse(edge.metadata ?? '{}');
if (typeof meta.position === 'number') position = meta.position;
} catch { /* ignore */ }
result.push({ node, status, position, belongsToEdge: edge, statusEdge });
}
return result;
});
const cardsByColumn = $derived.by(() => {
const grouped: Record<string, CardData[]> = {};
for (const col of columns) {
grouped[col] = [];
}
for (const card of cards) {
const col = columns.includes(card.status) ? card.status : columns[0] ?? 'todo';
if (!grouped[col]) grouped[col] = [];
grouped[col].push(card);
}
for (const col of Object.keys(grouped)) {
grouped[col].sort((a, b) => a.position - b.position);
}
return grouped;
});
// =========================================================================
// Internal drag-and-drop (column move)
// =========================================================================
let draggedCard = $state<CardData | null>(null);
let dragOverColumn = $state<string | null>(null);
function handleDragStart(e: DragEvent, card: CardData) {
draggedCard = card;
if (!e.dataTransfer) return;
// Set both internal state and cross-panel payload
e.dataTransfer.effectAllowed = 'move';
setDragPayload(e.dataTransfer, {
nodeId: card.node.id,
nodeKind: card.node.nodeKind,
sourcePanel: 'kanban',
});
}
function handleDragOver(e: DragEvent, column: string) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
dragOverColumn = column;
}
function handleDragLeave() {
dragOverColumn = null;
}
async function handleDrop(e: DragEvent, targetColumn: string) {
e.preventDefault();
dragOverColumn = null;
if (!draggedCard || !accessToken) return;
if (draggedCard.status === targetColumn) {
draggedCard = null;
return;
}
const card = draggedCard;
draggedCard = null;
try {
if (card.statusEdge) {
await updateEdge(accessToken, {
edge_id: card.statusEdge.id,
metadata: { value: targetColumn },
});
} else {
await createEdge(accessToken, {
source_id: card.node.id,
target_id: collection.id,
edge_type: 'status',
metadata: { value: targetColumn },
});
}
} catch (err) {
console.error('Feil ved statusendring:', err);
}
}
function handleDragEnd() {
draggedCard = null;
dragOverColumn = null;
}
// =========================================================================
// Card creation
// =========================================================================
let newCardTitle = $state('');
let addingToColumn = $state<string | null>(null);
let isCreating = $state(false);
async function handleCreateCard(column: string) {
if (!accessToken || !newCardTitle.trim() || isCreating) return;
isCreating = true;
try {
const colCards = cardsByColumn[column] ?? [];
const maxPos = colCards.length > 0
? Math.max(...colCards.map(c => c.position))
: 0;
const position = maxPos + 1;
const { node_id } = await createNode(accessToken, {
node_kind: 'content',
title: newCardTitle.trim(),
visibility: 'hidden',
});
await createEdge(accessToken, {
source_id: node_id,
target_id: collection.id,
edge_type: 'belongs_to',
metadata: { position },
});
await createEdge(accessToken, {
source_id: node_id,
target_id: collection.id,
edge_type: 'status',
metadata: { value: column },
});
newCardTitle = '';
addingToColumn = null;
} catch (err) {
console.error('Feil ved oppretting av kort:', err);
} finally {
isCreating = false;
}
}
function handleCardKeydown(e: KeyboardEvent, column: string) {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateCard(column);
} else if (e.key === 'Escape') {
addingToColumn = null;
newCardTitle = '';
}
}
// =========================================================================
// Helpers
// =========================================================================
function formatTime(node: Node): string {
if (!node.createdAt?.microsSinceUnixEpoch) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n);
const date = new Date(ms);
return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
}
</script>
<TraitPanel name="kanban" label="Kanban-brett" icon="📋">
{#snippet children()}
<a
href="/board/{collection.id}"
class="inline-flex items-center gap-2 rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200"
<!--
KanbanTrait — fullverdig BlockShell-panel for kanban-brett.
Viser kolonner med kort, drag-and-drop mellom kolonner,
og støtter mottak av noder fra andre paneler.
Forelder (collection page) wrapper dette i BlockShell.
-->
<div class="kanban-trait">
{#if cards.length === 0 && !accessToken}
<!-- Empty state (read-only) -->
<div class="kanban-empty">
<span class="kanban-empty-icon">📋</span>
<p>Ingen oppgaver på brettet ennå.</p>
</div>
{:else}
<!-- Board columns -->
<div class="kanban-board">
{#each columns as column (column)}
{@const colCards = cardsByColumn[column] ?? []}
<div
class="kanban-column {dragOverColumn === column ? 'kanban-column-dragover' : ''}"
ondragover={(e) => handleDragOver(e, column)}
ondragleave={handleDragLeave}
ondrop={(e) => handleDrop(e, column)}
role="list"
aria-label={columnLabel(column)}
>
Åpne brett &rarr;
</a>
{/snippet}
</TraitPanel>
<!-- Column header -->
<div class="kanban-column-header">
<span class="kanban-column-title">{columnLabel(column)}</span>
<span class="kanban-column-count">{colCards.length}</span>
</div>
<!-- Cards -->
<div class="kanban-column-cards">
{#each colCards as card (card.node.id)}
<div
class="kanban-card {draggedCard?.node.id === card.node.id ? 'kanban-card-dragging' : ''}"
draggable="true"
ondragstart={(e) => handleDragStart(e, card)}
ondragend={handleDragEnd}
role="listitem"
>
<span class="kanban-card-title">
{card.node.title || 'Uten tittel'}
</span>
{#if card.node.content}
<span class="kanban-card-content">
{card.node.content}
</span>
{/if}
{#if formatTime(card.node)}
<span class="kanban-card-time">{formatTime(card.node)}</span>
{/if}
</div>
{/each}
<!-- Add card -->
{#if addingToColumn === column}
<div class="kanban-add-form">
<input
type="text"
bind:value={newCardTitle}
onkeydown={(e) => handleCardKeydown(e, column)}
placeholder="Tittel…"
class="kanban-add-input"
disabled={isCreating}
/>
<div class="kanban-add-actions">
<button
onclick={() => handleCreateCard(column)}
disabled={isCreating || !newCardTitle.trim()}
class="kanban-add-btn"
>
{isCreating ? '…' : 'Legg til'}
</button>
<button
onclick={() => { addingToColumn = null; newCardTitle = ''; }}
class="kanban-cancel-btn"
>
Avbryt
</button>
</div>
</div>
{:else if accessToken}
<button
onclick={() => { addingToColumn = column; newCardTitle = ''; }}
class="kanban-add-trigger"
>
+ Nytt kort
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
/* ================================================================= */
/* Root — fills BlockShell content area */
/* ================================================================= */
.kanban-trait {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* ================================================================= */
/* Empty state */
/* ================================================================= */
.kanban-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 24px;
color: #9ca3af;
font-size: 13px;
text-align: center;
gap: 8px;
}
.kanban-empty-icon {
font-size: 28px;
}
/* ================================================================= */
/* Board layout — horizontal columns */
/* ================================================================= */
.kanban-board {
display: flex;
flex: 1;
gap: 8px;
padding: 8px;
overflow-x: auto;
overflow-y: hidden;
min-height: 0;
}
/* ================================================================= */
/* Column */
/* ================================================================= */
.kanban-column {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-width: 140px;
max-width: 320px;
background: #f3f4f6;
border-radius: 8px;
overflow: hidden;
transition: box-shadow 0.15s;
}
.kanban-column-dragover {
box-shadow: inset 0 0 0 2px #60a5fa;
}
.kanban-column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px;
flex-shrink: 0;
}
.kanban-column-title {
font-size: 12px;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.kanban-column-count {
font-size: 11px;
color: #9ca3af;
background: white;
border-radius: 9999px;
padding: 0 6px;
line-height: 18px;
}
.kanban-column-cards {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 6px 6px;
flex: 1;
overflow-y: auto;
min-height: 24px;
}
/* ================================================================= */
/* Card */
/* ================================================================= */
.kanban-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 8px 10px;
cursor: grab;
transition: box-shadow 0.1s, opacity 0.15s;
}
.kanban-card:hover {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.kanban-card:active {
cursor: grabbing;
}
.kanban-card-dragging {
opacity: 0.4;
}
.kanban-card-title {
display: block;
font-size: 13px;
font-weight: 500;
color: #1f2937;
line-height: 1.3;
word-break: break-word;
}
.kanban-card-content {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 12px;
color: #6b7280;
margin-top: 3px;
line-height: 1.3;
}
.kanban-card-time {
display: block;
font-size: 10px;
color: #9ca3af;
margin-top: 4px;
}
/* ================================================================= */
/* Add card form */
/* ================================================================= */
.kanban-add-trigger {
display: block;
width: 100%;
padding: 6px 8px;
border: none;
background: transparent;
font-size: 12px;
color: #9ca3af;
text-align: left;
cursor: pointer;
border-radius: 4px;
transition: background 0.1s, color 0.1s;
}
.kanban-add-trigger:hover {
background: rgba(255, 255, 255, 0.6);
color: #6b7280;
}
.kanban-add-form {
background: white;
border: 1px solid #bfdbfe;
border-radius: 6px;
padding: 6px;
}
.kanban-add-input {
width: 100%;
padding: 4px 6px;
border: 1px solid #e5e7eb;
border-radius: 4px;
font-size: 12px;
outline: none;
box-sizing: border-box;
}
.kanban-add-input:focus {
border-color: #60a5fa;
}
.kanban-add-actions {
display: flex;
gap: 4px;
margin-top: 4px;
}
.kanban-add-btn {
padding: 3px 8px;
border: none;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: #2563eb;
color: white;
cursor: pointer;
}
.kanban-add-btn:hover {
background: #1d4ed8;
}
.kanban-add-btn:disabled {
opacity: 0.5;
cursor: default;
}
.kanban-cancel-btn {
padding: 3px 8px;
border: none;
border-radius: 4px;
font-size: 11px;
background: transparent;
color: #6b7280;
cursor: pointer;
}
.kanban-cancel-btn:hover {
background: #f3f4f6;
}
/* ================================================================= */
/* Responsive within bounded container */
/* ================================================================= */
/* Small panels: stack columns vertically */
@container (max-width: 400px) {
.kanban-board {
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
gap: 6px;
padding: 6px;
}
.kanban-column {
max-width: none;
min-width: 0;
}
.kanban-column-cards {
max-height: 200px;
}
}
/* Medium panels: tighter spacing */
@container (max-width: 600px) {
.kanban-card {
padding: 6px 8px;
}
.kanban-card-title {
font-size: 12px;
}
.kanban-column-header {
padding: 6px 8px;
}
}
/* Mobile viewport fallback */
@media (max-width: 480px) {
.kanban-board {
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
gap: 6px;
padding: 6px;
}
.kanban-column {
max-width: none;
min-width: 0;
}
.kanban-column-cards {
max-height: 250px;
}
}
</style>

View file

@ -336,7 +336,7 @@
{:else if trait === 'chat'}
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'kanban'}
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'podcast'}
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'publishing'}
@ -391,7 +391,7 @@
{:else if trait === 'chat'}
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'kanban'}
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
<KanbanTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'podcast'}
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'publishing'}

View file

@ -228,8 +228,7 @@ Ref: `docs/features/universell_overfoering.md`, `docs/retninger/arbeidsflaten.md
- [x] 20.3 BlockReceiver interface: implementer `canReceive()`, `receive()`, `renderDropZone()` i alle trait-komponenter (Chat, Kanban, Kalender, Editor, Studio). Kompatibilitetsmatrise bestemmer godkjente drops. Ref: `docs/features/universell_overfoering.md` § 45.
- [x] 20.4 Transfer service: `innholdstransfer`-modus (ny node + source_material edge) og `lettvekts-triage` (eksisterende node + ny edge/placement). Bestem modus fra verktøy-par. Shift-modifier for override. Ref: `docs/features/universell_overfoering.md` § 1, 3.
- [x] 20.5 Panelrework — Chat: gjør ChatTrait til fullverdig BlockShell-panel med BlockReceiver, fullskjerm-toggle, og responsivt design innenfor begrenset container.
- [~] 20.6 Panelrework — Kanban: gjør KanbanTrait til BlockShell-panel med drag-and-drop aksept fra andre paneler, fullskjerm, responsivt.
> Påbegynt: 2026-03-18T08:30
- [x] 20.6 Panelrework — Kanban: gjør KanbanTrait til BlockShell-panel med drag-and-drop aksept fra andre paneler, fullskjerm, responsivt.
- [ ] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, fullskjerm, responsivt.
- [ ] 20.8 Panelrework — Editor/Artikkelverktøy: gjør artikkelverktøy til BlockShell-panel med source_material mottak fra andre paneler. Ref: `docs/features/artikkelverktoy.md`.
- [ ] 20.9 Panelrework — Studio: gjør StudioTrait til BlockShell-panel med drop-aksept for lydfiler, fullskjerm, responsivt.