refactor(comments): flat compact comment thread matching spec design
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
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
Rework CommentThread.svelte to match the annotation-transcription spec: - Flat message list (no nested reply threading) - Compact inline style: orange left border, tinted background - Chat bubble icon (💬) with comment count header - Avatar circles with author initials - Quoted text extracted and rendered as italic left-bordered snippet - Simple MentionEditor input at bottom (keeps @mention support) - Removed: reply-to-specific threading, edit/delete buttons, nesting Remove dead components no longer used after annotate mode removal: - AnnotationCommentPanel, AnnotationSidePanel, AnnotateHintStrip - PanelDiscussion, PanelHistory, PanelMetadata, PanelTranscription - Associated spec files Simplify prop chain: remove currentUserId, canAdmin, targetCommentId from CommentThread, TranscriptionBlock, TranscriptionEditView. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Props = {
|
||||
annotateMode: boolean;
|
||||
};
|
||||
|
||||
let { annotateMode }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if annotateMode}
|
||||
<div
|
||||
data-testid="annotate-hint-strip"
|
||||
class="hidden h-[29px] items-center gap-2 border-t border-dashed px-3.5 md:flex"
|
||||
style="background: rgba(1,40,81,0.05); border-color: rgba(1,40,81,0.20)"
|
||||
>
|
||||
<span class="text-[16px] font-bold tracking-wide text-primary uppercase"
|
||||
>{m.doc_panel_annotate()}</span
|
||||
>
|
||||
<span class="text-[16px] text-ink-2">{m.doc_panel_annotate_hint()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('AnnotateHintStrip', () => {
|
||||
it('is absent from the DOM when annotateMode is false', async () => {
|
||||
render(AnnotateHintStrip, { annotateMode: false });
|
||||
const strip = page.getByTestId('annotate-hint-strip');
|
||||
await expect.element(strip).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is present in the DOM when annotateMode is true', async () => {
|
||||
render(AnnotateHintStrip, { annotateMode: true });
|
||||
const strip = page.getByTestId('annotate-hint-strip');
|
||||
await expect.element(strip).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has hidden md:flex class to hide below 768px', async () => {
|
||||
render(AnnotateHintStrip, { annotateMode: true });
|
||||
const strip = page.getByTestId('annotate-hint-strip');
|
||||
await expect.element(strip).toHaveClass('hidden');
|
||||
await expect.element(strip).toHaveClass('md:flex');
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
annotationId: string;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
onClose: () => void;
|
||||
onCountChange?: (count: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
annotationId,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
onClose,
|
||||
onCountChange
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
|
||||
<div
|
||||
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-line bg-surface shadow-2xl sm:flex"
|
||||
>
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.comment_panel_title()}
|
||||
</h3>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<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>
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
annotationId={annotationId}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
loadOnMount={true}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile modal (< sm): fixed full-screen with slide-up sheet -->
|
||||
<div class="fixed inset-0 z-50 flex flex-col sm:hidden">
|
||||
<!-- Semi-transparent backdrop -->
|
||||
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
|
||||
|
||||
<!-- Slide-up panel -->
|
||||
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-surface shadow-2xl">
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-line px-4 py-3">
|
||||
<h3 class="font-sans text-xs font-bold tracking-widest text-ink uppercase">
|
||||
{m.comment_panel_title()}
|
||||
</h3>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<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>
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
annotationId={annotationId}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
loadOnMount={true}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,68 +0,0 @@
|
||||
<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;
|
||||
targetCommentId?: string | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
activeAnnotationId,
|
||||
activeAnnotationPage,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
targetCommentId = null,
|
||||
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-line bg-surface 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-line px-4 py-3">
|
||||
<span class="font-sans text-xs font-medium text-ink">
|
||||
{m.doc_panel_discussion_annotation_tab({ page: String(activeAnnotationPage ?? '?') })}
|
||||
</span>
|
||||
<button
|
||||
onclick={onClose}
|
||||
aria-label={m.comment_panel_close()}
|
||||
class="rounded p-1 text-ink-3 transition-colors hover:bg-muted hover:text-ink"
|
||||
>
|
||||
<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}
|
||||
targetCommentId={targetCommentId}
|
||||
loadOnMount={true}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,76 +0,0 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import AnnotationSidePanel from './AnnotationSidePanel.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => []
|
||||
})
|
||||
);
|
||||
|
||||
const baseProps = {
|
||||
documentId: 'doc-1',
|
||||
activeAnnotationPage: 1,
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
canAdmin: false,
|
||||
onClose: vi.fn()
|
||||
};
|
||||
|
||||
describe('AnnotationSidePanel – visibility', () => {
|
||||
it('is hidden (translated off-screen) when activeAnnotationId is null', async () => {
|
||||
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: null });
|
||||
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||
expect(panel?.classList.contains('translate-x-full')).toBe(true);
|
||||
expect(panel?.classList.contains('translate-x-0')).toBe(false);
|
||||
});
|
||||
|
||||
it('is visible when activeAnnotationId is set', async () => {
|
||||
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1' });
|
||||
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||
expect(panel?.classList.contains('translate-x-0')).toBe(true);
|
||||
expect(panel?.classList.contains('translate-x-full')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnnotationSidePanel – close button', () => {
|
||||
it('calls onClose when the close button is clicked', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(AnnotationSidePanel, { ...baseProps, activeAnnotationId: 'ann-1', onClose });
|
||||
await page.getByRole('button', { name: /schließen/i }).click();
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AnnotationSidePanel – targetCommentId forwarding', () => {
|
||||
it('renders CommentThread when annotation is active', async () => {
|
||||
render(AnnotationSidePanel, {
|
||||
...baseProps,
|
||||
activeAnnotationId: 'ann-1',
|
||||
targetCommentId: 'comment-42'
|
||||
});
|
||||
// CommentThread renders inside the panel when activeAnnotationId is set
|
||||
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel?.classList.contains('translate-x-0')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not render CommentThread when annotation is null', async () => {
|
||||
render(AnnotationSidePanel, {
|
||||
...baseProps,
|
||||
activeAnnotationId: null,
|
||||
targetCommentId: 'comment-42'
|
||||
});
|
||||
// Panel is hidden and no fetch should have been triggered for comments
|
||||
const panel = document.querySelector('[data-testid="annotation-side-panel"]');
|
||||
expect(panel?.classList.contains('translate-x-full')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick, untrack } from 'svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { Comment, CommentReply } from '$lib/types';
|
||||
import type { Comment } from '$lib/types';
|
||||
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
||||
import { renderBody, extractContent } from '$lib/utils/mention';
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
@@ -13,9 +13,6 @@ type Props = {
|
||||
initialComments?: Comment[];
|
||||
loadOnMount?: boolean;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
targetCommentId?: string | null;
|
||||
quotedText?: string | null;
|
||||
showCompose?: boolean;
|
||||
onCountChange?: (count: number) => void;
|
||||
@@ -28,25 +25,25 @@ let {
|
||||
initialComments = [],
|
||||
loadOnMount = false,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
targetCommentId = null,
|
||||
quotedText = null,
|
||||
showCompose = true,
|
||||
onCountChange
|
||||
}: Props = $props();
|
||||
|
||||
type FlatMessage = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
mentionDTOs?: MentionDTO[];
|
||||
};
|
||||
|
||||
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||
let highlightedCommentId: string | null = $state(untrack(() => targetCommentId ?? null));
|
||||
let newText: string = $state('');
|
||||
let replyingTo: string | null = $state(null);
|
||||
let replyText: string = $state('');
|
||||
let editingId: string | null = $state(null);
|
||||
let editText: string = $state('');
|
||||
let posting: boolean = $state(false);
|
||||
let newMentionCandidates: MentionDTO[] = $state([]);
|
||||
let replyMentionCandidates: MentionDTO[] = $state([]);
|
||||
let editMentionCandidates: MentionDTO[] = $state([]);
|
||||
|
||||
const commentsBase = $derived(
|
||||
blockId
|
||||
@@ -56,7 +53,10 @@ const commentsBase = $derived(
|
||||
: `/api/documents/${documentId}/comments`
|
||||
);
|
||||
|
||||
// Pre-fill comment box with quoted text when selection changes
|
||||
const flatMessages = $derived(
|
||||
comments.flatMap((thread) => [thread as FlatMessage, ...(thread.replies as FlatMessage[])])
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (quotedText && quotedText.trim()) {
|
||||
newText = `> "${quotedText}"\n\n`;
|
||||
@@ -78,8 +78,18 @@ function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
|
||||
return c.updatedAt > c.createdAt;
|
||||
}
|
||||
|
||||
function canModify(c: { authorId: string | null }): boolean {
|
||||
return (currentUserId != null && c.authorId === currentUserId) || canAdmin;
|
||||
function getInitials(name: string): string {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((w) => w.charAt(0).toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
function extractQuote(content: string): { quote: string | null; body: string } {
|
||||
const match = content.match(/^>\s*"(.+?)"\s*\n\n?([\s\S]*)$/);
|
||||
if (match) return { quote: match[1], body: match[2] };
|
||||
return { quote: null, body: content };
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
@@ -116,270 +126,94 @@ async function postComment() {
|
||||
}
|
||||
}
|
||||
|
||||
async function postReply(threadId: string) {
|
||||
const text = replyText.trim();
|
||||
if (!text || posting) return;
|
||||
posting = true;
|
||||
try {
|
||||
const { content, mentionedUserIds } = extractContent(text, replyMentionCandidates);
|
||||
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, mentionedUserIds })
|
||||
});
|
||||
if (res.ok) {
|
||||
replyText = '';
|
||||
replyMentionCandidates = [];
|
||||
replyingTo = null;
|
||||
await reload();
|
||||
}
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdit(commentId: string) {
|
||||
const text = editText.trim();
|
||||
if (!text || posting) return;
|
||||
posting = true;
|
||||
try {
|
||||
const { content, mentionedUserIds } = extractContent(text, editMentionCandidates);
|
||||
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, mentionedUserIds })
|
||||
});
|
||||
if (res.ok) {
|
||||
editingId = null;
|
||||
editMentionCandidates = [];
|
||||
await reload();
|
||||
}
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(commentId: string) {
|
||||
if (posting) return;
|
||||
posting = true;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
await reload();
|
||||
}
|
||||
} finally {
|
||||
posting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(comment: Comment | CommentReply) {
|
||||
editingId = comment.id;
|
||||
editText = comment.content;
|
||||
editMentionCandidates = [];
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
editText = '';
|
||||
}
|
||||
|
||||
function startReply(threadId: string) {
|
||||
replyingTo = threadId;
|
||||
replyText = '';
|
||||
}
|
||||
|
||||
function cancelReply() {
|
||||
replyingTo = null;
|
||||
replyText = '';
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
onMount(() => {
|
||||
if (loadOnMount) {
|
||||
reload();
|
||||
} else {
|
||||
const total = initialComments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
||||
onCountChange?.(total);
|
||||
}
|
||||
|
||||
if (targetCommentId) {
|
||||
await tick();
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
|
||||
// Remove highlight on first user interaction
|
||||
const clearHighlight = () => {
|
||||
highlightedCommentId = null;
|
||||
document.removeEventListener('click', clearHighlight, true);
|
||||
document.removeEventListener('keydown', clearHighlight, true);
|
||||
document.removeEventListener('scroll', clearHighlight, true);
|
||||
};
|
||||
document.addEventListener('click', clearHighlight, true);
|
||||
document.addEventListener('keydown', clearHighlight, true);
|
||||
document.addEventListener('scroll', clearHighlight, true);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Renders a single comment or reply entry.
|
||||
showReplyButton: whether the "Reply" button appears (only on last item in a thread).
|
||||
-->
|
||||
{#snippet commentEntry(comment: Comment | CommentReply, threadId: string, showReplyButton: boolean)}
|
||||
{#if editingId === comment.id}
|
||||
<div class="flex flex-col gap-2">
|
||||
<MentionEditor
|
||||
bind:value={editText}
|
||||
bind:mentionCandidates={editMentionCandidates}
|
||||
rows={3}
|
||||
disabled={posting}
|
||||
onsubmit={() => saveEdit(comment.id)}
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => saveEdit(comment.id)}
|
||||
>
|
||||
{m.btn_save()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={cancelEdit}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-sans text-xs font-semibold text-ink">{comment.authorName}</span>
|
||||
{#if wasEdited(comment)}
|
||||
<span class="font-sans text-xs text-ink-3"
|
||||
>{timeAgo(comment.updatedAt)} {m.comment_edited_label()}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="font-sans text-xs text-ink-3">{timeAgo(comment.createdAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="mt-1 font-serif text-sm leading-relaxed text-ink-2">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
||||
{@html renderBody(comment.content, comment.mentionDTOs ?? [])}
|
||||
</p>
|
||||
</div>
|
||||
{#if canModify(comment)}
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => startEdit(comment)}
|
||||
>
|
||||
{m.btn_edit()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={() => deleteComment(comment.id)}
|
||||
>
|
||||
{m.btn_delete()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showReplyButton && canComment}
|
||||
<div class="mt-1">
|
||||
<button
|
||||
class="font-sans text-xs font-medium text-primary transition-colors hover:text-ink-2"
|
||||
onclick={() => startReply(threadId)}
|
||||
>
|
||||
{m.comment_btn_reply()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each comments as thread, ti (thread.id)}
|
||||
<div class={ti > 0 ? 'border-t border-line pt-4' : ''}>
|
||||
<!-- Root comment -->
|
||||
<div
|
||||
data-comment-id={thread.id}
|
||||
class={highlightedCommentId === thread.id
|
||||
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
|
||||
: ''}
|
||||
{#if flatMessages.length > 0}
|
||||
<div
|
||||
class="rounded border-l-2 border-orange-400 bg-orange-50 p-2 dark:border-orange-600 dark:bg-orange-950/20"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex items-center gap-1.5 font-sans text-xs font-semibold text-orange-700 dark:text-orange-400"
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
{@render commentEntry(thread, thread.id, thread.replies.length === 0)}
|
||||
</div>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
|
||||
/>
|
||||
</svg>
|
||||
{flatMessages.length}
|
||||
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
|
||||
</div>
|
||||
|
||||
<!-- Replies -->
|
||||
{#each thread.replies as reply, ri (reply.id)}
|
||||
<div
|
||||
data-comment-id={reply.id}
|
||||
class="mt-3 ml-6 border-l-2 border-line pl-4 {highlightedCommentId === reply.id
|
||||
? 'rounded outline-2 outline-offset-1 outline-accent transition-shadow outline-dotted'
|
||||
: ''}"
|
||||
>
|
||||
{@render commentEntry(reply, thread.id, ri === thread.replies.length - 1)}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<!-- Reply compose box -->
|
||||
{#if replyingTo === thread.id}
|
||||
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||
<MentionEditor
|
||||
bind:value={replyText}
|
||||
bind:mentionCandidates={replyMentionCandidates}
|
||||
rows={3}
|
||||
placeholder={m.comment_placeholder()}
|
||||
disabled={posting}
|
||||
onsubmit={() => postReply(thread.id)}
|
||||
/>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting}
|
||||
onclick={() => postReply(thread.id)}
|
||||
>
|
||||
{m.comment_btn_post()}
|
||||
</button>
|
||||
<button
|
||||
class="font-sans text-xs text-ink-3 transition-colors hover:text-ink"
|
||||
onclick={cancelReply}
|
||||
>
|
||||
{m.btn_cancel()}
|
||||
</button>
|
||||
<div class="space-y-2">
|
||||
{#each flatMessages as msg (msg.id)}
|
||||
{@const parsed = extractQuote(msg.content)}
|
||||
<div class="flex gap-2">
|
||||
<div
|
||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
|
||||
>
|
||||
{getInitials(msg.authorName)}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-sans text-xs font-semibold text-ink">{msg.authorName}</span>
|
||||
{#if wasEdited(msg)}
|
||||
<span class="font-sans text-[10px] text-ink-3"
|
||||
>{timeAgo(msg.updatedAt)} {m.comment_edited_label()}</span
|
||||
>
|
||||
{:else}
|
||||
<span class="font-sans text-[10px] text-ink-3">{timeAgo(msg.createdAt)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if parsed.quote}
|
||||
<div class="my-1 border-l-2 border-line pl-2 font-serif text-xs text-ink-3 italic">
|
||||
“{parsed.quote}”
|
||||
</div>
|
||||
{/if}
|
||||
<p class="font-serif text-xs leading-relaxed text-ink-2">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
||||
{@html renderBody(parsed.body, msg.mentionDTOs ?? [])}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- New top-level comment -->
|
||||
{#if canComment && showCompose}
|
||||
<div class={comments.length > 0 ? 'border-t border-line pt-4' : ''}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<MentionEditor
|
||||
bind:value={newText}
|
||||
bind:mentionCandidates={newMentionCandidates}
|
||||
rows={3}
|
||||
placeholder={m.comment_placeholder()}
|
||||
disabled={posting}
|
||||
onsubmit={postComment}
|
||||
/>
|
||||
<div>
|
||||
<button
|
||||
class="rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting || !newText.trim()}
|
||||
onclick={postComment}
|
||||
>
|
||||
{m.comment_btn_post()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if canComment && showCompose}
|
||||
<div class="mt-2 flex gap-2">
|
||||
<div class="flex-1">
|
||||
<MentionEditor
|
||||
bind:value={newText}
|
||||
bind:mentionCandidates={newMentionCandidates}
|
||||
rows={1}
|
||||
placeholder={m.comment_placeholder()}
|
||||
disabled={posting}
|
||||
onsubmit={postComment}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<button
|
||||
class="self-end rounded bg-primary px-2.5 py-1.5 font-sans text-xs font-medium text-primary-fg hover:bg-primary/80 disabled:opacity-40"
|
||||
disabled={posting || !newText.trim()}
|
||||
onclick={postComment}
|
||||
>
|
||||
{m.comment_btn_post()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
initialComments: Comment[];
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
canAdmin: boolean;
|
||||
targetCommentId?: string | null;
|
||||
onCountChange?: (count: number) => void;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
initialComments,
|
||||
canComment,
|
||||
currentUserId,
|
||||
canAdmin,
|
||||
targetCommentId = null,
|
||||
onCountChange
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<CommentThread
|
||||
documentId={documentId}
|
||||
initialComments={initialComments}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={canAdmin}
|
||||
targetCommentId={targetCommentId}
|
||||
onCountChange={onCountChange}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,519 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { diffWords } from 'diff';
|
||||
|
||||
let { documentId }: { documentId: string } = $props();
|
||||
|
||||
type VersionSummary = {
|
||||
id: string;
|
||||
savedAt: string;
|
||||
editorName: string;
|
||||
changedFields: string[];
|
||||
};
|
||||
|
||||
type SnapshotDoc = {
|
||||
title?: string;
|
||||
documentDate?: string;
|
||||
location?: string;
|
||||
documentLocation?: string;
|
||||
transcription?: string;
|
||||
summary?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string } | null;
|
||||
receivers?: { id: string; firstName: string; lastName: string }[];
|
||||
tags?: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
type DiffEntry =
|
||||
| {
|
||||
kind: 'text';
|
||||
field: string;
|
||||
label: string;
|
||||
parts: { value: string; added?: boolean; removed?: boolean }[];
|
||||
}
|
||||
| { kind: 'scalar'; field: string; label: string; oldVal: string; newVal: string }
|
||||
| { kind: 'relation'; field: string; label: string; removed: string[]; added: string[] };
|
||||
|
||||
let historyLoaded = $state(false);
|
||||
let historyLoading = $state(false);
|
||||
let versions = $state<VersionSummary[]>([]);
|
||||
|
||||
let compareMode = $state(false);
|
||||
let compareA = $state('');
|
||||
let compareB = $state('');
|
||||
|
||||
let selectedVersionId = $state<string | null>(null);
|
||||
let diffEntries = $state<DiffEntry[]>([]);
|
||||
let diffLoading = $state(false);
|
||||
let noDiff = $state(false);
|
||||
|
||||
const fieldLabels: Record<string, () => string> = {
|
||||
title: m.history_field_title,
|
||||
documentDate: m.history_field_document_date,
|
||||
location: m.history_field_location,
|
||||
documentLocation: m.history_field_document_location,
|
||||
transcription: m.history_field_transcription,
|
||||
summary: m.history_field_summary,
|
||||
sender: m.history_field_sender,
|
||||
receivers: m.history_field_receivers,
|
||||
tags: m.history_field_tags
|
||||
};
|
||||
|
||||
const TEXT_FIELDS = ['title', 'summary', 'transcription'] as const;
|
||||
const SCALAR_FIELDS = ['documentDate', 'location', 'documentLocation'] as const;
|
||||
|
||||
function parseSnapshot(raw: string): SnapshotDoc {
|
||||
try {
|
||||
return JSON.parse(raw) as SnapshotDoc;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function personLabel(p: { firstName: string; lastName: string }): string {
|
||||
return `${p.firstName} ${p.lastName}`.trim();
|
||||
}
|
||||
|
||||
const DIFF_CONTEXT_WORDS = 4;
|
||||
|
||||
type DiffPart = { value: string; added?: boolean; removed?: boolean };
|
||||
|
||||
function trimContextParts(parts: DiffPart[]): DiffPart[] {
|
||||
return parts.flatMap((part, i) => {
|
||||
if (part.added || part.removed) return [part];
|
||||
const tokens = part.value.split(/(\s+)/).filter(Boolean);
|
||||
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
|
||||
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
|
||||
|
||||
function keepFirst(n: number): string {
|
||||
let count = 0;
|
||||
const out: string[] = [];
|
||||
for (const t of tokens) {
|
||||
out.push(t);
|
||||
if (/\S/.test(t) && ++count >= n) break;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
function keepLast(n: number): string {
|
||||
let count = 0;
|
||||
const out: string[] = [];
|
||||
for (const t of [...tokens].reverse()) {
|
||||
out.unshift(t);
|
||||
if (/\S/.test(t) && ++count >= n) break;
|
||||
}
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === parts.length - 1;
|
||||
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
|
||||
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||
});
|
||||
}
|
||||
|
||||
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
||||
const entries: DiffEntry[] = [];
|
||||
|
||||
for (const field of TEXT_FIELDS) {
|
||||
const a = older?.[field] ?? '';
|
||||
const b = newer[field] ?? '';
|
||||
if (a === b) continue;
|
||||
const parts = trimContextParts(diffWords(a, b));
|
||||
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
||||
}
|
||||
|
||||
for (const field of SCALAR_FIELDS) {
|
||||
const a = older?.[field] ?? '';
|
||||
const b = newer[field] ?? '';
|
||||
if (a === b) continue;
|
||||
entries.push({ kind: 'scalar', field, label: fieldLabels[field](), oldVal: a, newVal: b });
|
||||
}
|
||||
|
||||
const senderA = older?.sender ? personLabel(older.sender) : '';
|
||||
const senderB = newer.sender ? personLabel(newer.sender) : '';
|
||||
if (senderA !== senderB) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'sender',
|
||||
label: fieldLabels['sender'](),
|
||||
removed: senderA ? [senderA] : [],
|
||||
added: senderB ? [senderB] : []
|
||||
});
|
||||
}
|
||||
|
||||
const receiversA = new Set((older?.receivers ?? []).map(personLabel));
|
||||
const receiversB = new Set((newer.receivers ?? []).map(personLabel));
|
||||
const removedReceivers = [...receiversA].filter((r) => !receiversB.has(r));
|
||||
const addedReceivers = [...receiversB].filter((r) => !receiversA.has(r));
|
||||
if (removedReceivers.length > 0 || addedReceivers.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'receivers',
|
||||
label: fieldLabels['receivers'](),
|
||||
removed: removedReceivers,
|
||||
added: addedReceivers
|
||||
});
|
||||
}
|
||||
|
||||
const tagsA = new Set((older?.tags ?? []).map((t) => t.name));
|
||||
const tagsB = new Set((newer.tags ?? []).map((t) => t.name));
|
||||
const removedTags = [...tagsA].filter((t) => !tagsB.has(t));
|
||||
const addedTags = [...tagsB].filter((t) => !tagsA.has(t));
|
||||
if (removedTags.length > 0 || addedTags.length > 0) {
|
||||
entries.push({
|
||||
kind: 'relation',
|
||||
field: 'tags',
|
||||
label: fieldLabels['tags'](),
|
||||
removed: removedTags,
|
||||
added: addedTags
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function fetchSnapshot(versionId: string): Promise<SnapshotDoc> {
|
||||
const res = await fetch(`/api/documents/${documentId}/versions/${versionId}`);
|
||||
if (!res.ok) throw new Error('Failed to fetch version');
|
||||
const v = await res.json();
|
||||
return parseSnapshot(v.snapshot);
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
if (historyLoaded) return;
|
||||
historyLoading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/versions`);
|
||||
if (res.ok) {
|
||||
versions = await res.json();
|
||||
}
|
||||
historyLoaded = true;
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
historyLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectVersion(versionId: string) {
|
||||
if (selectedVersionId === versionId) {
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
return;
|
||||
}
|
||||
selectedVersionId = versionId;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const idx = versions.findIndex((v) => v.id === versionId);
|
||||
const newerSnap = await fetchSnapshot(versionId);
|
||||
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
|
||||
const entries = buildDiff(olderSnap, newerSnap);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyCompare() {
|
||||
if (!compareA || !compareB || compareA === compareB) return;
|
||||
selectedVersionId = null;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
diffLoading = true;
|
||||
try {
|
||||
const [snapA, snapB] = await Promise.all([fetchSnapshot(compareA), fetchSnapshot(compareB)]);
|
||||
const entries = buildDiff(snapA, snapB);
|
||||
if (entries.length === 0) {
|
||||
noDiff = true;
|
||||
} else {
|
||||
diffEntries = entries;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
diffLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function versionLabel(v: VersionSummary, index: number): string {
|
||||
return `Version ${index + 1} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
||||
}
|
||||
|
||||
// Load history when this panel mounts.
|
||||
$effect(() => {
|
||||
loadHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-4 p-6">
|
||||
{#if historyLoading}
|
||||
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
|
||||
{:else if !historyLoaded}
|
||||
<!-- initial state before effect runs — show nothing -->
|
||||
{:else if versions.length === 0}
|
||||
<p class="font-serif text-sm text-ink-3 italic">{m.history_empty()}</p>
|
||||
{:else}
|
||||
<!-- Compare mode toggle -->
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onclick={() => {
|
||||
compareMode = !compareMode;
|
||||
diffEntries = [];
|
||||
noDiff = false;
|
||||
selectedVersionId = null;
|
||||
}}
|
||||
class="font-sans text-xs font-medium transition {compareMode
|
||||
? 'text-ink underline'
|
||||
: 'text-ink-3 hover:text-ink'}"
|
||||
>
|
||||
{m.history_compare_mode()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if compareMode}
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<label for="compare-a" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
|
||||
>{m.history_compare_select_a()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-a"
|
||||
bind:value={compareA}
|
||||
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="compare-b" class="mb-1 block font-sans text-[10px] text-ink-3 uppercase"
|
||||
>{m.history_compare_select_b()}</label
|
||||
>
|
||||
<select
|
||||
id="compare-b"
|
||||
bind:value={compareB}
|
||||
class="w-full rounded border border-line bg-surface px-2 py-1 font-sans text-xs text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{#each versions as v, i (v.id)}
|
||||
<option value={v.id}>{versionLabel(v, i)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onclick={applyCompare}
|
||||
disabled={!compareA || !compareB || compareA === compareB}
|
||||
class="w-full rounded bg-primary px-3 py-1.5 font-sans text-xs font-medium text-primary-fg transition hover:bg-primary/80 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{m.history_compare_apply()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Diff panel for compare mode -->
|
||||
{#if diffLoading}
|
||||
<p class="font-sans text-xs text-ink-3">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 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-line bg-surface p-4"
|
||||
>
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 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-ink-3"
|
||||
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}
|
||||
<!-- Version list with inline diff below each selected item -->
|
||||
<ul class="divide-y divide-line">
|
||||
{#each versions as v, i (v.id)}
|
||||
<li>
|
||||
<button
|
||||
onclick={() => selectVersion(v.id)}
|
||||
data-testid="history-version"
|
||||
class="w-full py-2 text-left transition hover:bg-muted {selectedVersionId ===
|
||||
v.id
|
||||
? 'border-l-2 border-accent pl-2'
|
||||
: 'pl-0'}"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class="font-sans text-xs font-medium text-ink">
|
||||
Version {i + 1}
|
||||
</span>
|
||||
<span class="font-sans text-[10px] text-ink-3">
|
||||
{formatDateTime(v.savedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-sans text-[11px] text-ink-2">{v.editorName}</span>
|
||||
{#if v.changedFields && v.changedFields.length > 0}
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
{#each v.changedFields as field (field)}
|
||||
<span
|
||||
class="rounded bg-muted px-1.5 py-0.5 font-sans text-[10px] tracking-wide text-ink-2 uppercase"
|
||||
>
|
||||
{fieldLabels[field] ? fieldLabels[field]() : field}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</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-ink-3">{m.history_loading()}</p>
|
||||
{:else if noDiff}
|
||||
<div
|
||||
data-testid="history-diff"
|
||||
class="mb-2 rounded-sm border border-line bg-surface p-4 font-serif text-sm text-ink-3 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-line bg-surface p-4"
|
||||
>
|
||||
{#each diffEntries as entry (entry.field)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block font-sans text-[10px] font-bold tracking-wide text-ink-3 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-ink-3"
|
||||
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>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,198 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
type Person = { id: string; firstName: string; lastName: string; alias?: string | null };
|
||||
type Tag = { id: string; name: string };
|
||||
|
||||
type Doc = {
|
||||
documentDate?: string | null;
|
||||
location?: string | null;
|
||||
documentLocation?: string | null;
|
||||
tags?: Tag[] | null;
|
||||
sender?: Person | null;
|
||||
receivers?: Person[] | null;
|
||||
};
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-10 p-6">
|
||||
<!-- DETAILS GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{m.doc_section_details()}
|
||||
</h3>
|
||||
<div class="space-y-5">
|
||||
<!-- Date -->
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Calendar/Calendar-Add-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-ink">
|
||||
{doc.documentDate ? formatDate(doc.documentDate) : '—'}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-ink-2">{m.doc_label_document_date()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Creation Location -->
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Location-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-ink">
|
||||
{doc.location ? doc.location : '—'}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-ink-2">{m.doc_label_creation_location()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Physical Archive Location -->
|
||||
{#if doc.documentLocation}
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Folder-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<span class="block font-serif text-lg text-ink">
|
||||
{doc.documentLocation}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-ink-2"
|
||||
>{m.doc_label_archive_location_original()}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Tags -->
|
||||
{#if doc.tags && doc.tags.length > 0}
|
||||
<div class="flex items-start">
|
||||
<span class="mt-0.5 w-8 text-accent">
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Bookmark/Bookmark-Outline-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 flex flex-wrap gap-2">
|
||||
{#each doc.tags as tag (tag.id)}
|
||||
<a
|
||||
href="/?tag={encodeURIComponent(tag.name)}"
|
||||
class="inline-flex items-center rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-primary hover:text-primary-fg"
|
||||
title={m.doc_tag_filter_title({ name: tag.name })}
|
||||
>
|
||||
{tag.name}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="font-sans text-xs text-ink-2">{m.form_label_tags()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PERSONEN GROUP -->
|
||||
<div>
|
||||
<h3
|
||||
class="mb-4 border-b border-line pb-2 font-sans text-xs font-bold tracking-widest text-ink uppercase"
|
||||
>
|
||||
{m.doc_section_persons()}
|
||||
</h3>
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase">{m.form_label_sender()}</span>
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/persons/{doc.sender.id}"
|
||||
class="group block rounded border border-line bg-muted p-3 transition hover:border-accent hover:bg-accent/10"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full bg-primary font-serif text-sm text-primary-fg"
|
||||
>
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-serif text-ink group-hover:underline">
|
||||
{doc.sender.firstName}
|
||||
{doc.sender.lastName}
|
||||
</p>
|
||||
{#if doc.sender.alias}
|
||||
<p class="font-sans text-xs text-ink-2">{doc.sender.alias}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-ink-3 italic">{m.doc_sender_not_specified()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-2 block font-sans text-xs text-ink-3 uppercase"
|
||||
>{m.form_label_receivers()}</span
|
||||
>
|
||||
{#if doc.receivers && doc.receivers.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each doc.receivers as receiver (receiver.id)}
|
||||
<div
|
||||
class="group flex items-center justify-between rounded border border-line bg-surface p-3 transition hover:border-primary"
|
||||
>
|
||||
<a href="/persons/{receiver.id}" class="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
class="flex h-6 w-6 items-center justify-center rounded-full bg-muted font-serif text-xs text-ink-2"
|
||||
>
|
||||
{receiver.firstName[0]}{receiver.lastName[0]}
|
||||
</div>
|
||||
<span class="truncate font-serif text-sm text-ink">
|
||||
{receiver.firstName}
|
||||
{receiver.lastName}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{#if doc.sender}
|
||||
<a
|
||||
href="/korrespondenz?senderId={doc.sender.id}&receiverId={receiver.id}"
|
||||
class="text-ink-3 transition hover:text-accent"
|
||||
title={m.doc_conversation_title()}
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Chat-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-ink-3 italic">{m.doc_no_receivers()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Doc = {
|
||||
summary?: string | null;
|
||||
transcription?: string | null;
|
||||
};
|
||||
|
||||
let { doc }: { doc: Doc } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center px-6 py-8">
|
||||
<div class="w-full max-w-prose space-y-8">
|
||||
{#if !doc.summary && !doc.transcription}
|
||||
<p class="font-serif text-sm text-ink-3 italic">—</p>
|
||||
{/if}
|
||||
|
||||
{#if doc.summary}
|
||||
<div>
|
||||
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.doc_label_summary()}
|
||||
</span>
|
||||
<p class="font-serif text-base leading-relaxed text-ink">{doc.summary}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if doc.transcription}
|
||||
<div>
|
||||
<span class="mb-3 block font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.form_label_transcription()}
|
||||
</span>
|
||||
<p class="font-serif text-base leading-relaxed whitespace-pre-wrap text-ink">
|
||||
{doc.transcription}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,7 +13,6 @@ type Props = {
|
||||
active: boolean;
|
||||
saveState: SaveState;
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
onTextChange: (text: string) => void;
|
||||
onFocus: () => void;
|
||||
onDeleteClick: () => void;
|
||||
@@ -29,7 +28,6 @@ let {
|
||||
active,
|
||||
saveState,
|
||||
canComment,
|
||||
currentUserId,
|
||||
onTextChange,
|
||||
onFocus,
|
||||
onDeleteClick,
|
||||
@@ -204,8 +202,6 @@ function captureSelectionAndOpenComments() {
|
||||
blockId={blockId}
|
||||
loadOnMount={true}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
canAdmin={false}
|
||||
quotedText={selectedQuote}
|
||||
showCompose={commentOpen}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,6 @@ function renderBlock(overrides: Record<string, unknown> = {}) {
|
||||
active: false,
|
||||
saveState: 'idle' as const,
|
||||
canComment: true,
|
||||
currentUserId: 'user-1',
|
||||
onTextChange: vi.fn(),
|
||||
onFocus: vi.fn(),
|
||||
onDeleteClick: vi.fn(),
|
||||
|
||||
@@ -10,21 +10,12 @@ type Props = {
|
||||
documentId: string;
|
||||
blocks: TranscriptionBlockData[];
|
||||
canComment: boolean;
|
||||
currentUserId: string | null;
|
||||
onBlockFocus: (blockId: string) => void;
|
||||
onSaveBlock: (blockId: string, text: string) => Promise<void>;
|
||||
onDeleteBlock: (blockId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
let {
|
||||
documentId,
|
||||
blocks,
|
||||
canComment,
|
||||
currentUserId,
|
||||
onBlockFocus,
|
||||
onSaveBlock,
|
||||
onDeleteBlock
|
||||
}: Props = $props();
|
||||
let { documentId, blocks, canComment, onBlockFocus, onSaveBlock, onDeleteBlock }: Props = $props();
|
||||
|
||||
let activeBlockId: string | null = $state(null);
|
||||
let saveStates = new SvelteMap<string, SaveState>();
|
||||
@@ -165,7 +156,6 @@ $effect(() => {
|
||||
active={activeBlockId === block.id}
|
||||
saveState={getSaveState(block.id)}
|
||||
canComment={canComment}
|
||||
currentUserId={currentUserId}
|
||||
onTextChange={(text) => handleTextChange(block.id, text)}
|
||||
onFocus={() => handleFocus(block.id)}
|
||||
onDeleteClick={() => handleDelete(block.id)}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -218,7 +217,6 @@ onMount(() => {
|
||||
documentId={doc.id}
|
||||
blocks={transcriptionBlocks}
|
||||
canComment={canWrite}
|
||||
currentUserId={currentUserId}
|
||||
onBlockFocus={handleBlockFocus}
|
||||
onSaveBlock={saveBlock}
|
||||
onDeleteBlock={deleteBlock}
|
||||
|
||||
Reference in New Issue
Block a user