Files
familienarchiv/frontend/src/routes/documents/[id]/+page.svelte
Marcel b07f9efa9c
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m39s
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 2m46s
fix(document-detail): force edit panel on notification deep-link
Comments only render inside TranscriptionEditView, so a deep-link
into a document with existing reviewed transcriptions landed the
user in read mode with no comment element in the DOM — the scroll
target silently missed.

scrollToCommentFromQuery now takes a setPanelMode callback and calls
it with 'edit' whenever both query params are present. The page's
own transcribe-mode $effect checks a skipInitialPanelMode flag the
deep-link flow sets, so its default-panel-mode logic doesn't race
against the explicit override.

Two new helper tests pin the contract: panel mode is forced to
'edit' both when transcribe mode is off (entering fresh) and when
it is already on (same-page notification click).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:22:38 +02:00

489 lines
15 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { m } from '$lib/paraglide/messages.js';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
import TranscriptionReadView from '$lib/components/TranscriptionReadView.svelte';
import TranscriptionPanelHeader from '$lib/components/TranscriptionPanelHeader.svelte';
import type { TranscriptionBlockData } from '$lib/types';
import { getErrorMessage } from '$lib/errors';
import { translateOcrProgress } from '$lib/ocr/translateOcrProgress';
import { createFileLoader } from '$lib/hooks/useFileLoader.svelte';
import { scrollToCommentFromQuery } from '$lib/utils/deepLinkScroll';
let { data } = $props();
const doc = $derived(data.document);
const canWrite = $derived(data.canWrite ?? false);
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
// ── File loading ──────────────────────────────────────────────────────────────
const fileLoader = createFileLoader();
$effect(() => {
if (doc?.id && doc?.filePath) {
fileLoader.loadFile(`/api/documents/${doc.id}/file`);
}
});
onDestroy(() => fileLoader.destroy());
// ── Mode state ───────────────────────────────────────────────────────────────
let transcribeMode = $state(false);
let panelMode = $state<'read' | 'edit'>('read');
let activeAnnotationId = $state<string | null>(null);
let highlightBlockId = $state<string | null>(null);
let flashAnnotationId = $state<string | null>(null);
let pdfStripExpanded = $state(false);
// Flag set by the deep-link helper so the transcribe-mode $effect does not
// overwrite the panelMode it picked (e.g. forcing 'edit' on notification
// click-through). One-shot: consumed after the effect's loadBlocks resolves.
let skipInitialPanelMode = $state(false);
const prefersReducedMotion = $derived(
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
);
// ── Transcription blocks ─────────────────────────────────────────────────────
let transcriptionBlocks = $state<TranscriptionBlockData[]>([]);
let annotationReloadKey = $state(0);
const blockNumbers = $derived(
Object.fromEntries(
[...transcriptionBlocks]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((b, i) => [b.annotationId, i + 1])
)
);
const hasBlocks = $derived(transcriptionBlocks.length > 0);
const lastEditedAt = $derived.by(() => {
if (transcriptionBlocks.length === 0) return null;
const dates = transcriptionBlocks
.filter((b) => b.updatedAt)
.map((b) => new Date(b.updatedAt!).getTime());
if (dates.length === 0) return null;
return new Date(Math.max(...dates)).toISOString();
});
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);
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 toggleTrainingLabel(label: string, enrolled: boolean) {
const res = await fetch(`/api/documents/${doc.id}/training-labels`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, enrolled })
});
if (!res.ok) throw new Error('Failed to update training label');
}
let ocrRunning = $state(false);
let ocrProgressMessage = $state('');
let ocrErrorMessage = $state('');
let ocrPollTimer = $state<ReturnType<typeof setInterval> | null>(null);
let ocrSkippedPages = $state(0);
async function triggerOcr(scriptType: string, useExistingAnnotations: boolean = false) {
ocrRunning = true;
ocrErrorMessage = '';
try {
const res = await fetch(`/api/documents/${doc.id}/ocr`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scriptType, useExistingAnnotations })
});
if (res.ok) {
const data = await res.json();
pollOcrJob(data.jobId);
} else {
ocrRunning = false;
const body = await res.json().catch(() => null);
const code = (body as { code?: string } | null)?.code;
ocrErrorMessage = code ? getErrorMessage(code) : m.ocr_status_error();
}
} catch (e) {
console.error('Failed to trigger OCR:', e);
ocrRunning = false;
ocrErrorMessage = m.ocr_status_error();
}
}
function pollOcrJob(jobId: string) {
if (ocrPollTimer) clearInterval(ocrPollTimer);
ocrPollTimer = setInterval(async () => {
try {
const res = await fetch(`/api/ocr/jobs/${jobId}`);
if (!res.ok) return;
const job = await res.json();
const rawCode = job.progressMessage ?? '';
const progress = translateOcrProgress(rawCode);
ocrProgressMessage = progress.message;
if (progress.skippedPages !== undefined) ocrSkippedPages = progress.skippedPages;
if (job.status === 'DONE' || job.status === 'FAILED') {
if (ocrPollTimer) clearInterval(ocrPollTimer);
ocrPollTimer = null;
setTimeout(() => {
ocrRunning = false;
ocrProgressMessage = '';
ocrSkippedPages = 0;
}, 1000);
if (job.status === 'FAILED') {
ocrErrorMessage = m.ocr_status_error();
}
await loadTranscriptionBlocks();
annotationReloadKey++;
panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit';
}
} catch {
// polling is best-effort
}
}, 2000);
}
async function createBlockFromDraw(rect: {
x: number;
y: number;
width: number;
height: number;
pageNumber: number;
}) {
try {
const res = await fetch(`/api/documents/${doc.id}/transcription-blocks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pageNumber: rect.pageNumber,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
text: '',
label: null
})
});
if (res.ok) {
const created = (await res.json()) as TranscriptionBlockData;
transcriptionBlocks = [...transcriptionBlocks, created];
activeAnnotationId = created.annotationId;
}
} catch (e) {
console.error('Failed to create transcription block:', e);
}
}
function handleBlockFocus(blockId: string) {
const block = transcriptionBlocks.find((b) => b.id === blockId);
if (block) {
activeAnnotationId = block.annotationId;
}
}
async function handleAnnotationClick(annotationId: string) {
activeAnnotationId = annotationId;
if (!transcribeMode) {
transcribeMode = true;
await loadTranscriptionBlocks();
}
// In read mode, highlight the matching paragraph
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
if (block) {
highlightBlockId = block.id;
setTimeout(
() => {
highlightBlockId = null;
},
prefersReducedMotion ? 2000 : 1500
);
}
// Wait for DOM to render, then scroll to the matching block
const scrollBehavior = prefersReducedMotion ? 'instant' : 'smooth';
requestAnimationFrame(() => {
if (block) {
const el = document.querySelector(`[data-block-id="${block.id}"]`);
el?.scrollIntoView({ behavior: scrollBehavior, block: 'nearest' });
}
});
}
function handleParagraphClick(annotationId: string) {
activeAnnotationId = annotationId;
flashAnnotationId = annotationId;
pdfStripExpanded = true;
setTimeout(
() => {
flashAnnotationId = null;
},
prefersReducedMotion ? 2000 : 1500
);
}
async function checkOcrStatus() {
if (!doc?.id) return;
try {
const res = await fetch(`/api/documents/${doc.id}/ocr-status`);
if (!res.ok) return;
const status = await res.json();
if ((status.status === 'PENDING' || status.status === 'RUNNING') && status.jobId) {
ocrRunning = true;
pollOcrJob(status.jobId);
}
} catch {
// best-effort
}
}
// Load blocks and check OCR status when transcribe mode is entered
$effect(() => {
if (transcribeMode) {
loadTranscriptionBlocks().then(() => {
if (skipInitialPanelMode) {
skipInitialPanelMode = false;
} else {
panelMode = transcriptionBlocks.length > 0 ? 'read' : 'edit';
}
});
checkOcrStatus();
}
});
// ── Navigation / init ─────────────────────────────────────────────────────────
let navHeight = $state(0);
async function waitForPanelRender(): Promise<void> {
await tick();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
}
onMount(() => {
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
if (doc?.id) {
localStorage.setItem(
'familienarchiv.lastVisited',
JSON.stringify({ id: doc.id, title: doc.title ?? '' })
);
}
scrollToCommentFromQuery(new URL(page.url), {
transcribeMode,
setTranscribeMode: (v) => (transcribeMode = v),
setPanelMode: (m) => {
skipInitialPanelMode = true;
panelMode = m;
},
loadBlocks: loadTranscriptionBlocks,
setActiveAnnotationId: (id) => (activeAnnotationId = id),
flashAnnotation: (annotationId) => {
flashAnnotationId = annotationId;
setTimeout(() => (flashAnnotationId = null), prefersReducedMotion ? 2000 : 1500);
},
prefersReducedMotion,
afterTick: waitForPanelRender,
getElement: (id) => document.getElementById(id),
onStripUrl: () => replaceState(page.url.pathname, page.state ?? {})
}).catch((e) => console.error('deep-link scroll failed', e));
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && transcribeMode) {
transcribeMode = false;
}
}
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
if (ocrPollTimer) clearInterval(ocrPollTimer);
};
});
</script>
<svelte:head>
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
</svelte:head>
<div
class="fixed right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden bg-surface"
style="top: {navHeight}px"
data-hydrated
>
<DocumentTopBar
doc={doc}
canWrite={canWrite}
fileUrl={fileLoader.fileUrl}
bind:transcribeMode={transcribeMode}
/>
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
<div
class={transcribeMode
? `relative flex-1 overflow-hidden md:min-h-0 ${panelMode === 'read' ? (pdfStripExpanded ? 'max-h-[50vh] min-h-[50vh] md:max-h-none md:min-h-0' : 'max-h-[70px] min-h-[70px] md:max-h-none md:min-h-0') : 'min-h-[40vh]'} transition-[min-height,max-height] duration-300`
: 'absolute inset-0'}
>
<DocumentViewer
doc={doc}
fileUrl={fileLoader.fileUrl}
isLoading={fileLoader.isLoading}
error={fileLoader.fileError}
transcribeMode={transcribeMode && !ocrRunning}
blockNumbers={blockNumbers}
annotationReloadKey={annotationReloadKey}
annotationsDimmed={transcribeMode && panelMode === 'read'}
flashAnnotationId={flashAnnotationId}
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick}
onTranscriptionDraw={createBlockFromDraw}
/>
</div>
{#if transcribeMode && panelMode === 'read'}
<button
type="button"
onclick={() => (pdfStripExpanded = !pdfStripExpanded)}
class="flex h-7 w-full shrink-0 items-center justify-center border-t border-line bg-muted text-xs font-semibold text-ink-2 md:hidden"
aria-label={pdfStripExpanded ? m.scan_collapse() : m.scan_expand()}
>
<svg
class="mr-1 h-3 w-3 transition-transform duration-200 {pdfStripExpanded ? 'rotate-180' : ''}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
{pdfStripExpanded ? m.scan_collapse() : m.scan_expand()}
</button>
{/if}
{#if transcribeMode}
<div
class="flex min-h-0 flex-1 shrink-0 flex-col border-t border-line md:w-[400px] md:flex-none md:border-t-0 md:border-l lg:w-[480px]"
>
<TranscriptionPanelHeader
mode={panelMode}
hasBlocks={hasBlocks}
blockCount={transcriptionBlocks.length}
lastEditedAt={lastEditedAt}
onModeChange={(newMode) => (panelMode = newMode)}
onClose={() => (transcribeMode = false)}
/>
<div class="flex-1 overflow-y-auto">
{#if ocrErrorMessage}
<div class="mx-4 mt-4 rounded-sm border border-red-200 bg-red-50 px-4 py-3">
<p class="text-sm text-red-700">{ocrErrorMessage}</p>
</div>
{/if}
{#if ocrRunning}
<div class="flex flex-1 flex-col items-center justify-center px-6 py-12 text-center">
<svg
class="mb-4 h-8 w-8 animate-spin text-brand-mint"
viewBox="0 0 24 24"
fill="none"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
<p class="text-xs font-bold tracking-widest text-gray-400 uppercase">
{m.ocr_progress_heading()}
</p>
<p class="mt-2 text-sm text-ink-2">
{ocrProgressMessage}
</p>
{#if ocrSkippedPages > 0}
<p class="mt-1 text-xs text-amber-600">
{ocrSkippedPages} Seiten übersprungen
</p>
{/if}
</div>
{:else if panelMode === 'read'}
<TranscriptionReadView
blocks={transcriptionBlocks}
highlightBlockId={highlightBlockId}
onParagraphClick={handleParagraphClick}
/>
{:else}
<TranscriptionEditView
documentId={doc.id}
blocks={transcriptionBlocks}
canComment={canWrite}
currentUserId={currentUserId}
activeAnnotationId={activeAnnotationId}
storedScriptType={doc.scriptType ?? ''}
canRunOcr={canWrite && !!doc.filePath}
canWrite={canWrite}
trainingLabels={doc.trainingLabels ?? []}
onBlockFocus={handleBlockFocus}
onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock}
onReviewToggle={reviewToggle}
onTriggerOcr={triggerOcr}
onToggleTrainingLabel={toggleTrainingLabel}
/>
{/if}
</div>
</div>
{/if}
</div>
</div>