Add the comments panel, unread badge, and comment-policy setting (#92)
Some checks failed
CI / Lint, typecheck, test (push) Successful in 3m21s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m43s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Failing after 3m1s
CI / Import/export fidelity gate (push) Has been skipped

New comments panel on the page (toggle next to attachments, unread badge
counting comments newer than the last localStorage-recorded visit):
threaded display with relative times and author names, a Markdown
composer with hints, edit/delete for authors, resolve moving threads
into a collapsed resolved <details> section with reopen, and a
permission-aware composer — hidden with a hint when the pond's policy
bars the viewer (readers always see the discussion). The pond settings
page gains the "who may comment" select. Fixes PondsService.update
silently dropping commentPolicy from the settings merge (found by the
new two-user Playwright pack; the DB test now exercises the real pond
PATCH). New comments i18n namespace (de+en); the pack runs as its own
CI step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
This commit is contained in:
Claude Fable 5 2026-07-11 22:07:16 +02:00
parent 4549d6d13f
commit e54aaf76f9
13 changed files with 902 additions and 12 deletions

View File

@ -222,6 +222,16 @@ jobs:
E2E_BASE_URL=http://localhost:5173 \ E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/system.spec.ts pnpm --filter @dorfteich/web exec playwright test e2e/system.spec.ts
- name: Reset login rate limit before comments pack
run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
- name: Run comments pack
run: |
E2E_BASE_URL=http://localhost:5173 \
pnpm --filter @dorfteich/web exec playwright test e2e/comments.spec.ts
- name: Reset login rate limit before admin-quotas pack - name: Reset login rate limit before admin-quotas pack
run: | run: |
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \ echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \

View File

@ -194,7 +194,14 @@ describe.skipIf(!hasTestDb)('comments (e2e, issue #91)', () => {
}); });
it('enforces the editors-only policy', async () => { it('enforces the editors-only policy', async () => {
await setPolicy('editors'); // Through the real pond PATCH — guards the settings merge in
// PondsService.update (a silently dropped commentPolicy broke the UI
// pack during #92).
await api()
.patch(`/api/v1/ponds/${pondId}`)
.set('Cookie', cookies.owner!)
.send({ commentPolicy: 'editors' })
.expect(200);
await api() await api()
.post(`/api/v1/pages/${pageId}/comments`) .post(`/api/v1/pages/${pageId}/comments`)
.set('Cookie', cookies.reader!) .set('Cookie', cookies.reader!)

View File

@ -146,15 +146,19 @@ export class PondsService {
async update(_user: User, id: string, input: UpdatePondInput): Promise<PondView> { async update(_user: User, id: string, input: UpdatePondInput): Promise<PondView> {
const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } }); const pond = await this.prisma.pond.findFirst({ where: { id, deletedAt: null } });
if (!pond) throw new NotFoundException(); if (!pond) throw new NotFoundException();
// Stored settings hold only deviations from the defaults; merge in whichever // Stored settings hold only deviations from the defaults; merge in
// of `sidebarSort` / `fonts` this request changes (issue #26 / #66). // whichever of the settings keys this request changes (#26/#66/#91).
const settings = const settingsChanged =
input.sidebarSort === undefined && input.fonts === undefined input.sidebarSort !== undefined ||
input.fonts !== undefined ||
input.commentPolicy !== undefined;
const settings = !settingsChanged
? undefined ? undefined
: { : {
...(pond.settings as object), ...(pond.settings as object),
...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}), ...(input.sidebarSort !== undefined ? { sidebarSort: input.sidebarSort } : {}),
...(input.fonts !== undefined ? { fonts: input.fonts } : {}), ...(input.fonts !== undefined ? { fonts: input.fonts } : {}),
...(input.commentPolicy !== undefined ? { commentPolicy: input.commentPolicy } : {}),
}; };
const updated = await this.prisma.pond.update({ const updated = await this.prisma.pond.update({
where: { id }, where: { id },

View File

@ -0,0 +1,150 @@
import { expect, test, type BrowserContext } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* Comments UI (issue #92): the full two-user lifecycle in the panel,
* resolve/unresolve with the collapsed section, the permission variant
* (composer hidden with a hint), and the localStorage unread badge.
* The pack provisions its own page and grant, so it is repeatable.
*/
let pondId: string;
let pageUrl: string;
let pageId: string;
let readerUserId: string;
async function pondOf(owner: BrowserContext): Promise<{ id: string; slug: string }> {
const ponds = (await (await owner.request.get('/api/v1/ponds')).json()) as {
id: string;
slug: string;
type: string;
}[];
const shared = ponds.find((p) => p.type === 'shared');
expect(shared).toBeTruthy();
return shared!;
}
test.beforeAll(async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await pondOf(owner);
pondId = pond.id;
// Fresh page per run — never depends on fixture pages' trash state.
const created = await owner.request.post(`/api/v1/ponds/${pondId}/pages`, {
data: { title: `Comment stage ${Date.now()}` },
});
expect(created.ok()).toBe(true);
const page = (await created.json()) as { id: string; slug: string };
pageId = page.id;
pageUrl = `/p/${pond.slug}/${page.slug}`;
// fixture-editor becomes a reader-member of the pond (the second user);
// a leftover membership from an aborted run just yields a conflict we ignore.
await owner.request.post(`/api/v1/ponds/${pondId}/members`, {
data: { usernameOrEmail: 'fixture-editor', role: 'reader' },
});
const members = (await (await owner.request.get(`/api/v1/ponds/${pondId}/members`)).json()) as {
members: { id: string; username: string }[];
};
readerUserId = members.members.find((m) => m.username === 'fixture-editor')!.id;
// Deterministic starting policy.
await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'readers' } });
await owner.close();
});
test.afterAll(async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'readers' } });
if (readerUserId) await owner.request.delete(`/api/v1/ponds/${pondId}/members/${readerUserId}`);
if (pageId) await owner.request.delete(`/api/v1/pages/${pageId}`);
await owner.close();
});
test('two users run the full comment lifecycle with resolve/unresolve', async ({ browser }) => {
// The reader starts the thread.
const reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
const readerPage = await reader.newPage();
await readerPage.goto(pageUrl);
await readerPage.locator('.editor-shell__comments-toggle').click();
const readerPanel = readerPage.locator('.comments-panel');
await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?');
await readerPanel.locator('.comments-composer button[type="submit"]').click();
await expect(readerPanel.locator('.comment__body strong')).toHaveText('final');
// The owner sees the unread badge, replies, and resolves the thread.
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const ownerPage = await owner.newPage();
await ownerPage.goto(pageUrl);
await expect(ownerPage.locator('.comments-unread-badge')).toBeVisible();
await ownerPage.locator('.editor-shell__comments-toggle').click();
const ownerPanel = ownerPage.locator('.comments-panel');
await ownerPanel
.locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i })
.click();
await ownerPanel.locator('.comments-thread textarea').fill('Yes, shipping it.');
await ownerPanel.locator('.comments-thread button[type="submit"]').click();
await expect(ownerPanel.locator('.comments-thread__replies .comment__body')).toContainText(
'shipping',
);
// Resolve collapses the thread into the resolved section.
await ownerPanel
.locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i })
.first()
.click();
const resolvedSection = ownerPanel.locator('.comments-panel__resolved');
await expect(resolvedSection).toBeVisible();
await expect(resolvedSection.locator('details, summary').first()).toBeVisible();
// Collapsed: the thread body is hidden until the section is opened.
await expect(resolvedSection.locator('.comment__body strong')).toBeHidden();
await resolvedSection.locator('summary').click();
await expect(resolvedSection.locator('.comment__body strong')).toBeVisible();
// Unresolve restores it to the open list.
await resolvedSection.locator('.comments-link-button', { hasText: /reopen|öffnen/i }).click();
await expect(ownerPanel.locator('.comments-panel__resolved')).toHaveCount(0);
await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible();
// Author edits and deletes own reply.
const replyItem = ownerPanel.locator('.comments-thread__replies .comment');
await replyItem.locator('.comments-link-button', { hasText: /edit|bearbeit/i }).click();
await replyItem.locator('textarea').fill('Yes — shipped.');
await replyItem.locator('button[type="submit"]').click();
await expect(replyItem.locator('.comment__body')).toContainText('shipped.');
await expect(replyItem.locator('.comment__edited')).toBeVisible();
await replyItem.locator('.comments-link-button', { hasText: /delete|löschen/i }).click();
await expect(ownerPanel.locator('.comments-thread__replies .comment')).toHaveCount(0);
// Cleanup: the reader deletes their own (now reply-free) root.
await readerPage.reload();
await readerPage.locator('.editor-shell__comments-toggle').click();
await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).click();
await expect(readerPanel.locator('.comments-panel__empty')).toBeVisible();
await reader.close();
await owner.close();
});
test('the composer hides with a hint when the policy bars readers', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
await owner.request.patch(`/api/v1/ponds/${pondId}`, { data: { commentPolicy: 'editors' } });
await owner.close();
const reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
const page = await reader.newPage();
await page.goto(pageUrl);
await page.locator('.editor-shell__comments-toggle').click();
const panel = page.locator('.comments-panel');
await expect(panel.locator('.comments-panel__policy-hint')).toBeVisible();
await expect(panel.locator('.comments-composer')).toHaveCount(0);
await reader.close();
const ownerAgain = await contextForUser(browser, BASE_URL, 'fixture-user');
await ownerAgain.request.patch(`/api/v1/ponds/${pondId}`, {
data: { commentPolicy: 'readers' },
});
await ownerAgain.close();
});

View File

@ -0,0 +1,53 @@
import type { CommentPolicy } from '@dorfteich/shared';
import { useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FormError, FormSuccess } from '../components/forms';
import { apiPatch } from '../lib/api';
/**
* The pond's "who may comment" setting (issue #91/#92): every reader, or
* pond-wide editors only. Rides the generic pond PATCH.
*/
export function CommentPolicySetting({
pondId,
pondSlug,
value,
}: {
pondId: string;
pondSlug: string;
value: CommentPolicy;
}): React.JSX.Element {
const { t } = useTranslation('comments');
const queryClient = useQueryClient();
const [error, setError] = useState<unknown>(null);
const [saved, setSaved] = useState(false);
const save = async (policy: CommentPolicy): Promise<void> => {
setError(null);
setSaved(false);
try {
await apiPatch(`/ponds/${pondId}`, { commentPolicy: policy });
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
setSaved(true);
} catch (err) {
setError(err);
}
};
return (
<div className="comment-policy">
<FormError error={error} />
<FormSuccess message={saved ? t('policy.saved') : null} />
<label className="comment-policy__label">
{t('policy.label')}
<select value={value} onChange={(event) => void save(event.target.value as CommentPolicy)}>
<option value="readers">{t('policy.readers')}</option>
<option value="editors">{t('policy.editors')}</option>
</select>
</label>
<p className="comment-policy__hint">{t('policy.hint')}</p>
</div>
);
}

View File

@ -0,0 +1,330 @@
import type { CommentThreadView, CommentView } from '@dorfteich/shared';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { apiDelete, apiPatch, apiPost } from '../lib/api';
import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments';
/**
* The page's discussion panel (issue #92): threaded comments with a
* Markdown composer, edit/delete for authors, resolve with a collapsed
* resolved section, and the permission-aware composer (hidden with a hint
* when the pond's policy bars the viewer).
*/
export function CommentsPanel({
pageId,
mayComment,
onClose,
}: {
pageId: string;
/** Resolved by the caller from the pond's commentPolicy + collab mode. */
mayComment: boolean;
onClose: () => void;
}): React.JSX.Element {
const { t } = useTranslation('comments');
const comments = useComments(pageId);
const refresh = useInvalidateComments(pageId);
const [error, setError] = useState<unknown>(null);
// Opening the panel is "visiting" the discussion: the unread badge resets.
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);
}
};
const view = comments.data;
const open = (view?.threads ?? []).filter((thread) => !thread.resolved);
const resolved = (view?.threads ?? []).filter((thread) => thread.resolved);
return (
<section className="comments-panel" aria-label={t('title')}>
<div className="comments-panel__header">
<h2>
{t('title')}
{view && view.openCount > 0 && (
<span className="comments-panel__count">{view.openCount}</span>
)}
</h2>
<button type="button" className="button" onClick={onClose}>
{t('close')}
</button>
</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-panel__policy-hint">{t('composer.editorsOnly')}</p>
)}
{view && open.length === 0 && resolved.length === 0 && (
<p className="comments-panel__empty">{t('empty')}</p>
)}
<ul className="comments-panel__threads">
{open.map((thread) => (
<Thread
key={thread.root.id}
thread={thread}
pageId={pageId}
mayComment={mayComment}
run={run}
/>
))}
</ul>
{resolved.length > 0 && (
<details className="comments-panel__resolved">
<summary>{t('resolvedSection', { count: resolved.length })}</summary>
<ul className="comments-panel__threads">
{resolved.map((thread) => (
<Thread
key={thread.root.id}
thread={thread}
pageId={pageId}
mayComment={mayComment}
run={run}
/>
))}
</ul>
</details>
)}
</section>
);
}
function Thread({
thread,
pageId,
mayComment,
run,
}: {
thread: CommentThreadView;
pageId: string;
mayComment: boolean;
run: (action: () => Promise<unknown>) => Promise<void>;
}): 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} />
<ul className="comments-thread__replies">
{thread.replies.map((reply) => (
<li key={reply.id}>
<CommentItem comment={reply} run={run} isRoot={false} resolved={thread.resolved} />
</li>
))}
</ul>
{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,
}: {
comment: CommentView;
run: (action: () => Promise<unknown>) => Promise<void>;
isRoot: boolean;
resolved: 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 }} />
)}
<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 &&
(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');
}

