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>
340 lines
9.2 KiB
Svelte
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}
|