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:
parent
bc876eeb88
commit
ccc7d59b7f
4 changed files with 639 additions and 19 deletions
|
|
@ -22,6 +22,17 @@ action points fra Møterommet.
|
||||||
- Opprett kort direkte i kolonne (tittel-input)
|
- Opprett kort direkte i kolonne (tittel-input)
|
||||||
- Oppretting av nye brett fra mottak-siden
|
- 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
|
### Gjenstår
|
||||||
- Reposisjonering ved dra innad i kolonne (sortert rekkefølge)
|
- Reposisjonering ved dra innad i kolonne (sortert rekkefølge)
|
||||||
- Redigeringsmodal for kort (tittel/beskrivelse)
|
- Redigeringsmodal for kort (tittel/beskrivelse)
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,20 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Node } from '$lib/spacetime';
|
import type { Node, Edge } from '$lib/spacetime';
|
||||||
import { checkKanbanCompat, type DragPayload } from '$lib/transfer';
|
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 type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
|
||||||
import TraitPanel from './TraitPanel.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
collection: Node;
|
collection: Node;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
accessToken?: string;
|
||||||
/** Called when a drop is received on this panel */
|
/** Called when a drop is received on this panel */
|
||||||
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
|
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.
|
* BlockReceiver implementation for Kanban.
|
||||||
|
|
@ -29,21 +31,629 @@
|
||||||
mode: 'innholdstransfer',
|
mode: 'innholdstransfer',
|
||||||
contextId: collection?.id ?? '',
|
contextId: collection?.id ?? '',
|
||||||
contextType: 'kanban',
|
contextType: 'kanban',
|
||||||
position: { column_id: 'inbox', position: 0 },
|
position: { column_id: 'todo', position: 0 },
|
||||||
};
|
};
|
||||||
onReceiveDrop?.(payload, intent);
|
onReceiveDrop?.(payload, intent);
|
||||||
return 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>
|
</script>
|
||||||
|
|
||||||
<TraitPanel name="kanban" label="Kanban-brett" icon="📋">
|
<!--
|
||||||
{#snippet children()}
|
KanbanTrait — fullverdig BlockShell-panel for kanban-brett.
|
||||||
<a
|
Viser kolonner med kort, drag-and-drop mellom kolonner,
|
||||||
href="/board/{collection.id}"
|
og støtter mottak av noder fra andre paneler.
|
||||||
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"
|
Forelder (collection page) wrapper dette i BlockShell.
|
||||||
>
|
-->
|
||||||
Åpne brett →
|
|
||||||
</a>
|
<div class="kanban-trait">
|
||||||
{/snippet}
|
{#if cards.length === 0 && !accessToken}
|
||||||
</TraitPanel>
|
<!-- 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)}
|
||||||
|
>
|
||||||
|
<!-- 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>
|
||||||
|
|
|
||||||
|
|
@ -336,7 +336,7 @@
|
||||||
{:else if trait === 'chat'}
|
{:else if trait === 'chat'}
|
||||||
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||||
{:else if trait === 'kanban'}
|
{: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'}
|
{:else if trait === 'podcast'}
|
||||||
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||||
{:else if trait === 'publishing'}
|
{:else if trait === 'publishing'}
|
||||||
|
|
@ -391,7 +391,7 @@
|
||||||
{:else if trait === 'chat'}
|
{:else if trait === 'chat'}
|
||||||
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
<ChatTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||||
{:else if trait === 'kanban'}
|
{: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'}
|
{:else if trait === 'podcast'}
|
||||||
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
<PodcastTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||||
{:else if trait === 'publishing'}
|
{:else if trait === 'publishing'}
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -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` § 4–5.
|
- [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` § 4–5.
|
||||||
- [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.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.
|
- [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.
|
- [x] 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
|
|
||||||
- [ ] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, 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.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.
|
- [ ] 20.9 Panelrework — Studio: gjør StudioTrait til BlockShell-panel med drop-aksept for lydfiler, fullskjerm, responsivt.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue