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>
243 lines
6.5 KiB
Svelte
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>
|