dorfteich/apps/web/src/comments/CommentsSection.tsx
Claude Fable 5 f014a61480 #133 Kommentare fest inline im Lesemodus (Slide-in-Panel ablösen)
Kommentare erscheinen jetzt fest im Lesefluss zwischen Backlinks und
lokalem Graph statt in einem ein-/ausblendbaren Panel. Der
Kopfleisten-Toggle (Icon + Unread-Badge) entfällt.

Frontend:
- CommentsPanel → CommentsSection (Inline-Sektion, ohne Panel-Chrome/
  Close-Knopf; markiert beim Sichtbarwerden als gelesen). Neue
  Read-only-Variante PublicComments für die anonyme öffentliche Ansicht.
- Umzug auf die äußere Ebene in PageEditorPage (view-Modus, zwischen
  BacklinksPanel und LocalGraphPanel). Das Schreibrecht (collab rw) wird
  per onWriteAccess aus dem inneren PageEditor hochgereicht, damit die
  äußere Ebene den Composer bei commentPolicy=editors korrekt zeigt/
  verbirgt.
- Deep-Link ?comments=1 scrollt jetzt zur Inline-Sektion statt ein Panel
  zu öffnen. Resolve/Unresolve-Knöpfe zusätzlich an mayComment gekoppelt
  (früher nur an isRoot) — Leser sehen keine 403-Knöpfe mehr; Read-only
  blendet alle Aktions-Controls aus.
- CSS comments-panel* → comments-section*; tote Unread-Badge-Regeln raus.

Backend:
- GET /public/:pondSlug/:pageSlug/comments (@Public), read-only. Nutzt den
  vorhandenen resolve()-Pfad (erzwingt ggf. anonymen Lesezugriff → nicht
  öffentliche Seiten 404en) und CommentsService.list. PublicModule
  importiert CommentsModule.

Tests: public.e2e.db.test.ts um anonymen Kommentar-Lesezugriff + 404-Fälle
ergänzt (grün gegen frische Test-DB); comments.spec.ts auf die Inline-UI
umgestellt. typecheck/lint/i18n:check grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-19 01:22:22 +02:00

422 lines
12 KiB
TypeScript

