Fullfør oppgave 6.4: Bilder i TipTap via drag-and-drop/paste

Brukeren kan nå dra eller lime inn bilder i TipTap-editoren.
Bildet lastes opp til CAS via upload_media-endepunktet, og settes
inn som <img> med CAS-URL i metadata.document (HTML).

Endringer:
- Ny uploadMedia() og casUrl() i api.ts for multipart upload
- @tiptap/extension-image med CasImage-utvidelse (data-node-id attr)
- handleDrop/handlePaste i editor intercepter bildefiler
- Upload-status vises i editoren mens bilder lastes opp
- accessToken sendes ned til NodeEditor fra +page.svelte

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 17:07:17 +01:00
parent dcab564f74
commit dee9d5bf3a
6 changed files with 171 additions and 8 deletions

View file

@ -11,6 +11,7 @@
"@auth/core": "^0.34.3", "@auth/core": "^0.34.3",
"@auth/sveltekit": "^1.11.1", "@auth/sveltekit": "^1.11.1",
"@tiptap/core": "^3.20.4", "@tiptap/core": "^3.20.4",
"@tiptap/extension-image": "^3.20.4",
"@tiptap/extension-link": "^3.20.4", "@tiptap/extension-link": "^3.20.4",
"@tiptap/extension-placeholder": "^3.20.4", "@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/pm": "^3.20.4", "@tiptap/pm": "^3.20.4",
@ -1612,6 +1613,19 @@
"@tiptap/pm": "^3.20.4" "@tiptap/pm": "^3.20.4"
} }
}, },
"node_modules/@tiptap/extension-image": {
"version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.20.4.tgz",
"integrity": "sha512-57w2TevHQljTh6Xiry9duIm7NNOQAUSTwtwRn4GGLoKwHR8qXTxzp513ASrFOgR2kgs2TP471Au6RHf947P+jg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^3.20.4"
}
},
"node_modules/@tiptap/extension-italic": { "node_modules/@tiptap/extension-italic": {
"version": "3.20.4", "version": "3.20.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.4.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.4.tgz",

View file

@ -26,6 +26,7 @@
"@auth/core": "^0.34.3", "@auth/core": "^0.34.3",
"@auth/sveltekit": "^1.11.1", "@auth/sveltekit": "^1.11.1",
"@tiptap/core": "^3.20.4", "@tiptap/core": "^3.20.4",
"@tiptap/extension-image": "^3.20.4",
"@tiptap/extension-link": "^3.20.4", "@tiptap/extension-link": "^3.20.4",
"@tiptap/extension-placeholder": "^3.20.4", "@tiptap/extension-placeholder": "^3.20.4",
"@tiptap/pm": "^3.20.4", "@tiptap/pm": "^3.20.4",

View file

@ -84,3 +84,47 @@ export function createCommunication(
): Promise<CreateCommunicationResponse> { ): Promise<CreateCommunicationResponse> {
return post(accessToken, '/intentions/create_communication', data); return post(accessToken, '/intentions/create_communication', data);
} }
export interface UploadMediaRequest {
file: File;
source_id?: string;
visibility?: string;
title?: string;
}
export interface UploadMediaResponse {
media_node_id: string;
cas_hash: string;
size_bytes: number;
already_existed: boolean;
has_media_edge_id?: string;
}
export async function uploadMedia(
accessToken: string,
data: UploadMediaRequest
): Promise<UploadMediaResponse> {
const form = new FormData();
form.append('file', data.file);
if (data.source_id) form.append('source_id', data.source_id);
if (data.visibility) form.append('visibility', data.visibility);
if (data.title) form.append('title', data.title);
const res = await fetch(`${BASE_URL}/intentions/upload_media`, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body: form
});
if (!res.ok) {
const body = await res.text();
throw new Error(`upload_media failed (${res.status}): ${body}`);
}
return res.json();
}
/** Build the CAS URL for a given hash. */
export function casUrl(hash: string): string {
return `${BASE_URL}/cas/${hash}`;
}

