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:
parent
dcab564f74
commit
dee9d5bf3a
6 changed files with 171 additions and 8 deletions
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -84,3 +84,47 @@ export function createCommunication(
|
|||
): Promise<CreateCommunicationResponse> {
|
||||
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}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void>;
|
||||
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<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(() => {
|
||||
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 @@
|
|||
|
||||
<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}
|
||||
<p class="px-3 py-1 text-xs text-red-600">{error}</p>
|
||||
{/if}
|
||||
|
|
@ -100,15 +192,21 @@
|
|||
<div class="flex items-center justify-between border-t border-gray-100 px-3 py-2">
|
||||
<span class="text-xs text-gray-400">
|
||||
{#if editor}
|
||||
Markdown · Ctrl+B/I · Ctrl+Enter for å sende
|
||||
Markdown · Ctrl+B/I · Dra/lim bilder · Ctrl+Enter for å sende
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{submitting ? 'Sender…' : 'Opprett node'}
|
||||
{#if uploading > 0}
|
||||
Laster opp…
|
||||
{:else if submitting}
|
||||
Sender…
|
||||
{:else}
|
||||
Opprett node
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@
|
|||
|
||||
{#if connected && accessToken}
|
||||
<div class="mb-6">
|
||||
<NodeEditor onsubmit={handleCreateNode} disabled={!connected} />
|
||||
<NodeEditor onsubmit={handleCreateNode} disabled={!connected} accessToken={accessToken} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
|
|
|||
3
tasks.md
3
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue