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/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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
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.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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue