Files
familienarchiv/frontend/src/lib/components/OcrTrainingCard.svelte
Marcel 38a9719bdb fix(frontend): QUEUED badge test, touch target on dismiss button, focus ring on expand toggle
Add missing test coverage for the amber QUEUED status badge in TrainingHistory.
Fix WCAG 2.2 minimum touch target (24 × 24 px) on the success-message dismiss
button in OcrTrainingCard. Add focus-visible ring to the expand/collapse toggle
in TrainingHistory so keyboard users get a visible focus indicator.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 12:30:54 +02:00

104 lines
3.2 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import TrainingHistory from './TrainingHistory.svelte';
import { m } from '$lib/paraglide/messages.js';
import type { TrainingRun } from '$lib/types/training.js';
interface TrainingInfo {
availableBlocks?: number;
totalOcrBlocks?: number;
availableDocuments?: number;
ocrServiceAvailable?: boolean;
lastRun?: TrainingRun | null;
runs?: TrainingRun[];
personNames?: Record<string, string>;
}
interface Props {
trainingInfo: TrainingInfo | null;
}
let { trainingInfo }: Props = $props();
let training = $state(false);
let successMessage = $state<string | null>(null);
let errorMessage = $state<string | null>(null);
const available = $derived(trainingInfo?.availableBlocks ?? 0);
const tooFewBlocks = $derived(available < 5);
const serviceDown = $derived(trainingInfo?.ocrServiceAvailable === false);
const disabled = $derived(training || tooFewBlocks || serviceDown);
async function startTraining() {
training = true;
successMessage = null;
errorMessage = null;
try {
const res = await fetch('/api/ocr/train', { method: 'POST' });
if (res.ok) {
successMessage = m.training_success();
setTimeout(() => {
successMessage = null;
}, 5000);
} else {
errorMessage = m.training_start_failed();
}
} catch {
errorMessage = m.training_start_failed();
} finally {
training = false;
}
}
</script>
<div class="rounded-sm border border-line bg-surface p-6 shadow-sm">
<h2 class="mb-1 font-sans text-sm font-bold text-ink">{m.training_ocr_heading()}</h2>
<p class="mb-4 text-sm text-ink-2">{m.training_ocr_description()}</p>
<p class="mb-3 text-sm text-ink">
{m.training_ocr_blocks_ready({ blocks: available, docs: trainingInfo?.availableDocuments ?? 0 })}
<span class="text-ink-2"
>{m.training_ocr_blocks_total({ total: trainingInfo?.totalOcrBlocks ?? 0 })}</span
>
</p>
<button
onclick={startTraining}
disabled={disabled}
class="rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
>
{training ? '…' : m.training_start_btn()}
</button>
{#if tooFewBlocks}
<p class="mt-2 text-xs text-ink-3">
{m.training_too_few_blocks({ available })}
</p>
{:else if serviceDown}
<p class="mt-2 text-xs text-orange-600">{m.training_service_down()}</p>
{/if}
{#if successMessage}
<p class="mt-2 flex items-center gap-2 text-xs text-green-700" aria-live="polite">
{successMessage}
<button
type="button"
class="ml-1 inline-flex h-6 w-6 items-center justify-center rounded-sm underline hover:no-underline focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:outline-none"
onclick={() => (successMessage = null)}
aria-label={m.comp_dismiss()}>×</button
>
</p>
{/if}
{#if errorMessage}
<p class="mt-2 text-xs text-red-600" aria-live="assertive">{errorMessage}</p>
{/if}
<h3 class="mt-6 mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.training_history_heading()}
</h3>
<TrainingHistory
runs={(trainingInfo?.runs ?? []).filter((r) => r.modelName !== 'blla')}
personNames={trainingInfo?.personNames ?? {}}
/>
</div>