SpacetimeDB er nå helt fjernet fra Synops. Sanntid håndteres av PG LISTEN/NOTIFY + WebSocket i portvokteren (maskinrommet). Kode fjernet: - spacetimedb/ Rust-modul og spacetime.json - maskinrommet/src/stdb.rs (HTTP-klient for STDB-reducers) - frontend module_bindings/ (23 auto-genererte filer) - spacetimedb npm-avhengighet fra package.json - scripts/test-sanntid.sh (testet STDB-flyt) Infrastruktur: - Docker-container stoppet og fjernet fra docker-compose.yml - Caddy: fjernet /spacetime/* reverse proxy - maskinrommet-env.sh: fjernet STDB_IP og SPACETIMEDB_*-variabler - .env.example: fjernet SpacetimeDB-seksjoner Dokumentasjon oppdatert: - CLAUDE.md: stack, lagmodell, kjerneprinsipper, driftsmodell - docs/arkitektur.md: skrivestien, lesestien, datalag, teknologivalg - docs/retninger/datalaget.md: migrasjonshistorikk, status "fjernet" - 37 andre docs oppdatert (features, concepts, infra, ops, retninger) - Alle kode-kommentarer med STDB-referanser oppdatert Verifisert: maskinrommet bygger og starter OK, frontend bygger OK, helsesjekk returnerer 200. Caddy reloadet.
567 lines
18 KiB
Svelte
567 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import { connectionState, nodeStore, edgeStore } from '$lib/spacetime';
|
|
import {
|
|
casUrl,
|
|
audioAnalyze,
|
|
audioProcess,
|
|
fetchSegments,
|
|
createNode,
|
|
createEdge,
|
|
type EdlOperation,
|
|
type EdlDocument,
|
|
type LoudnessInfo,
|
|
type SilenceRegion,
|
|
type AudioInfo,
|
|
type Segment,
|
|
} from '$lib/api';
|
|
import StudioWaveform from '$lib/components/studio/StudioWaveform.svelte';
|
|
import OperationPanel from '$lib/components/studio/OperationPanel.svelte';
|
|
import RenderDialog from '$lib/components/studio/RenderDialog.svelte';
|
|
|
|
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
|
const accessToken = $derived(session?.accessToken as string | undefined);
|
|
const nodeId = $derived(session?.nodeId as string | undefined);
|
|
const connected = $derived(connectionState.current === 'connected');
|
|
const mediaNodeId = $derived($page.params.id ?? '');
|
|
|
|
// Media node from store
|
|
const mediaNode = $derived(connected ? nodeStore.get(mediaNodeId) : undefined);
|
|
const metadata = $derived.by(() => {
|
|
if (!mediaNode?.metadata) return null;
|
|
try {
|
|
return JSON.parse(mediaNode.metadata);
|
|
} catch {
|
|
console.error('Ugyldig metadata JSON for medienode:', mediaNodeId);
|
|
return null;
|
|
}
|
|
});
|
|
const casHash = $derived(metadata?.cas_hash as string | undefined);
|
|
const audioSrc = $derived(casHash ? casUrl(casHash) : '');
|
|
|
|
// Studio state
|
|
let operations: EdlOperation[] = $state([]);
|
|
let loudness: LoudnessInfo | null = $state(null);
|
|
let silenceRegions: SilenceRegion[] = $state([]);
|
|
let audioInfo: AudioInfo | null = $state(null);
|
|
let analyzing = $state(false);
|
|
let waveformRef: StudioWaveform | undefined = $state();
|
|
let currentTime = $state(0);
|
|
|
|
// Render state
|
|
let showRenderDialog = $state(false);
|
|
let rendering = $state(false);
|
|
let renderJobId: string | null = $state(null);
|
|
let resultNodeId: string | null = $state(null);
|
|
let renderError: string | null = $state(null);
|
|
|
|
// Polling cleanup
|
|
let pollIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
let pollTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function cleanupPolling() {
|
|
if (pollIntervalId !== null) { clearInterval(pollIntervalId); pollIntervalId = null; }
|
|
if (pollTimeoutId !== null) { clearTimeout(pollTimeoutId); pollTimeoutId = null; }
|
|
}
|
|
|
|
// Mobile tool panel sheet
|
|
let showToolSheet = $state(false);
|
|
|
|
// Session persistence
|
|
let sessionNodeId: string | null = $state(null);
|
|
let saving = $state(false);
|
|
|
|
// Version history: processed nodes derived from this media node
|
|
const versions = $derived.by(() => {
|
|
if (!connected || !mediaNodeId) return [];
|
|
const nodes: { id: string; title: string; createdAt: bigint }[] = [];
|
|
for (const edge of edgeStore.byTarget(mediaNodeId)) {
|
|
if (edge.edgeType !== 'derived_from') continue;
|
|
const node = nodeStore.get(edge.sourceId);
|
|
if (node) {
|
|
nodes.push({
|
|
id: node.id,
|
|
title: node.title ?? 'Prosessert',
|
|
createdAt: node.createdAt?.microsSinceUnixEpoch ?? 0n,
|
|
});
|
|
}
|
|
}
|
|
nodes.sort((a, b) => (b.createdAt > a.createdAt ? 1 : -1));
|
|
return nodes;
|
|
});
|
|
|
|
// Transcription
|
|
let segments: Segment[] = $state([]);
|
|
let segmentsLoaded = $state(false);
|
|
|
|
// Load existing studio session (if any)
|
|
$effect(() => {
|
|
if (!connected || !mediaNodeId) return;
|
|
for (const edge of edgeStore.byTarget(mediaNodeId)) {
|
|
if (edge.edgeType !== 'has_studio') continue;
|
|
const node = nodeStore.get(edge.sourceId);
|
|
if (node) {
|
|
try {
|
|
const meta = JSON.parse(node.metadata ?? '{}');
|
|
if (meta.edl?.operations) {
|
|
operations = meta.edl.operations;
|
|
sessionNodeId = node.id;
|
|
}
|
|
} catch { /* ignore */ }
|
|
break; // Use first session found
|
|
}
|
|
}
|
|
});
|
|
|
|
// Load segments on mount
|
|
$effect(() => {
|
|
if (accessToken && mediaNodeId && !segmentsLoaded) {
|
|
segmentsLoaded = true;
|
|
fetchSegments(accessToken, mediaNodeId)
|
|
.then((res) => {
|
|
segments = res.segments || [];
|
|
})
|
|
.catch(() => {
|
|
segments = [];
|
|
});
|
|
}
|
|
});
|
|
|
|
async function handleAnalyze() {
|
|
if (!accessToken || !casHash) return;
|
|
analyzing = true;
|
|
try {
|
|
const result = await audioAnalyze(accessToken, casHash);
|
|
loudness = result.loudness;
|
|
silenceRegions = result.silence_regions;
|
|
audioInfo = result.info;
|
|
// Show silence regions on waveform
|
|
waveformRef?.showSilenceRegions(silenceRegions);
|
|
} catch (e) {
|
|
console.error('Analyse feilet:', e);
|
|
} finally {
|
|
analyzing = false;
|
|
}
|
|
}
|
|
|
|
function handleCut() {
|
|
const region = waveformRef?.getActiveRegion();
|
|
if (!region) return;
|
|
operations = [
|
|
...operations,
|
|
{
|
|
type: 'cut',
|
|
start_ms: Math.round(region.start * 1000),
|
|
end_ms: Math.round(region.end * 1000),
|
|
},
|
|
];
|
|
// Convert selection to cut overlay
|
|
waveformRef?.clearActiveRegion();
|
|
waveformRef?.addCutRegion(
|
|
Math.round(region.start * 1000),
|
|
Math.round(region.end * 1000)
|
|
);
|
|
}
|
|
|
|
function handleAddOp(op: EdlOperation) {
|
|
// Prevent duplicate types for non-cut operations
|
|
if (op.type !== 'cut') {
|
|
operations = operations.filter((o) => o.type !== op.type);
|
|
}
|
|
operations = [...operations, op];
|
|
}
|
|
|
|
function handleRemoveOp(index: number) {
|
|
operations = operations.filter((_, i) => i !== index);
|
|
}
|
|
|
|
function handleUndo() {
|
|
if (operations.length === 0) return;
|
|
operations = operations.slice(0, -1);
|
|
}
|
|
|
|
function handleTrimSilence() {
|
|
operations = [
|
|
...operations,
|
|
{
|
|
type: 'trim_silence',
|
|
threshold_db: -30,
|
|
min_duration_ms: 500,
|
|
},
|
|
];
|
|
}
|
|
|
|
function handleRenderStart() {
|
|
showRenderDialog = true;
|
|
}
|
|
|
|
async function handleRenderConfirm(format: string) {
|
|
if (!accessToken || !casHash) return;
|
|
rendering = true;
|
|
|
|
const edl: EdlDocument = {
|
|
source_hash: casHash,
|
|
operations,
|
|
};
|
|
|
|
try {
|
|
const result = await audioProcess(accessToken, mediaNodeId, edl, format);
|
|
renderJobId = result.job_id;
|
|
// Poll for completion (simple approach)
|
|
pollJobResult(result.job_id);
|
|
} catch (e) {
|
|
console.error('Render feilet:', e);
|
|
rendering = false;
|
|
}
|
|
}
|
|
|
|
const MAX_POLL_FAILURES = 5;
|
|
|
|
function pollJobResult(jobId: string) {
|
|
// Poll job_queue for completion via simple interval
|
|
// In production this would use SSE or WebSocket
|
|
cleanupPolling();
|
|
let failures = 0;
|
|
|
|
pollIntervalId = setInterval(async () => {
|
|
try {
|
|
const res = await fetch(`/api/query/job_status?job_id=${encodeURIComponent(jobId)}`, {
|
|
headers: { Authorization: `Bearer ${accessToken}` },
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
failures = 0; // Reset on success
|
|
if (data.status === 'completed') {
|
|
cleanupPolling();
|
|
resultNodeId = data.result?.processed_node_id ?? null;
|
|
rendering = false;
|
|
} else if (data.status === 'error') {
|
|
cleanupPolling();
|
|
rendering = false;
|
|
renderError = data.error_msg ?? 'Jobb feilet';
|
|
}
|
|
} else {
|
|
failures++;
|
|
if (failures >= MAX_POLL_FAILURES) {
|
|
cleanupPolling();
|
|
rendering = false;
|
|
renderError = `Statussjekk feilet ${MAX_POLL_FAILURES} ganger — prøv igjen senere`;
|
|
}
|
|
}
|
|
} catch {
|
|
failures++;
|
|
if (failures >= MAX_POLL_FAILURES) {
|
|
cleanupPolling();
|
|
rendering = false;
|
|
renderError = `Mistet kontakt med serveren etter ${MAX_POLL_FAILURES} forsøk`;
|
|
}
|
|
}
|
|
}, 2000);
|
|
|
|
// Timeout after 5 minutes
|
|
pollTimeoutId = setTimeout(() => {
|
|
cleanupPolling();
|
|
if (rendering) {
|
|
rendering = false;
|
|
renderError = 'Rendering tok for lang tid (tidsavbrudd etter 5 minutter)';
|
|
}
|
|
}, 5 * 60 * 1000);
|
|
}
|
|
|
|
// Cleanup polling on component destroy (navigation away)
|
|
$effect(() => {
|
|
return () => cleanupPolling();
|
|
});
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
|
const region = waveformRef?.getActiveRegion();
|
|
if (region) {
|
|
e.preventDefault();
|
|
handleCut();
|
|
}
|
|
}
|
|
if (e.ctrlKey && e.key === 'z') {
|
|
e.preventDefault();
|
|
handleUndo();
|
|
}
|
|
}
|
|
|
|
async function handleSaveSession() {
|
|
if (!accessToken || !casHash || operations.length === 0) return;
|
|
saving = true;
|
|
try {
|
|
const edl: EdlDocument = { source_hash: casHash, operations };
|
|
if (sessionNodeId) {
|
|
// Update existing session node
|
|
const { updateNode } = await import('$lib/api');
|
|
await updateNode(accessToken, {
|
|
node_id: sessionNodeId,
|
|
metadata: { edl },
|
|
});
|
|
} else {
|
|
// Create new session node + has_studio edge
|
|
const res = await createNode(accessToken, {
|
|
node_kind: 'content',
|
|
title: `Studio-sesjon: ${mediaNode?.title ?? 'Ukjent'}`,
|
|
visibility: 'hidden',
|
|
metadata: { edl },
|
|
});
|
|
sessionNodeId = res.node_id;
|
|
await createEdge(accessToken, {
|
|
source_id: res.node_id,
|
|
target_id: mediaNodeId,
|
|
edge_type: 'has_studio',
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('Kunne ikke lagre sesjon:', e);
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
function handleSegmentClick(segment: Segment) {
|
|
waveformRef?.seekTo(segment.start_ms / 1000);
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeydown} />
|
|
|
|
<div class="flex min-h-screen flex-col bg-gray-50">
|
|
<!-- Header -->
|
|
<header class="flex items-center justify-between border-b border-gray-200 bg-white px-3 py-2 sm:px-4 sm:py-3">
|
|
<div class="flex min-w-0 items-center gap-2 sm:gap-3">
|
|
<a href="/" class="shrink-0 text-gray-400 hover:text-gray-600">
|
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
</a>
|
|
<h1 class="truncate text-base font-semibold text-gray-800 sm:text-lg">
|
|
{mediaNode?.title ?? 'Lydstudio'}
|
|
</h1>
|
|
{#if audioInfo}
|
|
<span class="hidden rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-500 sm:inline">
|
|
{audioInfo.codec} / {audioInfo.sample_rate}Hz / {audioInfo.channels}ch
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
{#if operations.length > 0}
|
|
<button
|
|
onclick={handleSaveSession}
|
|
disabled={saving}
|
|
class="rounded bg-gray-100 px-3 py-1.5 text-sm text-gray-700 transition-colors hover:bg-gray-200 disabled:opacity-50"
|
|
>
|
|
{saving ? 'Lagrer...' : sessionNodeId ? 'Oppdater sesjon' : 'Lagre sesjon'}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</header>
|
|
|
|
{#if !audioSrc}
|
|
<div class="flex flex-1 items-center justify-center">
|
|
<p class="text-gray-400">
|
|
{#if !connected}
|
|
Kobler til...
|
|
{:else if !mediaNode}
|
|
Finner ikke medienoden
|
|
{:else}
|
|
Ingen lydfil tilgjengelig
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-1 flex-col gap-4 p-4 lg:flex-row">
|
|
<!-- Main area: waveform + transcription -->
|
|
<div class="flex flex-1 flex-col gap-4 min-w-0">
|
|
<StudioWaveform
|
|
bind:this={waveformRef}
|
|
src={audioSrc}
|
|
onready={(d) => { audioInfo = audioInfo ?? { duration_ms: d * 1000, sample_rate: 0, channels: 0, codec: '', format: '', bit_rate: null }; }}
|
|
ontimeupdate={(t) => { currentTime = t; }}
|
|
/>
|
|
|
|
<!-- Transcription segments -->
|
|
{#if segments.length > 0}
|
|
<div class="rounded-lg border border-gray-200 bg-white p-3">
|
|
<h3 class="mb-2 text-sm font-semibold text-gray-700">Transkripsjon</h3>
|
|
<div class="max-h-64 space-y-0.5 overflow-y-auto">
|
|
{#each segments as seg}
|
|
{@const active = currentTime >= seg.start_ms / 1000 && currentTime < seg.end_ms / 1000}
|
|
<button
|
|
onclick={() => handleSegmentClick(seg)}
|
|
class="flex w-full items-start gap-2 rounded px-2 py-1 text-left transition-colors
|
|
{active ? 'bg-blue-50 text-blue-800' : 'hover:bg-gray-50 text-gray-600'}"
|
|
>
|
|
<span class="mt-0.5 shrink-0 font-mono text-[10px] text-gray-400">
|
|
{Math.floor(seg.start_ms / 60000)}:{Math.floor((seg.start_ms % 60000) / 1000).toString().padStart(2, '0')}
|
|
</span>
|
|
<span class="text-xs">{seg.content}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Sidebar: operation panel (desktop) -->
|
|
<div class="hidden w-72 shrink-0 flex-col gap-4 overflow-y-auto lg:flex lg:w-80">
|
|
<OperationPanel
|
|
{loudness}
|
|
{silenceRegions}
|
|
{audioInfo}
|
|
{analyzing}
|
|
{operations}
|
|
activeRegion={waveformRef?.getActiveRegion() ?? null}
|
|
onanalyze={handleAnalyze}
|
|
oncut={handleCut}
|
|
onaddop={handleAddOp}
|
|
onremoveop={handleRemoveOp}
|
|
onrender={handleRenderStart}
|
|
onundo={handleUndo}
|
|
ontrimsilence={handleTrimSilence}
|
|
/>
|
|
|
|
<!-- Version history -->
|
|
{#if versions.length > 0}
|
|
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
|
<h3 class="mb-2 text-sm font-semibold text-gray-700">Versjoner</h3>
|
|
<ul class="space-y-1">
|
|
<li class="flex items-center justify-between rounded bg-blue-50 px-2 py-1">
|
|
<span class="text-xs font-medium text-blue-700">Original</span>
|
|
<span class="text-[10px] text-blue-500">Navarende</span>
|
|
</li>
|
|
{#each versions as ver, i}
|
|
<li class="flex items-center justify-between rounded px-2 py-1 hover:bg-gray-50">
|
|
<a href="/studio/{ver.id}" class="text-xs text-gray-600 hover:text-blue-600">
|
|
v{i + 1}: {ver.title}
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile: floating tool button + bottom sheet -->
|
|
<div class="lg:hidden">
|
|
<!-- Floating action button -->
|
|
<button
|
|
onclick={() => { showToolSheet = !showToolSheet; }}
|
|
class="fixed bottom-4 right-4 z-40 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg transition-transform hover:bg-blue-700 active:scale-95"
|
|
aria-label="Verktoy"
|
|
>
|
|
{#if showToolSheet}
|
|
<!-- Close icon -->
|
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
{:else}
|
|
<!-- Settings/tool icon -->
|
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
{/if}
|
|
{#if operations.length > 0 && !showToolSheet}
|
|
<span class="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold">
|
|
{operations.length}
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Bottom sheet backdrop -->
|
|
{#if showToolSheet}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="fixed inset-0 z-40 bg-black/30"
|
|
onclick={() => { showToolSheet = false; }}
|
|
onkeydown={(e) => { if (e.key === 'Escape') showToolSheet = false; }}
|
|
></div>
|
|
|
|
<!-- Bottom sheet -->
|
|
<div class="fixed inset-x-0 bottom-0 z-50 flex max-h-[85vh] flex-col rounded-t-2xl bg-gray-50 shadow-2xl">
|
|
<!-- Handle -->
|
|
<div class="flex justify-center py-2">
|
|
<div class="h-1 w-10 rounded-full bg-gray-300"></div>
|
|
</div>
|
|
|
|
<!-- Sheet content -->
|
|
<div class="flex-1 overflow-y-auto px-4 pb-8">
|
|
<OperationPanel
|
|
{loudness}
|
|
{silenceRegions}
|
|
{audioInfo}
|
|
{analyzing}
|
|
{operations}
|
|
activeRegion={waveformRef?.getActiveRegion() ?? null}
|
|
onanalyze={handleAnalyze}
|
|
oncut={handleCut}
|
|
onaddop={handleAddOp}
|
|
onremoveop={handleRemoveOp}
|
|
onrender={handleRenderStart}
|
|
onundo={handleUndo}
|
|
ontrimsilence={handleTrimSilence}
|
|
/>
|
|
|
|
<!-- Version history -->
|
|
{#if versions.length > 0}
|
|
<div class="mt-4 rounded-lg border border-gray-200 bg-white p-4">
|
|
<h3 class="mb-2 text-sm font-semibold text-gray-700">Versjoner</h3>
|
|
<ul class="space-y-1">
|
|
<li class="flex items-center justify-between rounded bg-blue-50 px-2 py-1">
|
|
<span class="text-xs font-medium text-blue-700">Original</span>
|
|
<span class="text-[10px] text-blue-500">Navarende</span>
|
|
</li>
|
|
{#each versions as ver, i}
|
|
<li class="flex items-center justify-between rounded px-2 py-1 hover:bg-gray-50">
|
|
<a href="/studio/{ver.id}" class="text-xs text-gray-600 hover:text-blue-600">
|
|
v{i + 1}: {ver.title}
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Render error toast (only when dialog is closed) -->
|
|
{#if renderError && !showRenderDialog}
|
|
<div class="fixed bottom-4 left-4 z-50 max-w-sm rounded-lg border border-red-200 bg-red-50 px-4 py-3 shadow-lg">
|
|
<div class="flex items-start gap-2">
|
|
<svg class="mt-0.5 h-5 w-5 shrink-0 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium text-red-800">Rendering feilet</p>
|
|
<p class="mt-0.5 text-xs text-red-600">{renderError}</p>
|
|
</div>
|
|
<button onclick={() => { renderError = null; }} class="shrink-0 text-red-400 hover:text-red-600">
|
|
<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="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Render dialog -->
|
|
{#if showRenderDialog}
|
|
<RenderDialog
|
|
{operations}
|
|
{rendering}
|
|
jobId={renderJobId}
|
|
{resultNodeId}
|
|
errorMessage={renderError}
|
|
onconfirm={handleRenderConfirm}
|
|
onclose={() => { showRenderDialog = false; rendering = false; renderJobId = null; resultNodeId = null; renderError = null; }}
|
|
/>
|
|
{/if}
|