synops/frontend/src/lib/components/TranscriptionView.svelte
vegard 35701aeb2a Fullfør oppgave 7.8: SRT-eksport fra transkripsjons-segmenter
Nytt GET /query/segments/srt-endepunkt som genererer nedlastbar SRT-fil
fra transcription_segments-tabellen. Bruker RLS-verifisert tilgang.
Frontend har nedlastingsknapp i TranscriptionView med autentisert fetch.

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

340 lines
9.2 KiB
Svelte

<script lang="ts">
import {
fetchSegments,
updateSegment,
fetchTranscriptionVersions,
retranscribe,
downloadSrt,
type Segment,
type TranscriptionVersion
} from '$lib/api';
import RetranscriptionCompare from './RetranscriptionCompare.svelte';
interface Props {
/** Media node ID — brukes for å hente segmenter fra API */
nodeId: string;
/** Access token for API-kall */
accessToken: string;
/** Nåværende avspillingstid i sekunder (fra AudioPlayer) */
currentTime?: number;
/** Callback for å hoppe til tidspunkt i lydfilen */
onseek?: (timeSeconds: number) => void;
}
let { nodeId, accessToken, currentTime = 0, onseek }: Props = $props();
let segments: Segment[] = $state([]);
let loading = $state(true);
let error = $state<string | null>(null);
let editingId = $state<number | null>(null);
let editText = $state('');
let saving = $state(false);
// Re-transkripsjon state
let versions: TranscriptionVersion[] = $state([]);
let retranscribing = $state(false);
let showCompare = $state(false);
let transcribedAt = $state<string | null>(null);
// Hent segmenter og versjoner ved mount og når nodeId endres
$effect(() => {
loadSegments(nodeId, accessToken);
});
async function loadSegments(nid: string, token: string) {
loading = true;
error = null;
try {
const [segResp, versResp] = await Promise.all([
fetchSegments(token, nid),
fetchTranscriptionVersions(token, nid)
]);
segments = segResp.segments;
transcribedAt = segResp.transcribed_at;
versions = versResp.versions;
// Hvis det finnes 2+ versjoner og den gamle har redigerte segmenter,
// vis sammenligningsvisning automatisk
if (versions.length >= 2 && versions[1].edited_count > 0) {
showCompare = true;
} else {
showCompare = false;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Kunne ikke hente segmenter';
} finally {
loading = false;
}
}
/** Finn aktivt segment basert på nåværende avspillingstid */
const activeSegmentId = $derived.by(() => {
if (!currentTime || segments.length === 0) return null;
const timeMs = currentTime * 1000;
for (const seg of segments) {
if (timeMs >= seg.start_ms && timeMs < seg.end_ms) {
return seg.id;
}
}
return null;
});
function formatTimestamp(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function handleSeek(startMs: number) {
onseek?.(startMs / 1000);
}
function startEditing(seg: Segment) {
editingId = seg.id;
editText = seg.content;
}
function cancelEditing() {
editingId = null;
editText = '';
}
async function saveEdit(seg: Segment) {
const trimmed = editText.trim();
if (!trimmed || trimmed === seg.content) {
cancelEditing();
return;
}
saving = true;
try {
await updateSegment(accessToken, seg.id, trimmed);
seg.content = trimmed;
seg.edited = true;
segments = [...segments];
cancelEditing();
} catch (e) {
console.error('Kunne ikke lagre segment:', e);
} finally {
saving = false;
}
}
function handleKeydown(e: KeyboardEvent, seg: Segment) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
saveEdit(seg);
} else if (e.key === 'Escape') {
cancelEditing();
}
}
async function handleRetranscribe() {
retranscribing = true;
error = null;
try {
await retranscribe(accessToken, nodeId);
error = null;
// Vis melding — jobben kjører asynkront
retranscribing = false;
// Poll for nye versjoner etter en stund
pollForNewVersion();
} catch (e) {
error = e instanceof Error ? e.message : 'Kunne ikke starte re-transkripsjon';
retranscribing = false;
}
}
let polling = $state(false);
async function pollForNewVersion() {
polling = true;
const currentVersionCount = versions.length;
const maxAttempts = 60; // 5 minutter med 5-sekunders intervall
for (let i = 0; i < maxAttempts; i++) {
await new Promise((r) => setTimeout(r, 5000));
try {
const resp = await fetchTranscriptionVersions(accessToken, nodeId);
if (resp.versions.length > currentVersionCount) {
// Ny versjon er klar — last inn på nytt
await loadSegments(nodeId, accessToken);
polling = false;
return;
}
} catch {
// Ignorer feil under polling
}
}
polling = false;
}
async function handleDownloadSrt() {
try {
await downloadSrt(accessToken, nodeId);
} catch (e) {
console.error('SRT-nedlasting feilet:', e);
}
}
function handleComparisonDone() {
showCompare = false;
loadSegments(nodeId, accessToken);
}
</script>
{#if loading}
<p class="text-[10px] text-gray-400 px-1">Laster segmenter...</p>
{:else if error}
<p class="text-[10px] text-red-400 px-1">{error}</p>
{:else if segments.length === 0}
<p class="text-[10px] text-gray-400 px-1">Ingen segmenter</p>
{:else if showCompare && versions.length >= 2}
<!-- Sammenligningsvisning -->
<RetranscriptionCompare
{nodeId}
{accessToken}
newVersion={versions[0].transcribed_at}
oldVersion={versions[1].transcribed_at}
ondone={handleComparisonDone}
{onseek}
{currentTime}
/>
{:else}
<!-- Handlingsknapper -->
<div class="flex items-center justify-between px-1 mb-1">
<span class="text-[10px] text-gray-400">
{segments.length} segmenter
</span>
<div class="flex items-center gap-1">
{#if polling}
<span class="text-[10px] text-blue-500 flex items-center gap-0.5">
<svg class="h-3 w-3 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>
Transkriberer...
</span>
{:else if versions.length >= 2}
<button
onclick={() => {
showCompare = true;
}}
class="text-[10px] text-blue-500 hover:text-blue-700"
>
Sammenlign versjoner
</button>
{/if}
<button
onclick={handleDownloadSrt}
class="text-[10px] text-gray-400 hover:text-blue-600 flex items-center gap-0.5"
title="Last ned SRT-fil"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
SRT
</button>
<button
onclick={handleRetranscribe}
disabled={retranscribing || polling}
class="text-[10px] text-gray-400 hover:text-blue-600 disabled:text-gray-300 flex items-center gap-0.5"
title="Kjør ny transkripsjon"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Re-transkriber
</button>
</div>
</div>
<!-- Segment-liste -->
<div class="max-h-64 overflow-y-auto space-y-0.5">
{#each segments as seg (seg.id)}
{@const isActive = activeSegmentId === seg.id}
{@const isEditing = editingId === seg.id}
<div
class="flex items-start gap-1.5 rounded px-1.5 py-1 transition-colors
{isActive ? 'bg-blue-50' : 'hover:bg-gray-50'}"
>
<!-- Tidsstempel + avspillingsknapp -->
<button
onclick={() => handleSeek(seg.start_ms)}
class="shrink-0 mt-0.5 flex items-center gap-0.5 text-[10px] font-mono
{isActive ? 'text-blue-600 font-semibold' : 'text-gray-400 hover:text-blue-500'}
transition-colors"
title="Spill av fra {formatTimestamp(seg.start_ms)}"
>
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{formatTimestamp(seg.start_ms)}
</button>
<!-- Tekstinnhold -->
{#if isEditing}
<div class="flex-1 min-w-0">
<textarea
bind:value={editText}
onkeydown={(e) => handleKeydown(e, seg)}
disabled={saving}
class="w-full text-xs text-gray-800 bg-white border border-blue-300 rounded px-1.5 py-0.5 resize-none focus:outline-none focus:ring-1 focus:ring-blue-400"
rows={2}
></textarea>
<div class="flex gap-1 mt-0.5">
<button
onclick={() => saveEdit(seg)}
disabled={saving}
class="text-[10px] text-blue-600 hover:text-blue-800 disabled:text-gray-400"
>
{saving ? 'Lagrer...' : 'Lagre'}
</button>
<button
onclick={cancelEditing}
disabled={saving}
class="text-[10px] text-gray-400 hover:text-gray-600"
>
Avbryt
</button>
</div>
</div>
{:else}
<button
onclick={() => startEditing(seg)}
class="flex-1 min-w-0 text-left text-xs leading-relaxed cursor-text
{isActive ? 'text-gray-900' : 'text-gray-600'}
hover:text-gray-900 transition-colors"
title="Klikk for å redigere"
>
{seg.content}
{#if seg.edited}
<span class="text-[9px] text-amber-500 ml-1" title="Manuelt redigert"
>redigert</span
>
{/if}
</button>
{/if}
</div>
{/each}
</div>
{/if}