From fdbd5ce2ec837cbe56b94217230d28ee46eeb928 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 22:37:49 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(moderation):=20=E2=9C=A8=20report=20an?= =?UTF-8?q?y=20content=20record=20from=20public=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `contentTarget` and `ContentTarget` to `@vitnode/core/content` so a public action names its record from the content type definition. - `ReportButton` takes `target`, gets an accessible name ("Report {type}"), `aria-haspopup`, an optional `describedBy`, and a non-focusable skeleton while its messages load. - Export `ReportDialog` (controlled) and `REPORT_NAMESPACES`. - An expired session (401) now shows a "sign in again" toast. - Forum: enable reports on topics and comments, render the Report action for everyone but the author, and label the generated permissions. - Blog: switch to `target`. - Docs: list-item reporting, `ReportButton`/`ReportDialog` reference, and fixes in threads, approvals, public write and rate limit pages. BREAKING CHANGE: `ReportButton` takes `target` instead of `pluginId`, `targetType` and `targetId`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CE6w3pVi8mgjZMhax7f6tu --- .../content/docs/content-engine/approvals.mdx | 4 +- .../docs/content-engine/public-write-api.mdx | 2 +- .../content-engine/public-write-frontend.mdx | 11 +- .../content/docs/content-engine/reference.mdx | 2 +- .../content/docs/content-engine/threads.mdx | 23 +- .../docs/dev/moderation/enable-reports.mdx | 54 ++++- .../content/docs/dev/moderation/reference.mdx | 48 ++++- packages/vitnode/src/content/index.ts | 2 + packages/vitnode/src/content/target.ts | 45 ++++ packages/vitnode/src/locales/en.json | 1 + .../vitnode/src/tanstack/moderation/index.ts | 8 +- .../src/tanstack/moderation/report-button.tsx | 202 ++++++++++++------ .../views/moderation/moderation-mutations.ts | 2 + .../report/report-dialog-content.tsx | 1 + plugins/blog/src/pages/post-page.tsx | 9 +- plugins/forum/src/content/comment.ts | 2 + plugins/forum/src/content/topic.ts | 1 + plugins/forum/src/locales/en.json | 8 + plugins/forum/src/pages/thread.tsx | 23 +- plugins/forum/src/pages/topic-page.tsx | 35 +-- 20 files changed, 377 insertions(+), 106 deletions(-) create mode 100644 packages/vitnode/src/content/target.ts diff --git a/apps/web/content/docs/content-engine/approvals.mdx b/apps/web/content/docs/content-engine/approvals.mdx index bd0b14f02..e71ac2e1f 100644 --- a/apps/web/content/docs/content-engine/approvals.mdx +++ b/apps/web/content/docs/content-engine/approvals.mdx @@ -66,7 +66,7 @@ For a proposal, `values` is still the live record and `approval.values` holds wh To list everything an author sent, call the core moderation module. It returns pending, approved and rejected submissions, newest first, for the signed-in member. Pass `pluginId` to keep only your plugin's submissions - the filter runs before the page is cut, so every page is full and `total` counts only yours: -```ts title="plugins/forum/src/pages/my-submissions-data.ts" +```ts import { fetcher } from "@vitnode/core/tanstack/fetcher"; export const fetchMySubmissions = async (page: number) => { @@ -83,7 +83,7 @@ export const fetchMySubmissions = async (page: number) => { }; ``` -Each item has `kind`, `status`, `title`, `contentTypeId`, `itemId`, the container, `reason` and the dates. A guest gets `401`. +Each item has `kind`, `status`, `title`, `contentTypeId`, `itemId`, the container, `reason` and the dates. A guest gets `401`. The Forum plugin's `pending-page.tsx` renders this list at `/forum/me/pending`. ## What a decision does diff --git a/apps/web/content/docs/content-engine/public-write-api.mdx b/apps/web/content/docs/content-engine/public-write-api.mdx index 15ef0b777..c1bd01abf 100644 --- a/apps/web/content/docs/content-engine/public-write-api.mdx +++ b/apps/web/content/docs/content-engine/public-write-api.mdx @@ -263,7 +263,7 @@ The same member editing somebody else's draft gets `404` with `"code": "CONTENT_ ### Rate limit -`POST` and `PATCH` requests to a path containing `/content/`, outside `/admin/`, share a budget of 20 requests per 60 seconds per IP, on top of the write budget every mutation spends. Change it with `rateLimiter.contentWrite` in `apps/api/src/vitnode.api.config.ts`. It is off when `NODE_ENV=development`. See [Rate limiter](/docs/dev/advanced/rate-limiter) for the `429` body and Redis-backed counters. +`POST` and `PATCH` requests to a path containing `/content/`, outside `/admin/`, share a budget of 20 requests per 60 seconds, on top of the write budget every mutation spends. Change it with `rateLimiter.contentWrite` in `apps/api/src/vitnode.api.config.ts`. The budget counts per signed-in user when the request carries a session cookie, and per IP otherwise (`rateLimiter.perUser`, on by default). It is off when `NODE_ENV=development`. See [Rate limiter](/docs/dev/advanced/rate-limiter) for the `429` body and Redis-backed counters. ### Codes diff --git a/apps/web/content/docs/content-engine/public-write-frontend.mdx b/apps/web/content/docs/content-engine/public-write-frontend.mdx index dd5a12789..8b5d8821f 100644 --- a/apps/web/content/docs/content-engine/public-write-frontend.mdx +++ b/apps/web/content/docs/content-engine/public-write-frontend.mdx @@ -641,6 +641,15 @@ Restart `pnpm dev` and sign in with a member account: {/* Image prompt: The /discussions/new page of the example plugin: "Start a discussion" heading, a Title input and a Message rich text editor, and a "Post discussion" button. Light theme, 1440x900. */} +## Approvals and edit windows + +`example.discussion` publishes straight away. When a content type is filed in a container with [publishing policies](/docs/content-engine/container-policies), the same routes can answer differently: + +- A create or edit that needs a moderator still answers `201` or `200`, with `approval.status: "pending"`. Tell the author it is waiting instead of saying it is live. `GET /{id}/edit` returns the pending proposal in `approval.values`. See [Approvals](/docs/content-engine/approvals#what-the-author-sees). +- A create in a container with `create: "moderators"` answers `403` with the `moderators_only` issue. An owner edit after `authorEdit` stopped allowing it answers `403` with `edit_window_closed`. + +The Forum plugin handles all of these in `topic-edit-page.tsx` and `comment-composer.tsx`. + ## Ask for a captcha With `create.captcha: true`, every create needs a captcha token, as long as a [captcha provider](/docs/dev/captcha) is configured. Pass the deployment's captcha config to `AutoForm` and forward the token it hands to `onSubmit`: @@ -680,7 +689,7 @@ The routes do not care how the form is built. Any form library, or none, works a 2. On `400`, `401`, `403` or `404`, read the body with `parseContentPublicWriteRejection` from `@vitnode/core/content`. It takes the parsed JSON and returns the rejection, or `null` for any other shape. 3. On `409`, check `code` for `CONTENT_VERSION_CONFLICT` or `CONTENT_UNIQUE_CONFLICT`. -This plain React form turns a textarea into paragraphs and puts each issue under its field: +For example, this plain React form, which you write yourself, turns a textarea into paragraphs and puts each issue under its field: ```tsx title="plugins/example/src/pages/quick-discussion-form.tsx" import { diff --git a/apps/web/content/docs/content-engine/reference.mdx b/apps/web/content/docs/content-engine/reference.mdx index e6286dd52..097537852 100644 --- a/apps/web/content/docs/content-engine/reference.mdx +++ b/apps/web/content/docs/content-engine/reference.mdx @@ -303,7 +303,7 @@ visibility: true, ## `reports` -`reports: true` lets signed-in readers report a published record. It registers a report target, adds `can_view_reports` and `can_manage_reports` to the moderator catalog, plus `can_view_hidden` and `can_hide` with `visibility`, and needs no migration. Pass `{ titleField, snapshotFields }` to choose what the report's snapshot keeps. Guide: [Enable reports](/docs/dev/moderation/enable-reports). Options: [Moderation reference](/docs/dev/moderation/reference#reports-on-a-content-type). +`reports: true` lets signed-in readers report a published record. It registers a report target, adds `can_view_reports` and `can_manage_reports` to the moderator catalog, plus `can_view_hidden` and `can_hide` with `visibility`, and needs no migration. Pass `{ titleField, snapshotFields }` to choose what the report's snapshot keeps. Render the public button with `ReportButton` and `contentTarget(manifest.id, definition, record.id)`. Guide: [Enable reports](/docs/dev/moderation/enable-reports). Options and components: [Moderation reference](/docs/dev/moderation/reference#reports-on-a-content-type). ```ts title="plugins/blog/src/content/post.ts" publicApi: { path: "blog", fields: ["id", "title", "friendlyUrl", "content"] }, diff --git a/apps/web/content/docs/content-engine/threads.mdx b/apps/web/content/docs/content-engine/threads.mdx index ebc7052b7..7b3a47514 100644 --- a/apps/web/content/docs/content-engine/threads.mdx +++ b/apps/web/content/docs/content-engine/threads.mdx @@ -134,21 +134,28 @@ import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { manifest } from "@/manifest.gen"; -export const fetchTopicThread = async (topicId: number, after?: string) => { +export const fetchThreadPage = async ({ + after, + topicId, +}: { + after?: string; + topicId: number; +}) => { const res = await fetcher({ plugin: manifest.id, method: "get", module: "content/comments", path: "/thread", - args: { query: { root: String(topicId), first: 20, after } }, + args: { query: { root: String(topicId), first: 20, ...(after ? { after } : {}) } }, }); - if (!res.ok) throw new Error("Could not load the comments."); + if (res.status === 404) return null; + if (res.status !== 200) throw new Error("Loading the comments failed."); return await res.json(); }; ``` -`items` is already flattened depth first, so render it as a list and indent each item by its `depth`. Pass `pageInfo.endCursor` as `after` to load the next page. +`items` is already flattened depth first, so render it as a list and indent each item by its `depth`. Pass `pageInfo.endCursor` as `after` to load the next page. A `404` means the root record is missing or not public. The Forum plugin's `thread-data.ts` also validates the response with Zod, and `thread.tsx` renders it as a nested list. @@ -174,7 +181,7 @@ export const fetchTopicThread = async (topicId: number, after?: string) => { "id": 17, "slug": "k3v9q2m7xa", "body": { "type": "doc", "content": [] }, - "topic": 42, + "topic": { "id": 42 }, "parent": null, "publishedAt": "2026-10-13T09:20:00.000Z", "createdAt": "2026-10-13T09:20:00.000Z", @@ -189,7 +196,7 @@ export const fetchTopicThread = async (topicId: number, after?: string) => { } }, { "depth": 2, "id": 18, "parentId": 17, "removed": true, "record": null }, - { "depth": 3, "id": 21, "parentId": 18, "removed": false, "record": { "id": 21, "parent": 18 } } + { "depth": 3, "id": 21, "parentId": 18, "removed": false, "record": { "id": 21, "parent": { "id": 18 } } } ], "pageInfo": { "endCursor": "WyIyMDI2LTEwLTEzVDA5OjIwOjAwLjAwMFoiLDE3XQ", "hasNextPage": false, "totalCount": 1 }, "replyCount": 2, @@ -202,7 +209,9 @@ export const fetchTopicThread = async (topicId: number, after?: string) => { - **Limits.** One page carries at most 500 nodes and stops at `maxDepth`. `truncated: true` means some nested replies did not fit. - **The root must be public.** A missing, draft or hidden topic answers `404`. -`record` has the same shape as a record from the public list, including `people` summaries. A person is `null` when the account was deleted. +`record` has the same shape as a record from the public list, including `people` summaries. A to-one relation such as `topic` or `parent` comes back as `{ id }`, not a bare id. A person is `null` when the account was deleted. + +Readers can report a reply like any other record: turn on [`reports`](/docs/dev/moderation/enable-reports#report-items-in-a-list) for the reply type and render a `ReportButton` per reply. ## Placement rules diff --git a/apps/web/content/docs/dev/moderation/enable-reports.mdx b/apps/web/content/docs/dev/moderation/enable-reports.mdx index b7a9d83cc..73d5639c6 100644 --- a/apps/web/content/docs/dev/moderation/enable-reports.mdx +++ b/apps/web/content/docs/dev/moderation/enable-reports.mdx @@ -75,29 +75,73 @@ Enabling reports grants nothing. An administrator still has to give these permis ### Place the Report button -A plugin's own pages place the button themselves. Pass the record's stable id and the language the reader was served: +A plugin's own pages place the button themselves. Pass the record as a `target` built with `contentTarget`, plus the language the reader was served: ```tsx title="plugins/blog/src/pages/post-page.tsx" +import { contentTarget } from "@vitnode/core/content"; import { ReportButton } from "@vitnode/core/tanstack/moderation"; +import { blogPostContentType } from "@/content/post"; +import { manifest } from "@/manifest.gen"; + {!metadata.hidden && metadata.itemId !== null ? ( ) : null} ``` +`contentTarget(pluginId, definition, id)` returns `{ pluginId, type, id }`: your plugin id, the content type id (`"blog.post"`) and the record id. Building it from the definition keeps the type id in sync with what the API registered. `typeLabel` is the item's name, already translated, for the **Report article** title, so add it to your plugin's locale file: + +```json title="plugins/blog/src/locales/en.json" +{ + "@vitnode/blog": { + "post": { + "report_type": "article" + } + } +} +``` + The blog reads `itemId`, `locale` and `requestedLocale` from the [delivery resolution](/docs/content-engine/public-pages), so the button reports the article in the language the reader actually saw, even after a fallback. The form loads only when the dialog opens. A signed-out reader gets a **Sign in** link instead of the form. +## Report items in a list + +Comments, replies and other list items work the same way: the record id comes from the item you are rendering. The forum puts a button in each comment's action row, for everyone except the comment's author: + +```tsx title="plugins/forum/src/pages/thread.tsx" +import { contentTarget } from "@vitnode/core/content"; +import { ReportButton } from "@vitnode/core/tanstack/moderation"; + +import { commentContentType } from "@/content/comment"; +import { manifest } from "@/manifest.gen"; + +const authorId = `${commentAnchorId(comment.slug)}-author`; + + + + + +{isOwner ? null : ( + +)} +``` + +`comment.id` comes from the [thread](/docs/content-engine/threads) response. Comments are not localized, so the button needs no `locale`. `describedBy` points at the author's name, so a screen reader announces "Report comment, Jane" instead of 30 identical "Report comment" buttons. + +Hiding the button from the author is a choice the forum makes, not a server rule. Every button shares one request for its messages and loads the form only when its dialog opens. To open the dialog from your own trigger, such as a menu item, use [`ReportDialog`](/docs/dev/moderation/reference#reportdialog). + ## Check the result Rebuild with `pnpm build:plugins`, restart `pnpm dev`, sign in as a regular user and open a published article. Click **Report**, pick a reason and send it: @@ -111,7 +155,7 @@ A toast says **Report sent**. Try sending it again and the toast tells you, poli ## What the server checks -- The reporter must be signed in. A guest gets `401`. +- The reporter must be signed in. A guest gets `401`, and a reader whose session ended mid-dialog sees a "sign in again" message. - The target must be readable by the reporter through the public API. Anything else is `404`, the same answer as a typo. - **Other** needs at least 10 characters of details. Details are at most 2000 characters. - One reporter can hold one open report per target. Other reporters get their own reports. diff --git a/apps/web/content/docs/dev/moderation/reference.mdx b/apps/web/content/docs/dev/moderation/reference.mdx index 5d47ccd2b..eee531f5b 100644 --- a/apps/web/content/docs/dev/moderation/reference.mdx +++ b/apps/web/content/docs/dev/moderation/reference.mdx @@ -17,7 +17,7 @@ Exact names, types and behavior of the moderation APIs. For step-by-step guides, `reports: true` uses both defaults. It throws without `publicApi`, for an unknown option, for `reports.enabled`, and for a field that does not exist or cannot be snapshotted. -The server-side model can scope reports with `createContentModel(definition, { moderationScope: { column, family } })`, where `column` is a base-table column holding the resource id. +The server-side model can scope reports with `createContentModel(definition, { moderationScope: { column, family } })`, where `column` is a base-table column holding the resource id. A content type that reaches its container through a relation, such as a forum comment through its topic, has no such column, so its report permissions stay unscoped. ## Generated moderator permissions @@ -83,6 +83,52 @@ Content Engine types register their target automatically. Anything else register `run` executes inside the report's transaction and returns `{ changed, effects }`. `effects` runs after the commit. When `run` throws, the transaction rolls back and no timeline entry is written. +## Report UI + +Public pages report a target with the components from `@vitnode/core/tanstack/moderation`. Both load their own messages (`REPORT_NAMESPACES`) and lazy-load the form, and both show a **Sign in** link instead of the form to guests. For a walkthrough, see [Enable reports](/docs/dev/moderation/enable-reports). + +### `contentTarget` + +`contentTarget(pluginId, definition, id)` from `@vitnode/core/content` returns the `ContentTarget` of one record: `{ pluginId, type, id }`, where `type` is `definition.id` and `id` is the record id as a string. A custom report target uses the same shape with its own `type`. + +### `ReportButton` + +A ghost button labelled **Report** that opens the dialog. Its accessible name is "Report `typeLabel`". + +| Name | Type | Required | Default | Description | +| ----------------- | ---------------- | -------- | ------- | --------------------------------------------------------------------------- | +| `target` | `ContentTarget` | Yes | | The reported record | +| `typeLabel` | `string` | Yes | | What the item is called, already translated, for "Report `typeLabel`" | +| `describedBy` | `string` | No | | Id of the element naming the item, read after the label so list buttons differ | +| `locale` | `string \| null` | No | `null` | The locale the reader was served, for moderators | +| `requestedLocale` | `string \| null` | No | `null` | The locale the reader asked for, when the content fell back to another | +| `className` | `string` | No | | Classes for the button | + +### `ReportDialog` + +The same dialog, controlled by you. Use it for your own trigger, such as a menu item, or to share one dialog across a long list. It takes `target`, `typeLabel`, `locale` and `requestedLocale` like `ReportButton`, plus `open: boolean` and `onOpenChange(open: boolean)`. + +```tsx +import type { ContentTarget } from "@vitnode/core/content"; + +import { ReportDialog } from "@vitnode/core/tanstack/moderation"; + +const [reporting, setReporting] = useState(null); + +{reporting ? ( + { + if (!open) setReporting(null); + }} + open + target={reporting} + typeLabel={t("report_type")} + /> +) : null} +``` + +The dialog shows a toast with a description after every outcome: sent, already reported (`409 duplicate`), no longer available (`404`), too many reports (`429`), session ended (`401`), invalid details (`400`) and anything else. + ## Report lifecycle | Status | Reached by | diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts index 1d37375a2..b3bc1335f 100644 --- a/packages/vitnode/src/content/index.ts +++ b/packages/vitnode/src/content/index.ts @@ -526,6 +526,8 @@ export { } from "./sitemap"; export type { ContentSitemapEntry, ContentSitemapIndexEntry } from "./sitemap"; export { slugify } from "./slug"; +export { contentTarget } from "./target"; +export type { ContentTarget } from "./target"; export type { AnyContentTypeDefinition, ContentAdminActionConfig, diff --git a/packages/vitnode/src/content/target.ts b/packages/vitnode/src/content/target.ts new file mode 100644 index 000000000..4ed1c2f23 --- /dev/null +++ b/packages/vitnode/src/content/target.ts @@ -0,0 +1,45 @@ +import type { ContentId } from "./ids"; + +import { contentIdKey } from "./ids"; + +/** + * One record a public action points at, such as the item a `ReportButton` + * reports. The same three values identify the record on the API, so build it + * with `contentTarget` instead of passing them around one by one. + * + * A custom report target (`defineReportTarget`) uses the same shape, with its + * own `type`. + */ +export interface ContentTarget { + /** The record's id, as the API reads it from a path. */ + id: string; + /** The plugin that registered the content type - its `manifest.id`. */ + pluginId: string; + /** The content type id, e.g. `"forum.comment"`. */ + type: string; +} + +/** + * Builds the `ContentTarget` of one record from the content type definition, + * so the type id never drifts from the one the API registered. Browser-safe: + * a content definition holds no server code. + * + * @example + * ```tsx + * import { contentTarget } from "@vitnode/core/content"; + * + * import { commentContentType } from "@/content/comment"; + * import { manifest } from "@/manifest.gen"; + * + * const target = contentTarget(manifest.id, commentContentType, comment.id); + * ``` + */ +export const contentTarget = ( + pluginId: string, + definition: { id: string }, + id: ContentId, +): ContentTarget => ({ + id: contentIdKey(id), + pluginId, + type: definition.id, +}); diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index 8229b5e63..6842018ba 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -2207,6 +2207,7 @@ "duplicate": "You already reported this, and that report is still open.", "not_found": "This content is no longer available to report.", "rate_limited": "You have sent a lot of reports recently. Try again later.", + "unauthenticated": "Your session has ended. Sign in again to send the report.", "validation": "Check the reason and the details, then try again.", "unknown": "Something went wrong. Try again in a moment." } diff --git a/packages/vitnode/src/tanstack/moderation/index.ts b/packages/vitnode/src/tanstack/moderation/index.ts index d4589a643..54b39c81c 100644 --- a/packages/vitnode/src/tanstack/moderation/index.ts +++ b/packages/vitnode/src/tanstack/moderation/index.ts @@ -1,2 +1,8 @@ export { MODCP_NAMESPACES } from "./namespaces"; -export { ReportButton, type ReportButtonProps } from "./report-button"; +export { + REPORT_NAMESPACES, + ReportButton, + type ReportButtonProps, + ReportDialog, + type ReportDialogProps, +} from "./report-button"; diff --git a/packages/vitnode/src/tanstack/moderation/report-button.tsx b/packages/vitnode/src/tanstack/moderation/report-button.tsx index 6381eda74..078735600 100644 --- a/packages/vitnode/src/tanstack/moderation/report-button.tsx +++ b/packages/vitnode/src/tanstack/moderation/report-button.tsx @@ -3,6 +3,8 @@ import { FlagIcon } from "lucide-react"; import React from "react"; import { useTranslations } from "use-intl"; +import type { ContentTarget } from "@/content/target"; + import { Button, buttonVariants } from "@/components/ui/button"; import { Dialog, @@ -21,44 +23,115 @@ const ReportDialogContent = React.lazy( async () => await import("@/views/moderation/report/report-dialog-content"), ); +/** The message namespaces the report button and dialog render with. */ export const REPORT_NAMESPACES = ["core.global", "core.moderation"] as const; -export interface ReportButtonProps { - className?: string; +interface ReportTargetProps { /** The locale the reported content was shown in, for moderators. */ locale?: null | string; - /** The plugin that registered the report target. */ - pluginId: string; /** The locale the visitor asked for, when the content fell back to another. */ requestedLocale?: null | string; - /** The reported item's id. */ - targetId: number | string; - /** The report target type - for a content type with reports enabled, its id. */ - targetType: string; - /** What the item is called, already translated, for the dialog title ("Report {type}"). */ + /** + * The reported record. For a content type with `reports` enabled, build it + * with `contentTarget` from `@vitnode/core/content`. + */ + target: ContentTarget; + /** What the item is called, already translated, for "Report {type}". */ typeLabel: string; } -const ReportButtonInner = ({ - className, +export interface ReportDialogProps extends ReportTargetProps { + onOpenChange: (open: boolean) => void; + open: boolean; +} + +export interface ReportButtonProps extends ReportTargetProps { + className?: string; + /** + * The id of the element that names the item, such as its title or its + * author. Screen readers read it after the button's label, so a list of + * report buttons is told apart. + */ + describedBy?: string; +} + +const ReportDialogInner = ({ locale = null, - pluginId, + onOpenChange, + open, requestedLocale = null, - targetId, - targetType, + target, typeLabel, -}: ReportButtonProps) => { +}: ReportDialogProps) => { const t = useTranslations("core.moderation.report"); const session = useSessionQuery(); - const [open, setOpen] = React.useState(false); const returnTo = useRouterState({ select: state => returnToFor(state.location), }); const signedIn = Boolean(session.data?.user); + return ( + + + + + {t("title", { type: typeLabel })} + + + {signedIn ? t("desc") : t("signed_out")} + + + + {signedIn ? ( + + + + + } + > + { + onOpenChange(false); + }} + pluginId={target.pluginId} + requestedLocale={requestedLocale} + targetId={target.id} + targetType={target.type} + /> + + ) : ( +
+ + {t("sign_in")} + +
+ )} +
+
+ ); +}; + +const ReportButtonInner = ({ + className, + describedBy, + ...dialog +}: ReportButtonProps) => { + const t = useTranslations("core.moderation.report"); + const [open, setOpen] = React.useState(false); + return ( <> - - - - - {t("title", { type: typeLabel })} - - - {signedIn ? t("desc") : t("signed_out")} - - - - {signedIn ? ( - - - - - } - > - { - setOpen(false); - }} - pluginId={pluginId} - requestedLocale={requestedLocale} - targetId={String(targetId)} - targetType={targetType} - /> - - ) : ( -
- - {t("sign_in")} - -
- )} -
-
+ ); }; +/** + * The "report this" dialog on its own, controlled by you. Use it when the + * trigger is yours - a menu item, a keyboard shortcut - or to share one dialog + * across a long list instead of mounting one per row. Guests are offered a + * sign-in link instead of the form, and the form loads only once the dialog + * opens. + * + * @example + * ```tsx + * import type { ContentTarget } from "@vitnode/core/content"; + * + * import { ReportDialog } from "@vitnode/core/tanstack/moderation"; + * + * const [reporting, setReporting] = useState(null); + * + * {reporting ? ( + * { + * if (!open) setReporting(null); + * }} + * open + * target={reporting} + * typeLabel={t("report_type")} + * /> + * ) : null} + * ``` + */ +export const ReportDialog = (props: ReportDialogProps) => ( + + + + + +); + /** * A flag button that opens the "report this" dialog for one item. Guests are * offered a sign-in link instead of the form. Loads its own messages and the @@ -125,26 +190,27 @@ const ReportButtonInner = ({ * * The target must be registered on the API - a content type with `reports` * enabled, or a plugin's `moderation.reportTargets` - or the report is refused. + * The server re-checks everything: the reporter must be signed in and able to + * read the target. * * @example * ```tsx + * import { contentTarget } from "@vitnode/core/content"; * import { ReportButton } from "@vitnode/core/tanstack/moderation"; * + * import { commentContentType } from "@/content/comment"; + * import { manifest } from "@/manifest.gen"; + * * ; * ``` */ export const ReportButton = (props: ReportButtonProps) => ( - - - } + fallback={} > diff --git a/packages/vitnode/src/views/moderation/moderation-mutations.ts b/packages/vitnode/src/views/moderation/moderation-mutations.ts index 83ba158a1..ee59b884f 100644 --- a/packages/vitnode/src/views/moderation/moderation-mutations.ts +++ b/packages/vitnode/src/views/moderation/moderation-mutations.ts @@ -19,6 +19,7 @@ const REPORTED_FAILURES = [ ] as const; export type ModerationFailure = + | "unauthenticated" | "unknown" | "validation" | (typeof REPORTED_FAILURES)[number]; @@ -45,6 +46,7 @@ const failureOf = async (response: Response): Promise => { const reported = await reportedFailureOf(response); if (reported) return reported; if (response.status === 400) return "validation"; + if (response.status === 401) return "unauthenticated"; if (response.status === 403) return "forbidden"; if (response.status === 404) return "not_found"; diff --git a/packages/vitnode/src/views/moderation/report/report-dialog-content.tsx b/packages/vitnode/src/views/moderation/report/report-dialog-content.tsx index 1aba88467..e039a324f 100644 --- a/packages/vitnode/src/views/moderation/report/report-dialog-content.tsx +++ b/packages/vitnode/src/views/moderation/report/report-dialog-content.tsx @@ -78,6 +78,7 @@ export const ReportDialogContent = ({ result.failure === "duplicate" || result.failure === "not_found" || result.failure === "rate_limited" || + result.failure === "unauthenticated" || result.failure === "validation" ? result.failure : "unknown"; diff --git a/plugins/blog/src/pages/post-page.tsx b/plugins/blog/src/pages/post-page.tsx index 1c882ce17..58b7a30d6 100644 --- a/plugins/blog/src/pages/post-page.tsx +++ b/plugins/blog/src/pages/post-page.tsx @@ -9,6 +9,7 @@ import { AlertDescription, AlertTitle, } from "@vitnode/core/components/ui/alert"; +import { contentTarget } from "@vitnode/core/content"; import { resolveImageAlt } from "@vitnode/core/lib/files/resolve-alt"; import { contentDeliveryPage, @@ -78,10 +79,12 @@ const BlogPostPage = ({ ) : null} diff --git a/plugins/forum/src/content/comment.ts b/plugins/forum/src/content/comment.ts index a3d20b2b3..5a5b98ab6 100644 --- a/plugins/forum/src/content/comment.ts +++ b/plugins/forum/src/content/comment.ts @@ -27,6 +27,8 @@ export const commentContentType = defineContentType({ publication: true, visibility: true, + // A comment has no title of its own, so moderators read its body. + reports: { snapshotFields: ["body"] }, editorial: { revisions: { retention: 20 } }, indexes: [{ on: ["topic", "parent", "createdAt"] }], diff --git a/plugins/forum/src/content/topic.ts b/plugins/forum/src/content/topic.ts index 043565027..6ac3d5fac 100644 --- a/plugins/forum/src/content/topic.ts +++ b/plugins/forum/src/content/topic.ts @@ -16,6 +16,7 @@ export const topicContentType = defineContentType({ publication: true, visibility: true, + reports: true, editorial: { revisions: { retention: 20 } }, indexes: [{ on: ["forum", "publishedAt"] }], diff --git a/plugins/forum/src/locales/en.json b/plugins/forum/src/locales/en.json index eedbfb5a5..b3de652be 100644 --- a/plugins/forum/src/locales/en.json +++ b/plugins/forum/src/locales/en.json @@ -187,6 +187,7 @@ "new": "New topic", "posted": "Posted", "actions": "Topic actions", + "report_type": "topic", "created": { "title": "Topic posted", "desc": "Your topic is live. Now we wait for the comments to roll in." @@ -242,6 +243,7 @@ "edited_label": "edited", "reply": "Reply", "edit": "Edit", + "report_type": "comment", "someone": "this comment", "removed": "This comment was removed.", "show_answers": "{count, plural, one {Show # reply} other {Show # replies}}", @@ -287,6 +289,9 @@ "@vitnode/forum:topic:can_hide": "Hide and unhide topics", "@vitnode/forum:topic:can_restore": "Restore an earlier version of a topic", "@vitnode/forum:topic:can_approve": "Approve or decline topics waiting for review", + "@vitnode/forum:topic:can_view_reports": "View topic reports", + "@vitnode/forum:topic:can_manage_reports": "Claim, assign and resolve topic reports", + "@vitnode/forum:topic:can_view_hidden": "Open hidden topics", "@vitnode/forum:comment": "Comments", "@vitnode/forum:comment:can_view": "View comments", "@vitnode/forum:comment:can_create": "Create comments", @@ -296,6 +301,9 @@ "@vitnode/forum:comment:can_hide": "Hide and unhide comments", "@vitnode/forum:comment:can_restore": "Restore an earlier version of a comment", "@vitnode/forum:comment:can_approve": "Approve or decline comments waiting for review", + "@vitnode/forum:comment:can_view_reports": "View comment reports", + "@vitnode/forum:comment:can_manage_reports": "Claim, assign and resolve comment reports", + "@vitnode/forum:comment:can_view_hidden": "Open hidden comments", "@vitnode/forum:forum_forums": "Forums", "@vitnode/forum:forum_forums:can_view": "View forums", "@vitnode/forum:forum_forums:can_create": "Create forums", diff --git a/plugins/forum/src/pages/thread.tsx b/plugins/forum/src/pages/thread.tsx index 6dd159493..9a380e2dc 100644 --- a/plugins/forum/src/pages/thread.tsx +++ b/plugins/forum/src/pages/thread.tsx @@ -8,10 +8,11 @@ import { EmptyMedia, EmptyTitle, } from "@vitnode/core/components/ui/empty"; -import { zodRichTextDocument } from "@vitnode/core/content"; +import { contentTarget, zodRichTextDocument } from "@vitnode/core/content"; import { useSessionQuery } from "@vitnode/core/tanstack/auth"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { HrefLink } from "@vitnode/core/tanstack/link"; +import { ReportButton } from "@vitnode/core/tanstack/moderation"; import { cn } from "cn"; import { ChevronDownIcon, @@ -33,7 +34,7 @@ import { toast } from "sonner"; import { useTranslations } from "use-intl"; import { z } from "zod"; -import { FORUM_COMMENT_MAX_DEPTH } from "@/content/comment"; +import { commentContentType, FORUM_COMMENT_MAX_DEPTH } from "@/content/comment"; import { manifest } from "@/manifest.gen"; import type { CommentComposerProps } from "./comment-composer"; @@ -144,6 +145,7 @@ const CommentBody = ({ actions.currentUserId !== null && comment.author?.id === actions.currentUserId; const canAnswer = actions.canComment && node.depth < FORUM_COMMENT_MAX_DEPTH; + const authorId = `${commentAnchorId(comment.slug)}-author`; const edited = comment.publishedAt !== null && new Date(comment.updatedAt).getTime() - @@ -154,7 +156,9 @@ const CommentBody = ({
- + + + @@ -227,7 +231,18 @@ const CommentBody = ({
)} diff --git a/plugins/forum/src/pages/topic-page.tsx b/plugins/forum/src/pages/topic-page.tsx index 4736048cf..04790318a 100644 --- a/plugins/forum/src/pages/topic-page.tsx +++ b/plugins/forum/src/pages/topic-page.tsx @@ -11,6 +11,7 @@ import { } from "@vitnode/core/components/ui/breadcrumb"; import { buttonVariants } from "@vitnode/core/components/ui/button"; import { + contentTarget, richTextToPlainText, zodRichTextDocument, } from "@vitnode/core/content"; @@ -20,6 +21,7 @@ import { contentPageItem } from "@vitnode/core/tanstack/content"; import { fetcher } from "@vitnode/core/tanstack/fetcher"; import { HrefLink } from "@vitnode/core/tanstack/link"; import { localeAlternateLinks } from "@vitnode/core/tanstack/metadata"; +import { ReportButton } from "@vitnode/core/tanstack/moderation"; import { defineRoute } from "@vitnode/core/tanstack/plugin-routes"; import { useRecordView } from "@vitnode/core/tanstack/search"; import { PencilIcon } from "lucide-react"; @@ -202,8 +204,11 @@ export const route = defineRoute({ }, }); -/** Rendered in the browser from the session, so a cached page stays shared. */ -const TopicOwnerActions = ({ +/** + * Rendered in the browser from the session, so a cached page stays shared: the + * author gets Edit, everyone else gets Report. + */ +const TopicViewerActions = ({ author, topicId, }: { @@ -214,18 +219,24 @@ const TopicOwnerActions = ({ const { data: session } = useSessionQuery(); const userId = session?.user?.id; - if (userId === undefined || author === null || author.id !== userId) { - return null; + if (userId !== undefined && author !== null && author.id === userId) { + return ( + + + ); } return ( - - + ); }; @@ -328,7 +339,7 @@ const TopicPage = ({