Compare commits
15 Commits
feat/62-do
...
dd360ade8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd360ade8b | ||
|
|
f71712ab4b | ||
|
|
10783fdb55 | ||
|
|
5ea5590c89 | ||
|
|
142f296255 | ||
|
|
c19f7b3b1a | ||
|
|
db9d8ed457 | ||
|
|
65457a5650 | ||
|
|
1eb2659ba0 | ||
|
|
f18649fb79 | ||
|
|
a392e85f43 | ||
|
|
c9b4e6dad4 | ||
|
|
8519fbb48a | ||
|
|
ee85ce4668 | ||
|
|
ecfd80bf9a |
@@ -257,5 +257,8 @@
|
|||||||
"doc_panel_tab_history": "Verlauf",
|
"doc_panel_tab_history": "Verlauf",
|
||||||
"doc_panel_annotate": "Annotieren",
|
"doc_panel_annotate": "Annotieren",
|
||||||
"doc_panel_annotate_stop": "Fertig",
|
"doc_panel_annotate_stop": "Fertig",
|
||||||
"doc_panel_annotation_thread_title": "Annotation"
|
"doc_panel_annotation_thread_title": "Annotation",
|
||||||
|
"doc_panel_discussion_annotation_tab": "Annotation · Seite {page}",
|
||||||
|
"pdf_annotations_show": "Annotierungen anzeigen",
|
||||||
|
"pdf_annotations_hide": "Annotierungen verbergen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,5 +257,8 @@
|
|||||||
"doc_panel_tab_history": "History",
|
"doc_panel_tab_history": "History",
|
||||||
"doc_panel_annotate": "Annotate",
|
"doc_panel_annotate": "Annotate",
|
||||||
"doc_panel_annotate_stop": "Done",
|
"doc_panel_annotate_stop": "Done",
|
||||||
"doc_panel_annotation_thread_title": "Annotation"
|
"doc_panel_annotation_thread_title": "Annotation",
|
||||||
|
"doc_panel_discussion_annotation_tab": "Annotation · Page {page}",
|
||||||
|
"pdf_annotations_show": "Show annotations",
|
||||||
|
"pdf_annotations_hide": "Hide annotations"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,5 +257,8 @@
|
|||||||
"doc_panel_tab_history": "Historial",
|
"doc_panel_tab_history": "Historial",
|
||||||
"doc_panel_annotate": "Anotar",
|
"doc_panel_annotate": "Anotar",
|
||||||
"doc_panel_annotate_stop": "Listo",
|
"doc_panel_annotate_stop": "Listo",
|
||||||
"doc_panel_annotation_thread_title": "Anotación"
|
"doc_panel_annotation_thread_title": "Anotación",
|
||||||
|
"doc_panel_discussion_annotation_tab": "Anotación · Página {page}",
|
||||||
|
"pdf_annotations_show": "Mostrar anotaciones",
|
||||||
|
"pdf_annotations_hide": "Ocultar anotaciones"
|
||||||
}
|
}
|
||||||
|
|||||||
65
frontend/src/lib/components/AnnotationSidePanel.svelte
Normal file
65
frontend/src/lib/components/AnnotationSidePanel.svelte
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import CommentThread from './CommentThread.svelte';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documentId: string;
|
||||||
|
activeAnnotationId: string | null;
|
||||||
|
activeAnnotationPage: number | null;
|
||||||
|
canComment: boolean;
|
||||||
|
currentUserId: string | null;
|
||||||
|
canAdmin: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
documentId,
|
||||||
|
activeAnnotationId,
|
||||||
|
activeAnnotationPage,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
onClose
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const visible = $derived(activeAnnotationId !== null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-y-0 right-0 z-10 flex w-80 flex-col border-l border-brand-sand bg-white shadow-[-4px_0_16px_rgba(0,0,0,0.08)] transition-transform duration-200 {visible
|
||||||
|
? 'translate-x-0'
|
||||||
|
: 'pointer-events-none translate-x-full'}"
|
||||||
|
data-testid="annotation-side-panel"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
||||||
|
<span class="font-sans text-xs font-medium text-brand-navy">
|
||||||
|
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label={m.comment_panel_close()}
|
||||||
|
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comment thread -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
{#if activeAnnotationId}
|
||||||
|
{#key activeAnnotationId}
|
||||||
|
<CommentThread
|
||||||
|
documentId={documentId}
|
||||||
|
annotationId={activeAnnotationId}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
loadOnMount={true}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -48,8 +48,6 @@ type Props = {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
height: number;
|
height: number;
|
||||||
activeTab: Tab;
|
activeTab: Tab;
|
||||||
activeAnnotationId: string | null;
|
|
||||||
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -60,23 +58,25 @@ let {
|
|||||||
canAdmin,
|
canAdmin,
|
||||||
open = $bindable(),
|
open = $bindable(),
|
||||||
height = $bindable(),
|
height = $bindable(),
|
||||||
activeTab = $bindable(),
|
activeTab = $bindable()
|
||||||
activeAnnotationId,
|
|
||||||
onAnnotationCommentCountChange
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
const MIN_HEIGHT = 52; // drag handle (8px) + tab bar (~44px)
|
||||||
const DEFAULT_HEIGHT = 320;
|
|
||||||
|
|
||||||
let isDragging = $state(false);
|
let isDragging = $state(false);
|
||||||
let dragStartY = 0;
|
let dragStartY = 0;
|
||||||
let dragStartHeight = 0;
|
let dragStartHeight = 0;
|
||||||
|
|
||||||
|
function fullHeight() {
|
||||||
|
const topbar = document.querySelector('[data-topbar]');
|
||||||
|
return window.innerHeight - (topbar?.getBoundingClientRect().bottom ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
function openTab(tab: Tab) {
|
function openTab(tab: Tab) {
|
||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
if (!open) {
|
if (!open) {
|
||||||
open = true;
|
open = true;
|
||||||
if (height <= MIN_HEIGHT) height = DEFAULT_HEIGHT;
|
if (height <= MIN_HEIGHT) height = fullHeight();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,14 +95,14 @@ function onDragMove(e: PointerEvent) {
|
|||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
|
const delta = dragStartY - e.clientY; // positive = dragging up = bigger panel
|
||||||
const newHeight = dragStartHeight + delta;
|
const newHeight = dragStartHeight + delta;
|
||||||
const maxHeight = Math.floor(window.innerHeight * 0.8);
|
const maxHeight = fullHeight();
|
||||||
|
|
||||||
if (newHeight <= MIN_HEIGHT + 20) {
|
if (newHeight <= MIN_HEIGHT + 20) {
|
||||||
// collapsed past threshold → close
|
// collapsed past threshold → close
|
||||||
open = false;
|
open = false;
|
||||||
} else {
|
} else {
|
||||||
open = true;
|
open = true;
|
||||||
height = Math.max(DEFAULT_HEIGHT / 4, Math.min(newHeight, maxHeight));
|
height = Math.max(80, Math.min(newHeight, maxHeight));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,12 +181,10 @@ const panelHeight = $derived(open ? height : MIN_HEIGHT);
|
|||||||
{:else if activeTab === 'discussion'}
|
{:else if activeTab === 'discussion'}
|
||||||
<PanelDiscussion
|
<PanelDiscussion
|
||||||
documentId={doc.id}
|
documentId={doc.id}
|
||||||
activeAnnotationId={activeAnnotationId}
|
|
||||||
initialComments={comments}
|
initialComments={comments}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
onAnnotationCommentCountChange={onAnnotationCommentCountChange}
|
|
||||||
/>
|
/>
|
||||||
{:else if activeTab === 'history'}
|
{:else if activeTab === 'history'}
|
||||||
<PanelHistory documentId={doc.id} />
|
<PanelHistory documentId={doc.id} />
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const compactMeta = $derived.by(() => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
class="z-20 flex shrink-0 items-center justify-between border-b border-brand-sand bg-white px-6 py-3 shadow-sm"
|
class="z-20 flex shrink-0 items-center justify-between border-b border-brand-sand bg-white px-6 py-3 shadow-sm"
|
||||||
|
data-topbar
|
||||||
>
|
>
|
||||||
<!-- Left: back + title -->
|
<!-- Left: back + title -->
|
||||||
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
|
<div class="flex min-w-0 items-center gap-4 overflow-hidden">
|
||||||
@@ -100,10 +101,16 @@ const compactMeta = $derived.by(() => {
|
|||||||
<button
|
<button
|
||||||
onclick={() => (annotateMode = !annotateMode)}
|
onclick={() => (annotateMode = !annotateMode)}
|
||||||
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
aria-label={annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||||
class="rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
|
class="flex items-center gap-1.5 rounded px-3 py-1.5 font-sans text-xs font-medium transition {annotateMode
|
||||||
? 'bg-brand-navy text-white'
|
? 'bg-brand-navy text-white'
|
||||||
: 'border border-brand-navy text-brand-navy hover:bg-brand-navy hover:text-white'}"
|
: 'border border-brand-navy text-brand-navy hover:bg-brand-navy hover:text-white'}"
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Note/Note-Add-MD.svg"
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
class="h-4 w-4 {annotateMode ? 'invert' : ''}"
|
||||||
|
/>
|
||||||
{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
{annotateMode ? m.doc_panel_annotate_stop() : m.doc_panel_annotate()}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type Props = {
|
|||||||
error: string;
|
error: string;
|
||||||
annotateMode: boolean;
|
annotateMode: boolean;
|
||||||
activeAnnotationId: string | null;
|
activeAnnotationId: string | null;
|
||||||
|
activeAnnotationPage: number | null;
|
||||||
onAnnotationClick: (id: string) => void;
|
onAnnotationClick: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ let {
|
|||||||
error,
|
error,
|
||||||
annotateMode = $bindable(),
|
annotateMode = $bindable(),
|
||||||
activeAnnotationId = $bindable(),
|
activeAnnotationId = $bindable(),
|
||||||
|
activeAnnotationPage = $bindable(),
|
||||||
onAnnotationClick
|
onAnnotationClick
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
@@ -79,6 +81,7 @@ let {
|
|||||||
documentId={doc.id}
|
documentId={doc.id}
|
||||||
bind:annotateMode={annotateMode}
|
bind:annotateMode={annotateMode}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
|
bind:activeAnnotationPage={activeAnnotationPage}
|
||||||
onAnnotationClick={onAnnotationClick}
|
onAnnotationClick={onAnnotationClick}
|
||||||
/>
|
/>
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
|
||||||
import CommentThread from './CommentThread.svelte';
|
import CommentThread from './CommentThread.svelte';
|
||||||
|
|
||||||
type CommentReply = {
|
type CommentReply = {
|
||||||
@@ -23,63 +22,21 @@ type Comment = {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
activeAnnotationId: string | null;
|
|
||||||
initialComments: Comment[];
|
initialComments: Comment[];
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
canAdmin: boolean;
|
canAdmin: boolean;
|
||||||
onAnnotationCommentCountChange?: (annotationId: string, count: number) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let { documentId, initialComments, canComment, currentUserId, canAdmin }: Props = $props();
|
||||||
documentId,
|
|
||||||
activeAnnotationId,
|
|
||||||
initialComments,
|
|
||||||
canComment,
|
|
||||||
currentUserId,
|
|
||||||
canAdmin,
|
|
||||||
onAnnotationCommentCountChange
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-8 p-6">
|
<div class="flex-1 overflow-y-auto p-6">
|
||||||
<!-- Annotation thread (shown when an annotation is active) -->
|
<CommentThread
|
||||||
{#if activeAnnotationId}
|
documentId={documentId}
|
||||||
<div>
|
initialComments={initialComments}
|
||||||
<h4
|
canComment={canComment}
|
||||||
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
currentUserId={currentUserId}
|
||||||
>
|
canAdmin={canAdmin}
|
||||||
{m.doc_panel_annotation_thread_title()}
|
/>
|
||||||
</h4>
|
|
||||||
{#key activeAnnotationId}
|
|
||||||
<CommentThread
|
|
||||||
documentId={documentId}
|
|
||||||
annotationId={activeAnnotationId}
|
|
||||||
canComment={canComment}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
canAdmin={canAdmin}
|
|
||||||
loadOnMount={true}
|
|
||||||
onCountChange={(count) => onAnnotationCommentCountChange?.(activeAnnotationId, count)}
|
|
||||||
/>
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- General document discussion -->
|
|
||||||
<div>
|
|
||||||
{#if activeAnnotationId}
|
|
||||||
<h4
|
|
||||||
class="mb-3 border-b border-brand-sand pb-2 font-sans text-xs font-bold tracking-widest text-brand-navy uppercase"
|
|
||||||
>
|
|
||||||
{m.comment_section_title()}
|
|
||||||
</h4>
|
|
||||||
{/if}
|
|
||||||
<CommentThread
|
|
||||||
documentId={documentId}
|
|
||||||
initialComments={initialComments}
|
|
||||||
canComment={canComment}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
canAdmin={canAdmin}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -333,8 +333,79 @@ $effect(() => {
|
|||||||
{m.history_compare_apply()}
|
{m.history_compare_apply()}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Diff panel for compare mode -->
|
||||||
|
{#if diffLoading}
|
||||||
|
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||||
|
{:else if noDiff}
|
||||||
|
<div
|
||||||
|
data-testid="history-diff"
|
||||||
|
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
||||||
|
>
|
||||||
|
{m.history_diff_no_changes()}
|
||||||
|
</div>
|
||||||
|
{:else if diffEntries.length > 0}
|
||||||
|
<div
|
||||||
|
data-testid="history-diff"
|
||||||
|
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
|
||||||
|
>
|
||||||
|
{#each diffEntries as entry (entry.field)}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
||||||
|
>{entry.label}</span
|
||||||
|
>
|
||||||
|
{#if entry.kind === 'text'}
|
||||||
|
<p class="font-serif text-sm leading-relaxed">
|
||||||
|
{#each entry.parts as part, partIdx (partIdx)}
|
||||||
|
{#if part.added}
|
||||||
|
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||||
|
{:else if part.removed}
|
||||||
|
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||||
|
{:else}
|
||||||
|
<span>{part.value}</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{:else if entry.kind === 'scalar'}
|
||||||
|
<div class="flex items-center gap-2 font-serif text-sm">
|
||||||
|
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||||
|
<svg
|
||||||
|
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||||
|
</div>
|
||||||
|
{:else if entry.kind === 'relation'}
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each entry.removed as item (item)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||||
|
>{item}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#each entry.added as item (item)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||||
|
>{item}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Version list -->
|
<!-- Version list with inline diff below each selected item -->
|
||||||
<ul class="divide-y divide-brand-sand">
|
<ul class="divide-y divide-brand-sand">
|
||||||
{#each versions as v, i (v.id)}
|
{#each versions as v, i (v.id)}
|
||||||
<li>
|
<li>
|
||||||
@@ -367,80 +438,82 @@ $effect(() => {
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Diff shown inline below the selected version -->
|
||||||
|
{#if selectedVersionId === v.id}
|
||||||
|
{#if diffLoading}
|
||||||
|
<p class="pb-3 pl-2 font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
||||||
|
{:else if noDiff}
|
||||||
|
<div
|
||||||
|
data-testid="history-diff"
|
||||||
|
class="mb-2 rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
||||||
|
>
|
||||||
|
{m.history_diff_no_changes()}
|
||||||
|
</div>
|
||||||
|
{:else if diffEntries.length > 0}
|
||||||
|
<div
|
||||||
|
data-testid="history-diff"
|
||||||
|
class="mb-2 space-y-4 rounded-sm border border-brand-sand bg-white p-4"
|
||||||
|
>
|
||||||
|
{#each diffEntries as entry (entry.field)}
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
||||||
|
>{entry.label}</span
|
||||||
|
>
|
||||||
|
{#if entry.kind === 'text'}
|
||||||
|
<p class="font-serif text-sm leading-relaxed">
|
||||||
|
{#each entry.parts as part, partIdx (partIdx)}
|
||||||
|
{#if part.added}
|
||||||
|
<span class="bg-green-50 text-green-700">{part.value}</span>
|
||||||
|
{:else if part.removed}
|
||||||
|
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
||||||
|
{:else}
|
||||||
|
<span>{part.value}</span>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{:else if entry.kind === 'scalar'}
|
||||||
|
<div class="flex items-center gap-2 font-serif text-sm">
|
||||||
|
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
||||||
|
<svg
|
||||||
|
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-green-700">{entry.newVal || '—'}</span>
|
||||||
|
</div>
|
||||||
|
{:else if entry.kind === 'relation'}
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
{#each entry.removed as item (item)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
||||||
|
>{item}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
{#each entry.added as item (item)}
|
||||||
|
<span
|
||||||
|
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
||||||
|
>{item}</span
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Diff panel -->
|
|
||||||
{#if diffLoading}
|
|
||||||
<p class="font-sans text-xs text-gray-400">{m.history_loading()}</p>
|
|
||||||
{:else if noDiff}
|
|
||||||
<div
|
|
||||||
data-testid="history-diff"
|
|
||||||
class="rounded-sm border border-brand-sand bg-white p-4 font-serif text-sm text-gray-400 italic"
|
|
||||||
>
|
|
||||||
{m.history_diff_no_changes()}
|
|
||||||
</div>
|
|
||||||
{:else if diffEntries.length > 0}
|
|
||||||
<div
|
|
||||||
data-testid="history-diff"
|
|
||||||
class="space-y-4 rounded-sm border border-brand-sand bg-white p-4"
|
|
||||||
>
|
|
||||||
{#each diffEntries as entry (entry.field)}
|
|
||||||
<div>
|
|
||||||
<span
|
|
||||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-gray-400 uppercase"
|
|
||||||
>{entry.label}</span
|
|
||||||
>
|
|
||||||
{#if entry.kind === 'text'}
|
|
||||||
<p class="font-serif text-sm leading-relaxed">
|
|
||||||
{#each entry.parts as part, partIdx (partIdx)}
|
|
||||||
{#if part.added}
|
|
||||||
<span class="bg-green-50 text-green-700">{part.value}</span>
|
|
||||||
{:else if part.removed}
|
|
||||||
<span class="bg-red-50 text-red-600 line-through">{part.value}</span>
|
|
||||||
{:else}
|
|
||||||
<span>{part.value}</span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</p>
|
|
||||||
{:else if entry.kind === 'scalar'}
|
|
||||||
<div class="flex items-center gap-2 font-serif text-sm">
|
|
||||||
<span class="text-red-600 line-through">{entry.oldVal || '—'}</span>
|
|
||||||
<svg
|
|
||||||
class="h-3 w-3 flex-shrink-0 text-gray-400"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="currentColor"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-green-700">{entry.newVal || '—'}</span>
|
|
||||||
</div>
|
|
||||||
{:else if entry.kind === 'relation'}
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
{#each entry.removed as item (item)}
|
|
||||||
<span
|
|
||||||
class="rounded bg-red-50 px-1.5 py-0.5 font-sans text-[11px] text-red-600 line-through"
|
|
||||||
>{item}</span
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
{#each entry.added as item (item)}
|
|
||||||
<span
|
|
||||||
class="rounded bg-green-50 px-1.5 py-0.5 font-sans text-[11px] text-green-700"
|
|
||||||
>{item}</span
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,18 +3,21 @@ import { onMount } from 'svelte';
|
|||||||
import { SvelteMap } from 'svelte/reactivity';
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||||
import AnnotationLayer from './AnnotationLayer.svelte';
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
url,
|
url,
|
||||||
documentId = '',
|
documentId = '',
|
||||||
annotateMode = $bindable(false),
|
annotateMode = $bindable(false),
|
||||||
activeAnnotationId = $bindable<string | null>(null),
|
activeAnnotationId = $bindable<string | null>(null),
|
||||||
|
activeAnnotationPage = $bindable<number | null>(null),
|
||||||
onAnnotationClick
|
onAnnotationClick
|
||||||
}: {
|
}: {
|
||||||
url: string;
|
url: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
annotateMode?: boolean;
|
annotateMode?: boolean;
|
||||||
activeAnnotationId?: string | null;
|
activeAnnotationId?: string | null;
|
||||||
|
activeAnnotationPage?: number | null;
|
||||||
onAnnotationClick?: (id: string) => void;
|
onAnnotationClick?: (id: string) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -53,6 +56,7 @@ type Annotation = {
|
|||||||
let annotations = $state<Annotation[]>([]);
|
let annotations = $state<Annotation[]>([]);
|
||||||
let annotateColor = $state('#ffff00');
|
let annotateColor = $state('#ffff00');
|
||||||
let commentCounts = new SvelteMap<string, number>();
|
let commentCounts = new SvelteMap<string, number>();
|
||||||
|
let showAnnotations = $state(true);
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||||
@@ -213,6 +217,7 @@ async function handleAnnotationDraw(rect: { x: number; y: number; width: number;
|
|||||||
const created: Annotation = await res.json();
|
const created: Annotation = await res.json();
|
||||||
annotations = [...annotations, created];
|
annotations = [...annotations, created];
|
||||||
activeAnnotationId = created.id;
|
activeAnnotationId = created.id;
|
||||||
|
activeAnnotationPage = created.pageNumber;
|
||||||
onAnnotationClick?.(created.id);
|
onAnnotationClick?.(created.id);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -236,6 +241,8 @@ async function handleAnnotationDelete(annotationId: string) {
|
|||||||
|
|
||||||
function handleAnnotationClick(id: string) {
|
function handleAnnotationClick(id: string) {
|
||||||
activeAnnotationId = id;
|
activeAnnotationId = id;
|
||||||
|
const ann = annotations.find((a) => a.id === id);
|
||||||
|
activeAnnotationPage = ann?.pageNumber ?? null;
|
||||||
onAnnotationClick?.(id);
|
onAnnotationClick?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +403,44 @@ function zoomOut() {
|
|||||||
title="Farbe wählen"
|
title="Farbe wählen"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- Annotation visibility toggle (shown when annotations exist) -->
|
||||||
|
{#if annotations.length > 0}
|
||||||
|
<button
|
||||||
|
onclick={() => (showAnnotations = !showAnnotations)}
|
||||||
|
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
|
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
||||||
|
? 'text-gray-300 hover:bg-white/10'
|
||||||
|
: 'bg-white/10 text-brand-mint'}"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 shrink-0"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
{#if showAnnotations}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</svg>
|
||||||
|
{showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PDF canvas area -->
|
<!-- PDF canvas area -->
|
||||||
@@ -419,15 +464,17 @@ function zoomOut() {
|
|||||||
class="textLayer"
|
class="textLayer"
|
||||||
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
||||||
></div>
|
></div>
|
||||||
<AnnotationLayer
|
{#if showAnnotations}
|
||||||
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
|
<AnnotationLayer
|
||||||
canAnnotate={annotateMode}
|
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
|
||||||
color={annotateColor}
|
canAnnotate={annotateMode}
|
||||||
onDraw={handleAnnotationDraw}
|
color={annotateColor}
|
||||||
onDelete={handleAnnotationDelete}
|
onDraw={handleAnnotationDraw}
|
||||||
commentCounts={Object.fromEntries(commentCounts)}
|
onDelete={handleAnnotationDelete}
|
||||||
onAnnotationClick={handleAnnotationClick}
|
commentCounts={Object.fromEntries(commentCounts)}
|
||||||
/>
|
onAnnotationClick={handleAnnotationClick}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { onMount } from 'svelte';
|
|||||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
||||||
|
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
||||||
|
|
||||||
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
type Tab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||||
|
|
||||||
@@ -56,53 +57,59 @@ async function loadFile(id: string) {
|
|||||||
|
|
||||||
let annotateMode = $state(false);
|
let annotateMode = $state(false);
|
||||||
let activeAnnotationId = $state<string | null>(null);
|
let activeAnnotationId = $state<string | null>(null);
|
||||||
|
let activeAnnotationPage = $state<number | null>(null);
|
||||||
|
|
||||||
// When an annotation is clicked, open the Diskussion tab.
|
// Close the panel when entering annotate mode so the PDF is fully visible.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (activeAnnotationId) {
|
if (annotateMode) panelOpen = false;
|
||||||
activeTab = 'discussion';
|
|
||||||
panelOpen = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Bottom panel state ────────────────────────────────────────────────────────
|
// ── Bottom panel state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const LS_KEY_OPEN = 'doc-panel-open';
|
|
||||||
const LS_KEY_HEIGHT = 'doc-panel-height';
|
const LS_KEY_HEIGHT = 'doc-panel-height';
|
||||||
const LS_KEY_TAB = 'doc-panel-tab';
|
const LS_KEY_TAB = 'doc-panel-tab';
|
||||||
|
|
||||||
let panelOpen = $state(false);
|
let panelOpen = $state(false);
|
||||||
let panelHeight = $state(320);
|
let panelHeight = $state(0); // set to full height on mount
|
||||||
|
let navHeight = $state(0);
|
||||||
let activeTab = $state<Tab>('metadata');
|
let activeTab = $state<Tab>('metadata');
|
||||||
let localStorageRestored = $state(false);
|
let localStorageRestored = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const savedOpen = localStorage.getItem(LS_KEY_OPEN);
|
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||||
|
|
||||||
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
|
const savedHeight = localStorage.getItem(LS_KEY_HEIGHT);
|
||||||
const savedTab = localStorage.getItem(LS_KEY_TAB);
|
const savedTab = localStorage.getItem(LS_KEY_TAB);
|
||||||
|
|
||||||
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
|
if (savedTab && ['metadata', 'transcription', 'discussion', 'history'].includes(savedTab)) {
|
||||||
activeTab = savedTab as Tab;
|
activeTab = savedTab as Tab;
|
||||||
}
|
}
|
||||||
|
const topbar = document.querySelector('[data-topbar]');
|
||||||
|
panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0);
|
||||||
if (savedHeight) {
|
if (savedHeight) {
|
||||||
const h = parseInt(savedHeight, 10);
|
const h = parseInt(savedHeight, 10);
|
||||||
if (!isNaN(h) && h >= 80) panelHeight = h;
|
if (!isNaN(h) && h >= 80) panelHeight = h;
|
||||||
}
|
}
|
||||||
if (savedOpen !== null) {
|
|
||||||
panelOpen = savedOpen === 'true';
|
|
||||||
} else if (!doc.filePath) {
|
|
||||||
// No previous state and no file → open to Metadaten by default
|
|
||||||
panelOpen = true;
|
|
||||||
activeTab = 'metadata';
|
|
||||||
}
|
|
||||||
|
|
||||||
localStorageRestored = true;
|
localStorageRestored = true;
|
||||||
|
|
||||||
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (activeAnnotationId) {
|
||||||
|
activeAnnotationId = null;
|
||||||
|
activeAnnotationPage = null;
|
||||||
|
} else if (panelOpen) {
|
||||||
|
panelOpen = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', onKeyDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist panel state whenever it changes (after initial restore).
|
// Persist panel state whenever it changes (after initial restore).
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!localStorageRestored) return;
|
if (!localStorageRestored) return;
|
||||||
localStorage.setItem(LS_KEY_OPEN, String(panelOpen));
|
|
||||||
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
|
localStorage.setItem(LS_KEY_HEIGHT, String(panelHeight));
|
||||||
localStorage.setItem(LS_KEY_TAB, activeTab);
|
localStorage.setItem(LS_KEY_TAB, activeTab);
|
||||||
});
|
});
|
||||||
@@ -112,7 +119,11 @@ $effect(() => {
|
|||||||
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
|
<title>{doc.title || doc.originalFilename || 'Dokument'}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="flex h-screen flex-col overflow-hidden bg-white" data-hydrated>
|
<div
|
||||||
|
class="fixed right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden bg-white"
|
||||||
|
style="top: {navHeight}px"
|
||||||
|
data-hydrated
|
||||||
|
>
|
||||||
<DocumentTopBar
|
<DocumentTopBar
|
||||||
doc={doc}
|
doc={doc}
|
||||||
canWrite={data.canWrite ?? false}
|
canWrite={data.canWrite ?? false}
|
||||||
@@ -129,21 +140,33 @@ $effect(() => {
|
|||||||
error={fileError}
|
error={fileError}
|
||||||
bind:annotateMode={annotateMode}
|
bind:annotateMode={annotateMode}
|
||||||
bind:activeAnnotationId={activeAnnotationId}
|
bind:activeAnnotationId={activeAnnotationId}
|
||||||
|
bind:activeAnnotationPage={activeAnnotationPage}
|
||||||
onAnnotationClick={(id) => {
|
onAnnotationClick={(id) => {
|
||||||
activeAnnotationId = id;
|
activeAnnotationId = id;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<AnnotationSidePanel
|
||||||
|
documentId={doc.id}
|
||||||
|
activeAnnotationId={activeAnnotationId}
|
||||||
|
activeAnnotationPage={activeAnnotationPage}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
onClose={() => {
|
||||||
|
activeAnnotationId = null;
|
||||||
|
activeAnnotationPage = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<DocumentBottomPanel
|
<DocumentBottomPanel
|
||||||
doc={doc}
|
doc={doc}
|
||||||
comments={(data.comments ?? []) as never[]}
|
comments={(data.comments ?? []) as never[]}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
canAdmin={canAdmin}
|
canAdmin={canAdmin}
|
||||||
bind:open={panelOpen}
|
bind:open={panelOpen}
|
||||||
bind:height={panelHeight}
|
bind:height={panelHeight}
|
||||||
bind:activeTab={activeTab}
|
bind:activeTab={activeTab}
|
||||||
activeAnnotationId={activeAnnotationId}
|
/>
|
||||||
/>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user