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
422 lines
12 KiB
TypeScript
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');
|
|
}
|