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
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>
235 lines
6.6 KiB
Svelte
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>
|