import type { PageCommentsView, CommentThreadView, CommentView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments';
/**
* The page's discussion (issue #92), rendered inline in the read view between
* the backlinks and the local graph (issue #133): threaded comments with a
* Markdown composer, edit/delete for authors, and resolve with a collapsed
* resolved section. The composer is hidden with a hint when the pond's policy
* bars the viewer. Replaces the former slide-in panel.
*/
export function CommentsSection({
pageId,
mayComment,
}: {
pageId: string;
/** Resolved by the caller from the pond's commentPolicy + write access. */
mayComment: boolean;
}): React.JSX.Element {
const { t } = useTranslation('comments');
const comments = useComments(pageId);
const refresh = useInvalidateComments(pageId);
const [error, setError] = useState<unknown>(null);
// Seeing the inline section is "visiting" the discussion: the unread notion
// (used by comment notifications) resets while it is on screen.
useEffect(() => {
markCommentsSeen(pageId);
return () => markCommentsSeen(pageId);
}, [pageId, comments.data]);
const run = async (action: () => Promise<unknown>): Promise<void> => {
setError(null);
try {
await action();
await refresh();
} catch (err) {
setError(err);
}
};
return (
<section id="comments" className="comments-section" aria-label={t('title')}>
<div className="comments-section__header">
<h2>
{t('title')}
{comments.data && comments.data.openCount > 0 && (
<span className="comments-section__count">{comments.data.openCount}</span>
)}
</h2>
</div>
<FormError error={error} />
{mayComment ? (
<Composer
label={t('composer.placeholder')}
submitLabel={t('composer.submit')}
onSubmit={(body) => run(() => apiPost(`/pages/${pageId}/comments`, { body }))}
/>
) : (
<p className="comments-section__policy-hint">{t('composer.editorsOnly')}</p>
)}
<CommentThreads
view={comments.data}
pageId={pageId}
mayComment={mayComment}
run={run}
readOnly={false}
/>
</section>
);
}
/**
* Read-only comments for the anonymous public view (issue #133): fetched from
* the public endpoint (no auth, no composer, no action controls). The section
* is omitted entirely when the page has no comments, so public pages stay clean.
*/
export function PublicComments({
pondSlug,
pageSlug,
}: {
pondSlug: string;
pageSlug: string;
}): React.JSX.Element | null {
const { t } = useTranslation('comments');
const query = useQuery({
queryKey: ['public-comments', pondSlug, pageSlug],
queryFn: () => apiGet<PageCommentsView>(`/public/${pondSlug}/${pageSlug}/comments`),
enabled: Boolean(pondSlug && pageSlug),
retry: false,
});
const view = query.data;
if (!view || view.threads.length === 0) return null;
return (
<section className="comments-section comments-section--readonly" aria-label={t('title')}>
<div className="comments-section__header">
<h2>
{t('title')}
{view.openCount > 0 && <span className="comments-section__count">{view.openCount}</span>}
</h2>
</div>
<CommentThreads view={view} pageId="" mayComment={false} run={noop} readOnly />
</section>
);
}
const noop = async (): Promise<void> => {};
function CommentThreads({
view,
pageId,
mayComment,
run,
readOnly,
}: {
view: PageCommentsView | undefined;
pageId: string;
mayComment: boolean;
run: (action: () => Promise<unknown>) => Promise<void>;
readOnly: boolean;
}): React.JSX.Element {
const { t } = useTranslation('comments');
const open = (view?.threads ?? []).filter((thread) => !thread.resolved);
const resolved = (view?.threads ?? []).filter((thread) => thread.resolved);
return (
<>
{view && open.length === 0 && resolved.length === 0 && (
<p className="comments-section__empty">{t('empty')}</p>
)}
<ul className="comments-section__threads">
{open.map((thread) => (
<Thread
key={thread.root.id}
thread={thread}
pageId={pageId}
mayComment={mayComment}
run={run}
readOnly={readOnly}
/>
))}
</ul>
{resolved.length > 0 && (
<details className="comments-section__resolved">
<summary>{t('resolvedSection', { count: resolved.length })}</summary>
<ul className="comments-section__threads">
{resolved.map((thread) => (
<Thread
key={thread.root.id}
thread={thread}
pageId={pageId}
mayComment={mayComment}
run={run}
readOnly={readOnly}
/>
))}
</ul>
</details>
)}
</>
);
}
function Thread({
thread,
pageId,
mayComment,
run,
readOnly,
}: {
thread: CommentThreadView;
pageId: string;
mayComment: boolean;
run: (action: () => Promise<unknown>) => Promise<void>;
readOnly: boolean;
}): React.JSX.Element {
const { t } = useTranslation('comments');
const [replying, setReplying] = useState(false);
return (
<li className={`comments-thread${thread.resolved ? ' comments-thread--resolved' : ''}`}>
<CommentItem
comment={thread.root}
run={run}
isRoot
resolved={thread.resolved}
mayComment={mayComment}
readOnly={readOnly}
/>
<ul className="comments-thread__replies">
{thread.replies.map((reply) => (
<li key={reply.id}>
<CommentItem
comment={reply}
run={run}
isRoot={false}
resolved={thread.resolved}
mayComment={mayComment}
readOnly={readOnly}
/>
</li>
))}
</ul>
{!readOnly && mayComment && !thread.resolved && (
<div className="comments-thread__actions">
{replying ? (
<Composer
label={t('reply.placeholder')}
submitLabel={t('reply.submit')}
autoFocus
onCancel={() => setReplying(false)}
onSubmit={async (body) => {
await run(() =>
apiPost(`/pages/${pageId}/comments`, { body, parentId: thread.root.id }),
);
setReplying(false);
}}
/>
) : (
<button
type="button"
className="comments-link-button"
onClick={() => setReplying(true)}
>
{t('reply.open')}
</button>
)}
</div>
)}
</li>
);
}
function CommentItem({
comment,
run,
isRoot,
resolved,
mayComment,
readOnly,
}: {
comment: CommentView;
run: (action: () => Promise<unknown>) => Promise<void>;
isRoot: boolean;
resolved: boolean;
mayComment: boolean;
readOnly: boolean;
}): React.JSX.Element {
const { t, i18n } = useTranslation('comments');
const { user } = useAuth();
const [editing, setEditing] = useState(false);
const own = user?.id === comment.author?.id;
return (
<article className="comment" aria-label={t('commentBy', { name: authorName(comment, t) })}>
<header className="comment__meta">
<span className="comment__author">{authorName(comment, t)}</span>
<time dateTime={comment.createdAt} title={new Date(comment.createdAt).toLocaleString()}>
{relativeTime(comment.createdAt, i18n.language)}
</time>
{comment.editedAt && <span className="comment__edited">{t('edited')}</span>}
</header>
{editing ? (
<Composer
label={t('edit.placeholder')}
submitLabel={t('edit.submit')}
initialValue={comment.body}
autoFocus
onCancel={() => setEditing(false)}
onSubmit={async (body) => {
await run(() => apiPatch(`/comments/${comment.id}`, { body }));
setEditing(false);
}}
/>
) : (
// Server-sanitized render (shared pipeline, issue #91) — safe by contract.
<div className="comment__body" dangerouslySetInnerHTML={{ __html: comment.html }} />
)}
{!readOnly && (
<footer className="comment__actions">
{own && !editing && (
<>
<button
type="button"
className="comments-link-button"
onClick={() => setEditing(true)}
>
{t('edit.open')}
</button>
<button
type="button"
className="comments-link-button"
onClick={() => void run(() => apiDelete(`/comments/${comment.id}`))}
>
{t('delete')}
</button>
</>
)}
{isRoot &&
mayComment &&
(resolved ? (
<button
type="button"
className="comments-link-button"
onClick={() => void run(() => apiDelete(`/comments/${comment.id}/resolve`))}
>
{t('unresolve')}
</button>
) : (
<button
type="button"
className="comments-link-button"
onClick={() => void run(() => apiPost(`/comments/${comment.id}/resolve`, {}))}
>
{t('resolve')}
</button>
))}
</footer>
)}
</article>
);
}
function Composer({
label,
submitLabel,
onSubmit,
onCancel,
initialValue = '',
autoFocus = false,
}: {
label: string;
submitLabel: string;
onSubmit: (body: string) => Promise<void> | void;
onCancel?: () => void;
initialValue?: string;
autoFocus?: boolean;
}): React.JSX.Element {
const { t } = useTranslation('comments');
const [body, setBody] = useState(initialValue);
const [busy, setBusy] = useState(false);
const submit = async (): Promise<void> => {
const trimmed = body.trim();
if (!trimmed || busy) return;
setBusy(true);
try {
await onSubmit(trimmed);
setBody('');
} finally {
setBusy(false);
}
};
return (
<form
className="comments-composer"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<textarea
value={body}
rows={3}
placeholder={label}
aria-label={label}
autoFocus={autoFocus}
onChange={(event) => setBody(event.target.value)}
/>
<div className="comments-composer__footer">
<span className="comments-composer__hint">{t('composer.markdownHint')}</span>
{onCancel && (
<button type="button" className="comments-link-button" onClick={onCancel}>
{t('composer.cancel')}
</button>
)}
<button type="submit" className="button" disabled={busy || body.trim().length === 0}>
{submitLabel}
</button>
</div>
</form>
);
}
function authorName(comment: CommentView, t: (key: string) => string): string {
return comment.author?.displayName ?? t('deletedAuthor');
}
/** "5 minutes ago" in the UI language; falls back to the local date. */
function relativeTime(iso: string, locale: string): string {
const seconds = Math.round((new Date(iso).getTime() - Date.now()) / 1000);
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
const table: [Intl.RelativeTimeFormatUnit, number][] = [
['second', 60],
['minute', 60],
['hour', 24],
['day', 7],
['week', 4.35],
['month', 12],
];
let value = seconds;
for (const [unit, next] of table) {
if (Math.abs(value) < next) return formatter.format(Math.round(value), unit);
value /= next;
}
return formatter.format(Math.round(value), 'year');
}