View File

@ -0,0 +1,60 @@
import type { PageCommentsView } from '@dorfteich/shared';
import { useQuery, useQueryClient, type UseQueryResult } from '@tanstack/react-query';
import { apiGet } from '../lib/api';
/**
* Page comments data (issue #92). One query per page, shared between the
* panel and the toggle button's unread badge. "Unread" is a purely local
* notion: newer than the last time this browser opened the panel
* (localStorage no server round-trip, per the issue's design).
*/
export const commentsQueryKey = (pageId: string): [string, string] => ['comments', pageId];
export function useComments(pageId: string): UseQueryResult<PageCommentsView> {
return useQuery({
queryKey: commentsQueryKey(pageId),
queryFn: () => apiGet<PageCommentsView>(`/pages/${pageId}/comments`),
});
}
export function useInvalidateComments(pageId: string): () => Promise<void> {
const queryClient = useQueryClient();
return () => queryClient.invalidateQueries({ queryKey: commentsQueryKey(pageId) });
}
const seenKey = (pageId: string): string => `dorfteich.comments.seen.${pageId}`;
export function markCommentsSeen(pageId: string): void {
try {
localStorage.setItem(seenKey(pageId), new Date().toISOString());
} catch {
// Storage full/blocked: the badge simply stays — never break the page.
}
}
/** Comments newer than the last visit, not authored by the viewer. */
export function unreadCount(
view: PageCommentsView | undefined,
pageId: string,
viewerId: string | undefined,
): number {
if (!view) return 0;
let since = 0;
try {
const stored = localStorage.getItem(seenKey(pageId));
since = stored ? new Date(stored).getTime() : 0;
} catch {
since = 0;
}
let count = 0;
for (const thread of view.threads) {
for (const comment of [thread.root, ...thread.replies]) {
if (new Date(comment.createdAt).getTime() > since && comment.author?.id !== viewerId) {
count += 1;
}
}
}
return count;
}

View File