View file

@ -3,21 +3,81 @@
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link'; import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder'; import Placeholder from '@tiptap/extension-placeholder';
import { uploadMedia, casUrl } from '$lib/api';
interface Props { interface Props {
onsubmit: (data: { title: string; content: string; html: string }) => Promise<void>; onsubmit: (data: { title: string; content: string; html: string }) => Promise<void>;
disabled?: boolean; disabled?: boolean;
accessToken?: string;
} }
let { onsubmit, disabled = false }: Props = $props(); let { onsubmit, disabled = false, accessToken }: Props = $props();
let editorElement: HTMLDivElement | undefined = $state(); let editorElement: HTMLDivElement | undefined = $state();
let editor: Editor | undefined = $state(); let editor: Editor | undefined = $state();
let title = $state(''); let title = $state('');
let submitting = $state(false); let submitting = $state(false);
let uploading = $state(0);
let error = $state(''); let error = $state('');
async function handleImageUpload(file: File): Promise<void> {
if (!accessToken) {
error = 'Ikke innlogget — kan ikke laste opp bilder';
return;
}
if (!file.type.startsWith('image/')) return;
uploading++;
try {
const result = await uploadMedia(accessToken, {
file,
title: file.name,
visibility: 'hidden'
});
const src = casUrl(result.cas_hash);
editor?.chain().focus().setImage({
src,
alt: file.name,
title: file.name
}).run();
// Add data-node-id to the just-inserted image
// TipTap Image extension stores src/alt/title as attributes.
// We extend the node attribute below to include data-node-id.
} catch (e) {
error = e instanceof Error ? e.message : 'Feil ved bildeopplasting';
} finally {
uploading--;
}
}
/** Handle files from drop or paste events */
function handleFiles(files: FileList | File[]) {
for (const file of files) {
if (file.type.startsWith('image/')) {
handleImageUpload(file);
}
}
}
// Extend the Image extension to include data-node-id attribute
const CasImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
'data-node-id': {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-node-id'),
renderHTML: (attributes: Record<string, unknown>) => {
if (!attributes['data-node-id']) return {};
return { 'data-node-id': attributes['data-node-id'] };
}
}
};
}
});
onMount(() => { onMount(() => {
editor = new Editor({ editor = new Editor({
element: editorElement!, element: editorElement!,
@ -31,6 +91,13 @@
openOnClick: false, openOnClick: false,
HTMLAttributes: { class: 'text-blue-600 underline' } HTMLAttributes: { class: 'text-blue-600 underline' }
}), }),
CasImage.configure({
inline: false,
allowBase64: false,
HTMLAttributes: {
class: 'max-w-full h-auto rounded'
}
}),
Placeholder.configure({ Placeholder.configure({
placeholder: 'Skriv noe…' placeholder: 'Skriv noe…'
}) })
@ -38,6 +105,27 @@
editorProps: { editorProps: {
attributes: { attributes: {
class: 'prose prose-sm max-w-none focus:outline-none min-h-[80px] px-3 py-2' class: 'prose prose-sm max-w-none focus:outline-none min-h-[80px] px-3 py-2'
},
handleDrop: (_view, event, _slice, moved) => {
if (moved || !event.dataTransfer?.files?.length) return false;
const imageFiles = Array.from(event.dataTransfer.files).filter(f =>
f.type.startsWith('image/')
);
if (imageFiles.length === 0) return false;
event.preventDefault();
handleFiles(imageFiles);
return true;
},
handlePaste: (_view, event) => {
const files = event.clipboardData?.files;
if (!files?.length) return false;
const imageFiles = Array.from(files).filter(f =>
f.type.startsWith('image/')
);
if (imageFiles.length === 0) return false;
event.preventDefault();
handleFiles(imageFiles);
return true;
} }
}, },
onTransaction: () => { onTransaction: () => {
@ -54,7 +142,7 @@
const isEmpty = $derived(!title.trim() && (!editor || editor.isEmpty)); const isEmpty = $derived(!title.trim() && (!editor || editor.isEmpty));
async function handleSubmit() { async function handleSubmit() {
if (isEmpty || submitting || disabled) return; if (isEmpty || submitting || disabled || uploading > 0) return;
submitting = true; submitting = true;
error = ''; error = '';
@ -93,6 +181,10 @@
<div bind:this={editorElement}></div> <div bind:this={editorElement}></div>
{#if uploading > 0}
<p class="px-3 py-1 text-xs text-blue-600">Laster opp {uploading} bilde{uploading > 1 ? 'r' : ''}</p>
{/if}
{#if error} {#if error}
<p class="px-3 py-1 text-xs text-red-600">{error}</p> <p class="px-3 py-1 text-xs text-red-600">{error}</p>
{/if} {/if}
@ -100,15 +192,21 @@
<div class="flex items-center justify-between border-t border-gray-100 px-3 py-2"> <div class="flex items-center justify-between border-t border-gray-100 px-3 py-2">
<span class="text-xs text-gray-400"> <span class="text-xs text-gray-400">
{#if editor} {#if editor}
Markdown · Ctrl+B/I · Ctrl+Enter for å sende Markdown · Ctrl+B/I · Dra/lim bilder · Ctrl+Enter for å sende
{/if} {/if}
</span> </span>
<button <button
onclick={handleSubmit} onclick={handleSubmit}
disabled={isEmpty || submitting || disabled} disabled={isEmpty || submitting || disabled || uploading > 0}
class="rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40" class="rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"
> >
{submitting ? 'Sender…' : 'Opprett node'} {#if uploading > 0}
Laster opp…
{:else if submitting}
Sender…
{:else}
Opprett node
{/if}
</button> </button>
</div> </div>
</div> </div>
@ -121,4 +219,11 @@
height: 0; height: 0;
pointer-events: none; pointer-events: none;
} }
:global(.tiptap img) {
max-width: 100%;
height: auto;
border-radius: 0.375rem;
margin: 0.5rem 0;
}
</style> </style>

View file

@ -232,7 +232,7 @@
{#if connected && accessToken} {#if connected && accessToken}
<div class="mb-6"> <div class="mb-6">
<NodeEditor onsubmit={handleCreateNode} disabled={!connected} /> <NodeEditor onsubmit={handleCreateNode} disabled={!connected} accessToken={accessToken} />
</div> </div>
{/if} {/if}

View file

@ -89,8 +89,7 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 6.1 CAS-lagring: filsystem med content-addressable hashing (SHA-256). Katalogstruktur med hash-prefix. Deduplisering. - [x] 6.1 CAS-lagring: filsystem med content-addressable hashing (SHA-256). Katalogstruktur med hash-prefix. Deduplisering.
- [x] 6.2 Upload-endepunkt: `POST /intentions/upload_media` → hash fil, lagre i CAS, opprett media-node med `has_media`-edge. - [x] 6.2 Upload-endepunkt: `POST /intentions/upload_media` → hash fil, lagre i CAS, opprett media-node med `has_media`-edge.
- [x] 6.3 Serving: `GET /cas/{hash}` → stream fil fra disk. Caddy kan serve direkte for ytelse. - [x] 6.3 Serving: `GET /cas/{hash}` → stream fil fra disk. Caddy kan serve direkte for ytelse.
- [~] 6.4 Bilder i TipTap: drag-and-drop/paste → upload → CAS-node → inline i `metadata.document` via `node_id`. - [x] 6.4 Bilder i TipTap: drag-and-drop/paste → upload → CAS-node → inline i `metadata.document` via `node_id`.
> Påbegynt: 2026-03-17T17:01
## Fase 7: Lyd-pipeline ## Fase 7: Lyd-pipeline