diff --git a/frontend/src/lib/components/DocumentTopBar.svelte b/frontend/src/lib/components/DocumentTopBar.svelte index 5f63473d..a58eb3bd 100644 --- a/frontend/src/lib/components/DocumentTopBar.svelte +++ b/frontend/src/lib/components/DocumentTopBar.svelte @@ -31,9 +31,17 @@ type Props = { canAnnotate: boolean; fileUrl: string; annotateMode: boolean; + transcribeMode: boolean; }; -let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props(); +let { + doc, + canWrite, + canAnnotate, + fileUrl, + annotateMode = $bindable(), + transcribeMode = $bindable() +}: Props = $props(); let detailsOpen = $state(false); @@ -92,6 +100,65 @@ let mobileMenuOpen = $state(false); {/snippet} +{#snippet transcribeBtn(mobile: boolean)} + +{/snippet} + +{#snippet transcribeStopBtn(mobile: boolean)} + +{/snippet} + {#snippet downloadLink(mobile: boolean)}
- {#if canAnnotate && isPdf && !annotateMode} + {#if canWrite && isPdf && !transcribeMode && !annotateMode} + {@render transcribeBtn(false)} + {/if} + + {#if transcribeMode} + {@render transcribeStopBtn(false)} + {/if} + + {#if canAnnotate && isPdf && !annotateMode && !transcribeMode} {@render annotateBtn(false)} {/if} @@ -197,7 +272,7 @@ let mobileMenuOpen = $state(false); {@render annotateStopBtn(false)} {/if} - {#if canWrite && !annotateMode} + {#if canWrite && !annotateMode && !transcribeMode} - {#if canAnnotate && isPdf && !annotateMode} + {#if canWrite && isPdf && !transcribeMode && !annotateMode} + {@render transcribeBtn(true)} + {/if} + + {#if canAnnotate && isPdf && !annotateMode && !transcribeMode} {@render annotateBtn(true)} {/if} diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte new file mode 100644 index 00000000..6dfbe548 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -0,0 +1,158 @@ + + +
+
+ +
+ + {blockNumber} + + {#if label} + + {label} + + {/if} +
+ + + + + +
+
+ + {#if active} + + {m.transcription_block_quote_hint()} + + {/if} +
+ +
+ + {#if saveState === 'saving'} + + {m.transcription_block_save_saving()} + + {:else if saveState === 'saved'} + + {m.transcription_block_save_saved()} + + {:else if saveState === 'error'} + + {m.transcription_block_save_error()} + + + + {/if} + + + +
+
+
+
diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte new file mode 100644 index 00000000..34067c61 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -0,0 +1,183 @@ + + +
+ {#if hasBlocks} +
+ {#each sortedBlocks as block, i (block.id)} +
+ handleTextChange(block.id, text)} + onFocus={() => handleFocus(block.id)} + onCommentClick={handleCommentClick} + onDeleteClick={() => handleDelete(block.id)} + onRetry={() => handleRetry(block.id)} + /> +
+ {/each} +
+ {:else} +
+ + + +

+ {m.transcription_empty_cta()} +

+
+ {/if} +
diff --git a/frontend/src/routes/documents/[id]/+page.svelte b/frontend/src/routes/documents/[id]/+page.svelte index dd51e27a..543c7178 100644 --- a/frontend/src/routes/documents/[id]/+page.svelte +++ b/frontend/src/routes/documents/[id]/+page.svelte @@ -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(null); let activeAnnotationPage = $state(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([]); + +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(() => { > -
- { - activeAnnotationId = id; - }} - /> - { - activeAnnotationId = null; - activeAnnotationPage = null; - }} - /> +
+
+ { + activeAnnotationId = id; + }} + /> +
+ + {#if !transcribeMode} + { + activeAnnotationId = null; + activeAnnotationPage = null; + }} + /> + {/if} + + {#if transcribeMode} +
+ +
+ {/if}