#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
This commit is contained in:
Claude Fable 5 2026-07-19 01:22:22 +02:00
parent c858f12592
commit f014a61480
10 changed files with 274 additions and 163 deletions

View File

@ -1,4 +1,5 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common'; import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import type { PageCommentsView } from '@dorfteich/shared';
import type { Response } from 'express'; import type { Response } from 'express';
import { AuthedRequest, Public } from '../auth/auth.guard'; import { AuthedRequest, Public } from '../auth/auth.guard';
@ -24,6 +25,16 @@ export class PublicController {
return this.publicPages.content(request.user ?? null, pondSlug, pageSlug); return this.publicPages.content(request.user ?? null, pondSlug, pageSlug);
} }
@Get(':pondSlug/:pageSlug/comments')
@Public()
async comments(
@Param('pondSlug') pondSlug: string,
@Param('pageSlug') pageSlug: string,
@Req() request: AuthedRequest,
): Promise<PageCommentsView> {
return this.publicPages.comments(request.user ?? null, pondSlug, pageSlug);
}
@Get(':pondSlug/:pageSlug') @Get(':pondSlug/:pageSlug')
@Public() @Public()
async html( async html(

View File

@ -115,12 +115,36 @@ describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => {
await api().get(`/api/v1/public/${pondSlug}/does-not-exist`).expect(404); await api().get(`/api/v1/public/${pondSlug}/does-not-exist`).expect(404);
}); });
it('404s both endpoints once the public grant is removed', async () => { it('serves a pages comments read-only to an anonymous visitor (issue #133)', async () => {
const page = await prisma.page.findFirstOrThrow({ where: { pondId, slug: pageSlug } });
const root = await prisma.comment.create({
data: { pageId: page.id, authorId: ownerId, body: 'A public remark' },
});
await prisma.comment.create({
data: { pageId: page.id, parentId: root.id, authorId: ownerId, body: 'A public reply' },
});
const res = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(200);
const body = res.body as {
threads: { root: { body: string }; replies: { body: string }[] }[];
openCount: number;
};
expect(body.openCount).toBe(1);
expect(body.threads).toHaveLength(1);
expect(body.threads[0].root.body).toBe('A public remark');
expect(body.threads[0].replies.map((r) => r.body)).toEqual(['A public reply']);
// A non-public page never reveals its comments either.
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/comments`).expect(404);
});
it('404s all public endpoints once the public grant is removed', async () => {
await prisma.roleGrant.delete({ where: { id: publicGrantId } }); await prisma.roleGrant.delete({ where: { id: publicGrantId } });
// The API route invalidates on its own mutations; this test deletes // The API route invalidates on its own mutations; this test deletes
// directly, so drop the cached pond context to mirror that (issue #39). // directly, so drop the cached pond context to mirror that (issue #39).
app.get(PondPermissionCache).invalidate(pondId); app.get(PondPermissionCache).invalidate(pondId);
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(404); await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(404);
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(404); await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(404);
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(404);
}); });
}); });

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CommentsModule } from '../comments/comments.module';
import { PluginsModule } from '../plugins/plugins.module'; import { PluginsModule } from '../plugins/plugins.module';
import { PublicController } from './public.controller'; import { PublicController } from './public.controller';
@ -12,7 +13,7 @@ import { PublicService } from './public.service';
* marks `GET /media/:fileId` public too. * marks `GET /media/:fileId` public too.
*/ */
@Module({ @Module({
imports: [PluginsModule], imports: [PluginsModule, CommentsModule],
controllers: [PublicController], controllers: [PublicController],
providers: [PublicService], providers: [PublicService],
}) })

View File

@ -1,6 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import type { PageCommentsView } from '@dorfteich/shared';
import { Pond, User } from '@prisma/client'; import { Pond, User } from '@prisma/client';
import { CommentsService } from '../comments/comments.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@ -38,6 +40,7 @@ export class PublicService {
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly fallbacks: PluginFallbackRenderer, private readonly fallbacks: PluginFallbackRenderer,
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly commentsService: CommentsService,
) {} ) {}
private async resolve( private async resolve(
@ -77,6 +80,18 @@ export class PublicService {
}; };
} }
/**
* The page's comments for the anonymous public view (issue #133), read-only.
* `resolve()` enforces (possibly anonymous) read access a non-public page
* 404s here too, so comments never leak. `list` builds the same
* `PageCommentsView` the authenticated endpoint returns; the SPA renders it
* without any composer or action controls.
*/
async comments(user: User | null, pondSlug: string, pageSlug: string): Promise<PageCommentsView> {
const { page } = await this.resolve(user, pondSlug, pageSlug);
return this.commentsService.list(page.id, 'all');
}
/** A complete, self-contained HTML document for crawlers / PDF export. */ /** A complete, self-contained HTML document for crawlers / PDF export. */
async html( async html(
user: User | null, user: User | null,

View File

@ -5,10 +5,11 @@ import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/** /**
* Comments UI (issue #92): the full two-user lifecycle in the panel, * Comments UI (issues #92, #133): the discussion is a fixed inline section in
* resolve/unresolve with the collapsed section, the permission variant * the read view (no toggle), between the backlinks and the local graph. The
* (composer hidden with a hint), and the localStorage unread badge. * pack drives the full two-user lifecycle, resolve/unresolve with the collapsed
* The pack provisions its own page and grant, so it is repeatable. * section, and the permission variant (composer hidden with a hint). It
* provisions its own page and grant, so it is repeatable.
*/ */
let pondId: string; let pondId: string;
@ -52,23 +53,21 @@ test.afterAll(async ({ browser }) => {
}); });
test('two users run the full comment lifecycle with resolve/unresolve', async ({ browser }) => { test('two users run the full comment lifecycle with resolve/unresolve', async ({ browser }) => {
// The reader starts the thread. // The reader starts the thread — the section is inline in the read view.
const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); const reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
const readerPage = await reader.newPage(); const readerPage = await reader.newPage();
await readerPage.goto(pageUrl); await readerPage.goto(pageUrl);
await readerPage.locator('.editor-shell__comments-toggle').click(); const readerPanel = readerPage.locator('.comments-section');
const readerPanel = readerPage.locator('.comments-panel');
await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?'); await readerPanel.locator('.comments-composer textarea').fill('Is this **final**?');
await readerPanel.locator('.comments-composer button[type="submit"]').click(); await readerPanel.locator('.comments-composer button[type="submit"]').click();
await expect(readerPanel.locator('.comment__body strong')).toHaveText('final'); await expect(readerPanel.locator('.comment__body strong')).toHaveText('final');
// The owner sees the unread badge, replies, and resolves the thread. // The owner opens the page and sees the thread inline, replies, resolves.
const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const ownerPage = await owner.newPage(); const ownerPage = await owner.newPage();
await ownerPage.goto(pageUrl); await ownerPage.goto(pageUrl);
await expect(ownerPage.locator('.comments-unread-badge')).toBeVisible(); const ownerPanel = ownerPage.locator('.comments-section');
await ownerPage.locator('.editor-shell__comments-toggle').click(); await expect(ownerPanel.locator('.comment__body strong')).toHaveText('final');
const ownerPanel = ownerPage.locator('.comments-panel');
await ownerPanel await ownerPanel
.locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i }) .locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i })
.click(); .click();
@ -83,7 +82,7 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({
.locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i }) .locator('.comment__actions .comments-link-button', { hasText: /resolve|erledig/i })
.first() .first()
.click(); .click();
const resolvedSection = ownerPanel.locator('.comments-panel__resolved'); const resolvedSection = ownerPanel.locator('.comments-section__resolved');
await expect(resolvedSection).toBeVisible(); await expect(resolvedSection).toBeVisible();
await expect(resolvedSection.locator('details, summary').first()).toBeVisible(); await expect(resolvedSection.locator('details, summary').first()).toBeVisible();
// Collapsed: the thread body is hidden until the section is opened. // Collapsed: the thread body is hidden until the section is opened.
@ -93,7 +92,7 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({
// Unresolve restores it to the open list. // Unresolve restores it to the open list.
await resolvedSection.locator('.comments-link-button', { hasText: /reopen|öffnen/i }).click(); 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-section__resolved')).toHaveCount(0);
await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible(); await expect(ownerPanel.locator('.comments-thread .comment__body strong')).toBeVisible();
// Author edits and deletes own reply. // Author edits and deletes own reply.
@ -108,9 +107,8 @@ test('two users run the full comment lifecycle with resolve/unresolve', async ({
// Cleanup: the reader deletes their own (now reply-free) root. // Cleanup: the reader deletes their own (now reply-free) root.
await readerPage.reload(); await readerPage.reload();
await readerPage.locator('.editor-shell__comments-toggle').click();
await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).click(); await readerPanel.locator('.comments-link-button', { hasText: /delete|löschen/i }).click();
await expect(readerPanel.locator('.comments-panel__empty')).toBeVisible(); await expect(readerPanel.locator('.comments-section__empty')).toBeVisible();
await reader.close(); await reader.close();
await owner.close(); await owner.close();
@ -127,9 +125,8 @@ test('the composer hides with a hint when the policy bars readers', async ({ bro
const reader = await contextForUser(browser, BASE_URL, 'fixture-editor'); const reader = await contextForUser(browser, BASE_URL, 'fixture-editor');
const page = await reader.newPage(); const page = await reader.newPage();
await page.goto(pageUrl); await page.goto(pageUrl);
await page.locator('.editor-shell__comments-toggle').click(); const panel = page.locator('.comments-section');
const panel = page.locator('.comments-panel'); await expect(panel.locator('.comments-section__policy-hint')).toBeVisible();
await expect(panel.locator('.comments-panel__policy-hint')).toBeVisible();
await expect(panel.locator('.comments-composer')).toHaveCount(0); await expect(panel.locator('.comments-composer')).toHaveCount(0);
await reader.close(); await reader.close();

View File

@ -1,35 +1,36 @@
import type { CommentThreadView, CommentView } from '@dorfteich/shared'; import type { PageCommentsView, CommentThreadView, CommentView } from '@dorfteich/shared';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { apiDelete, apiPatch, apiPost } from '../lib/api'; import { apiDelete, apiGet, apiPatch, apiPost } from '../lib/api';
import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments'; import { markCommentsSeen, useComments, useInvalidateComments } from './use-comments';
/** /**
* The page's discussion panel (issue #92): threaded comments with a * The page's discussion (issue #92), rendered inline in the read view between
* Markdown composer, edit/delete for authors, resolve with a collapsed * the backlinks and the local graph (issue #133): threaded comments with a
* resolved section, and the permission-aware composer (hidden with a hint * Markdown composer, edit/delete for authors, and resolve with a collapsed
* when the pond's policy bars the viewer). * 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 CommentsPanel({ export function CommentsSection({
pageId, pageId,
mayComment, mayComment,
onClose,
}: { }: {
pageId: string; pageId: string;
/** Resolved by the caller from the pond's commentPolicy + collab mode. */ /** Resolved by the caller from the pond's commentPolicy + write access. */
mayComment: boolean; mayComment: boolean;
onClose: () => void;
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('comments'); const { t } = useTranslation('comments');
const comments = useComments(pageId); const comments = useComments(pageId);
const refresh = useInvalidateComments(pageId); const refresh = useInvalidateComments(pageId);
const [error, setError] = useState<unknown>(null); const [error, setError] = useState<unknown>(null);
// Opening the panel is "visiting" the discussion: the unread badge resets. // Seeing the inline section is "visiting" the discussion: the unread notion
// (used by comment notifications) resets while it is on screen.
useEffect(() => { useEffect(() => {
markCommentsSeen(pageId); markCommentsSeen(pageId);
return () => markCommentsSeen(pageId); return () => markCommentsSeen(pageId);
@ -45,22 +46,15 @@ export function CommentsPanel({
} }
}; };
const view = comments.data;
const open = (view?.threads ?? []).filter((thread) => !thread.resolved);
const resolved = (view?.threads ?? []).filter((thread) => thread.resolved);
return ( return (
<section className="comments-panel" aria-label={t('title')}> <section id="comments" className="comments-section" aria-label={t('title')}>
<div className="comments-panel__header"> <div className="comments-section__header">
<h2> <h2>
{t('title')} {t('title')}
{view && view.openCount > 0 && ( {comments.data && comments.data.openCount > 0 && (
<span className="comments-panel__count">{view.openCount}</span> <span className="comments-section__count">{comments.data.openCount}</span>
)} )}
</h2> </h2>
<button type="button" className="button" onClick={onClose}>
{t('close')}
</button>
</div> </div>
<FormError error={error} /> <FormError error={error} />
@ -71,14 +65,82 @@ export function CommentsPanel({
onSubmit={(body) => run(() => apiPost(`/pages/${pageId}/comments`, { body }))} onSubmit={(body) => run(() => apiPost(`/pages/${pageId}/comments`, { body }))}
/> />
) : ( ) : (
<p className="comments-panel__policy-hint">{t('composer.editorsOnly')}</p> <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 && ( {view && open.length === 0 && resolved.length === 0 && (
<p className="comments-panel__empty">{t('empty')}</p> <p className="comments-section__empty">{t('empty')}</p>
)} )}
<ul className="comments-panel__threads"> <ul className="comments-section__threads">
{open.map((thread) => ( {open.map((thread) => (
<Thread <Thread
key={thread.root.id} key={thread.root.id}
@ -86,14 +148,15 @@ export function CommentsPanel({
pageId={pageId} pageId={pageId}
mayComment={mayComment} mayComment={mayComment}
run={run} run={run}
readOnly={readOnly}
/> />
))} ))}
</ul> </ul>
{resolved.length > 0 && ( {resolved.length > 0 && (
<details className="comments-panel__resolved"> <details className="comments-section__resolved">
<summary>{t('resolvedSection', { count: resolved.length })}</summary> <summary>{t('resolvedSection', { count: resolved.length })}</summary>
<ul className="comments-panel__threads"> <ul className="comments-section__threads">
{resolved.map((thread) => ( {resolved.map((thread) => (
<Thread <Thread
key={thread.root.id} key={thread.root.id}
@ -101,12 +164,13 @@ export function CommentsPanel({
pageId={pageId} pageId={pageId}
mayComment={mayComment} mayComment={mayComment}
run={run} run={run}
readOnly={readOnly}
/> />
))} ))}
</ul> </ul>
</details> </details>
)} )}
</section> </>
); );
} }
@ -115,26 +179,42 @@ function Thread({
pageId, pageId,
mayComment, mayComment,
run, run,
readOnly,
}: { }: {
thread: CommentThreadView; thread: CommentThreadView;
pageId: string; pageId: string;
mayComment: boolean; mayComment: boolean;
run: (action: () => Promise<unknown>) => Promise<void>; run: (action: () => Promise<unknown>) => Promise<void>;
readOnly: boolean;
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('comments'); const { t } = useTranslation('comments');
const [replying, setReplying] = useState(false); const [replying, setReplying] = useState(false);
return ( return (
<li className={`comments-thread${thread.resolved ? ' comments-thread--resolved' : ''}`}> <li className={`comments-thread${thread.resolved ? ' comments-thread--resolved' : ''}`}>
<CommentItem comment={thread.root} run={run} isRoot resolved={thread.resolved} /> <CommentItem
comment={thread.root}
run={run}
isRoot
resolved={thread.resolved}
mayComment={mayComment}
readOnly={readOnly}
/>
<ul className="comments-thread__replies"> <ul className="comments-thread__replies">
{thread.replies.map((reply) => ( {thread.replies.map((reply) => (
<li key={reply.id}> <li key={reply.id}>
<CommentItem comment={reply} run={run} isRoot={false} resolved={thread.resolved} /> <CommentItem
comment={reply}
run={run}
isRoot={false}
resolved={thread.resolved}
mayComment={mayComment}
readOnly={readOnly}
/>
</li> </li>
))} ))}
</ul> </ul>
{mayComment && !thread.resolved && ( {!readOnly && mayComment && !thread.resolved && (
<div className="comments-thread__actions"> <div className="comments-thread__actions">
{replying ? ( {replying ? (
<Composer <Composer
@ -169,11 +249,15 @@ function CommentItem({
run, run,
isRoot, isRoot,
resolved, resolved,
mayComment,
readOnly,
}: { }: {
comment: CommentView; comment: CommentView;
run: (action: () => Promise<unknown>) => Promise<void>; run: (action: () => Promise<unknown>) => Promise<void>;
isRoot: boolean; isRoot: boolean;
resolved: boolean; resolved: boolean;
mayComment: boolean;
readOnly: boolean;
}): React.JSX.Element { }): React.JSX.Element {
const { t, i18n } = useTranslation('comments'); const { t, i18n } = useTranslation('comments');
const { user } = useAuth(); const { user } = useAuth();
@ -205,40 +289,47 @@ function CommentItem({
// Server-sanitized render (shared pipeline, issue #91) — safe by contract. // Server-sanitized render (shared pipeline, issue #91) — safe by contract.
<div className="comment__body" dangerouslySetInnerHTML={{ __html: comment.html }} /> <div className="comment__body" dangerouslySetInnerHTML={{ __html: comment.html }} />
)} )}
<footer className="comment__actions"> {!readOnly && (
{own && !editing && ( <footer className="comment__actions">
<> {own && !editing && (
<button type="button" className="comments-link-button" onClick={() => setEditing(true)}> <>
{t('edit.open')} <button
</button> type="button"
<button className="comments-link-button"
type="button" onClick={() => setEditing(true)}
className="comments-link-button" >
onClick={() => void run(() => apiDelete(`/comments/${comment.id}`))} {t('edit.open')}
> </button>
{t('delete')} <button
</button> type="button"
</> className="comments-link-button"
)} onClick={() => void run(() => apiDelete(`/comments/${comment.id}`))}
{isRoot && >
(resolved ? ( {t('delete')}
<button </button>
type="button" </>
className="comments-link-button" )}
onClick={() => void run(() => apiDelete(`/comments/${comment.id}/resolve`))} {isRoot &&
> mayComment &&
{t('unresolve')} (resolved ? (
</button> <button
) : ( type="button"
<button className="comments-link-button"
type="button" onClick={() => void run(() => apiDelete(`/comments/${comment.id}/resolve`))}
className="comments-link-button" >
onClick={() => void run(() => apiPost(`/comments/${comment.id}/resolve`, {}))} {t('unresolve')}
> </button>
{t('resolve')} ) : (
</button> <button
))} type="button"
</footer> className="comments-link-button"
onClick={() => void run(() => apiPost(`/comments/${comment.id}/resolve`, {}))}
>
{t('resolve')}
</button>
))}
</footer>
)}
</article> </article>
); );
} }

View File

@ -7,7 +7,6 @@ import {
Ellipsis, Ellipsis,
FolderInput, FolderInput,
History, History,
MessageSquare,
Paperclip, Paperclip,
Pencil, Pencil,
Save, Save,
@ -35,9 +34,6 @@ interface PageActionsProps {
pondSlug: string; pondSlug: string;
mode: 'view' | 'edit'; mode: 'view' | 'edit';
onToggleMode: () => void; onToggleMode: () => void;
unread: number;
showComments: boolean;
onToggleComments: () => void;
showAttachments: boolean; showAttachments: boolean;
onToggleAttachments: () => void; onToggleAttachments: () => void;
hasTools: boolean; hasTools: boolean;
@ -68,20 +64,6 @@ export function PageActions(props: PageActionsProps): React.JSX.Element {
</IconButton> </IconButton>
{props.mode === 'edit' && <SaveVersionButton pageId={props.pageId} />} {props.mode === 'edit' && <SaveVersionButton pageId={props.pageId} />}
<WatchToggle targetType="page" targetId={props.pageId} variant="icon" /> <WatchToggle targetType="page" targetId={props.pageId} variant="icon" />
<IconButton
className="editor-shell__comments-toggle"
label={
props.unread > 0
? `${t('comments:toggle')}${t('comments:unread', { count: props.unread })}`
: t('comments:toggle')
}
active={props.showComments}
aria-expanded={props.showComments}
onClick={props.onToggleComments}
>
<MessageSquare aria-hidden />
{props.unread > 0 && <span className="comments-unread-badge">{props.unread}</span>}
</IconButton>
<IconButton <IconButton
className="editor-shell__attachments-toggle" className="editor-shell__attachments-toggle"
label={t('files:title')} label={t('files:title')}

View File

@ -11,8 +11,7 @@ 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 { CommentsSection } from '../comments/CommentsSection';
import { unreadCount, useComments } from '../comments/use-comments';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
@ -128,22 +127,20 @@ function PageEditor({
page, page,
mode, mode,
pondSlug, pondSlug,
commentPolicy,
showAttachments, showAttachments,
showComments,
showPageTools, showPageTools,
onCloseAttachments, onCloseAttachments,
onCloseComments, onWriteAccess,
}: { }: {
page: ResolvedPage; page: ResolvedPage;
mode: Mode; mode: Mode;
pondSlug: string; pondSlug: string;
commentPolicy: 'readers' | 'editors';
showAttachments: boolean; showAttachments: boolean;
showComments: boolean;
showPageTools: boolean; showPageTools: boolean;
onCloseAttachments: () => void; onCloseAttachments: () => void;
onCloseComments: () => void; /** Reports write access up so the outer page can render the inline
* comments composer (issue #133); the collab mode lives with the provider. */
onWriteAccess: (canWrite: boolean) => void;
}): React.JSX.Element { }): React.JSX.Element {
const { t } = useTranslation('editor'); const { t } = useTranslation('editor');
const { user } = useAuth(); const { user } = useAuth();
@ -170,10 +167,12 @@ function PageEditor({
const collab = useCollabProvider(ydoc, page.id); const collab = useCollabProvider(ydoc, page.id);
const readOnly = collab.mode === 'ro'; const readOnly = collab.mode === 'ro';
// `readers` = everyone who can see the page; `editors` = the collab token // The collab token's rw grant is the authoritative write signal; report it up
// explicitly granted rw (while it is still null, stay conservative — the // so the outer page can decide whether to show the inline comment composer
// composer appears once the mode resolves). // (issue #133). `readers`-policy ponds let anyone comment regardless.
const mayComment = commentPolicy === 'readers' || collab.mode === 'rw'; useEffect(() => {
onWriteAccess(collab.mode === 'rw');
}, [collab.mode, onWriteAccess]);
// 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;
@ -276,9 +275,6 @@ function PageEditor({
onClose={onCloseAttachments} onClose={onCloseAttachments}
/> />
)} )}
{showComments && (
<CommentsPanel pageId={page.id} mayComment={mayComment} onClose={onCloseComments} />
)}
{/* The connection status renders as an icon in the content footer {/* The connection status renders as an icon in the content footer
(left half); the localized text stays for screen readers and as (left half); the localized text stays for screen readers and as
the hover tooltip. */} the hover tooltip. */}
@ -340,12 +336,10 @@ export function PageEditorPage(): React.JSX.Element {
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const [showLabels, setShowLabels] = useState(false); const [showLabels, setShowLabels] = useState(false);
const [showAttachments, setShowAttachments] = useState(false); const [showAttachments, setShowAttachments] = useState(false);
// Deep link from a comment notification (issue #94): ?comments=1 opens
// the panel immediately.
const [showComments, setShowComments] = useState(
() => new URLSearchParams(window.location.search).get('comments') === '1',
);
const [showPageTools, setShowPageTools] = useState(false); const [showPageTools, setShowPageTools] = useState(false);
// Write access reported up by the editor's collab provider, so the outer page
// can decide whether to show the inline comment composer (issue #133).
const [canWrite, setCanWrite] = useState(false);
const actionsSlot = usePageActionsSlot(); const actionsSlot = usePageActionsSlot();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const showToast = useToast(); const showToast = useToast();
@ -460,12 +454,26 @@ export function PageEditorPage(): React.JSX.Element {
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [pageId, user, mode, queryClient, t, showToast]); }, [pageId, user, mode, queryClient, t, showToast]);
// TopBar action data (issue #101): the comments badge and the plugin // TopBar action data (issue #101): the plugin page-tools visibility lives
// page-tools visibility live next to the icons, not inside the editor. // next to the icons, not inside the editor.
const comments = useComments(resolved?.id);
const unread = showComments || !resolved ? 0 : unreadCount(comments.data, resolved.id, user?.id);
const pagePlugins = usePondPlugins(resolved?.pondId); const pagePlugins = usePondPlugins(resolved?.pondId);
// Comments are shown inline in the read view (issue #133). `readers`-policy
// ponds let any reader comment; `editors` require the collab rw grant the
// editor reports up via onWriteAccess.
const mayComment = (pond.data?.settings.commentPolicy ?? 'readers') === 'readers' || canWrite;
// Deep link from a comment notification (issue #94): ?comments=1 scrolls to
// the inline discussion once it has mounted in read mode.
useEffect(() => {
if (mode !== 'view') return;
if (new URLSearchParams(window.location.search).get('comments') !== '1') return;
const timer = window.setTimeout(() => {
document.getElementById('comments')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 200);
return () => window.clearTimeout(timer);
}, [mode, resolved?.id]);
async function saveTitle(): Promise<void> { async function saveTitle(): Promise<void> {
if (!resolved || title === resolved.title) return; if (!resolved || title === resolved.title) return;
await apiPatch(`/pages/${resolved.id}`, { title }); await apiPatch(`/pages/${resolved.id}`, { title });
@ -506,9 +514,6 @@ export function PageEditorPage(): React.JSX.Element {
pondSlug={pondSlug} pondSlug={pondSlug}
mode={mode} mode={mode}
onToggleMode={() => setMode(mode === 'edit' ? 'view' : 'edit')} onToggleMode={() => setMode(mode === 'edit' ? 'view' : 'edit')}
unread={unread}
showComments={showComments}
onToggleComments={() => setShowComments((open) => !open)}
showAttachments={showAttachments} showAttachments={showAttachments}
onToggleAttachments={() => setShowAttachments((open) => !open)} onToggleAttachments={() => setShowAttachments((open) => !open)}
hasTools={hasPageTools(pagePlugins.data)} hasTools={hasPageTools(pagePlugins.data)}
@ -543,12 +548,10 @@ export function PageEditorPage(): React.JSX.Element {
page={resolved} page={resolved}
mode={mode} mode={mode}
pondSlug={pondSlug} pondSlug={pondSlug}
commentPolicy={pond.data?.settings.commentPolicy ?? 'readers'}
showAttachments={showAttachments} showAttachments={showAttachments}
showComments={showComments}
showPageTools={showPageTools} showPageTools={showPageTools}
onCloseAttachments={() => setShowAttachments(false)} onCloseAttachments={() => setShowAttachments(false)}
onCloseComments={() => setShowComments(false)} onWriteAccess={setCanWrite}
/> />
{/* Side panels stack vertically in one column (M10 follow-up). */} {/* Side panels stack vertically in one column (M10 follow-up). */}
{(showLabels || showHistory) && ( {(showLabels || showHistory) && (
@ -568,8 +571,10 @@ export function PageEditorPage(): React.JSX.Element {
)} )}
</div> </div>
{/* "Linked from" appears below the content in read mode (issue #48); {/* "Linked from" appears below the content in read mode (issue #48);
the local neighborhood graph joins it there (issue #113). */} the inline discussion (issue #133) and the local neighborhood graph
(issue #113) follow it, in that order. */}
{mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />} {mode === 'view' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
{mode === 'view' && <CommentsSection pageId={resolved.id} mayComment={mayComment} />}
{mode === 'view' && ( {mode === 'view' && (
<LocalGraphPanel pageId={resolved.id} pondId={resolved.pondId} pondSlug={pondSlug} /> <LocalGraphPanel pageId={resolved.id} pondId={resolved.pondId} pondSlug={pondSlug} />
)} )}

View File

@ -3,6 +3,7 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { PublicComments } from '../comments/CommentsSection';
import { ApiError, apiGet } from '../lib/api'; import { ApiError, apiGet } from '../lib/api';
import { countWords, htmlToText } from '../lib/word-count'; import { countWords, htmlToText } from '../lib/word-count';
import { NotFoundPage } from './NotFoundPage'; import { NotFoundPage } from './NotFoundPage';
@ -55,6 +56,8 @@ export function PublicPageView(): React.JSX.Element {
{/* The HTML comes from the server's content cache (issue #24), derived {/* The HTML comes from the server's content cache (issue #24), derived
from the sanitized editor schema safe to render. */} from the sanitized editor schema safe to render. */}
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} /> <div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
{/* Existing comments, read-only for anonymous visitors (issue #133). */}
<PublicComments pondSlug={pondSlug} pageSlug={pageSlug} />
</article> </article>
); );
} }

View File

@ -522,15 +522,6 @@ button {
gap: var(--space-1); gap: var(--space-1);
} }
.icon-button .comments-unread-badge {
position: absolute;
top: -4px;
right: -6px;
margin-left: 0;
line-height: 1.1rem;
padding: 0 0.3rem;
}
.page-actions__more { .page-actions__more {
position: relative; position: relative;
} }
@ -3346,8 +3337,8 @@ ul[data-type='task_list'] li > div > p:last-child {
margin-top: var(--space-3); margin-top: var(--space-3);
} }
/* Comments panel (issue #92) */ /* Inline comments section, read view (issues #92, #133) */
.comments-panel { .comments-section {
border: 1px solid var(--color-border, #cbd5e1); border: 1px solid var(--color-border, #cbd5e1);
border-radius: 8px; border-radius: 8px;
padding: var(--space-3); padding: var(--space-3);
@ -3355,19 +3346,19 @@ ul[data-type='task_list'] li > div > p:last-child {
background: var(--color-surface, #fff); background: var(--color-surface, #fff);
} }
.comments-panel__header { .comments-section__header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
.comments-panel__header h2 { .comments-section__header h2 {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1.1rem;
} }
.comments-panel__count { .comments-section__count {
margin-left: var(--space-2); margin-left: var(--space-2);
font-size: 0.8125rem; font-size: 0.8125rem;
background: var(--color-surface-muted, #e2e8f0); background: var(--color-surface-muted, #e2e8f0);
@ -3375,12 +3366,12 @@ ul[data-type='task_list'] li > div > p:last-child {
padding: 0.05rem 0.5rem; padding: 0.05rem 0.5rem;
} }
.comments-panel__policy-hint, .comments-section__policy-hint,
.comments-panel__empty { .comments-section__empty {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.comments-panel__threads { .comments-section__threads {
list-style: none; list-style: none;
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -3389,16 +3380,16 @@ ul[data-type='task_list'] li > div > p:last-child {
gap: var(--space-3); gap: var(--space-3);
} }
.comments-panel__resolved { .comments-section__resolved {
margin-top: var(--space-3); margin-top: var(--space-3);
} }
.comments-panel__resolved > summary { .comments-section__resolved > summary {
cursor: pointer; cursor: pointer;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.comments-panel__resolved > .comments-panel__threads { .comments-section__resolved > .comments-section__threads {
margin-top: var(--space-2); margin-top: var(--space-2);
} }
@ -3471,15 +3462,6 @@ ul[data-type='task_list'] li > div > p:last-child {
color: var(--color-text-muted); 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 { .comment-policy__label {
display: flex; display: flex;
align-items: center; align-items: center;