feat(frontend): add OCR UI components and translations

- ScriptTypeSelect: native select for TYPEWRITER/HANDWRITING_LATIN/KURRENT
- OcrTrigger: wraps script type select + start button + confirmation dialog
- OcrProgress: SSE-based progress display with page counter and progress bar
- Paraglide translations for OCR (de/en/es): script types, trigger labels,
  confirmation dialog, progress messages, error messages
- ErrorCode type + getErrorMessage: OCR_SERVICE_UNAVAILABLE, OCR_JOB_NOT_FOUND,
  OCR_DOCUMENT_NOT_UPLOADED, OCR_PROCESSING_FAILED

All 687 frontend tests pass.

Refs #226

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-12 15:36:00 +02:00
parent cf8dc3559f
commit a4651aa317
7 changed files with 239 additions and 3 deletions

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
interface Props {
jobId: string;
onDone: () => void;
}
let { jobId, onDone }: Props = $props();
let status: 'running' | 'done' | 'error' = $state('running');
let processed: number = $state(0);
let total: number = $state(0);
let currentPage: number = $state(0);
let totalPages: number = $state(0);
let progressPercent = $derived(total > 0 ? Math.round((processed / total) * 100) : 0);
$effect(() => {
const source = new EventSource(`/api/ocr/jobs/${jobId}/progress`);
source.addEventListener('document', (e) => {
const data = JSON.parse(e.data);
processed = data.processed;
total = data.total;
});
source.addEventListener('page', (e) => {
const data = JSON.parse(e.data);
currentPage = data.page;
totalPages = data.totalPages;
});
source.addEventListener('done', () => {
status = 'done';
source.close();
onDone();
});
source.addEventListener('error', () => {
status = 'error';
source.close();
});
source.onerror = () => {
status = 'error';
source.close();
};
return () => {
source.close();
};
});
</script>
{#if status === 'running'}
<div class="border-brand-sand rounded-sm border bg-white p-4">
<h3 class="mb-3 text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.ocr_progress_heading()}
</h3>
<div class="bg-brand-sand h-2 w-full overflow-hidden rounded-full">
<div
class="h-full bg-brand-mint transition-all duration-300"
style="width: {progressPercent}%"
role="progressbar"
aria-valuenow={progressPercent}
aria-valuemin={0}
aria-valuemax={100}
></div>
</div>
<p class="mt-2 text-right text-sm text-gray-500">
{m.ocr_progress_page({ current: String(currentPage), total: String(totalPages) })}
</p>
</div>
{:else if status === 'error'}
<div class="border-brand-sand rounded-sm border border-l-4 border-l-red-500 bg-white p-4">
<h3 class="mb-2 text-sm font-semibold text-red-700">
{m.ocr_error_heading()}
</h3>
<button
type="button"
onclick={() => { status = 'running'; }}
class="text-sm font-medium text-brand-navy transition-colors hover:text-brand-navy/80"
>
{m.ocr_error_retry()}
</button>
</div>
{/if}

View File

@@ -0,0 +1,49 @@
<script lang="ts">
import { untrack } from 'svelte';
import * as m from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte';
import ScriptTypeSelect from './ScriptTypeSelect.svelte';
interface Props {
existingBlockCount: number;
storedScriptType: string;
onTrigger: (scriptType: string) => void;
}
let { existingBlockCount, storedScriptType, onTrigger }: Props = $props();
const { confirm } = getConfirmService();
let selectedScriptType: string = $state(
untrack(() => (storedScriptType && storedScriptType !== 'UNKNOWN' ? storedScriptType : ''))
);
async function handleClick() {
if (!selectedScriptType) return;
if (existingBlockCount > 0) {
const confirmed = await confirm({
title: m.ocr_confirm_title(),
body: m.ocr_confirm_body({ count: String(existingBlockCount) }),
confirmLabel: m.ocr_confirm_btn(),
destructive: true
});
if (!confirmed) return;
}
onTrigger(selectedScriptType);
}
</script>
<div class="flex flex-col gap-3">
<ScriptTypeSelect bind:value={selectedScriptType} />
<button
type="button"
disabled={!selectedScriptType}
title={!selectedScriptType ? m.ocr_trigger_btn_disabled() : undefined}
onclick={handleClick}
class="min-h-[44px] w-full rounded-sm bg-brand-navy font-sans text-sm font-medium text-white transition-colors hover:bg-brand-navy/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{m.ocr_trigger_btn()}
</button>
</div>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
interface Props {
value: string;
disabled?: boolean;
}
let { value = $bindable(), disabled = false }: Props = $props();
</script>
<div>
<label for="script-type-select" class="text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.ocr_trigger_label()}
</label>
<select
id="script-type-select"
bind:value={value}
disabled={disabled}
class="border-brand-sand min-h-[44px] w-full rounded-sm border bg-white px-3 py-2 font-serif text-sm text-brand-navy focus:ring-2 focus:ring-brand-mint focus:outline-none"
>
<option value="" disabled>{m.ocr_trigger_select_placeholder()}</option>
<option value="TYPEWRITER">{m.ocr_script_type_typewriter()}</option>
<option value="HANDWRITING_LATIN">{m.ocr_script_type_handwriting_latin()}</option>
<option value="HANDWRITING_KURRENT">{m.ocr_script_type_handwriting_kurrent()}</option>
</select>
</div>