refactor(comments): extract CommentMessage component from CommentThread (#198)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
111
frontend/src/lib/components/CommentMessage.svelte
Normal file
111
frontend/src/lib/components/CommentMessage.svelte
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import type { FlatMessage } from '$lib/types';
|
||||||
|
import { extractQuote } from '$lib/utils/comment';
|
||||||
|
import { getInitials } from '$lib/utils/personFormat';
|
||||||
|
import { relativeTime } from '$lib/utils/time';
|
||||||
|
import { renderBody } from '$lib/utils/mention';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
message: FlatMessage;
|
||||||
|
isOwn: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
editText: string;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onEditTextChange: (text: string) => void;
|
||||||
|
onEditKeydown: (e: KeyboardEvent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
message,
|
||||||
|
isOwn,
|
||||||
|
isEditing,
|
||||||
|
editText,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
onEditTextChange,
|
||||||
|
onEditKeydown
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const wasEdited = $derived(message.updatedAt > message.createdAt);
|
||||||
|
const parsed = $derived(extractQuote(message.content));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div role="article" class="flex gap-2">
|
||||||
|
<!-- Avatar circle with initials -->
|
||||||
|
<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(message.authorName)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<!-- Author + timestamp -->
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="font-sans text-sm font-semibold text-ink">{message.authorName}</span>
|
||||||
|
{#if wasEdited}
|
||||||
|
<span class="font-sans text-xs text-ink-3"
|
||||||
|
>{relativeTime(message.updatedAt)} {m.comment_edited_label()}</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="font-sans text-xs text-ink-3">{relativeTime(message.createdAt)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quote block (if present) -->
|
||||||
|
{#if parsed.quote}
|
||||||
|
<div class="my-1 border-l-2 border-line pl-2 font-serif text-base text-ink-3 italic">
|
||||||
|
“{parsed.quote}”
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit mode vs view mode -->
|
||||||
|
{#if isEditing}
|
||||||
|
<textarea
|
||||||
|
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
|
||||||
|
rows={2}
|
||||||
|
value={editText}
|
||||||
|
oninput={(e) => onEditTextChange((e.currentTarget as HTMLTextAreaElement).value)}
|
||||||
|
onkeydown={onEditKeydown}
|
||||||
|
></textarea>
|
||||||
|
<div class="mt-1 font-sans text-xs text-ink-3">Enter speichern · Esc abbrechen</div>
|
||||||
|
{:else}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="relative" onclick={() => { if (isOwn) onEdit(); }}>
|
||||||
|
<p
|
||||||
|
class="font-serif text-base leading-relaxed text-ink-2 {isOwn
|
||||||
|
? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
||||||
|
{@html renderBody(parsed.body, message.mentionDTOs ?? [])}
|
||||||
|
</p>
|
||||||
|
{#if isOwn}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-2 text-ink-3 transition-colors"
|
||||||
|
aria-label="{m.btn_delete()} {message.authorName}"
|
||||||
|
onclick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
85
frontend/src/lib/components/CommentMessage.svelte.spec.ts
Normal file
85
frontend/src/lib/components/CommentMessage.svelte.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page, userEvent } from 'vitest/browser';
|
||||||
|
import CommentMessage from './CommentMessage.svelte';
|
||||||
|
import type { FlatMessage } from '$lib/types';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const baseMsg: FlatMessage = {
|
||||||
|
id: 'msg-1',
|
||||||
|
authorId: 'user-1',
|
||||||
|
authorName: 'Anna Müller',
|
||||||
|
content: 'Hello world',
|
||||||
|
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||||
|
updatedAt: new Date(Date.now() - 5 * 60_000).toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultProps(overrides: Partial<Parameters<typeof render>[1]> = {}) {
|
||||||
|
return {
|
||||||
|
message: baseMsg,
|
||||||
|
isOwn: false,
|
||||||
|
isEditing: false,
|
||||||
|
editText: '',
|
||||||
|
onEdit: vi.fn(),
|
||||||
|
onDelete: vi.fn(),
|
||||||
|
onEditTextChange: vi.fn(),
|
||||||
|
onEditKeydown: vi.fn(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CommentMessage', () => {
|
||||||
|
it('renders author name', async () => {
|
||||||
|
render(CommentMessage, defaultProps());
|
||||||
|
await expect.element(page.getByText('Anna Müller')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders initials in avatar', async () => {
|
||||||
|
render(CommentMessage, defaultProps());
|
||||||
|
await expect.element(page.getByText('AM')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders message body', async () => {
|
||||||
|
render(CommentMessage, defaultProps());
|
||||||
|
await expect.element(page.getByText('Hello world')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders quoted section when content contains a quote', async () => {
|
||||||
|
render(
|
||||||
|
CommentMessage,
|
||||||
|
defaultProps({
|
||||||
|
message: { ...baseMsg, content: '> "Interesting passage"\n\nMy reply' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await expect.element(page.getByText(/Interesting passage/)).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('My reply')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button for messages not owned by current user', async () => {
|
||||||
|
render(CommentMessage, defaultProps({ isOwn: false }));
|
||||||
|
await expect.element(page.getByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows delete button for own messages', async () => {
|
||||||
|
render(CommentMessage, defaultProps({ isOwn: true }));
|
||||||
|
await expect.element(page.getByRole('button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDelete when delete button is clicked', async () => {
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
render(CommentMessage, defaultProps({ isOwn: true, onDelete }));
|
||||||
|
await userEvent.click(page.getByRole('button'));
|
||||||
|
expect(onDelete).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows edit textarea when isEditing is true', async () => {
|
||||||
|
render(
|
||||||
|
CommentMessage,
|
||||||
|
defaultProps({ isOwn: true, isEditing: true, editText: 'current edit text' })
|
||||||
|
);
|
||||||
|
const textarea = page.getByRole('textbox');
|
||||||
|
await expect.element(textarea).toBeInTheDocument();
|
||||||
|
await expect.element(textarea).toHaveValue('current edit text');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { Comment } from '$lib/types';
|
import type { Comment, FlatMessage } from '$lib/types';
|
||||||
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
import MentionEditor from '$lib/components/MentionEditor.svelte';
|
||||||
import { renderBody, extractContent } from '$lib/utils/mention';
|
import CommentMessage from '$lib/components/CommentMessage.svelte';
|
||||||
import { relativeTime } from '$lib/utils/time';
|
import { extractContent } from '$lib/utils/mention';
|
||||||
import { getInitials } from '$lib/utils/personFormat';
|
|
||||||
import type { MentionDTO } from '$lib/types';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
annotationId?: string | null;
|
annotationId?: string | null;
|
||||||
@@ -34,16 +31,6 @@ let {
|
|||||||
onCountChange
|
onCountChange
|
||||||
}: Props = $props();
|
}: 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 comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||||
let newText: string = $state('');
|
let newText: string = $state('');
|
||||||
let posting: boolean = $state(false);
|
let posting: boolean = $state(false);
|
||||||
@@ -69,20 +56,10 @@ $effect(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
|
|
||||||
return c.updatedAt > c.createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOwn(c: { authorId: string | null }): boolean {
|
function isOwn(c: { authorId: string | null }): boolean {
|
||||||
return currentUserId !== null && c.authorId === currentUserId;
|
return currentUserId !== null && c.authorId === currentUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
async function reload() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(commentsBase);
|
const res = await fetch(commentsBase);
|
||||||
@@ -204,77 +181,18 @@ onMount(() => {
|
|||||||
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
|
{flatMessages.length === 1 ? 'Kommentar' : 'Kommentare'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div role="log" class="space-y-2">
|
||||||
{#each flatMessages as msg (msg.id)}
|
{#each flatMessages as msg (msg.id)}
|
||||||
{@const parsed = extractQuote(msg.content)}
|
<CommentMessage
|
||||||
<div class="flex gap-2">
|
message={msg}
|
||||||
<div
|
isOwn={isOwn(msg)}
|
||||||
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-fg"
|
isEditing={editingId === msg.id}
|
||||||
>
|
editText={editText}
|
||||||
{getInitials(msg.authorName)}
|
onEdit={() => startEdit(msg)}
|
||||||
</div>
|
onDelete={() => deleteComment(msg.id)}
|
||||||
<div class="min-w-0 flex-1">
|
onEditTextChange={(text) => { editText = text; }}
|
||||||
<div class="flex items-center gap-1.5">
|
onEditKeydown={(e) => handleEditKeydown(e, msg.id)}
|
||||||
<span class="font-sans text-sm font-semibold text-ink">{msg.authorName}</span>
|
/>
|
||||||
{#if wasEdited(msg)}
|
|
||||||
<span class="font-sans text-xs text-ink-3"
|
|
||||||
>{relativeTime(msg.updatedAt)} {m.comment_edited_label()}</span
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<span class="font-sans text-xs text-ink-3">{relativeTime(msg.createdAt)}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if parsed.quote}
|
|
||||||
<div class="my-1 border-l-2 border-line pl-2 font-serif text-base text-ink-3 italic">
|
|
||||||
“{parsed.quote}”
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if editingId === msg.id}
|
|
||||||
<textarea
|
|
||||||
class="mt-1 w-full resize-none rounded border border-line bg-surface px-2 py-1 font-serif text-sm leading-relaxed text-ink outline-none focus:border-primary"
|
|
||||||
rows={2}
|
|
||||||
bind:value={editText}
|
|
||||||
onkeydown={(e) => handleEditKeydown(e, msg.id)}
|
|
||||||
></textarea>
|
|
||||||
<div class="mt-1 font-sans text-xs text-ink-3">Enter speichern · Esc abbrechen</div>
|
|
||||||
{:else}
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div class="relative" onclick={() => { if (isOwn(msg)) startEdit(msg); }}>
|
|
||||||
<p
|
|
||||||
class="font-serif text-base leading-relaxed text-ink-2 {isOwn(msg) ? '-mx-1 cursor-text rounded px-1 transition-colors hover:bg-surface' : ''}"
|
|
||||||
>
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- renderBody escapes all HTML before injecting mention links -->
|
|
||||||
{@html renderBody(parsed.body, msg.mentionDTOs ?? [])}
|
|
||||||
</p>
|
|
||||||
{#if isOwn(msg)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="hover:text-error absolute -right-1 -bottom-1 cursor-pointer rounded p-0.5 text-ink-3 transition-colors"
|
|
||||||
title={m.btn_delete()}
|
|
||||||
aria-label={m.btn_delete()}
|
|
||||||
onclick={(e) => { e.stopPropagation(); deleteComment(msg.id); }}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="1.5"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ export type CommentReply = {
|
|||||||
mentionDTOs?: MentionDTO[];
|
mentionDTOs?: MentionDTO[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FlatMessage = {
|
||||||
|
id: string;
|
||||||
|
authorId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
mentionDTOs?: MentionDTO[];
|
||||||
|
};
|
||||||
|
|
||||||
export type Comment = {
|
export type Comment = {
|
||||||
id: string;
|
id: string;
|
||||||
authorId: string | null;
|
authorId: string | null;
|
||||||
|
|||||||
40
frontend/src/lib/utils/comment.spec.ts
Normal file
40
frontend/src/lib/utils/comment.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractQuote } from './comment';
|
||||||
|
|
||||||
|
describe('extractQuote', () => {
|
||||||
|
it('returns null quote and full body for plain text', () => {
|
||||||
|
const result = extractQuote('Hello world');
|
||||||
|
expect(result.quote).toBeNull();
|
||||||
|
expect(result.body).toBe('Hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts quote and body with double newline separator', () => {
|
||||||
|
const result = extractQuote('> "Some quoted text"\n\nReply body');
|
||||||
|
expect(result.quote).toBe('Some quoted text');
|
||||||
|
expect(result.body).toBe('Reply body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts quote and body with single newline separator', () => {
|
||||||
|
const result = extractQuote('> "Quote"\nBody');
|
||||||
|
expect(result.quote).toBe('Quote');
|
||||||
|
expect(result.body).toBe('Body');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null quote when format does not match', () => {
|
||||||
|
const result = extractQuote('> Not a quote format');
|
||||||
|
expect(result.quote).toBeNull();
|
||||||
|
expect(result.body).toBe('> Not a quote format');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty string', () => {
|
||||||
|
const result = extractQuote('');
|
||||||
|
expect(result.quote).toBeNull();
|
||||||
|
expect(result.body).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not match when quotes are missing', () => {
|
||||||
|
const result = extractQuote('> just a blockquote\n\nbody');
|
||||||
|
expect(result.quote).toBeNull();
|
||||||
|
expect(result.body).toBe('> just a blockquote\n\nbody');
|
||||||
|
});
|
||||||
|
});
|
||||||
5
frontend/src/lib/utils/comment.ts
Normal file
5
frontend/src/lib/utils/comment.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user