feat(transcription): add frontend transcription editing UI (#176)
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m27s
CI / Backend Unit Tests (push) Failing after 2m40s
CI / E2E Tests (push) Failing after 4m44s
CI / Unit & Component Tests (pull_request) Failing after 1m21s
CI / Backend Unit Tests (pull_request) Failing after 2m27s
CI / E2E Tests (pull_request) Failing after 4m47s
Some checks failed
CI / Unit & Component Tests (push) Failing after 1m27s
CI / Backend Unit Tests (push) Failing after 2m40s
CI / E2E Tests (push) Failing after 4m44s
CI / Unit & Component Tests (pull_request) Failing after 1m21s
CI / Backend Unit Tests (pull_request) Failing after 2m27s
CI / E2E Tests (pull_request) Failing after 4m47s
TranscriptionBlock.svelte: editable block card with auto-resize textarea, per-block save indicator, turquoise focus border, delete with confirmation TranscriptionEditView.svelte: right panel with sorted block list, debounced auto-save (1.5s), beforeunload flush, empty state CTA DocumentTopBar: add Transcribe/Done toggle with turquoise styling, mode exclusivity (transcribe and annotate mutually exclusive) Document detail page: split view in transcribe mode (PDF left, blocks right), load/save/delete blocks via fetch, block focus syncs to annotation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
183
frontend/src/lib/components/TranscriptionEditView.svelte
Normal file
183
frontend/src/lib/components/TranscriptionEditView.svelte
Normal file
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import TranscriptionBlock from './TranscriptionBlock.svelte';
|
||||
|
||||
type TranscriptionBlockData = {
|
||||
id: string;
|
||||
annotationId: string;
|
||||
documentId: string;
|
||||
text: string;
|
||||
label: string | null;
|
||||
sortOrder: number;
|
||||
version: number;
|
||||
};
|
||||
|
||||
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||
|
||||
type Props = {
|
||||
blocks: TranscriptionBlockData[];
|
||||
onBlockFocus: (blockId: string) => void;
|
||||
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
||||
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
let { 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, 'idle');
|
||||
}
|
||||
}, 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 handleCommentClick() {
|
||||
// Placeholder for future comment functionality
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
function onBeforeUnload() {
|
||||
flushAllPending();
|
||||
}
|
||||
|
||||
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)}
|
||||
onCommentClick={handleCommentClick}
|
||||
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>
|
||||
Reference in New Issue
Block a user