Files
familienarchiv/frontend/src/routes/documents/[id]/+page.svelte
Marcel feadf372a0 refactor(confirm-service): normalise import spelling to no-extension form
Production code referenced $lib/shared/services/confirm.svelte under two
spellings — 4 files with the .js extension and one without. Standardise on
the no-extension form to match Svelte 5 rune-module convention and the
source file basename (confirm.svelte.ts).

Why this matters: vitest browser mode's @vitest/browser-playwright resolves
both spellings to the same module URL but registers a separate Playwright
route per spelling. The route-cleanup logic only unregisters the latest,
leaving an orphan that crashes the next session with
"[birpc] rpc is closed, cannot call resolveManualMock". Fixed upstream in
vitest PR #10267 (merged, not yet released). Normalising the spelling
removes the trigger from our side.

Refs: #553. Companion test-file changes follow in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:02:26 +02:00

369 lines
12 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/document/DocumentTopBar.svelte';
import DocumentViewer from '$lib/document/DocumentViewer.svelte';
import TranscriptionEditView from '$lib/document/transcription/TranscriptionEditView.svelte';
import TranscriptionReadView from '$lib/document/transcription/TranscriptionReadView.svelte';
import TranscriptionPanelHeader from '$lib/document/transcription/TranscriptionPanelHeader.svelte';
import { createOcrJob } from '$lib/ocr/useOcrJob.svelte';
import { createTranscriptionBlocks } from '$lib/document/transcription/useTranscriptionBlocks.svelte';
import { createFileLoader } from '$lib/document/viewer/useFileLoader.svelte';
import { scrollToCommentFromQuery } from '$lib/shared/utils/deepLinkScroll';
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
let { data } = $props();
const { confirm } = getConfirmService();
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 ─────────────────────────────────────────────────────
const transcription = createTranscriptionBlocks({
documentId: () => doc?.id ?? ''
});
async function handleAnnotationDeleteRequest(annotationId: string) {
const confirmed = await confirm({
title: m.transcription_block_delete_confirm(),
destructive: true
});
if (!confirmed) return;
await transcription.deleteAnnotation(annotationId);
}
const ocrJob = createOcrJob({
documentId: () => doc?.id ?? '',
onJobFinished: async () => {
await transcription.load();
transcription.bumpAnnotationReloadKey();
panelMode = transcription.hasBlocks ? 'read' : 'edit';
}
});
async function triggerOcr(scriptType: string, useExistingAnnotations: boolean = false) {
await ocrJob.triggerOcr(scriptType, useExistingAnnotations);
}
async function createBlockFromDraw(rect: {
x: number;
y: number;
width: number;
height: number;
pageNumber: number;
}) {
const created = await transcription.createFromDraw(rect);
if (created) {
activeAnnotationId = created.annotationId;
}
}
function handleBlockFocus(blockId: string) {
const block = transcription.blocks.find((b) => b.id === blockId);
if (block) {
activeAnnotationId = block.annotationId;
}
}
async function handleAnnotationClick(annotationId: string) {
activeAnnotationId = annotationId;
if (!transcribeMode) {
transcribeMode = true;
await transcription.load();
}
// In read mode, highlight the matching paragraph
const block = transcription.findByAnnotationId(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
);
}
// Load blocks and check OCR status when transcribe mode is entered
$effect(() => {
if (transcribeMode) {
transcription.load().then(() => {
if (skipInitialPanelMode) {
skipInitialPanelMode = false;
} else {
panelMode = transcription.hasBlocks ? 'read' : 'edit';
}
});
ocrJob.checkStatus();
}
});
// ── 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 ?? '' })
);
}
if (page.url.searchParams.get('task') === 'transcribe') {
transcribeMode = true;
tick()
.then(() => {
const closeBtn = document.querySelector<HTMLElement>('[data-testid="panel-close"]');
closeBtn?.scrollIntoView({
behavior: prefersReducedMotion ? 'instant' : 'smooth',
block: 'nearest'
});
closeBtn?.focus({ preventScroll: true });
replaceState(page.url.pathname, page.state ?? {});
})
.catch((e) => console.error('task deep-link failed', e));
}
scrollToCommentFromQuery(new URL(page.url), {
transcribeMode,
setTranscribeMode: (v) => (transcribeMode = v),
setPanelMode: (m) => {
skipInitialPanelMode = true;
panelMode = m;
},
loadBlocks: () => transcription.load(),
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);
ocrJob.destroy();
};
});
</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}
inferredRelationship={data.inferredRelationship}
geschichten={data.geschichten ?? []}
canBlogWrite={data.canBlogWrite ?? false}
/>
<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 && !ocrJob.running}
blockNumbers={transcription.blockNumbers}
annotationReloadKey={transcription.annotationReloadKey}
annotationsDimmed={transcribeMode && panelMode === 'read'}
flashAnnotationId={flashAnnotationId}
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick}
onTranscriptionDraw={createBlockFromDraw}
onDeleteAnnotationRequest={handleAnnotationDeleteRequest}
/>
</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={transcription.hasBlocks}
blockCount={transcription.blocks.length}
lastEditedAt={transcription.lastEditedAt}
onModeChange={(newMode) => (panelMode = newMode)}
onClose={() => (transcribeMode = false)}
/>
<div class="flex-1 overflow-y-auto">
{#if ocrJob.errorMessage}
<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">{ocrJob.errorMessage}</p>
</div>
{/if}
{#if ocrJob.running}
<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">
{ocrJob.progressMessage}
</p>
{#if ocrJob.skippedPages > 0}
<p class="mt-1 text-xs text-amber-600">
{ocrJob.skippedPages} Seiten übersprungen
</p>
{/if}
</div>
{:else if panelMode === 'read'}
<TranscriptionReadView
blocks={transcription.blocks}
highlightBlockId={highlightBlockId}
onParagraphClick={handleParagraphClick}
/>
{:else}
<TranscriptionEditView
documentId={doc.id}
blocks={transcription.blocks}
canComment={canWrite}
currentUserId={currentUserId}
activeAnnotationId={activeAnnotationId}
storedScriptType={doc.scriptType ?? ''}
canRunOcr={canWrite && !!doc.filePath}
canWrite={canWrite}
trainingLabels={doc.trainingLabels ?? []}
onBlockFocus={handleBlockFocus}
onSaveBlock={transcription.save}
onDeleteBlock={transcription.delete}
onReviewToggle={transcription.reviewToggle}
onMarkAllReviewed={transcription.markAllReviewed}
onTriggerOcr={triggerOcr}
onToggleTrainingLabel={transcription.toggleTrainingLabel}
/>
{/if}
</div>
</div>
{/if}
</div>
</div>