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

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:
Marcel
2026-04-05 11:34:01 +02:00
parent 5211e0b9f7
commit 1efd3d8e23
4 changed files with 541 additions and 33 deletions

View File

@@ -4,6 +4,7 @@ import { page } from '$app/state';
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
let { data } = $props();
@@ -11,6 +12,7 @@ const targetCommentId = $derived(page.url.searchParams.get('commentId'));
const targetAnnotationId = $derived(page.url.searchParams.get('annotationId'));
const doc = $derived(data.document);
const canWrite = $derived(data.canWrite ?? false);
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
const canAdmin = $derived(
(data.user?.groups as Array<{ permissions: string[] }> | undefined)?.some((g) =>
@@ -54,12 +56,79 @@ async function loadFile(id: string) {
}
}
// ── Annotation state (lifted from PdfViewer) ──────────────────────────────────
// ── Mode state (mutually exclusive) ──────────────────────────────────────────
let annotateMode = $state(false);
let transcribeMode = $state(false);
let activeAnnotationId = $state<string | null>(null);
let activeAnnotationPage = $state<number | null>(null);
// Mode exclusivity: entering one mode exits the other
$effect(() => {
if (annotateMode && transcribeMode) {
transcribeMode = false;
}
});
// ── Transcription blocks ─────────────────────────────────────────────────────
type TranscriptionBlockData = {
id: string;
annotationId: string;
documentId: string;
text: string;
label: string | null;
sortOrder: number;
version: number;
};
let transcriptionBlocks = $state<TranscriptionBlockData[]>([]);
async function loadTranscriptionBlocks() {
if (!doc?.id) return;
try {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks`);
if (res.ok) {
transcriptionBlocks = await res.json();
}
} catch (e) {
console.error('Failed to load transcription blocks:', e);
}
}
async function saveBlock(blockId: string, text: string) {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (!res.ok) throw new Error('Save failed');
const updated = await res.json();
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
}
async function deleteBlock(blockId: string) {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete failed');
transcriptionBlocks = transcriptionBlocks.filter((b) => b.id !== blockId);
}
function handleBlockFocus(blockId: string) {
const block = transcriptionBlocks.find((b) => b.id === blockId);
if (block) {
activeAnnotationId = block.annotationId;
}
}
// Load blocks when transcribe mode is entered
$effect(() => {
if (transcribeMode) {
loadTranscriptionBlocks();
}
});
// ── Navigation / init ─────────────────────────────────────────────────────────
let navHeight = $state(0);
@@ -80,7 +149,9 @@ onMount(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (activeAnnotationId) {
if (transcribeMode) {
transcribeMode = false;
} else if (activeAnnotationId) {
activeAnnotationId = null;
activeAnnotationPage = null;
}
@@ -102,37 +173,54 @@ onMount(() => {
>
<DocumentTopBar
doc={doc}
canWrite={data.canWrite ?? false}
canWrite={canWrite}
canAnnotate={data.canAnnotate ?? false}
fileUrl={fileUrl}
bind:annotateMode={annotateMode}
bind:transcribeMode={transcribeMode}
/>
<div class="relative flex-1 overflow-hidden">
<DocumentViewer
doc={doc}
fileUrl={fileUrl}
isLoading={isLoading}
error={fileError}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={(id) => {
activeAnnotationId = id;
}}
/>
<AnnotationSidePanel
documentId={doc.id}
activeAnnotationId={activeAnnotationId}
activeAnnotationPage={activeAnnotationPage}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetAnnotationId ? targetCommentId : null}
onClose={() => {
activeAnnotationId = null;
activeAnnotationPage = null;
}}
/>
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex' : ''}">
<div class={transcribeMode ? 'relative flex-1 overflow-hidden' : 'absolute inset-0'}>
<DocumentViewer
doc={doc}
fileUrl={fileUrl}
isLoading={isLoading}
error={fileError}
bind:annotateMode={annotateMode}
bind:activeAnnotationId={activeAnnotationId}
bind:activeAnnotationPage={activeAnnotationPage}
onAnnotationClick={(id) => {
activeAnnotationId = id;
}}
/>
</div>
{#if !transcribeMode}
<AnnotationSidePanel
documentId={doc.id}
activeAnnotationId={activeAnnotationId}
activeAnnotationPage={activeAnnotationPage}
canComment={canComment}
currentUserId={currentUserId}
canAdmin={canAdmin}
targetCommentId={targetAnnotationId ? targetCommentId : null}
onClose={() => {
activeAnnotationId = null;
activeAnnotationPage = null;
}}
/>
{/if}
{#if transcribeMode}
<div class="w-[400px] shrink-0 border-l border-line lg:w-[480px]">
<TranscriptionEditView
blocks={transcriptionBlocks}
onBlockFocus={handleBlockFocus}
onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock}
/>
</div>
{/if}
</div>
</div>