M17–M19: #133–#137 (Kommentare inline, Statuszeile, Transklusion, Excalidraw, Checkbox-Fix) #138
@ -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(
|
||||
|
||||
@ -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 page’s 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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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],
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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')}
|
||||
|
||||
@ -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} />
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user