feat(frontend): wire OCR trigger + review toggle into transcription panel
Some checks failed
CI / Unit & Component Tests (push) Failing after 1s
CI / Backend Unit Tests (push) Failing after 1s
CI / Unit & Component Tests (pull_request) Failing after 1s
CI / Backend Unit Tests (pull_request) Failing after 1s

- OcrTrigger component rendered in the transcription empty state when
  the document has a file and user has write permission
- Review checkmark toggle on each TranscriptionBlock (turquoise when
  reviewed, muted outline when not). Calls PUT .../review to toggle.
- TranscriptionBlockData type: added source + reviewed fields
- +page.svelte: triggerOcr() and reviewToggle() functions wired up
- Paraglide translations (de/en/es) for review toggle + reviewed count

All 687 frontend tests pass.

Refs #226, #230

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-12 22:02:56 +02:00
parent 3aaec01421
commit 8dc9243add
7 changed files with 102 additions and 7 deletions

View File

@@ -520,5 +520,8 @@
"ocr_error_heading": "OCR fehlgeschlagen",
"ocr_error_retry": "Erneut versuchen",
"ocr_batch_running": "OCR läuft · {processed} von {total} Dokumente abgeschlossen",
"ocr_batch_done": "OCR abgeschlossen · {processed} erfolgreich · {errors} fehlgeschlagen"
"ocr_batch_done": "OCR abgeschlossen · {processed} erfolgreich · {errors} fehlgeschlagen",
"transcription_block_review": "Als geprüft markieren",
"transcription_block_unreview": "Markierung aufheben",
"transcription_reviewed_count": "{reviewed} von {total} geprüft"
}

View File

@@ -520,5 +520,8 @@
"ocr_error_heading": "OCR failed",
"ocr_error_retry": "Try again",
"ocr_batch_running": "OCR running · {processed} of {total} documents complete",
"ocr_batch_done": "OCR complete · {processed} successful · {errors} failed"
"ocr_batch_done": "OCR complete · {processed} successful · {errors} failed",
"transcription_block_review": "Mark as reviewed",
"transcription_block_unreview": "Unmark as reviewed",
"transcription_reviewed_count": "{reviewed} of {total} reviewed"
}

View File

@@ -520,5 +520,8 @@
"ocr_error_heading": "OCR fallido",
"ocr_error_retry": "Intentar de nuevo",
"ocr_batch_running": "OCR en curso · {processed} de {total} documentos completados",
"ocr_batch_done": "OCR completado · {processed} exitosos · {errors} fallidos"
"ocr_batch_done": "OCR completado · {processed} exitosos · {errors} fallidos",
"transcription_block_review": "Marcar como revisado",
"transcription_block_unreview": "Desmarcar como revisado",
"transcription_reviewed_count": "{reviewed} de {total} revisados"
}

View File

