Fullfører oppgave 18.5: Drag-and-drop integrasjon mellom verktøy

Implementerer toveis drag-and-drop mellom AI-verktøypanelet og
innholdspaneler, med visuell feedback og kompatibilitetssjekking.

Nye filer:
- transfer.ts: Sentralisert transfer-tjeneste med kompatibilitetsmatrise,
  DragPayload-format (application/x-synops-transfer), og
  inkompatibilitets-meldinger for lyd/bilde-noder.

Endringer:
- AiToolPanel: Drop-sone bruker presetets farge (dynamisk border/bg),
  viser rød sone med forklaring ved inkompatible noder (lyd/bilde),
  presets er draggable ut (for tool_to_node retning).
- EditorTrait: Aksepterer AI-preset drops på innholdsnoder (tool_to_node),
  viser visuell feedback (lilla=kompatibel, rød=inkompatibel),
  trigger in-place revisjon via aiProcess API.
- ChatTrait: Kommunikasjonsnoder er nå draggable til AI-verktøyet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 06:58:15 +00:00
parent 999f46f15d
commit 35d39962dd
5 changed files with 393 additions and 83 deletions

View file

@ -7,12 +7,15 @@
* *
* Retning bestemmes av interaksjon: * Retning bestemmes av interaksjon:
* - Dra en node HIT → node_to_tool (ny node med AI-output) * - Dra en node HIT → node_to_tool (ny node med AI-output)
* - Dra verktøyet PÅ en node → tool_to_node (in-place revisjon) * - Dra et preset PÅ en node → tool_to_node (in-place revisjon)
* For nå støtter panelet kun node_to_tool (drop-mottak). *
* Drop-sonen bruker presetets farge for visuell feedback.
* Inkompatible noder (lyd, bilde) får rød sone med forklaring.
*/ */
import type { Node } from '$lib/spacetime'; import type { Node } from '$lib/spacetime';
import { nodeStore } from '$lib/spacetime'; import { nodeStore } from '$lib/spacetime';
import { aiProcess } from '$lib/api'; import { aiProcess } from '$lib/api';
import { getDragPayload, checkAiToolCompat, setDragPayload } from '$lib/transfer';
interface Props { interface Props {
accessToken?: string; accessToken?: string;
@ -25,6 +28,7 @@
let selectedPresetId = $state<string | null>(null); let selectedPresetId = $state<string | null>(null);
let customPrompt = $state(''); let customPrompt = $state('');
let dragOver = $state(false); let dragOver = $state(false);
let dragIncompat = $state<{ reason: string; suggestion?: string } | null>(null);
let processing = $state(false); let processing = $state(false);
let lastResult = $state<{ success: boolean; message: string } | null>(null); let lastResult = $state<{ success: boolean; message: string } | null>(null);
let droppedNodeId = $state<string | null>(null); let droppedNodeId = $state<string | null>(null);
@ -32,7 +36,6 @@
// --- Derived: AI-preset noder fra STDB --- // --- Derived: AI-preset noder fra STDB ---
const presets = $derived.by(() => { const presets = $derived.by(() => {
const all = nodeStore.byKind('ai_preset'); const all = nodeStore.byKind('ai_preset');
// Sorter: standard først, deretter custom, alfabetisk innenfor
return all.sort((a, b) => { return all.sort((a, b) => {
const metaA = parseMetadata(a); const metaA = parseMetadata(a);
const metaB = parseMetadata(b); const metaB = parseMetadata(b);
@ -49,6 +52,9 @@
const selectedMeta = $derived(selectedPreset ? parseMetadata(selectedPreset) : null); const selectedMeta = $derived(selectedPreset ? parseMetadata(selectedPreset) : null);
/** The color of the selected preset, used for drop-zone feedback */
const presetColor = $derived((selectedMeta?.color as string) ?? '#9333ea');
const modelLabel = $derived.by(() => { const modelLabel = $derived.by(() => {
if (!selectedMeta) return ''; if (!selectedMeta) return '';
switch (selectedMeta.model_profile) { switch (selectedMeta.model_profile) {
@ -75,7 +81,6 @@
function presetIcon(meta: Record<string, unknown>): string { function presetIcon(meta: Record<string, unknown>): string {
const icon = meta.icon as string; const icon = meta.icon as string;
// Map heroicon names to unicode/emoji approximations
const iconMap: Record<string, string> = { const iconMap: Record<string, string> = {
sparkles: '✨', sparkles: '✨',
check_badge: '✅', check_badge: '✅',
@ -89,56 +94,78 @@
return iconMap[icon] ?? '🤖'; return iconMap[icon] ?? '🤖';
} }
// --- Drag-and-drop --- // --- Drag-and-drop: receiving nodes ---
function handleDragOver(e: DragEvent) { function handleDragOver(e: DragEvent) {
e.preventDefault(); e.preventDefault();
if (e.dataTransfer) { if (!e.dataTransfer) return;
e.dataTransfer.dropEffect = 'copy';
// Check compatibility during drag-over for visual feedback
const payload = getDragPayload(e.dataTransfer);
if (payload) {
const node = nodeStore.get(payload.nodeId);
if (node) {
const compat = checkAiToolCompat(node.nodeKind, !!node.content?.trim());
if (!compat.compatible) {
dragIncompat = { reason: compat.reason!, suggestion: compat.suggestion };
e.dataTransfer.dropEffect = 'none';
dragOver = true;
return;
}
}
} }
dragIncompat = null;
e.dataTransfer.dropEffect = 'copy';
dragOver = true; dragOver = true;
} }
function handleDragLeave(e: DragEvent) { function handleDragLeave(e: DragEvent) {
// Only leave if actually leaving the drop zone
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX; const x = e.clientX;
const y = e.clientY; const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
dragOver = false; dragOver = false;
dragIncompat = null;
} }
} }
function handleDrop(e: DragEvent) { function handleDrop(e: DragEvent) {
e.preventDefault(); e.preventDefault();
dragOver = false; dragOver = false;
dragIncompat = null;
const nodeId = e.dataTransfer?.getData('text/plain'); if (!e.dataTransfer) return;
if (!nodeId) return; const payload = getDragPayload(e.dataTransfer);
if (!payload) return;
const node = nodeStore.get(nodeId); const node = nodeStore.get(payload.nodeId);
if (!node) return; if (!node) return;
// Sjekk kompatibilitet: kun tekstnoder // Check compatibility
if (node.nodeKind !== 'content' && node.nodeKind !== 'communication') { const compat = checkAiToolCompat(node.nodeKind, !!node.content?.trim());
if (!compat.compatible) {
lastResult = { lastResult = {
success: false, success: false,
message: `AI-verktøyet behandler kun tekst. "${node.title ?? 'Noden'}" er av type ${node.nodeKind}.` message: [compat.reason, compat.suggestion].filter(Boolean).join(' ')
}; };
return; return;
} }
if (!node.content?.trim()) { droppedNodeId = payload.nodeId;
lastResult = {
success: false,
message: 'Noden har ikke noe tekstinnhold å prosessere.'
};
return;
}
droppedNodeId = nodeId;
lastResult = null; lastResult = null;
} }
// --- Drag-and-drop: dragging presets OUT (for tool_to_node) ---
function handlePresetDragStart(e: DragEvent, presetId: string) {
if (!e.dataTransfer) return;
setDragPayload(e.dataTransfer, {
nodeId: presetId,
nodeKind: 'ai_preset',
presetId,
sourcePanel: 'ai_tool'
});
}
// --- Prosessering --- // --- Prosessering ---
async function processNode() { async function processNode() {
if (!accessToken || !selectedPresetId || !droppedNodeId) return; if (!accessToken || !selectedPresetId || !droppedNodeId) return;
@ -188,7 +215,7 @@
</div> </div>
<div class="p-4 space-y-4"> <div class="p-4 space-y-4">
<!-- Prompt-velger --> <!-- Prompt-velger (presets er draggable for tool_to_node) -->
<div> <div>
<label for="ai-preset-select" class="block text-xs font-medium text-gray-500 mb-1"> <label for="ai-preset-select" class="block text-xs font-medium text-gray-500 mb-1">
Velg verktøy Velg verktøy
@ -201,17 +228,21 @@
{@const meta = parseMetadata(preset)} {@const meta = parseMetadata(preset)}
<button <button
onclick={() => { selectedPresetId = preset.id; }} onclick={() => { selectedPresetId = preset.id; }}
class="flex items-center gap-1.5 rounded-lg border px-2.5 py-2 text-left text-xs transition-colors draggable="true"
ondragstart={(e) => handlePresetDragStart(e, preset.id)}
class="flex items-center gap-1.5 rounded-lg border px-2.5 py-2 text-left text-xs transition-colors cursor-grab active:cursor-grabbing
{selectedPresetId === preset.id {selectedPresetId === preset.id
? 'border-purple-400 bg-purple-50 text-purple-800 shadow-sm' ? 'border-purple-400 bg-purple-50 text-purple-800 shadow-sm'
: 'border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50'}" : 'border-gray-200 text-gray-600 hover:border-gray-300 hover:bg-gray-50'}"
style={selectedPresetId === preset.id ? `border-color: ${meta.color}` : ''} style={selectedPresetId === preset.id && meta.color ? `border-color: ${meta.color}` : ''}
title="Klikk for å velge, eller dra til en tekstnode for in-place revisjon"
> >
<span class="text-sm shrink-0">{presetIcon(meta)}</span> <span class="text-sm shrink-0">{presetIcon(meta)}</span>
<span class="truncate">{preset.title}</span> <span class="truncate">{preset.title}</span>
</button> </button>
{/each} {/each}
</div> </div>
<p class="mt-1 text-xs text-gray-300">Dra et verktøy til en tekstnode for in-place revisjon</p>
{/if} {/if}
</div> </div>
@ -248,19 +279,21 @@
</p> </p>
</div> </div>
<!-- Drop-sone --> <!-- Drop-sone med dynamisk farge fra valgt preset -->
<div <div
role="region" role="region"
aria-label="Slipp tekstnode her" aria-label="Slipp tekstnode her"
ondragover={handleDragOver} ondragover={handleDragOver}
ondragleave={handleDragLeave} ondragleave={handleDragLeave}
ondrop={handleDrop} ondrop={handleDrop}
class="rounded-lg border-2 border-dashed p-6 text-center transition-colors class="rounded-lg border-2 border-dashed p-6 text-center transition-colors"
{dragOver class:border-gray-200={!dragOver && !droppedNode}
? 'border-purple-400 bg-purple-50' class:bg-gray-50={!dragOver && !droppedNode}
: droppedNode class:border-red-400={dragOver && dragIncompat}
? 'border-green-300 bg-green-50' class:bg-red-50={dragOver && dragIncompat}
: 'border-gray-200 bg-gray-50'}" class:border-green-300={!dragOver && !!droppedNode}
class:bg-green-50={!dragOver && !!droppedNode}
style={dragOver && !dragIncompat ? `border-color: ${presetColor}; background-color: ${presetColor}11;` : ''}
> >
{#if droppedNode} {#if droppedNode}
<!-- Viser droppet node --> <!-- Viser droppet node -->
@ -275,10 +308,10 @@
<button <button
onclick={processNode} onclick={processNode}
disabled={!selectedPresetId || processing} disabled={!selectedPresetId || processing}
class="rounded-lg px-4 py-1.5 text-xs font-medium text-white transition-colors class="rounded-lg px-4 py-1.5 text-xs font-medium text-white transition-colors"
{!selectedPresetId || processing class:bg-gray-300={!selectedPresetId || processing}
? 'bg-gray-300 cursor-not-allowed' class:cursor-not-allowed={!selectedPresetId || processing}
: 'bg-purple-600 hover:bg-purple-700'}" style={selectedPresetId && !processing ? `background-color: ${presetColor};` : ''}
> >
{#if processing} {#if processing}
Prosesserer… Prosesserer…
@ -294,8 +327,14 @@
</button> </button>
</div> </div>
</div> </div>
{:else if dragOver && dragIncompat}
<!-- Inkompatibel node -->
<p class="text-sm text-red-600 font-medium">{dragIncompat.reason}</p>
{#if dragIncompat.suggestion}
<p class="text-xs text-red-400 mt-1">{dragIncompat.suggestion}</p>
{/if}
{:else if dragOver} {:else if dragOver}
<p class="text-sm text-purple-600 font-medium">Slipp her for AI-prosessering</p> <p class="text-sm font-medium" style="color: {presetColor};">Slipp her for AI-prosessering</p>
{:else} {:else}
<p class="text-sm text-gray-400">Dra en tekstnode hit</p> <p class="text-sm text-gray-400">Dra en tekstnode hit</p>
<p class="text-xs text-gray-300 mt-1">Støtter innholds- og kommunikasjonsnoder</p> <p class="text-xs text-gray-300 mt-1">Støtter innholds- og kommunikasjonsnoder</p>

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Node } from '$lib/spacetime'; import type { Node } from '$lib/spacetime';
import { edgeStore, nodeStore } from '$lib/spacetime'; import { edgeStore, nodeStore } from '$lib/spacetime';
import { setDragPayload } from '$lib/transfer';
import TraitPanel from './TraitPanel.svelte'; import TraitPanel from './TraitPanel.svelte';
interface Props { interface Props {
@ -31,6 +32,15 @@
} }
return nodes; return nodes;
}); });
function handleDragStart(e: DragEvent, node: Node) {
if (!e.dataTransfer) return;
setDragPayload(e.dataTransfer, {
nodeId: node.id,
nodeKind: node.nodeKind,
sourcePanel: 'chat'
});
}
</script> </script>
<TraitPanel name="chat" label="Samtaler" icon="💬"> <TraitPanel name="chat" label="Samtaler" icon="💬">
@ -40,7 +50,11 @@
{:else} {:else}
<ul class="space-y-2"> <ul class="space-y-2">
{#each chatNodes as node (node.id)} {#each chatNodes as node (node.id)}
<li> <li
draggable="true"
ondragstart={(e) => handleDragStart(e, node)}
class="cursor-grab active:cursor-grabbing"
>
<a <a
href="/chat/{node.id}" href="/chat/{node.id}"
class="block rounded border border-gray-100 px-3 py-2 transition-colors hover:border-blue-300 hover:bg-blue-50" class="block rounded border border-gray-100 px-3 py-2 transition-colors hover:border-blue-300 hover:bg-blue-50"

View file

@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { Node, Edge } from '$lib/spacetime'; import type { Node, Edge } from '$lib/spacetime';
import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime'; import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime';
import { deleteEdge } from '$lib/api'; import { deleteEdge, aiProcess } from '$lib/api';
import TraitPanel from './TraitPanel.svelte'; import TraitPanel from './TraitPanel.svelte';
import PublishDialog from '$lib/components/PublishDialog.svelte'; import PublishDialog from '$lib/components/PublishDialog.svelte';
import { getDragPayload, setDragPayload, checkToolToNodeCompat } from '$lib/transfer';
interface Props { interface Props {
collection: Node; collection: Node;
@ -59,7 +60,6 @@
if (!isPublishingCollection || !userId) return []; if (!isPublishingCollection || !userId) return [];
const inCollection = new Set(contentItems.map(i => i.node.id)); const inCollection = new Set(contentItems.map(i => i.node.id));
const nodes: Node[] = []; const nodes: Node[] = [];
// Find content nodes owned by user that aren't in this collection
for (const edge of edgeStore.bySource(userId)) { for (const edge of edgeStore.bySource(userId)) {
if (edge.edgeType !== 'owner') continue; if (edge.edgeType !== 'owner') continue;
const node = nodeStore.get(edge.targetId); const node = nodeStore.get(edge.targetId);
@ -82,6 +82,108 @@
let unpublishError = $state<string | null>(null); let unpublishError = $state<string | null>(null);
let confirmUnpublish = $state<ContentItem | null>(null); let confirmUnpublish = $state<ContentItem | null>(null);
// --- AI tool_to_node drop state ---
let dropTargetNodeId = $state<string | null>(null);
let dropIncompat = $state<{ reason: string; suggestion?: string } | null>(null);
let aiProcessing = $state<string | null>(null);
let aiResult = $state<{ nodeId: string; success: boolean; message: string } | null>(null);
function parsePresetMetadata(presetId: string): Record<string, unknown> {
const node = nodeStore.get(presetId);
if (!node) return {};
try { return JSON.parse(node.metadata ?? '{}'); } catch { return {}; }
}
// --- Drag-and-drop: items as drag source ---
function handleItemDragStart(e: DragEvent, item: ContentItem) {
if (!e.dataTransfer) return;
setDragPayload(e.dataTransfer, {
nodeId: item.node.id,
nodeKind: item.node.nodeKind,
sourcePanel: 'editor'
});
}
// --- Drag-and-drop: accept AI preset drops (tool_to_node) ---
function handleItemDragOver(e: DragEvent, item: ContentItem) {
e.preventDefault();
if (!e.dataTransfer) return;
const payload = getDragPayload(e.dataTransfer);
if (!payload || payload.nodeKind !== 'ai_preset' || !payload.presetId) {
// Not an AI preset — ignore (don't accept arbitrary drops on items)
return;
}
const compat = checkToolToNodeCompat(item.node.nodeKind, !!item.node.content?.trim());
if (!compat.compatible) {
dropIncompat = { reason: compat.reason!, suggestion: compat.suggestion };
e.dataTransfer.dropEffect = 'none';
} else {
dropIncompat = null;
e.dataTransfer.dropEffect = 'copy';
}
dropTargetNodeId = item.node.id;
}
function handleItemDragLeave(e: DragEvent, item: ContentItem) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
if (dropTargetNodeId === item.node.id) {
dropTargetNodeId = null;
dropIncompat = null;
}
}
}
async function handleItemDrop(e: DragEvent, item: ContentItem) {
e.preventDefault();
dropTargetNodeId = null;
dropIncompat = null;
if (!e.dataTransfer || !accessToken) return;
const payload = getDragPayload(e.dataTransfer);
if (!payload || payload.nodeKind !== 'ai_preset' || !payload.presetId) return;
const compat = checkToolToNodeCompat(item.node.nodeKind, !!item.node.content?.trim());
if (!compat.compatible) {
aiResult = {
nodeId: item.node.id,
success: false,
message: [compat.reason, compat.suggestion].filter(Boolean).join(' ')
};
return;
}
// Trigger tool_to_node AI processing
aiProcessing = item.node.id;
aiResult = null;
try {
const res = await aiProcess(accessToken, {
source_node_id: item.node.id,
ai_preset_id: payload.presetId,
direction: 'tool_to_node'
});
const presetNode = nodeStore.get(payload.presetId);
aiResult = {
nodeId: item.node.id,
success: true,
message: `${presetNode?.title ?? 'AI'}: jobb opprettet (${res.job_id.slice(0, 8)}…). Noden oppdateres i sanntid.`
};
} catch (err) {
aiResult = {
nodeId: item.node.id,
success: false,
message: `Feil: ${err instanceof Error ? err.message : String(err)}`
};
} finally {
aiProcessing = null;
}
}
async function handleUnpublish(item: ContentItem) { async function handleUnpublish(item: ContentItem) {
if (!accessToken || unpublishing) return; if (!accessToken || unpublishing) return;
unpublishing = item.node.id; unpublishing = item.node.id;
@ -123,52 +225,88 @@
{:else} {:else}
<ul class="space-y-2"> <ul class="space-y-2">
{#each contentItems as item (item.node.id)} {#each contentItems as item (item.node.id)}
{@const isDropTarget = dropTargetNodeId === item.node.id}
{@const isIncompat = isDropTarget && dropIncompat}
{@const isProcessing = aiProcessing === item.node.id}
{@const itemResult = aiResult?.nodeId === item.node.id ? aiResult : null}
<li <li
class="group rounded border border-gray-100 px-3 py-2 cursor-grab active:cursor-grabbing" class="group rounded border px-3 py-2 transition-colors"
draggable="true" class:cursor-grab={!isProcessing}
ondragstart={(e) => { class:active:cursor-grabbing={!isProcessing}
if (e.dataTransfer) { class:border-gray-100={!isDropTarget && !isProcessing}
e.dataTransfer.effectAllowed = 'copy'; class:border-purple-400={isDropTarget && !isIncompat}
e.dataTransfer.setData('text/plain', item.node.id); class:bg-purple-50={isDropTarget && !isIncompat}
} class:border-red-400={!!isIncompat}
}} class:bg-red-50={!!isIncompat}
class:border-amber-300={isProcessing}
class:bg-amber-50={isProcessing}
draggable={!isProcessing}
ondragstart={(e) => handleItemDragStart(e, item)}
ondragover={(e) => handleItemDragOver(e, item)}
ondragleave={(e) => handleItemDragLeave(e, item)}
ondrop={(e) => handleItemDrop(e, item)}
> >
<div class="flex items-start justify-between gap-2"> {#if isIncompat}
<div class="min-w-0 flex-1"> <!-- Incompat feedback overlay -->
<h4 class="text-sm font-medium text-gray-900">{item.node.title || 'Uten tittel'}</h4> <div class="text-center py-1">
{#if item.node.content} <p class="text-xs text-red-600 font-medium">{dropIncompat?.reason}</p>
<p class="mt-0.5 text-xs text-gray-500 line-clamp-2">{item.node.content.slice(0, 140)}</p> {#if dropIncompat?.suggestion}
{/if} <p class="text-xs text-red-400 mt-0.5">{dropIncompat.suggestion}</p>
{#if isPublishingCollection && pubSlug}
<p class="mt-1 text-xs text-gray-400">
/pub/{pubSlug}/{item.node.id.slice(0, 8)}
</p>
{/if} {/if}
</div> </div>
{#if isPublishingCollection && accessToken} {:else if isDropTarget}
<div class="flex shrink-0 items-center gap-1"> <!-- Compatible drop target -->
{#if pubSlug} <div class="text-center py-1">
<a <p class="text-xs text-purple-600 font-medium">Slipp for in-place revisjon</p>
href="/api/pub/{pubSlug}/{item.node.id.slice(0, 8)}" </div>
target="_blank" {:else}
rel="noopener" <div class="flex items-start justify-between gap-2">
class="rounded px-2 py-1 text-xs text-gray-500 opacity-0 group-hover:opacity-100 hover:bg-gray-100 hover:text-gray-700" <div class="min-w-0 flex-1">
title="Se publisert artikkel" <h4 class="text-sm font-medium text-gray-900">
> {item.node.title || 'Uten tittel'}
Se {#if isProcessing}
</a> <span class="ml-1 text-xs text-amber-600">Prosesserer…</span>
{/if}
</h4>
{#if item.node.content}
<p class="mt-0.5 text-xs text-gray-500 line-clamp-2">{item.node.content.slice(0, 140)}</p>
{/if}
{#if isPublishingCollection && pubSlug}
<p class="mt-1 text-xs text-gray-400">
/pub/{pubSlug}/{item.node.id.slice(0, 8)}
</p>
{/if}
{#if itemResult}
<p class="mt-1 text-xs {itemResult.success ? 'text-green-600' : 'text-red-600'}">
{itemResult.message}
</p>
{/if} {/if}
<button
onclick={() => { confirmUnpublish = item; }}
class="rounded px-2 py-1 text-xs text-red-500 opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-700"
title="Fjern fra publisering"
disabled={unpublishing === item.node.id}
>
{unpublishing === item.node.id ? '...' : 'Avpubliser'}
</button>
</div> </div>
{/if} {#if isPublishingCollection && accessToken}
</div> <div class="flex shrink-0 items-center gap-1">
{#if pubSlug}
<a
href="/api/pub/{pubSlug}/{item.node.id.slice(0, 8)}"
target="_blank"
rel="noopener"
class="rounded px-2 py-1 text-xs text-gray-500 opacity-0 group-hover:opacity-100 hover:bg-gray-100 hover:text-gray-700"
title="Se publisert artikkel"
>
Se
</a>
{/if}
<button
onclick={() => { confirmUnpublish = item; }}
class="rounded px-2 py-1 text-xs text-red-500 opacity-0 group-hover:opacity-100 hover:bg-red-50 hover:text-red-700"
title="Fjern fra publisering"
disabled={unpublishing === item.node.id}
>
{unpublishing === item.node.id ? '...' : 'Avpubliser'}
</button>
</div>
{/if}
</div>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>

View file

@ -0,0 +1,120 @@
/**
* Transfer service drag-and-drop compatibility between tool panels.
*
* Centralizes compatibility checking and incompatibility messages
* for the universal transfer system (universell overføring).
*
* Ref: docs/features/universell_overfoering.md
* Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
*/
export type ToolType = 'ai_tool' | 'editor' | 'chat' | 'kanban' | 'calendar' | 'studio';
export interface DragPayload {
nodeId: string;
nodeKind: string;
/** Optional: AI preset ID when dragging from AI tool panel */
presetId?: string;
sourcePanel: ToolType;
}
export interface CompatResult {
compatible: boolean;
/** Human-readable reason when incompatible */
reason?: string;
/** Suggested alternative action */
suggestion?: string;
}
const MIME_TYPE = 'application/x-synops-transfer';
/** Encode drag payload into dataTransfer */
export function setDragPayload(dt: DataTransfer, payload: DragPayload): void {
dt.setData('text/plain', payload.nodeId);
dt.setData(MIME_TYPE, JSON.stringify(payload));
dt.effectAllowed = 'copy';
}
/** Decode drag payload from dataTransfer. Falls back to plain text node ID. */
export function getDragPayload(dt: DataTransfer): DragPayload | null {
const json = dt.getData(MIME_TYPE);
if (json) {
try {
return JSON.parse(json) as DragPayload;
} catch { /* fall through */ }
}
const nodeId = dt.getData('text/plain');
if (nodeId) {
return { nodeId, nodeKind: 'unknown', sourcePanel: 'editor' as ToolType };
}
return null;
}
/** Media node kinds that are incompatible with text-only tools */
const MEDIA_KINDS = new Set(['media', 'audio', 'image', 'video']);
/** Check if a node kind represents media content */
function isMediaKind(nodeKind: string): boolean {
return MEDIA_KINDS.has(nodeKind);
}
/** Check if a node kind represents audio */
function isAudioKind(nodeKind: string): boolean {
return nodeKind === 'audio' || nodeKind === 'media'; // media is often audio in this context
}
/**
* Check compatibility for dropping a node onto the AI tool panel.
*/
export function checkAiToolCompat(nodeKind: string, hasContent: boolean): CompatResult {
if (isMediaKind(nodeKind)) {
return {
compatible: false,
reason: 'AI-verktøyet behandler kun tekst.',
suggestion: nodeKind === 'audio' || nodeKind === 'media'
? 'Transkriber lydfilen først, og dra transkripsjonen hit.'
: 'Kun tekstnoder kan prosesseres av AI-verktøyet.'
};
}
if (nodeKind !== 'content' && nodeKind !== 'communication') {
return {
compatible: false,
reason: `Nodetypen «${nodeKind}» støttes ikke av AI-verktøyet.`,
suggestion: 'Kun innholds- og kommunikasjonsnoder med tekst kan prosesseres.'
};
}
if (!hasContent) {
return {
compatible: false,
reason: 'Noden har ikke noe tekstinnhold å prosessere.'
};
}
return { compatible: true };
}
/**
* Check compatibility for dropping an AI preset onto a content node
* (tool_to_node direction in-place revision).
*/
export function checkToolToNodeCompat(nodeKind: string, hasContent: boolean): CompatResult {
if (isMediaKind(nodeKind)) {
return {
compatible: false,
reason: 'AI-verktøyet kan ikke revidere mediefiler.',
suggestion: 'Kun tekstnoder kan revideres in-place.'
};
}
if (nodeKind !== 'content') {
return {
compatible: false,
reason: `Kun innholdsnoder kan revideres in-place.`
};
}
if (!hasContent) {
return {
compatible: false,
reason: 'Noden har ikke noe tekstinnhold å revidere.'
};
}
return { compatible: true };
}

View file

@ -205,8 +205,7 @@ Ref: `docs/features/ai_verktoy.md`, `docs/retninger/arbeidsflaten.md`
- [x] 18.2 AI-prosessering endepunkt: `POST /intentions/ai_process` med source_node_id, ai_preset_id, direction (node_to_tool / tool_to_node). Maskinrommet henter kilde-content og preset-prompt, mapper modellprofil → LiteLLM-alias, sender til AI Gateway. Logg forbruk i ai_usage_log. - [x] 18.2 AI-prosessering endepunkt: `POST /intentions/ai_process` med source_node_id, ai_preset_id, direction (node_to_tool / tool_to_node). Maskinrommet henter kilde-content og preset-prompt, mapper modellprofil → LiteLLM-alias, sender til AI Gateway. Logg forbruk i ai_usage_log.
- [x] 18.3 Direction-logikk: `tool_to_node` → lagre original som revisjon, oppdater node content. `node_to_tool` → opprett ny node med AI-output, opprett `derived_from`-edge til kilde + `processed_by`-edge til AI-preset. - [x] 18.3 Direction-logikk: `tool_to_node` → lagre original som revisjon, oppdater node content. `node_to_tool` → opprett ny node med AI-output, opprett `derived_from`-edge til kilde + `processed_by`-edge til AI-preset.
- [x] 18.4 AI-verktøy panel (frontend): Svelte-komponent for arbeidsflaten. Prompt-velger med standardprompter, fritekst-felt for egendefinert prompt, modell-indikator (readonly). Drag-and-drop mottak for tekstnoder. - [x] 18.4 AI-verktøy panel (frontend): Svelte-komponent for arbeidsflaten. Prompt-velger med standardprompter, fritekst-felt for egendefinert prompt, modell-indikator (readonly). Drag-and-drop mottak for tekstnoder.
- [~] 18.5 Drag-and-drop integrasjon: node → verktøy (ny node), verktøy → node (in-place revisjon). Drop-sone feedback med verktøyets farge. Inkompatibilitet for lyd/bilde-noder med forklaring. - [x] 18.5 Drag-and-drop integrasjon: node → verktøy (ny node), verktøy → node (in-place revisjon). Drop-sone feedback med verktøyets farge. Inkompatibilitet for lyd/bilde-noder med forklaring.
> Påbegynt: 2026-03-18T06:51
- [ ] 18.6 Egendefinerte presets: brukere kan opprette egne AI-preset-noder med custom prompt. Dele via edges til samling/team. Modellprofil satt av admin. - [ ] 18.6 Egendefinerte presets: brukere kan opprette egne AI-preset-noder med custom prompt. Dele via edges til samling/team. Modellprofil satt av admin.
## Fase 19: Arbeidsflaten — Spatial Canvas ## Fase 19: Arbeidsflaten — Spatial Canvas