M17–M19: #133–#137 (Kommentare inline, Statuszeile, Transklusion, Excalidraw, Checkbox-Fix) #138

Merged
fable-5 merged 10 commits from m17-m19-issues into main 2026-07-19 09:38:38 +02:00
10 changed files with 274 additions and 163 deletions
Showing only changes of commit f014a61480 - Show all commits

View File

@ -1,4 +1,5 @@
import { Controller, Get, Param, Req, Res } from '@nestjs/common';
import type { PageCommentsView } from '@dorfteich/shared';
import type { Response } from 'express';
import { AuthedRequest, Public } from '../auth/auth.guard';
@ -24,6 +25,16 @@ export class PublicController {
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')
@Public()
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);
});
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 } });
// The API route invalidates on its own mutations; this test deletes
// directly, so drop the cached pond context to mirror that (issue #39).
app.get(PondPermissionCache).invalidate(pondId);
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}/comments`).expect(404);
});
});

View File

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

View File

@ -1,6 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import type { PageCommentsView } from '@dorfteich/shared';
import { Pond, User } from '@prisma/client';
import { CommentsService } from '../comments/comments.service';
import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PrismaService } from '../prisma/prisma.service';
@ -38,6 +40,7 @@ export class PublicService {
private readonly permissions: PermissionService,
private readonly fallbacks: PluginFallbackRenderer,
private readonly settings: InstanceSettingsService,
private readonly commentsService: CommentsService,
) {}
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. */
async html(
user: User | null,

View File

@ -5,10 +5,11 @@ 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.
* Comments UI (issues #92, #133): the discussion is a fixed inline section in
* the read view (no toggle), between the backlinks and the local graph. The
* pack drives the full two-user lifecycle, resolve/unresolve with the collapsed
* section, and the permission variant (composer hidden with a hint). It
* provisions its own page and grant, so it is repeatable.
*/
let pondId: string;
@ -52,23 +53,21 @@ test.afterAll(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 readerPage = await reader.newPage();
await readerPage.goto(pageUrl);
await readerPage.locator('.editor-shell__comments-toggle').click();
const readerPanel = readerPage.locator('.comments-panel');
const readerPanel = readerPage.locator('.comments-section');
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.
// The owner opens the page and sees the thread inline, replies, resolves.
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');
const ownerPanel = ownerPage.locator('.comments-section');
await expect(ownerPanel.locator('.comment__body strong')).toHaveText('final');
await ownerPanel
.locator('.comments-thread .comments-link-button', { hasText: /reply|antwort/i })
.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 })
.first()
.click();
const resolvedSection = ownerPanel.locator('.comments-panel__resolved');
const resolvedSection = ownerPanel.locator('.comments-section__resolved');
await expect(resolvedSection).toBeVisible();
await expect(resolvedSection.locator('details, summary').first()).toBeVisible();
// 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.
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();
// 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.
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 expect(readerPanel.locator('.comments-section__empty')).toBeVisible();
await reader.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 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();
const panel = page.locator('.comments-section');
await expect(panel.locator('.comments-section__policy-hint')).toBeVisible();
await expect(panel.locator('.comments-composer')).toHaveCount(0);
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 { useTranslation } from 'react-i18next';
import { useAuth } from '../auth/auth-context';
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';
/**
* 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).
* 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 CommentsPanel({
export function CommentsSection({
pageId,
mayComment,
onClose,
}: {
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;
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.
// 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);
@ -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 (
<section className="comments-panel" aria-label={t('title')}>
<div className="comments-panel__header">
<section id="comments" className="comments-section" aria-label={t('title')}>
<div className="comments-section__header">
<h2>
{t('title')}
{view && view.openCount > 0 && (
<span className="comments-panel__count">{view.openCount}</span>
{comments.data && comments.data.openCount > 0 && (
<span className="comments-section__count">{comments.data.openCount}</span>
)}
</h2>
<button type="button" className="button" onClick={onClose}>
{t('close')}
</button>
</div>
<FormError error={error} />
@ -71,14 +65,82 @@ export function CommentsPanel({
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 && (
<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) => (
<Thread
key={thread.root.id}
@ -86,14 +148,15 @@ export function CommentsPanel({
pageId={pageId}
mayComment={mayComment}
run={run}
readOnly={readOnly}
/>
))}
</ul>
{resolved.length > 0 && (
<details className="comments-panel__resolved">
<details className="comments-section__resolved">
<summary>{t('resolvedSection', { count: resolved.length })}</summary>
<ul className="comments-panel__threads">
<ul className="comments-section__threads">
{resolved.map((thread) => (
<Thread
key={thread.root.id}
@ -101,12 +164,13 @@ export function CommentsPanel({
pageId={pageId}
mayComment={mayComment}
run={run}
readOnly={readOnly}
/>
))}
</ul>
</details>
)}
</section>
</>
);
}
@ -115,26 +179,42 @@ function 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} />
<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} />
<CommentItem
comment={reply}
run={run}
isRoot={false}
resolved={thread.resolved}
mayComment={mayComment}
readOnly={readOnly}
/>
</li>
))}
</ul>
{mayComment && !thread.resolved && (
{!readOnly && mayComment && !thread.resolved && (
<div className="comments-thread__actions">
{replying ? (
<Composer
@ -169,11 +249,15 @@ function CommentItem({
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();
@ -205,10 +289,15 @@ function CommentItem({
// 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)}>
<button
type="button"
className="comments-link-button"
onClick={() => setEditing(true)}
>
{t('edit.open')}
</button>
<button
@ -221,6 +310,7 @@ function CommentItem({
</>
)}
{isRoot &&
mayComment &&
(resolved ? (
<button
type="button"
@ -239,6 +329,7 @@ function CommentItem({
</button>
))}
</footer>
)}
</article>
);
}

View File

@ -7,7 +7,6 @@ import {
Ellipsis,
FolderInput,
History,
MessageSquare,
Paperclip,
Pencil,
Save,
@ -35,9 +34,6 @@ interface PageActionsProps {
pondSlug: string;
mode: 'view' | 'edit';
onToggleMode: () => void;
unread: number;
showComments: boolean;
onToggleComments: () => void;
showAttachments: boolean;
onToggleAttachments: () => void;
hasTools: boolean;
@ -68,20 +64,6 @@ export function PageActions(props: PageActionsProps): React.JSX.Element {
</IconButton>
{props.mode === 'edit' && <SaveVersionButton pageId={props.pageId} />}
<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
className="editor-shell__attachments-toggle"
label={t('files:title')}

View File

@ -11,8 +11,7 @@ import { Link, useNavigate, useParams } from 'react-router-dom';
import * as Y from 'yjs';
import { useAuth } from '../auth/auth-context';
import { CommentsPanel } from '../comments/CommentsPanel';
import { unreadCount, useComments } from '../comments/use-comments';
import { CommentsSection } from '../comments/CommentsSection';
import { FormError } from '../components/forms';
import { useToast } from '../components/Toast';
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
@ -128,22 +127,20 @@ function PageEditor({
page,
mode,
pondSlug,
commentPolicy,
showAttachments,
showComments,
showPageTools,
onCloseAttachments,
onCloseComments,
onWriteAccess,
}: {
page: ResolvedPage;
mode: Mode;
pondSlug: string;
commentPolicy: 'readers' | 'editors';
showAttachments: boolean;
showComments: boolean;
showPageTools: boolean;
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 {
const { t } = useTranslation('editor');
const { user } = useAuth();
@ -170,10 +167,12 @@ function PageEditor({
const collab = useCollabProvider(ydoc, page.id);
const readOnly = collab.mode === 'ro';
// `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';
// The collab token's rw grant is the authoritative write signal; report it up
// so the outer page can decide whether to show the inline comment composer
// (issue #133). `readers`-policy ponds let anyone comment regardless.
useEffect(() => {
onWriteAccess(collab.mode === 'rw');
}, [collab.mode, onWriteAccess]);
// A revoked page can no longer be edited (issue #39); the content stays
// visible for export via the dialog below.
const canEdit = mode === 'edit' && !readOnly && !collab.accessRevoked;
@ -276,9 +275,6 @@ function PageEditor({
onClose={onCloseAttachments}
/>
)}
{showComments && (
<CommentsPanel pageId={page.id} mayComment={mayComment} onClose={onCloseComments} />
)}
{/* The connection status renders as an icon in the content footer
(left half); the localized text stays for screen readers and as
the hover tooltip. */}
@ -340,12 +336,10 @@ export function PageEditorPage(): React.JSX.Element {
const [showHistory, setShowHistory] = useState(false);
const [showLabels, setShowLabels] = 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);
// 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 queryClient = useQueryClient();
const showToast = useToast();
@ -460,12 +454,26 @@ export function PageEditorPage(): React.JSX.Element {
return () => window.removeEventListener('keydown', onKeyDown);
}, [pageId, user, mode, queryClient, t, showToast]);
// TopBar action data (issue #101): the comments badge and the plugin
// page-tools visibility live 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);
// TopBar action data (issue #101): the plugin page-tools visibility lives
// next to the icons, not inside the editor.
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> {
if (!resolved || title === resolved.title) return;
await apiPatch(`/pages/${resolved.id}`, { title });
@ -506,9 +514,6 @@ export function PageEditorPage(): React.JSX.Element {
pondSlug={pondSlug}
mode={mode}
onToggleMode={() => setMode(mode === 'edit' ? 'view' : 'edit')}
unread={unread}
showComments={showComments}
onToggleComments={() => setShowComments((open) => !open)}
showAttachments={showAttachments}
onToggleAttachments={() => setShowAttachments((open) => !open)}
hasTools={hasPageTools(pagePlugins.data)}
@ -543,12 +548,10 @@ export function PageEditorPage(): React.JSX.Element {
page={resolved}
mode={mode}
pondSlug={pondSlug}
commentPolicy={pond.data?.settings.commentPolicy ?? 'readers'}
showAttachments={showAttachments}
showComments={showComments}
showPageTools={showPageTools}
onCloseAttachments={() => setShowAttachments(false)}
onCloseComments={() => setShowComments(false)}
onWriteAccess={setCanWrite}
/>
{/* Side panels stack vertically in one column (M10 follow-up). */}
{(showLabels || showHistory) && (
@ -568,8 +571,10 @@ export function PageEditorPage(): React.JSX.Element {
)}
</div>
{/* "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' && <CommentsSection pageId={resolved.id} mayComment={mayComment} />}
{mode === 'view' && (
<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 { useParams } from 'react-router-dom';
import { PublicComments } from '../comments/CommentsSection';
import { ApiError, apiGet } from '../lib/api';
import { countWords, htmlToText } from '../lib/word-count';
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
from the sanitized editor schema safe to render. */}
<div className="public-page__body" dangerouslySetInnerHTML={{ __html: page.html }} />
{/* Existing comments, read-only for anonymous visitors (issue #133). */}
<PublicComments pondSlug={pondSlug} pageSlug={pageSlug} />
</article>
);
}

View File

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