@ -1,5 +1,6 @@
import deAccess from '@dorfteich/shared/i18n/de/access.json'; import deAccess from '@dorfteich/shared/i18n/de/access.json';
import deAuth from '@dorfteich/shared/i18n/de/auth.json'; import deAuth from '@dorfteich/shared/i18n/de/auth.json';
import deComments from '@dorfteich/shared/i18n/de/comments.json';
import deCommon from '@dorfteich/shared/i18n/de/common.json'; import deCommon from '@dorfteich/shared/i18n/de/common.json';
import deEditor from '@dorfteich/shared/i18n/de/editor.json'; import deEditor from '@dorfteich/shared/i18n/de/editor.json';
import deErrors from '@dorfteich/shared/i18n/de/errors.json'; import deErrors from '@dorfteich/shared/i18n/de/errors.json';
@ -21,6 +22,7 @@ import deUsers from '@dorfteich/shared/i18n/de/users.json';
import deSettings from '@dorfteich/shared/i18n/de/settings.json'; import deSettings from '@dorfteich/shared/i18n/de/settings.json';
import enAccess from '@dorfteich/shared/i18n/en/access.json'; import enAccess from '@dorfteich/shared/i18n/en/access.json';
import enAuth from '@dorfteich/shared/i18n/en/auth.json'; import enAuth from '@dorfteich/shared/i18n/en/auth.json';
import enComments from '@dorfteich/shared/i18n/en/comments.json';
import enCommon from '@dorfteich/shared/i18n/en/common.json'; import enCommon from '@dorfteich/shared/i18n/en/common.json';
import enEditor from '@dorfteich/shared/i18n/en/editor.json'; import enEditor from '@dorfteich/shared/i18n/en/editor.json';
import enErrors from '@dorfteich/shared/i18n/en/errors.json'; import enErrors from '@dorfteich/shared/i18n/en/errors.json';
@ -57,6 +59,7 @@ void i18n
resources: { resources: {
en: { en: {
common: enCommon, common: enCommon,
comments: enComments,
errors: enErrors, errors: enErrors,
access: enAccess, access: enAccess,
auth: enAuth, auth: enAuth,
@ -80,6 +83,7 @@ void i18n
}, },
de: { de: {
common: deCommon, common: deCommon,
comments: deComments,
errors: deErrors, errors: deErrors,
access: deAccess, access: deAccess,
auth: deAuth, auth: deAuth,

View File

@ -9,6 +9,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import * as Y from 'yjs'; import * as Y from 'yjs';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { CommentsPanel } from '../comments/CommentsPanel';
import { unreadCount, useComments } from '../comments/use-comments';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
import { AttachmentsPanel } from '../files/AttachmentsPanel'; import { AttachmentsPanel } from '../files/AttachmentsPanel';
@ -59,15 +61,18 @@ function PageEditor({
page, page,
mode, mode,
pondSlug, pondSlug,
commentPolicy,
}: { }: {
page: ResolvedPage; page: ResolvedPage;
mode: Mode; mode: Mode;
pondSlug: string; pondSlug: string;
commentPolicy: 'readers' | 'editors';
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('editor'); const { t } = useTranslation('editor');
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [showAttachments, setShowAttachments] = useState(false); const [showAttachments, setShowAttachments] = useState(false);
const [showComments, setShowComments] = useState(false);
const [showPageTools, setShowPageTools] = useState(false); const [showPageTools, setShowPageTools] = useState(false);
// Created and destroyed within the same effect (not `useMemo` + a separate // Created and destroyed within the same effect (not `useMemo` + a separate
@ -89,6 +94,13 @@ function PageEditor({
const collab = useCollabProvider(ydoc, page.id); const collab = useCollabProvider(ydoc, page.id);
const readOnly = collab.mode === 'ro'; const readOnly = collab.mode === 'ro';
// Comments (issue #92): the badge needs the data before the panel opens.
const comments = useComments(page.id);
const unread = showComments ? 0 : unreadCount(comments.data, page.id, user?.id);
// `readers` = everyone who can see the page; `editors` = the collab token
// explicitly granted rw (while it is still null, stay conservative — the
// composer appears once the mode resolves).
const mayComment = commentPolicy === 'readers' || collab.mode === 'rw';
// A revoked page can no longer be edited (issue #39); the content stays // A revoked page can no longer be edited (issue #39); the content stays
// visible for export via the dialog below. // visible for export via the dialog below.
const canEdit = mode === 'edit' && !readOnly && !collab.accessRevoked; const canEdit = mode === 'edit' && !readOnly && !collab.accessRevoked;
@ -187,6 +199,19 @@ function PageEditor({
> >
{t('files:title')} {t('files:title')}
</button> </button>
<button
type="button"
className="button editor-shell__comments-toggle"
aria-expanded={showComments}
onClick={() => setShowComments((open) => !open)}
>
{t('comments:toggle')}
{unread > 0 && (
<span className="comments-unread-badge">
{t('comments:unread', { count: unread })}
</span>
)}
</button>
{hasPageTools(pondPlugins.data) && ( {hasPageTools(pondPlugins.data) && (
<button <button
type="button" type="button"
@ -209,6 +234,13 @@ function PageEditor({
onClose={() => setShowAttachments(false)} onClose={() => setShowAttachments(false)}
/> />
)} )}
{showComments && (
<CommentsPanel
pageId={page.id}
mayComment={mayComment}
onClose={() => setShowComments(false)}
/>
)}
<div className="editor-connection" role="status" data-status={collab.status}> <div className="editor-connection" role="status" data-status={collab.status}>
{t(`connection.${collab.status}`)} {t(`connection.${collab.status}`)}
</div> </div>
@ -413,7 +445,12 @@ export function PageEditorPage(): React.JSX.Element {
/> />
</div> </div>
<div className="editor-page__body"> <div className="editor-page__body">
<PageEditor page={resolved} mode={mode} pondSlug={pondSlug} /> <PageEditor
page={resolved}
mode={mode}
pondSlug={pondSlug}
commentPolicy={pond.data?.settings.commentPolicy ?? 'readers'}
/>
{showLabels && ( {showLabels && (
<LabelPicker <LabelPicker
pageId={resolved.id} pageId={resolved.id}

View File

@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { CommentPolicySetting } from '../comments/CommentPolicySetting';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { AppearanceManager } from '../fonts/AppearanceManager'; import { AppearanceManager } from '../fonts/AppearanceManager';
import { LabelManager } from '../labels/LabelManager'; import { LabelManager } from '../labels/LabelManager';
@ -30,6 +31,7 @@ export function PondSettingsPage(): React.JSX.Element {
const { t: tErrors } = useTranslation('errors'); const { t: tErrors } = useTranslation('errors');
const { t: tFiles } = useTranslation('files'); const { t: tFiles } = useTranslation('files');
const { t: tExport } = useTranslation('export'); const { t: tExport } = useTranslation('export');
const { t: tComments } = useTranslation('comments');
const { t: tFont } = useTranslation('font'); const { t: tFont } = useTranslation('font');
const { pondSlug = '' } = useParams<{ pondSlug: string }>(); const { pondSlug = '' } = useParams<{ pondSlug: string }>();
const { user } = useAuth(); const { user } = useAuth();
@ -86,6 +88,16 @@ export function PondSettingsPage(): React.JSX.Element {
</section> </section>
)} )}
{canModify && <PondPluginSettings pondId={pond.data.id} />} {canModify && <PondPluginSettings pondId={pond.data.id} />}
{canModify && (
<section>
<h2>{tComments('policy.title')}</h2>
<CommentPolicySetting
pondId={pond.data.id}
pondSlug={pondSlug}
value={pond.data.settings.commentPolicy}
/>
</section>
)}
<section className="pond-export"> <section className="pond-export">
<h2>{tExport('pond.heading')}</h2> <h2>{tExport('pond.heading')}</h2>
<p className="pond-export__hint">{tExport('pond.hint')}</p> <p className="pond-export__hint">{tExport('pond.hint')}</p>

View File

@ -2350,3 +2350,148 @@ button {
gap: var(--space-3); gap: var(--space-3);
margin-top: var(--space-3); margin-top: var(--space-3);
} }
/* Comments panel (issue #92) */
.comments-panel {
border: 1px solid var(--color-border, #cbd5e1);
border-radius: 8px;
padding: var(--space-3);
margin: var(--space-3) 0;
background: var(--color-surface, #fff);
}
.comments-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
}
.comments-panel__header h2 {
margin: 0;
font-size: 1.1rem;
}
.comments-panel__count {
margin-left: var(--space-2);
font-size: 0.8125rem;
background: var(--color-surface-muted, #e2e8f0);
border-radius: 999px;
padding: 0.05rem 0.5rem;
}
.comments-panel__policy-hint,
.comments-panel__empty {
color: var(--color-text-muted);
}
.comments-panel__threads {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.comments-panel__resolved {
margin-top: var(--space-3);
}
.comments-panel__resolved > summary {
cursor: pointer;
color: var(--color-text-muted);
}
.comments-panel__resolved > .comments-panel__threads {
margin-top: var(--space-2);
}
.comments-thread {
border-top: 1px solid var(--color-border, #e2e8f0);
padding-top: var(--space-2);
}
.comments-thread--resolved {
opacity: 0.75;
}
.comments-thread__replies {
list-style: none;
margin: var(--space-2) 0 0;
padding-left: var(--space-4);
border-left: 2px solid var(--color-border, #e2e8f0);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.comment__meta {
display: flex;
gap: var(--space-2);
align-items: baseline;
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.comment__author {
font-weight: 600;
color: var(--color-text, inherit);
}
.comment__body p {
margin: var(--space-1) 0;
}
.comment__actions {
display: flex;
gap: var(--space-3);
}
.comments-link-button {
background: none;
border: none;
padding: 0;
color: var(--color-primary, #2f6f4f);
cursor: pointer;
font-size: 0.8125rem;
text-decoration: underline;
}
.comments-composer textarea {
width: 100%;
resize: vertical;
}
.comments-composer__footer {
display: flex;
align-items: center;
gap: var(--space-3);
margin-top: var(--space-1);
}
.comments-composer__hint {
flex: 1;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.comments-unread-badge {
margin-left: var(--space-1);
font-size: 0.75rem;
background: var(--color-primary, #2f6f4f);
color: #fff;
border-radius: 999px;
padding: 0.05rem 0.45rem;
}
.comment-policy__label {
display: flex;
align-items: center;
gap: var(--space-2);
}
.comment-policy__hint {
color: var(--color-text-muted);
font-size: 0.8125rem;
}

View File

@ -0,0 +1,39 @@
{
"title": "Kommentare",
"close": "Schließen",
"empty": "Noch keine Kommentare — starte die Diskussion.",
"commentBy": "Kommentar von {{name}}",
"deletedAuthor": "Gelöschte Nutzerin/gelöschter Nutzer",
"edited": "(bearbeitet)",
"resolve": "Erledigen",
"unresolve": "Wieder öffnen",
"delete": "Löschen",
"resolvedSection": "Erledigt ({{count}})",
"composer": {
"placeholder": "Kommentar schreiben…",
"submit": "Kommentieren",
"cancel": "Abbrechen",
"markdownHint": "Markdown möglich: **fett**, *kursiv*, `Code`, Listen.",
"editorsOnly": "Kommentare sind in diesem Teich auf Bearbeitende beschränkt."
},
"reply": {
"open": "Antworten",
"placeholder": "Antwort schreiben…",
"submit": "Antworten"
},
"edit": {
"open": "Bearbeiten",
"placeholder": "Kommentar bearbeiten…",
"submit": "Speichern"
},
"toggle": "Kommentare",
"unread": "{{count}} neu",
"policy": {
"title": "Kommentare",
"label": "Wer darf kommentieren",
"readers": "Alle mit Lesezugriff",
"editors": "Nur Bearbeitende",
"hint": "Lesende sehen die Diskussion immer; das steuert nur, wer schreiben darf.",
"saved": "Gespeichert."
}
}

View File

@ -0,0 +1,39 @@
{
"title": "Comments",
"close": "Close",
"empty": "No comments yet — start the discussion.",
"commentBy": "Comment by {{name}}",
"deletedAuthor": "Deleted user",
"edited": "(edited)",
"resolve": "Resolve",
"unresolve": "Reopen",
"delete": "Delete",
"resolvedSection": "Resolved ({{count}})",
"composer": {
"placeholder": "Write a comment…",
"submit": "Comment",
"cancel": "Cancel",
"markdownHint": "Markdown supported: **bold**, *italic*, `code`, lists.",
"editorsOnly": "Comments on this pond are limited to editors."
},
"reply": {
"open": "Reply",
"placeholder": "Write a reply…",
"submit": "Reply"
},
"edit": {
"open": "Edit",
"placeholder": "Edit your comment…",
"submit": "Save"
},
"toggle": "Comments",
"unread": "{{count}} new",
"policy": {
"title": "Comments",
"label": "Who may comment",
"readers": "Everyone who can read",
"editors": "Editors only",
"hint": "Readers always see the discussion; this only controls who may write.",
"saved": "Saved."
}
}