synops/frontend/src/lib/components/ChatInput.svelte
vegard 6729a35435 Lokasjon-input: del posisjon i chat med kartvisning (oppgave 29.9)
Ny «Del posisjon»-knapp i ChatInput ved siden av tale/video-knappene.
Bruker Geolocation API for å hente brukerens posisjon, oppretter en
content-node med metadata.location { lat, lon, address }.

Reverse geocoding via Nominatim (best-effort) gir adresse i metadata.
Kartvisning i chat via Leaflet/OpenStreetMap viser posisjonen inline.

Komponenter:
- LocationShare.svelte: knapp + geolocation + geocoding + node-opprettelse
- LocationMap.svelte: Leaflet-kart med markør og adresse-popup
- Leaflet lastes via CDN (unpkg) i app.html
2026-03-18 22:36:08 +00:00

171 lines
5 KiB
Svelte

<script lang="ts">
import VoiceRecorder from './VoiceRecorder.svelte';
import VideoRecorder from './VideoRecorder.svelte';
import LocationShare from './LocationShare.svelte';
import { uploadMedia, casUrl } from '$lib/api';
interface Props {
onsubmit: (content: string) => Promise<void>;
disabled?: boolean;
accessToken?: string;
/** Context node for voice memo attachment (e.g. communication node) */
contextId?: string;
}
let { onsubmit, disabled = false, accessToken, contextId }: Props = $props();
let content = $state('');
let submitting = $state(false);
let error = $state('');
let uploading = $state(0);
let textareaEl: HTMLTextAreaElement | undefined = $state();
/** Preview for pasted image before upload completes */
let imagePreview: { url: string; name: string } | undefined = $state();
const isEmpty = $derived(!content.trim() && uploading === 0);
async function handleSubmit() {
if (isEmpty || submitting || disabled) return;
submitting = true;
error = '';
try {
await onsubmit(content.trim());
content = '';
// Reset textarea height
if (textareaEl) textareaEl.style.height = 'auto';
} catch (e) {
error = e instanceof Error ? e.message : 'Noe gikk galt';
} finally {
submitting = false;
textareaEl?.focus();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}
function autoResize(e: Event) {
const el = e.target as HTMLTextAreaElement;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}
/** Handle paste event — detect images from clipboard */
function handlePaste(e: ClipboardEvent) {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) handleImageUpload(file);
return;
}
}
}
/** Upload a pasted/dropped image as a screenshot media node */
async function handleImageUpload(file: File) {
if (!accessToken) {
error = 'Ikke innlogget — kan ikke laste opp bilder';
return;
}
uploading++;
// Show preview
const previewUrl = URL.createObjectURL(file);
imagePreview = { url: previewUrl, name: file.name || 'Skjermklipp' };
try {
await uploadMedia(accessToken, {
file,
source_id: contextId,
title: file.name || 'Skjermklipp',
visibility: 'hidden',
metadata_extra: { source: 'screenshot' },
});
} catch (e) {
error = e instanceof Error ? e.message : 'Feil ved bildeopplasting';
} finally {
uploading--;
if (imagePreview) {
URL.revokeObjectURL(imagePreview.url);
imagePreview = undefined;
}
}
}
</script>
<div class="flex flex-col gap-2">
{#if imagePreview}
<div class="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2">
<img src={imagePreview.url} alt="Forhåndsvisning" class="h-12 w-12 rounded object-cover" />
<span class="text-xs text-blue-700">Laster opp {imagePreview.name}</span>
</div>
{/if}
{#if uploading > 0 && !imagePreview}
<p class="text-xs text-blue-600">Laster opp bilde…</p>
{/if}
<div class="flex items-end gap-2">
<div class="relative flex-1">
<textarea
bind:this={textareaEl}
bind:value={content}
onkeydown={handleKeydown}
oninput={autoResize}
onpaste={handlePaste}
placeholder="Skriv en melding (lim inn bilde med Ctrl+V)"
disabled={disabled || submitting}
rows={1}
class="w-full resize-none rounded-xl border border-gray-200 bg-gray-50 px-4 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-blue-300 focus:bg-white focus:outline-none focus:ring-1 focus:ring-blue-300"
></textarea>
{#if error}
<p class="absolute -top-6 left-0 text-xs text-red-600">{error}</p>
{/if}
</div>
<VoiceRecorder
{accessToken}
sourceId={contextId}
disabled={disabled || submitting}
onerror={(msg) => { error = msg; }}
/>
<VideoRecorder
{accessToken}
sourceId={contextId}
disabled={disabled || submitting}
onerror={(msg) => { error = msg; }}
/>
<LocationShare
{accessToken}
contextId={contextId}
disabled={disabled || submitting}
onerror={(msg) => { error = msg; }}
/>
<button
onclick={handleSubmit}
disabled={isEmpty || submitting || disabled}
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"
aria-label="Send melding"
>
{#if submitting}
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{:else}
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14M12 5l7 7-7 7" />
</svg>
{/if}
</button>
</div>
</div>