Files
familienarchiv/frontend/src/lib/components/TranscriptionEditView.svelte
Marcel 052f70e871 fix(transcription): use navigator.sendBeacon for beforeunload save
Replace async executeSave in beforeunload handler with
navigator.sendBeacon — synchronous and reliable for page unload.
Sends pending text as JSON blob to the block update endpoint.

Fixes @Sara: "beforeunload handlers cannot reliably await async"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 20:28:37 +02:00

186 lines
4.9 KiB
Svelte

<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
import { SvelteMap } from 'svelte/reactivity';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import type { TranscriptionBlockData } from '$lib/types';
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
type Props = {
documentId: string;
blocks: TranscriptionBlockData[];
onBlockFocus: (blockId: string) => void;
onSaveBlock: (blockId: string, text: string) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>;
};
let { documentId, blocks, onBlockFocus, onSaveBlock, onDeleteBlock }: Props = $props();
let activeBlockId: string | null = $state(null);
let saveStates = new SvelteMap<string, SaveState>();
let debounceTimers = new SvelteMap<string, ReturnType<typeof setTimeout>>();
let pendingTexts = new SvelteMap<string, string>();
let sortedBlocks = $derived([...blocks].sort((a, b) => a.sortOrder - b.sortOrder));
let hasBlocks = $derived(blocks.length > 0);
function getSaveState(blockId: string): SaveState {
return saveStates.get(blockId) ?? 'idle';
}
function setSaveState(blockId: string, state: SaveState) {
saveStates.set(blockId, state);
}
async function executeSave(blockId: string) {
const text = pendingTexts.get(blockId);
if (text === undefined) return;
pendingTexts.delete(blockId);
setSaveState(blockId, 'saving');
try {
await onSaveBlock(blockId, text);
setSaveState(blockId, 'saved');
scheduleSavedFade(blockId);
} catch {
setSaveState(blockId, 'error');
}
}
function scheduleSavedFade(blockId: string) {
setTimeout(() => {
if (getSaveState(blockId) === 'saved') {
setSaveState(blockId, 'fading');
setTimeout(() => {
if (getSaveState(blockId) === 'fading') {
setSaveState(blockId, 'idle');
}
}, 300);
}
}, 2000);
}
function scheduleDebounce(blockId: string) {
clearDebounce(blockId);
const timer = setTimeout(() => {
debounceTimers.delete(blockId);
executeSave(blockId);
}, 1500);
debounceTimers.set(blockId, timer);
}
function clearDebounce(blockId: string) {
const existing = debounceTimers.get(blockId);
if (existing !== undefined) {
clearTimeout(existing);
debounceTimers.delete(blockId);
}
}
function flushAllPending() {
for (const [blockId] of debounceTimers) {
clearDebounce(blockId);
executeSave(blockId);
}
}
function handleTextChange(blockId: string, text: string) {
pendingTexts.set(blockId, text);
scheduleDebounce(blockId);
}
function handleFocus(blockId: string) {
activeBlockId = blockId;
onBlockFocus(blockId);
}
function handleBlur() {
flushAllPending();
}
async function handleRetry(blockId: string) {
const block = blocks.find((b) => b.id === blockId);
if (!block) return;
const pending = pendingTexts.get(blockId);
const text = pending ?? block.text;
pendingTexts.set(blockId, text);
await executeSave(blockId);
}
function handleDelete(blockId: string) {
clearDebounce(blockId);
pendingTexts.delete(blockId);
saveStates.delete(blockId);
onDeleteBlock(blockId);
}
function flushViaBeacon() {
for (const [blockId, text] of pendingTexts) {
clearDebounce(blockId);
const url = `/api/documents/${documentId}/transcription-blocks/${blockId}`;
const body = JSON.stringify({ text });
navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }));
pendingTexts.delete(blockId);
}
}
$effect(() => {
function onBeforeUnload() {
flushViaBeacon();
}
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
for (const timer of debounceTimers.values()) {
clearTimeout(timer);
}
};
});
</script>
<div class="flex h-full flex-col overflow-y-auto bg-surface p-4">
{#if hasBlocks}
<div class="flex flex-col gap-3">
{#each sortedBlocks as block, i (block.id)}
<div onblur={handleBlur}>
<TranscriptionBlock
blockId={block.id}
blockNumber={i + 1}
text={block.text}
label={block.label}
active={activeBlockId === block.id}
saveState={getSaveState(block.id)}
onTextChange={(text) => handleTextChange(block.id, text)}
onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)}
onRetry={() => handleRetry(block.id)}
/>
</div>
{/each}
</div>
{:else}
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
<svg
class="mb-4 h-16 w-16 text-ink-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
<p class="max-w-xs text-sm leading-relaxed text-ink-3">
{m.transcription_empty_cta()}
</p>
</div>
{/if}
</div>