synops/frontend/src/lib/components/NodeEditor.svelte
vegard ebbec982b3 Fullfør oppgave 7.3: Voice memo — opptak-knapp i frontend
Legger til VoiceRecorder-komponent som bruker MediaRecorder API for
lydopptak i nettleseren. Opptaket lastes opp til CAS via eksisterende
uploadMedia-endepunkt, som automatisk trigger Whisper-transkripsjon.

Komponenten er integrert i:
- ChatInput: mikrofon-knapp mellom tekstfelt og send-knapp
- NodeEditor: mikrofon-knapp i verktøylinjen

Flyten: opptak → webm/opus blob → upload → CAS → whisper_transcribe-jobb.
Ingen backend-endringer nødvendig — hele transkripsjons-pipelinen fra
oppgave 7.2 gjenbrukes uendret.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:51:40 +01:00

243 lines
6.5 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
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';
import VoiceRecorder from './VoiceRecorder.svelte';
interface Props {
onsubmit: (data: { title: string; content: string; html: string }) => Promise<void>;
disabled?: boolean;
accessToken?: string;
}
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('');
let voiceMemoInfo = $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!,
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
codeBlock: false,
horizontalRule: false
}),
Link.configure({
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…'
})
],
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: () => {
// Force Svelte reactivity
editor = editor;
}
});
});
onDestroy(() => {
editor?.destroy();
});
const isEmpty = $derived(!title.trim() && (!editor || editor.isEmpty));
async function handleSubmit() {
if (isEmpty || submitting || disabled || uploading > 0) return;
submitting = true;
error = '';
try {
const content = editor?.getText() ?? '';
const html = editor?.getHTML() ?? '';
await onsubmit({ title: title.trim(), content, html });
// Clear editor on success
title = '';
editor?.commands.clearContent();
} catch (e) {
error = e instanceof Error ? e.message : 'Noe gikk galt';
} finally {
submitting = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="rounded-lg border border-gray-200 bg-white shadow-sm" onkeydown={handleKeydown}>
<input
type="text"
bind:value={title}
placeholder="Tittel (valgfritt)"
disabled={disabled || submitting}
class="w-full border-b border-gray-100 bg-transparent px-3 py-2 text-sm font-medium text-gray-900 placeholder:text-gray-400 focus:outline-none"
/>
<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}
{#if voiceMemoInfo}
<p class="px-3 py-1 text-xs text-green-600">{voiceMemoInfo}</p>
{/if}
<div class="flex items-center justify-between border-t border-gray-100 px-3 py-2">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">
{#if editor}
Markdown · Ctrl+B/I · Dra/lim bilder · Ctrl+Enter for å sende
{/if}
</span>
<VoiceRecorder
{accessToken}
disabled={disabled || submitting}
onerror={(msg) => { error = msg; }}
onrecorded={() => { voiceMemoInfo = 'Talenotat lastet opp — transkriberes i bakgrunnen'; setTimeout(() => { voiceMemoInfo = ''; }, 5000); }}
/>
</div>
<button
onclick={handleSubmit}
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"
>
{#if uploading > 0}
Laster opp…
{:else if submitting}
Sender…
{:else}
Opprett node
{/if}
</button>
</div>
</div>
<style>
:global(.tiptap p.is-editor-empty:first-child::before) {
color: #9ca3af;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
:global(.tiptap img) {
max-width: 100%;
height: auto;
border-radius: 0.375rem;
margin: 0.5rem 0;
}
</style>