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
171 lines
5 KiB
Svelte
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>
|