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:
@@ -31,9 +31,17 @@ type Props = {
|
|||||||
canAnnotate: boolean;
|
canAnnotate: boolean;
|
||||||
fileUrl: string;
|
fileUrl: string;
|
||||||
annotateMode: boolean;
|
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);
|
let detailsOpen = $state(false);
|
||||||
|
|
||||||
@@ -92,6 +100,65 @@ let mobileMenuOpen = $state(false);
|
|||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet transcribeBtn(mobile: boolean)}
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
transcribeMode = true;
|
||||||
|
annotateMode = false;
|
||||||
|
if (mobile) mobileMenuOpen = false;
|
||||||
|
}}
|
||||||
|
aria-label={m.transcription_mode_label()}
|
||||||
|
aria-pressed={false}
|
||||||
|
class={mobile
|
||||||
|
? 'flex w-full items-center gap-2 rounded px-3 py-2 text-left text-[16px] text-ink transition hover:bg-muted focus-visible:ring-2 focus-visible:ring-primary'
|
||||||
|
: 'hidden items-center gap-1.5 rounded border border-[#00C7B1] px-3 py-1.5 font-sans text-[16px] font-medium text-ink transition hover:bg-[#00C7B1] hover:text-white focus-visible:ring-2 focus-visible:ring-primary md:flex'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{m.transcription_mode_label()}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet transcribeStopBtn(mobile: boolean)}
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
transcribeMode = false;
|
||||||
|
if (mobile) mobileMenuOpen = false;
|
||||||
|
}}
|
||||||
|
aria-label={m.transcription_mode_stop()}
|
||||||
|
aria-pressed={true}
|
||||||
|
class={mobile
|
||||||
|
? 'flex w-full items-center gap-2 rounded bg-[#00C7B1] px-3 py-2 text-left text-[16px] text-white transition focus-visible:ring-2 focus-visible:ring-primary'
|
||||||
|
: 'flex items-center gap-1.5 rounded bg-[#00C7B1] px-3 py-1.5 font-sans text-[16px] font-medium text-white transition focus-visible:ring-2 focus-visible:ring-primary'}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{m.transcription_mode_stop()}
|
||||||
|
</button>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet downloadLink(mobile: boolean)}
|
{#snippet downloadLink(mobile: boolean)}
|
||||||
<a
|
<a
|
||||||
href={fileUrl}
|
href={fileUrl}
|
||||||
@@ -189,7 +256,15 @@ let mobileMenuOpen = $state(false);
|
|||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
|
<div class="flex shrink-0 items-center gap-1.5 pr-3 font-sans">
|
||||||
{#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)}
|
{@render annotateBtn(false)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -197,7 +272,7 @@ let mobileMenuOpen = $state(false);
|
|||||||
{@render annotateStopBtn(false)}
|
{@render annotateStopBtn(false)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if canWrite && !annotateMode}
|
{#if canWrite && !annotateMode && !transcribeMode}
|
||||||
<a
|
<a
|
||||||
href="/documents/{doc.id}/edit"
|
href="/documents/{doc.id}/edit"
|
||||||
aria-label={m.btn_edit()}
|
aria-label={m.btn_edit()}
|
||||||
@@ -246,7 +321,11 @@ let mobileMenuOpen = $state(false);
|
|||||||
role="menu"
|
role="menu"
|
||||||
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
|
class="absolute top-full right-0 z-50 mt-1 min-w-[200px] rounded-md border border-line bg-surface p-2 shadow-lg"
|
||||||
>
|
>
|
||||||
{#if canAnnotate && isPdf && !annotateMode}
|
{#if canWrite && isPdf && !transcribeMode && !annotateMode}
|
||||||
|
{@render transcribeBtn(true)}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if canAnnotate && isPdf && !annotateMode && !transcribeMode}
|
||||||
{@render annotateBtn(true)}
|
{@render annotateBtn(true)}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
158
frontend/src/lib/components/TranscriptionBlock.svelte
Normal file
158
frontend/src/lib/components/TranscriptionBlock.svelte
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type SaveState = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
blockId: string;
|
||||||
|
blockNumber: number;
|
||||||
|
text: string;
|
||||||
|
label: string | null;
|
||||||
|
active: boolean;
|
||||||
|
saveState: SaveState;
|
||||||
|
onTextChange: (text: string) => void;
|
||||||
|
onFocus: () => void;
|
||||||
|
onCommentClick: () => void;
|
||||||
|
onDeleteClick: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
blockId,
|
||||||
|
blockNumber,
|
||||||
|
text,
|
||||||
|
label = null,
|
||||||
|
active,
|
||||||
|
saveState,
|
||||||
|
onTextChange,
|
||||||
|
onFocus,
|
||||||
|
onCommentClick,
|
||||||
|
onDeleteClick,
|
||||||
|
onRetry
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let leftBorderClass = $derived(
|
||||||
|
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-[#00C7B1]' : ''
|
||||||
|
);
|
||||||
|
|
||||||
|
function autoresize(node: HTMLTextAreaElement) {
|
||||||
|
function resize() {
|
||||||
|
node.style.height = 'auto';
|
||||||
|
node.style.height = `${node.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resize();
|
||||||
|
|
||||||
|
return {
|
||||||
|
update() {
|
||||||
|
resize();
|
||||||
|
},
|
||||||
|
destroy() {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const target = event.target as HTMLTextAreaElement;
|
||||||
|
onTextChange(target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (confirm(m.transcription_block_delete_confirm())) {
|
||||||
|
onDeleteClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="overflow-hidden rounded border border-line {leftBorderClass}" data-block-id={blockId}>
|
||||||
|
<div class="p-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-2 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="flex h-6 w-6 items-center justify-center rounded-full bg-[#002850] text-xs font-bold text-white"
|
||||||
|
>
|
||||||
|
{blockNumber}
|
||||||
|
</span>
|
||||||
|
{#if label}
|
||||||
|
<span class="text-xs font-medium tracking-wide text-ink-2 uppercase">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Textarea -->
|
||||||
|
<textarea
|
||||||
|
use:autoresize={text}
|
||||||
|
class="w-full resize-none border-none bg-transparent font-serif text-base leading-relaxed text-ink outline-none placeholder:text-ink-3"
|
||||||
|
placeholder={m.transcription_block_placeholder()}
|
||||||
|
rows={1}
|
||||||
|
value={text}
|
||||||
|
oninput={handleInput}
|
||||||
|
onfocus={onFocus}
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex items-center justify-between border-t border-line pt-2">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
|
onclick={onCommentClick}
|
||||||
|
>
|
||||||
|
{m.transcription_block_comment_btn()}
|
||||||
|
</button>
|
||||||
|
{#if active}
|
||||||
|
<span class="text-xs text-ink-3">
|
||||||
|
{m.transcription_block_quote_hint()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Save state indicator -->
|
||||||
|
{#if saveState === 'saving'}
|
||||||
|
<span class="animate-pulse text-xs text-ink-3">
|
||||||
|
{m.transcription_block_save_saving()}
|
||||||
|
</span>
|
||||||
|
{:else if saveState === 'saved'}
|
||||||
|
<span class="text-xs text-green-600">
|
||||||
|
{m.transcription_block_save_saved()} <span class="inline-block">✓</span>
|
||||||
|
</span>
|
||||||
|
{:else if saveState === 'error'}
|
||||||
|
<span class="text-error text-xs">
|
||||||
|
{m.transcription_block_save_error()}
|
||||||
|
<span class="mx-1">—</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="underline transition-colors hover:text-ink"
|
||||||
|
onclick={onRetry}
|
||||||
|
>
|
||||||
|
{m.transcription_block_save_retry()}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:text-error text-ink-3 transition-colors"
|
||||||
|
aria-label={m.btn_delete()}
|
||||||
|
onclick={handleDelete}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
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>
|
||||||
@@ -4,6 +4,7 @@ import { page } from '$app/state';
|
|||||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
||||||
|
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ const targetCommentId = $derived(page.url.searchParams.get('commentId'));
|
|||||||
const targetAnnotationId = $derived(page.url.searchParams.get('annotationId'));
|
const targetAnnotationId = $derived(page.url.searchParams.get('annotationId'));
|
||||||
|
|
||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
|
const canWrite = $derived(data.canWrite ?? false);
|
||||||
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
||||||
const canAdmin = $derived(
|
const canAdmin = $derived(
|
||||||
(data.user?.groups as Array<{ permissions: string[] }> | undefined)?.some((g) =>
|
(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 annotateMode = $state(false);
|
||||||
|
let transcribeMode = $state(false);
|
||||||
let activeAnnotationId = $state<string | null>(null);
|
let activeAnnotationId = $state<string | null>(null);
|
||||||
let activeAnnotationPage = $state<number | 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 ─────────────────────────────────────────────────────────
|
// ── Navigation / init ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let navHeight = $state(0);
|
let navHeight = $state(0);
|
||||||
@@ -80,7 +149,9 @@ onMount(() => {
|
|||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (activeAnnotationId) {
|
if (transcribeMode) {
|
||||||
|
transcribeMode = false;
|
||||||
|
} else if (activeAnnotationId) {
|
||||||
activeAnnotationId = null;
|
activeAnnotationId = null;
|
||||||
activeAnnotationPage = null;
|
activeAnnotationPage = null;
|
||||||
}
|
}
|
||||||
@@ -102,37 +173,54 @@ onMount(() => {
|
|||||||
>
|
>
|
||||||
<DocumentTopBar
|
<DocumentTopBar
|
||||||
doc={doc}
|
doc={doc}
|
||||||
canWrite={data.canWrite ?? false}
|
canWrite={canWrite}
|
||||||
canAnnotate={data.canAnnotate ?? false}
|
canAnnotate={data.canAnnotate ?? false}
|
||||||
fileUrl={fileUrl}
|
fileUrl={fileUrl}
|
||||||
bind:annotateMode={annotateMode}
|
bind:annotateMode={annotateMode}
|
||||||
|
bind:transcribeMode={transcribeMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative flex-1 overflow-hidden">
|
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex' : ''}">
|
||||||
<DocumentViewer
|
<div class={transcribeMode ? 'relative flex-1 overflow-hidden' : 'absolute inset-0'}>
|
||||||
doc={doc}
|
<DocumentViewer
|
||||||
fileUrl={fileUrl}
|
doc={doc}
|
||||||
isLoading={isLoading}
|
fileUrl={fileUrl}
|
||||||
error={fileError}
|
isLoading={isLoading}
|
||||||
bind:annotateMode={annotateMode}
|
error={fileError}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:annotateMode={annotateMode}
|
||||||
bind:activeAnnotationPage={activeAnnotationPage}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
onAnnotationClick={(id) => {
|
bind:activeAnnotationPage={activeAnnotationPage}
|
||||||
activeAnnotationId = id;
|
onAnnotationClick={(id) => {
|
||||||
}}
|
activeAnnotationId = id;
|
||||||
/>
|
}}
|
||||||
<AnnotationSidePanel
|
/>
|
||||||
documentId={doc.id}
|
</div>
|
||||||
activeAnnotationId={activeAnnotationId}
|
|
||||||
activeAnnotationPage={activeAnnotationPage}
|
{#if !transcribeMode}
|
||||||
canComment={canComment}
|
<AnnotationSidePanel
|
||||||
currentUserId={currentUserId}
|
documentId={doc.id}
|
||||||
canAdmin={canAdmin}
|
activeAnnotationId={activeAnnotationId}
|
||||||
targetCommentId={targetAnnotationId ? targetCommentId : null}
|
activeAnnotationPage={activeAnnotationPage}
|
||||||
onClose={() => {
|
canComment={canComment}
|
||||||
activeAnnotationId = null;
|
currentUserId={currentUserId}
|
||||||
activeAnnotationPage = null;
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user