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:
@@ -500,5 +500,25 @@
|
||||
"person_alias_delete_title": "Alias entfernen?",
|
||||
"person_alias_delete_body": "Dieser Name wird aus der Suche entfernt.",
|
||||
"person_alias_btn_delete": "Entfernen",
|
||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden."
|
||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||
"error_ocr_job_not_found": "Der OCR-Auftrag wurde nicht gefunden.",
|
||||
"error_ocr_document_not_uploaded": "Das Dokument hat keine Datei — OCR ist nicht möglich.",
|
||||
"error_ocr_processing_failed": "Die OCR-Verarbeitung ist fehlgeschlagen.",
|
||||
"ocr_script_type_typewriter": "Schreibmaschine",
|
||||
"ocr_script_type_handwriting_latin": "Handschrift (lateinisch)",
|
||||
"ocr_script_type_handwriting_kurrent": "Handschrift (Kurrent/Sütterlin)",
|
||||
"ocr_trigger_label": "Schrifttyp",
|
||||
"ocr_trigger_select_placeholder": "Schrifttyp wählen…",
|
||||
"ocr_trigger_btn": "OCR starten",
|
||||
"ocr_trigger_btn_disabled": "Bitte wählen Sie einen Schrifttyp",
|
||||
"ocr_confirm_title": "Vorhandene Transkription ersetzen?",
|
||||
"ocr_confirm_body": "Alle {count} vorhandenen Blöcke werden gelöscht und durch die OCR-Ergebnisse ersetzt. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"ocr_confirm_btn": "Ersetzen",
|
||||
"ocr_progress_heading": "OCR läuft",
|
||||
"ocr_progress_page": "Seite {current} von {total}",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -500,5 +500,25 @@
|
||||
"person_alias_delete_title": "Remove alias?",
|
||||
"person_alias_delete_body": "This name will be removed from search results.",
|
||||
"person_alias_btn_delete": "Remove",
|
||||
"error_alias_not_found": "The name alias was not found."
|
||||
"error_alias_not_found": "The name alias was not found.",
|
||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||
"error_ocr_job_not_found": "The OCR job was not found.",
|
||||
"error_ocr_document_not_uploaded": "The document has no file — OCR is not possible.",
|
||||
"error_ocr_processing_failed": "OCR processing failed.",
|
||||
"ocr_script_type_typewriter": "Typewriter",
|
||||
"ocr_script_type_handwriting_latin": "Handwriting (Latin)",
|
||||
"ocr_script_type_handwriting_kurrent": "Handwriting (Kurrent/Sütterlin)",
|
||||
"ocr_trigger_label": "Script type",
|
||||
"ocr_trigger_select_placeholder": "Select script type…",
|
||||
"ocr_trigger_btn": "Start OCR",
|
||||
"ocr_trigger_btn_disabled": "Please select a script type",
|
||||
"ocr_confirm_title": "Replace existing transcription?",
|
||||
"ocr_confirm_body": "All {count} existing blocks will be deleted and replaced with OCR results. This action cannot be undone.",
|
||||
"ocr_confirm_btn": "Replace",
|
||||
"ocr_progress_heading": "OCR running",
|
||||
"ocr_progress_page": "Page {current} of {total}",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -500,5 +500,25 @@
|
||||
"person_alias_delete_title": "Eliminar alias?",
|
||||
"person_alias_delete_body": "Este nombre se eliminara de los resultados de busqueda.",
|
||||
"person_alias_btn_delete": "Eliminar",
|
||||
"error_alias_not_found": "No se encontro el alias de nombre."
|
||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||
"error_ocr_job_not_found": "No se encontró el trabajo OCR.",
|
||||
"error_ocr_document_not_uploaded": "El documento no tiene archivo — OCR no es posible.",
|
||||
"error_ocr_processing_failed": "El procesamiento OCR ha fallado.",
|
||||
"ocr_script_type_typewriter": "Máquina de escribir",
|
||||
"ocr_script_type_handwriting_latin": "Escritura manuscrita (latina)",
|
||||
"ocr_script_type_handwriting_kurrent": "Escritura manuscrita (Kurrent/Sütterlin)",
|
||||
"ocr_trigger_label": "Tipo de escritura",
|
||||
"ocr_trigger_select_placeholder": "Seleccionar tipo de escritura…",
|
||||
"ocr_trigger_btn": "Iniciar OCR",
|
||||
"ocr_trigger_btn_disabled": "Por favor seleccione un tipo de escritura",
|
||||
"ocr_confirm_title": "¿Reemplazar transcripción existente?",
|
||||
"ocr_confirm_body": "Los {count} bloques existentes serán eliminados y reemplazados con los resultados del OCR. Esta acción no se puede deshacer.",
|
||||
"ocr_confirm_btn": "Reemplazar",
|
||||
"ocr_progress_heading": "OCR en curso",
|
||||
"ocr_progress_page": "Página {current} de {total}",
|
||||
"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"
|
||||
}
|
||||
|
||||
88
frontend/src/lib/components/OcrProgress.svelte
Normal file
88
frontend/src/lib/components/OcrProgress.svelte
Normal 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}
|
||||
49
frontend/src/lib/components/OcrTrigger.svelte
Normal file
49
frontend/src/lib/components/OcrTrigger.svelte
Normal 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>
|
||||
27
frontend/src/lib/components/ScriptTypeSelect.svelte
Normal file
27
frontend/src/lib/components/ScriptTypeSelect.svelte
Normal 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>
|
||||
@@ -22,6 +22,10 @@ export type ErrorCode =
|
||||
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
|
||||
| 'TRANSCRIPTION_BLOCK_CONFLICT'
|
||||
| 'COMMENT_NOT_FOUND'
|
||||
| 'OCR_SERVICE_UNAVAILABLE'
|
||||
| 'OCR_JOB_NOT_FOUND'
|
||||
| 'OCR_DOCUMENT_NOT_UPLOADED'
|
||||
| 'OCR_PROCESSING_FAILED'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
@@ -85,6 +89,14 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
return m.error_transcription_block_conflict();
|
||||
case 'COMMENT_NOT_FOUND':
|
||||
return m.error_comment_not_found();
|
||||
case 'OCR_SERVICE_UNAVAILABLE':
|
||||
return m.error_ocr_service_unavailable();
|
||||
case 'OCR_JOB_NOT_FOUND':
|
||||
return m.error_ocr_job_not_found();
|
||||
case 'OCR_DOCUMENT_NOT_UPLOADED':
|
||||
return m.error_ocr_document_not_uploaded();
|
||||
case 'OCR_PROCESSING_FAILED':
|
||||
return m.error_ocr_processing_failed();
|
||||
case 'UNAUTHORIZED':
|
||||
return m.error_unauthorized();
|
||||
case 'FORBIDDEN':
|
||||
|
||||
Reference in New Issue
Block a user