@@ -14,6 +14,7 @@ type Props = {
text: string;
label: string | null;
active: boolean;
reviewed: boolean;
saveState: SaveState;
canComment: boolean;
currentUserId: string | null;
@@ -21,6 +22,7 @@ type Props = {
onFocus: () => void;
onDeleteClick: () => void;
onRetry: () => void;
onReviewToggle: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
isFirst?: boolean;
@@ -34,6 +36,7 @@ let {
text,
label = null,
active,
reviewed,
saveState,
canComment,
currentUserId,
@@ -41,6 +44,7 @@ let {
onFocus,
onDeleteClick,
onRetry,
onReviewToggle,
onMoveUp,
onMoveDown,
isFirst = false,
@@ -239,6 +243,29 @@ function handleTextareaMouseUp() {
</span>
{/if}
<!-- Review toggle -->
<button
type="button"
class="cursor-pointer transition-colors {reviewed ? 'text-turquoise hover:text-turquoise/70' : 'text-ink-3 hover:text-turquoise'}"
aria-label={reviewed ? m.transcription_block_unreview() : m.transcription_block_review()}
title={reviewed ? m.transcription_block_unreview() : m.transcription_block_review()}
onclick={onReviewToggle}
>
<svg
class="h-4 w-4"
fill={reviewed ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
<!-- Delete button -->
<button
type="button"

View File

@@ -2,6 +2,7 @@
import { m } from '$lib/paraglide/messages.js';
import { SvelteMap } from 'svelte/reactivity';
import TranscriptionBlock from './TranscriptionBlock.svelte';
import OcrTrigger from './OcrTrigger.svelte';
import type { TranscriptionBlockData } from '$lib/types';
type SaveState = 'idle' | 'saving' | 'saved' | 'fading' | 'error';
@@ -12,9 +13,13 @@ type Props = {
canComment: boolean;
currentUserId: string | null;
activeAnnotationId?: string | null;
storedScriptType?: string;
canRunOcr?: boolean;
onBlockFocus: (blockId: string) => void;
onSaveBlock: (blockId: string, text: string) => Promise<void>;
onDeleteBlock: (blockId: string) => Promise<void>;
onReviewToggle: (blockId: string) => Promise<void>;
onTriggerOcr?: (scriptType: string) => void;
};
let {
@@ -23,9 +28,13 @@ let {
canComment,
currentUserId,
activeAnnotationId = null,
storedScriptType = '',
canRunOcr = false,
onBlockFocus,
onSaveBlock,
onDeleteBlock
onDeleteBlock,
onReviewToggle,
onTriggerOcr
}: Props = $props();
let activeBlockId: string | null = $state(null);
@@ -282,6 +291,7 @@ $effect(() => {
text={block.text}
label={block.label}
active={activeBlockId === block.id}
reviewed={block.reviewed ?? false}
saveState={getSaveState(block.id)}
canComment={canComment}
currentUserId={currentUserId}
@@ -289,6 +299,7 @@ $effect(() => {
onFocus={() => handleFocus(block.id)}
onDeleteClick={() => handleDelete(block.id)}
onRetry={() => handleRetry(block.id)}
onReviewToggle={() => onReviewToggle(block.id)}
onMoveUp={() => handleMoveUp(block.id)}
onMoveDown={() => handleMoveDown(block.id)}
isFirst={i === 0}
@@ -323,9 +334,26 @@ $effect(() => {
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>
{#if canRunOcr && onTriggerOcr}
<p class="mb-6 max-w-xs text-sm leading-relaxed text-ink-3">
{m.transcription_empty_title()}
</p>
<div class="w-full max-w-xs">
<OcrTrigger
existingBlockCount={0}
storedScriptType={storedScriptType}
onTrigger={onTriggerOcr}
/>
</div>
<p class="mt-4 text-xs text-ink-3">
{m.transcription_empty_desc()}
</p>
{:else}
<p class="max-w-xs text-sm leading-relaxed text-ink-3">
{m.transcription_empty_cta()}
</p>
{/if}
</div>
{/if}
</div>

View File

@@ -35,6 +35,8 @@ export type TranscriptionBlockData = {
label: string | null;
sortOrder: number;
version: number;
source: 'MANUAL' | 'OCR';
reviewed: boolean;
updatedAt?: string | null;
};

View File

@@ -118,6 +118,31 @@ async function deleteBlock(blockId: string) {
annotationReloadKey++;
}
async function reviewToggle(blockId: string) {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks/${blockId}/review`, {
method: 'PUT'
});
if (!res.ok) return;
const updated = await res.json();
transcriptionBlocks = transcriptionBlocks.map((b) => (b.id === blockId ? updated : b));
}
async function triggerOcr(scriptType: string) {
try {
const res = await fetch(`/api/documents/${doc.id}/ocr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scriptType })
});
if (res.ok) {
await loadTranscriptionBlocks();
annotationReloadKey++;
}
} catch (e) {
console.error('Failed to trigger OCR:', e);
}
}
async function createBlockFromDraw(rect: {
x: number;
y: number;
@@ -316,9 +341,13 @@ onMount(() => {
canComment={canWrite}
currentUserId={currentUserId}
activeAnnotationId={activeAnnotationId}
storedScriptType={doc.scriptType ?? ''}
canRunOcr={canWrite && !!doc.filePath}
onBlockFocus={handleBlockFocus}
onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock}
onReviewToggle={reviewToggle}
onTriggerOcr={triggerOcr}
/>
{/if}
</div>