#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:
parent
c858f12592
commit
f014a61480
@ -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(
|
||||||
|
|||||||
@ -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 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 } });
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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,10 +289,15 @@ 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 }} />
|
||||||
)}
|
)}
|
||||||
|
{!readOnly && (
|
||||||
<footer className="comment__actions">
|
<footer className="comment__actions">
|
||||||
{own && !editing && (
|
{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')}
|
{t('edit.open')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -221,6 +310,7 @@ function CommentItem({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isRoot &&
|
{isRoot &&
|
||||||
|
mayComment &&
|
||||||
(resolved ? (
|
(resolved ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -239,6 +329,7 @@ function CommentItem({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</footer>
|
</footer>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -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')}
|
||||||
|
|||||||
@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user