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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user