Implementer BlockReceiver i alle trait-komponenter (oppgave 20.3)

Hver trait-komponent (Chat, Kanban, Kalender, Editor, Studio) har nå
en BlockReceiver med canReceive() som sjekker kompatibilitetsmatrisen.
Inkompatible drops viser forklaring og forslag til alternativ.

Endringer:
- transfer.ts: Per-verktøy compat-sjekker (checkChatCompat, checkKanbanCompat,
  checkCalendarCompat, checkEditorCompat, checkStudioCompat) + createBlockReceiver factory
- types.ts: BlockReceiver utvidet med optional receive() + PlacementIntent type
- BlockShell.svelte: Validerer payload på faktisk drop (ikke bare drag-over)
- Alle 5 traits: Eksporterer BlockReceiver med canReceive + receive
- workspace/+page.svelte: Kobler receivers til BlockShell i spatial canvas
- Doc oppdatert til å reflektere faktisk implementasjon

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 08:12:54 +00:00
parent 90b5117a5f
commit 5b0881d5d9
11 changed files with 363 additions and 17 deletions

View file

@ -142,17 +142,31 @@ for komplett oversikt over hva som kan dras til hva.
```typescript
interface BlockReceiver {
/** Kan denne verktøy-panelen motta dette objektet? */
canReceive(message: Message): boolean;
canReceive(payload: DragPayload): CompatResult;
/** Opprett plassering for mottatt objekt */
receive(message: Message, dropPosition?: { x: number, y: number }): Placement;
/** Visuell feedback for aktiv drag */
renderDropZone(): void;
/** Opprett plassering for mottatt objekt (optional — full impl i transfer service) */
receive?(payload: DragPayload, dropPosition?: { x: number; y: number }): PlacementIntent;
}
```
Alle verktøy-panel-typer implementerer dette interfacet.
Alle verktøy-panel-typer implementerer `canReceive()`. `receive()` er optional —
den fulle overføringslogikken (ny node + edges) håndteres av transfer service (§ 1).
**Drop-zone rendering** (`renderDropZone`) håndteres av `BlockShell`, ikke
av trait-komponentene selv. BlockShell bruker `receiver.canReceive()` for å
bestemme visuell tilstand (`compatible` / `incompatible`), og viser overlay
med forklaring ved inkompatibilitet.
**Kompatibilitetssjekker** per verktøy-type finnes i `$lib/transfer.ts`:
- `checkChatCompat()` — Chat aksepterer alt unntatt fra egen panel
- `checkKanbanCompat()` — Kanban aksepterer kommunikasjon og innhold
- `checkCalendarCompat()` — Kalender aksepterer kommunikasjon og innhold
- `checkEditorCompat()` — Artikkelverktøy aksepterer tekst og media
- `checkStudioCompat()` — Studio aksepterer kun lyd
- `checkAiToolCompat()` — AI-verktøy aksepterer kun tekst
Factory-funksjon `createBlockReceiver(toolType)` oppretter en `BlockReceiver`
for en gitt verktøy-type.
## 5. SpacetimeDB-integrasjon

View file

@ -257,6 +257,20 @@
if (dropZoneState === 'compatible' && e.dataTransfer) {
const payload = getDragPayload(e.dataTransfer);
if (payload) {
// Validate on actual drop (we couldn't read payload during drag)
if (receiver) {
const result = receiver.canReceive(payload);
if (!result.compatible) {
// Show incompatible feedback briefly
dropZoneState = 'incompatible';
dropFeedback = result.reason ?? 'Kan ikke motta dette innholdet';
setTimeout(() => {
dropZoneState = 'idle';
dropFeedback = '';
}, 1500);
return;
}
}
onDrop?.(payload);
}
}

View file

