diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 39e09e9..6687007 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@auth/core": "^0.34.3", "@auth/sveltekit": "^1.11.1", "@tiptap/core": "^3.20.4", + "@tiptap/extension-image": "^3.20.4", "@tiptap/extension-link": "^3.20.4", "@tiptap/extension-placeholder": "^3.20.4", "@tiptap/pm": "^3.20.4", @@ -1612,6 +1613,19 @@ "@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": { "version": "3.20.4", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.20.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2c0ad1b..4d564c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,6 +26,7 @@ "@auth/core": "^0.34.3", "@auth/sveltekit": "^1.11.1", "@tiptap/core": "^3.20.4", + "@tiptap/extension-image": "^3.20.4", "@tiptap/extension-link": "^3.20.4", "@tiptap/extension-placeholder": "^3.20.4", "@tiptap/pm": "^3.20.4", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 61e4f08..68f8c1d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -84,3 +84,47 @@ export function createCommunication( ): Promise { 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 { + 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}`; +} diff --git a/frontend/src/lib/components/NodeEditor.svelte b/frontend/src/lib/components/NodeEditor.svelte index 37f1fff..5e0e808 100644 --- a/frontend/src/lib/components/NodeEditor.svelte +++ b/frontend/src/lib/components/NodeEditor.svelte @@ -3,21 +3,81 @@ import { Editor } from '@tiptap/core'; import StarterKit from '@tiptap/starter-kit'; import Link from '@tiptap/extension-link'; + import Image from '@tiptap/extension-image'; import Placeholder from '@tiptap/extension-placeholder'; + import { uploadMedia, casUrl } from '$lib/api'; interface Props { onsubmit: (data: { title: string; content: string; html: string }) => Promise; disabled?: boolean; + accessToken?: string; } - let { onsubmit, disabled = false }: Props = $props(); + let { onsubmit, disabled = false, accessToken }: Props = $props(); let editorElement: HTMLDivElement | undefined = $state(); let editor: Editor | undefined = $state(); let title = $state(''); let submitting = $state(false); + let uploading = $state(0); let error = $state(''); + async function handleImageUpload(file: File): Promise { + 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) => { + if (!attributes['data-node-id']) return {}; + return { 'data-node-id': attributes['data-node-id'] }; + } + } + }; + } + }); + onMount(() => { editor = new Editor({ element: editorElement!, @@ -31,6 +91,13 @@ openOnClick: false, HTMLAttributes: { class: 'text-blue-600 underline' } }), + CasImage.configure({ + inline: false, + allowBase64: false, + HTMLAttributes: { + class: 'max-w-full h-auto rounded' + } + }), Placeholder.configure({ placeholder: 'Skriv noe…' }) @@ -38,6 +105,27 @@ editorProps: { attributes: { 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: () => { @@ -54,7 +142,7 @@ const isEmpty = $derived(!title.trim() && (!editor || editor.isEmpty)); async function handleSubmit() { - if (isEmpty || submitting || disabled) return; + if (isEmpty || submitting || disabled || uploading > 0) return; submitting = true; error = ''; @@ -93,6 +181,10 @@
+ {#if uploading > 0} +

Laster opp {uploading} bilde{uploading > 1 ? 'r' : ''}…

+ {/if} + {#if error}

{error}

{/if} @@ -100,15 +192,21 @@
{#if editor} - Markdown · Ctrl+B/I · Ctrl+Enter for å sende + Markdown · Ctrl+B/I · Dra/lim bilder · Ctrl+Enter for å sende {/if}
@@ -121,4 +219,11 @@ height: 0; pointer-events: none; } + + :global(.tiptap img) { + max-width: 100%; + height: auto; + border-radius: 0.375rem; + margin: 0.5rem 0; + } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index a813ff6..c8c7e1f 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -232,7 +232,7 @@ {#if connected && accessToken}
- +
{/if} diff --git a/tasks.md b/tasks.md index 3619d81..541dee5 100644 --- a/tasks.md +++ b/tasks.md @@ -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.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. -- [~] 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 +- [x] 6.4 Bilder i TipTap: drag-and-drop/paste → upload → CAS-node → inline i `metadata.document` via `node_id`. ## Fase 7: Lyd-pipeline