Files
familienarchiv/frontend/src/routes/documents/[id]/+page.svelte
Marcel ef11cbee4e
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
feat(transcription): clicking annotation focuses corresponding block
Pass activeAnnotationId to TranscriptionEditView. An $effect watches
it and sets activeBlockId to the block matching the annotation,
activating its turquoise focus border.

2 new tests (RED/GREEN):
- activates block matching activeAnnotationId (turquoise border)
- no block activated when activeAnnotationId is null

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:36:06 +02:00

235 lines
6.6 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
import TranscriptionEditView from '$lib/components/TranscriptionEditView.svelte';
import type { TranscriptionBlockData } from '$lib/types';
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 ──────────────────────────────────────────────────────────────
let fileUrl = $state('');
let isLoading = $state(false);
let fileError = $state('');
$effect(() => {
if (doc?.id && doc?.filePath) {
loadFile(doc.id);
}
});
async function loadFile(id: string) {
isLoading = true;
fileError = '';
fileUrl = '';
try {
const response = await fetch(`/api/documents/${id}/file`);
if (!response.ok) {
if (response.status === 401) throw new Error('Nicht eingeloggt');
throw new Error('Fehler beim Laden der Datei');
}
const blob = await response.blob();
fileUrl = URL.createObjectURL(blob);
} catch (e) {
console.error(e);
fileError = 'Vorschau konnte nicht geladen werden.';
} finally {
isLoading = false;
}
}
// ── Mode state ───────────────────────────────────────────────────────────────
let transcribeMode = $state(false);
let activeAnnotationId = $state<string | null>(null);
// ── Transcription blocks ─────────────────────────────────────────────────────
let transcriptionBlocks = $state<TranscriptionBlockData[]>([]);
const blockNumbers = $derived(
Object.fromEntries(
[...transcriptionBlocks]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((b, i) => [b.annotationId, i + 1])
)
);
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);
}
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();
}
// Wait for DOM to render the blocks, then scroll to the matching one
requestAnimationFrame(() => {
const block = transcriptionBlocks.find((b) => b.annotationId === annotationId);
if (block) {
const el = document.querySelector(`[data-block-id="${block.id}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
}
// Load blocks when transcribe mode is entered
$effect(() => {
if (transcribeMode) {
loadTranscriptionBlocks();
}
});
// ── Navigation / init ─────────────────────────────────────────────────────────
let navHeight = $state(0);
onMount(() => {
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
if (doc?.id) {
localStorage.setItem(
'familienarchiv.lastVisited',
JSON.stringify({ id: doc.id, title: doc.title ?? '' })
);
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && transcribeMode) {
transcribeMode = false;
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
});
</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={fileUrl}
bind:transcribeMode={transcribeMode}
/>
<div class="relative flex-1 overflow-hidden {transcribeMode ? 'flex flex-col md:flex-row' : ''}">
<div
class={transcribeMode ? 'relative min-h-[40vh] flex-1 overflow-hidden md:min-h-0' : 'absolute inset-0'}
>
<DocumentViewer
doc={doc}
fileUrl={fileUrl}
isLoading={isLoading}
error={fileError}
transcribeMode={transcribeMode}
blockNumbers={blockNumbers}
bind:activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick}
onTranscriptionDraw={createBlockFromDraw}
/>
</div>
{#if transcribeMode}
<div
class="shrink-0 border-t border-line md:w-[400px] md:border-t-0 md:border-l lg:w-[480px]"
>
<TranscriptionEditView
documentId={doc.id}
blocks={transcriptionBlocks}
canComment={canWrite}
currentUserId={currentUserId}
activeAnnotationId={activeAnnotationId}
onBlockFocus={handleBlockFocus}
onSaveBlock={saveBlock}
onDeleteBlock={deleteBlock}
/>
</div>
{/if}
</div>
</div>