@ -8,7 +8,19 @@
* Ref: docs/features/universell_overfoering.md § 8.1
*/
import type { DragPayload, CompatResult } from '$lib/transfer.js';
import type { DragPayload, CompatResult, ToolType } from '$lib/transfer.js';
/** Placement intent returned by BlockReceiver.receive() */
export interface PlacementIntent {
/** What happens on receive: new node or new edge/placement */
mode: 'innholdstransfer' | 'lettvekts-triage';
/** Target context ID (collection, board, calendar, channel) */
contextId: string;
/** Context type for placement table */
contextType: string;
/** Position data (column for kanban, date for calendar, etc.) */
position?: Record<string, unknown>;
}
/** Size constraints for a panel */
export interface SizeConstraints {
@ -40,10 +52,16 @@ export type DropZoneState = 'idle' | 'compatible' | 'incompatible';
/**
* BlockReceiver interface implemented by tool panels that accept drops.
* Ref: docs/features/universell_overfoering.md § 4.3
*
* - canReceive(): compatibility check (called during drag-over)
* - receive(): creates placement/edge (called on drop)
* - renderDropZone is handled by BlockShell (visual feedback via CSS)
*/
export interface BlockReceiver {
/** Can this panel receive this payload? */
canReceive(payload: DragPayload): CompatResult;
/** Process a received drop — returns placement intent for the transfer service */
receive?(payload: DragPayload, dropPosition?: { x: number; y: number }): PlacementIntent;
}
/** Events emitted by BlockShell */

View file

@ -1,15 +1,43 @@
<script lang="ts">
import type { Node } from '$lib/spacetime';
import { edgeStore, nodeStore } from '$lib/spacetime';
import { checkCalendarCompat, 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;
/** Called when a drop is received on this panel */
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
}
let { collection, config, userId }: Props = $props();
let { collection, config, userId, onReceiveDrop }: Props = $props();
/**
* BlockReceiver implementation for Calendar.
* Accepts communication and content nodes as new events/scheduled items.
* Kanban cards get a scheduled edge.
* Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
*/
export const receiver: BlockReceiver = {
canReceive(payload: DragPayload) {
return checkCalendarCompat(payload);
},
receive(payload: DragPayload) {
const today = new Date().toISOString().slice(0, 10);
const mode = payload.sourcePanel === 'kanban' ? 'lettvekts-triage' as const : 'innholdstransfer' as const;
const intent: PlacementIntent = {
mode,
contextId: collection?.id ?? '',
contextType: 'calendar',
position: { date: today, all_day: true },
};
onReceiveDrop?.(payload, intent);
return intent;
}
};
/** Scheduled events connected to this collection */
const events = $derived.by(() => {

View file

@ -1,16 +1,39 @@
<script lang="ts">
import type { Node } from '$lib/spacetime';
import { edgeStore, nodeStore } from '$lib/spacetime';
import { setDragPayload } from '$lib/transfer';
import { setDragPayload, checkChatCompat, 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;
/** Called when a drop is received on this panel */
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
}
let { collection, config, userId }: Props = $props();
let { collection, config, userId, onReceiveDrop }: Props = $props();
/**
* BlockReceiver implementation for Chat.
* Accepts any node — wraps as message with mentions.
* Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
*/
export const receiver: BlockReceiver = {
canReceive(payload: DragPayload) {
return checkChatCompat(payload);
},
receive(payload: DragPayload) {
const intent: PlacementIntent = {
mode: 'lettvekts-triage',
contextId: collection?.id ?? '',
contextType: 'chat',
};
onReceiveDrop?.(payload, intent);
return intent;
}
};
/** Communication nodes linked to this collection */
const chatNodes = $derived.by(() => {

View file

@ -4,7 +4,8 @@
import { deleteEdge, aiProcess } from '$lib/api';
import TraitPanel from './TraitPanel.svelte';
import PublishDialog from '$lib/components/PublishDialog.svelte';
import { getDragPayload, setDragPayload, checkToolToNodeCompat } from '$lib/transfer';
import { getDragPayload, setDragPayload, checkToolToNodeCompat, checkEditorCompat, type DragPayload } from '$lib/transfer';
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
interface Props {
collection: Node;
@ -13,9 +14,36 @@
accessToken?: string;
/** Full collection metadata (for reading traits) */
collectionMetadata?: Record<string, unknown>;
/** Called when a drop is received on this panel */
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
}
let { collection, config, userId, accessToken, collectionMetadata }: Props = $props();
let { collection, config, userId, accessToken, collectionMetadata, onReceiveDrop }: Props = $props();
/**
* BlockReceiver implementation for Editor (Artikkelverktøy).
* Accepts text nodes as source_material, media as embedded content.
* Also handles AI preset drops (tool_to_node) separately via inline handlers.
* Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
*/
export const receiver: BlockReceiver = {
canReceive(payload: DragPayload) {
// AI preset drops are handled by the inline item-level handlers
if (payload.nodeKind === 'ai_preset' && payload.presetId) {
return { compatible: true };
}
return checkEditorCompat(payload);
},
receive(payload: DragPayload) {
const intent: PlacementIntent = {
mode: 'innholdstransfer',
contextId: collection?.id ?? '',
contextType: 'editor',
};
onReceiveDrop?.(payload, intent);
return intent;
}
};
const preset = $derived((config.preset as string) ?? 'longform');

View file

@ -1,14 +1,40 @@
<script lang="ts">
import type { Node } from '$lib/spacetime';
import { 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;
/** Called when a drop is received on this panel */
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
}
let { collection, config, userId }: Props = $props();
let { collection, config, userId, onReceiveDrop }: Props = $props();
/**
* BlockReceiver implementation for Kanban.
* Accepts communication and content nodes as new tasks.
* Creates source_material edge to the original.
* Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
*/
export const receiver: BlockReceiver = {
canReceive(payload: DragPayload) {
return checkKanbanCompat(payload);
},
receive(payload: DragPayload) {
const intent: PlacementIntent = {
mode: 'innholdstransfer',
contextId: collection?.id ?? '',
contextType: 'kanban',
position: { column_id: 'inbox', position: 0 },
};
onReceiveDrop?.(payload, intent);
return intent;
}
};
</script>
<TraitPanel name="kanban" label="Kanban-brett" icon="📋">

View file

@ -1,15 +1,39 @@
<script lang="ts">
import type { Node } from '$lib/spacetime';
import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime';
import { checkStudioCompat, 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;
/** Called when a drop is received on this panel */
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
}
let { collection, config, userId }: Props = $props();
let { collection, config, userId, onReceiveDrop }: Props = $props();
/**
* BlockReceiver implementation for Studio (Lydstudio).
* Only accepts audio files — opens them in the studio.
* Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
*/
export const receiver: BlockReceiver = {
canReceive(payload: DragPayload) {
return checkStudioCompat(payload);
},
receive(payload: DragPayload) {
const intent: PlacementIntent = {
mode: 'lettvekts-triage',
contextId: collection?.id ?? '',
contextType: 'studio',
};
onReceiveDrop?.(payload, intent);
return intent;
}
};
/** Media nodes (audio files) belonging to this collection */
const audioNodes = $derived.by(() => {

View file

@ -118,3 +118,141 @@ export function checkToolToNodeCompat(nodeKind: string, hasContent: boolean): Co
}
return { compatible: true };
}
// --- Per-tool compatibility checks ---
// Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
/**
* Check compatibility for dropping a node onto the Chat panel.
* Chat accepts practically anything it wraps the node as a message with mentions.
*/
export function checkChatCompat(payload: DragPayload): CompatResult {
if (payload.sourcePanel === 'chat') {
return {
compatible: false,
reason: 'Noden er allerede i denne chatten.',
suggestion: 'Dra til en annen kontekst for å dele.'
};
}
return { compatible: true };
}
/**
* Check compatibility for dropping a node onto the Kanban panel.
* Kanban accepts text-based nodes (communication, content) as new tasks.
* Media and images can't become tasks directly.
*/
export function checkKanbanCompat(payload: DragPayload): CompatResult {
if (payload.sourcePanel === 'kanban') {
// Same board — handled as column move, not a new drop
return { compatible: true };
}
if (isMediaKind(payload.nodeKind)) {
return {
compatible: false,
reason: 'Mediefiler kan ikke bli oppgaver direkte.',
suggestion: 'Dra til Chat for å dele, eller til Artikkelverktøyet for å sette inn.'
};
}
if (payload.nodeKind === 'communication' || payload.nodeKind === 'content') {
return { compatible: true };
}
return {
compatible: false,
reason: `Nodetypen «${payload.nodeKind}» kan ikke legges til som oppgave.`
};
}
/**
* Check compatibility for dropping a node onto the Calendar panel.
* Calendar accepts text nodes as events, and kanban cards for scheduling.
*/
export function checkCalendarCompat(payload: DragPayload): CompatResult {
if (isMediaKind(payload.nodeKind)) {
return {
compatible: false,
reason: 'Mediefiler kan ikke planlegges i kalenderen.',
suggestion: 'Opprett en oppgave først, og planlegg den.'
};
}
if (payload.nodeKind === 'communication' || payload.nodeKind === 'content') {
return { compatible: true };
}
return {
compatible: false,
reason: `Nodetypen «${payload.nodeKind}» kan ikke legges til i kalenderen.`
};
}
/**
* Check compatibility for dropping a node onto the Editor (Artikkelverktøy) panel.
* Editor accepts most node types text as source_material, media as embedded content.
*/
export function checkEditorCompat(payload: DragPayload): CompatResult {
if (payload.nodeKind === 'communication' || payload.nodeKind === 'content') {
return { compatible: true };
}
if (isMediaKind(payload.nodeKind)) {
// Media (audio, image) can be embedded
return { compatible: true };
}
// Agent, person, collection etc. don't make sense in editor
return {
compatible: false,
reason: `Nodetypen «${payload.nodeKind}» kan ikke settes inn i artikkelverktøyet.`
};
}
/**
* Check compatibility for dropping a node onto the Studio panel.
* Studio only accepts audio files.
*/
export function checkStudioCompat(payload: DragPayload): CompatResult {
if (payload.sourcePanel === 'studio') {
return {
compatible: false,
reason: 'Lydfiler kan ikke slås sammen direkte.',
suggestion: 'Åpne i Studioet for å redigere.'
};
}
if (isAudioKind(payload.nodeKind)) {
return { compatible: true };
}
if (isMediaKind(payload.nodeKind)) {
return {
compatible: false,
reason: 'Studioet behandler kun lydfiler.',
suggestion: payload.nodeKind === 'image'
? 'Bilder kan dras til Artikkelverktøyet.'
: undefined
};
}
return {
compatible: false,
reason: 'Studioet behandler kun lydfiler.',
suggestion: 'Dra lydfiler hit for å åpne dem i studioet.'
};
}
/** Map of tool type to its compatibility check function */
const TOOL_COMPAT_CHECKS: Record<ToolType, (payload: DragPayload) => CompatResult> = {
chat: checkChatCompat,
kanban: checkKanbanCompat,
calendar: checkCalendarCompat,
editor: checkEditorCompat,
studio: checkStudioCompat,
ai_tool: (payload) => checkAiToolCompat(payload.nodeKind, true), // hasContent not knowable from payload alone
};
/**
* Create a BlockReceiver for a given tool type.
* Used by workspace/BlockShell to validate drops.
*/
export function createBlockReceiver(toolType: ToolType): { canReceive: (payload: DragPayload) => CompatResult } {
const checkFn = TOOL_COMPAT_CHECKS[toolType];
return {
canReceive(payload: DragPayload): CompatResult {
return checkFn(payload);
}
};
}

View file

@ -36,6 +36,8 @@
import MixerTrait from '$lib/components/traits/MixerTrait.svelte';
import GenericTrait from '$lib/components/traits/GenericTrait.svelte';
import AiToolPanel from '$lib/components/AiToolPanel.svelte';
import { createBlockReceiver, type DragPayload } from '$lib/transfer';
import type { BlockReceiver } from '$lib/components/blockshell/types';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const nodeId = $derived(session?.nodeId as string | undefined);
@ -321,6 +323,36 @@
'editor', 'chat', 'kanban', 'podcast', 'publishing',
'rss', 'calendar', 'recording', 'transcription', 'studio', 'mixer'
]);
// =========================================================================
// BlockReceiver per tool type — wired into BlockShell for drop compatibility
// Ref: docs/features/universell_overfoering.md § 4
// =========================================================================
/** Traits that support BlockReceiver drops */
const RECEIVER_TRAITS = new Set(['editor', 'chat', 'kanban', 'calendar', 'studio']);
/** Cache receivers by trait name — one per type */
const receiverCache = new Map<string, BlockReceiver>();
function getReceiverForTrait(trait: string): BlockReceiver | undefined {
if (!RECEIVER_TRAITS.has(trait)) return undefined;
if (!receiverCache.has(trait)) {
const toolType = trait as import('$lib/transfer').ToolType;
receiverCache.set(trait, createBlockReceiver(toolType));
}
return receiverCache.get(trait);
}
function handlePanelDrop(trait: string, payload: DragPayload) {
// Drop handling will be fully implemented in task 20.4 (transfer service).
// For now, log the intent for debugging.
const receiver = getReceiverForTrait(trait);
if (receiver?.receive) {
const intent = receiver.receive(payload);
console.log(`[universell-overføring] ${payload.sourcePanel} → ${trait}:`, intent);
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
@ -516,9 +548,11 @@
width={panel?.width ?? obj.width}
height={panel?.height ?? obj.height}
minimized={panel?.minimized ?? false}
receiver={getReceiverForTrait(trait)}
onResize={(w, h) => handlePanelResize(trait, w, h)}
onClose={() => handlePanelClose(trait)}
onMinimizeChange={(m) => handlePanelMinimize(trait, m)}
onDrop={(payload) => handlePanelDrop(trait, payload)}
>
{#if knownTraits.has(trait)}
{#if trait === 'editor'}

View file

@ -225,8 +225,7 @@ Ref: `docs/features/universell_overfoering.md`, `docs/retninger/arbeidsflaten.md
- [x] 20.1 message_placements tabell: PG-migrasjon + SpacetimeDB-modul med `place_message`, `remove_placement`, `move_on_canvas` reducers. Synk STDB→PG. Ref: `docs/features/universell_overfoering.md` § 2.
- [x] 20.2 source_material edge-type: legg til i edge-skjema + maskinrommet-validering. Støtt kontekst-metadata (quoted, summarized, referenced) og excerpt-felt. Ref: `docs/retninger/arbeidsflaten.md` § "source_material-edge".
- [~] 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.
> Påbegynt: 2026-03-18T08:05
- [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.
- [ ] 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.
- [ ] 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.