diff --git a/packages/payload-plugin-comments/.gitignore b/packages/payload-plugin-comments/.gitignore new file mode 100644 index 00000000..83631f81 --- /dev/null +++ b/packages/payload-plugin-comments/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +*.tsbuildinfo diff --git a/packages/payload-plugin-comments/.prettierignore b/packages/payload-plugin-comments/.prettierignore new file mode 100644 index 00000000..1eae0cf6 --- /dev/null +++ b/packages/payload-plugin-comments/.prettierignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/packages/payload-plugin-comments/.prettierrc.json b/packages/payload-plugin-comments/.prettierrc.json new file mode 100644 index 00000000..07fb385a --- /dev/null +++ b/packages/payload-plugin-comments/.prettierrc.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/prettierrc", + "experimentalTernaries": true, + "experimentalOperatorPosition": "start", + "printWidth": 120, + "tabWidth": 2, + "useTabs": false, + "semi": true, + "singleQuote": false, + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "trailingComma": "all", + "bracketSpacing": true, + "objectWrap": "preserve", + "bracketSameLine": true, + "arrowParens": "always", + "requirePragma": false, + "insertPragma": false, + "checkIgnorePragma": false, + "proseWrap": "preserve", + "htmlWhitespaceSensitivity": "css", + "endOfLine": "lf", + "embeddedLanguageFormatting": "auto", + "singleAttributePerLine": false +} diff --git a/packages/payload-plugin-comments/CHANGELOG.md b/packages/payload-plugin-comments/CHANGELOG.md new file mode 100644 index 00000000..69a6307d --- /dev/null +++ b/packages/payload-plugin-comments/CHANGELOG.md @@ -0,0 +1,50 @@ +# [1.1.0](https://github.com/focusreactive/payload-plugin-comments/compare/v1.0.0...v1.1.0) (2026-03-11) + + +### Features + +* detect plugin base path ([5a9e2ca](https://github.com/focusreactive/payload-plugin-comments/commit/5a9e2ca1faa62786db073d95cd2cc618ca6e5125)) + +# 1.0.0 (2026-03-11) + + +### Bug Fixes + +* add cancel translation ([3dbfef9](https://github.com/focusreactive/payload-plugin-comments/commit/3dbfef9a5123cd76def54d77c66eb271f88dadc6)) +* add placeholder when editor is empty ([4e4234b](https://github.com/focusreactive/payload-plugin-comments/commit/4e4234b470ad1b7b2258843de69d364135281f98)) +* add type button with handleSubmit on click ([6d549ff](https://github.com/focusreactive/payload-plugin-comments/commit/6d549ff71d4a0495c65349ef8b6f921b68306c05)) +* document LabelFunction guard in getCollectionLabels ([0871ca2](https://github.com/focusreactive/payload-plugin-comments/commit/0871ca200cfbf369916c417aa37c9359f908d348)) +* fix detectPluginBasePath ([f9dd81b](https://github.com/focusreactive/payload-plugin-comments/commit/f9dd81bec6fd32985016b7e1d46973cbb642ae30)) +* fix js docs ([80c2a6d](https://github.com/focusreactive/payload-plugin-comments/commit/80c2a6d03c4a771dd8a09e0b77399a4e5b49b661)) +* fix providers data logic ([f75bd88](https://github.com/focusreactive/payload-plugin-comments/commit/f75bd88c22bdba1033da9081eab10687cd276242)) +* remove getting Payload config from [@payload-config](https://github.com/payload-config) external path ([11e65b4](https://github.com/focusreactive/payload-plugin-comments/commit/11e65b45bf4d33bc95e2d5cdc808c5b6653b5f68)) +* sort documents and collections by oldest comment like fields sorting ([853bee4](https://github.com/focusreactive/payload-plugin-comments/commit/853bee4c2c655108df216fda2e01447c6a2259f9)) +* use lazy initializer for setting isCollapsed for CollapsibleGroup ([59226d3](https://github.com/focusreactive/payload-plugin-comments/commit/59226d381672106d24e42cd8eaa5c11570453eb8)) + + +### Features + +* accept locale prop in GlobalCommentsLoader ([5e950f0](https://github.com/focusreactive/payload-plugin-comments/commit/5e950f051866708fd0f633d1c4820dd33bed165a)) +* add getCollectionLabels service ([b1ea114](https://github.com/focusreactive/payload-plugin-comments/commit/b1ea1147129155a44cd143b7bb82bdbca241aa60)) +* add locale to BaseServiceOptions ([04cfddf](https://github.com/focusreactive/payload-plugin-comments/commit/04cfddf86ea26ca6f3da0949b9f4b849328973f7)) +* add resolveCollectionLabel utility ([b34641d](https://github.com/focusreactive/payload-plugin-comments/commit/b34641d8e3cf6e1a0d2fb9051b5c662bdd9e58a6)) +* add styles to comments drawer ([3e950c4](https://github.com/focusreactive/payload-plugin-comments/commit/3e950c4a19495cff992d0ece6095e1e6082ec970)) +* add translations to all text in UI ([750a386](https://github.com/focusreactive/payload-plugin-comments/commit/750a3865c403f4b9262ff450b6305f81547b1718)) +* create base plugin ([57841b1](https://github.com/focusreactive/payload-plugin-comments/commit/57841b154958387e4836d6aeb33751046717af92)) +* create CollectionLabels type ([ef0cd79](https://github.com/focusreactive/payload-plugin-comments/commit/ef0cd7925aaba41c90e9a1e500a7a23a6b53ce53)) +* create CommentEditor component ([04d9a7a](https://github.com/focusreactive/payload-plugin-comments/commit/04d9a7a3c0c7a257e9cedd35e910e0aa725e8d59)) +* create extractPayload utility ([e5fb348](https://github.com/focusreactive/payload-plugin-comments/commit/e5fb348525f6c2a2171cb9af1d2dd5b1f10f816d)) +* forward locale in getDocumentTitles ([a07d48c](https://github.com/focusreactive/payload-plugin-comments/commit/a07d48cbcf8da18a0e962dddb83c106f9741ae2d)) +* forward locale through syncAllCommentsData ([f26e23e](https://github.com/focusreactive/payload-plugin-comments/commit/f26e23e0ec14a559c51a11d08b4ae056b30918c4)) +* load collection labels ([b7d5ec7](https://github.com/focusreactive/payload-plugin-comments/commit/b7d5ec73e548f5e9aa9eb712a03f7bdb3dfe233f)) +* pass currentLocale when re-syncing in CommentsProvider ([e41b5b7](https://github.com/focusreactive/payload-plugin-comments/commit/e41b5b73681d5e8d5c1916e5948f0835023dff38)) +* provide payload as a prop to all services ([ce05a34](https://github.com/focusreactive/payload-plugin-comments/commit/ce05a342d1ee1990b45cd40ec38c64acd85918c0)) +* provide translations for UI, merge translations ([8e312a8](https://github.com/focusreactive/payload-plugin-comments/commit/8e312a87e2f680fdf49c0cb02192f54c9e7237ff)) +* remove passing just slug to CollectionEntry ([1271fab](https://github.com/focusreactive/payload-plugin-comments/commit/1271fab3841de97fac3be05c76e87c1d215235ad)) +* resolve collection label in collapsible group ([81ce334](https://github.com/focusreactive/payload-plugin-comments/commit/81ce334337c45f5a07b4b07f6199274107fd3591)) +* set payload config in GlobalCommentsLoader ([f01aca3](https://github.com/focusreactive/payload-plugin-comments/commit/f01aca3a19f6eadf7a61f1cdd9324027593fa428)) +* set payload config to globalThis ([ab9343f](https://github.com/focusreactive/payload-plugin-comments/commit/ab9343f1ba1df3352fb0ed19b5a82df82814d038)) +* split utils by domain folders ([30de5af](https://github.com/focusreactive/payload-plugin-comments/commit/30de5af5e687132863c1f77e40b3ca3e41ed14fa)) +* store Payload config ([35ea924](https://github.com/focusreactive/payload-plugin-comments/commit/35ea924f8998a9852ab47c8c5ca5c2bfd246d9b0)) +* use payload native drawer ([8996ba2](https://github.com/focusreactive/payload-plugin-comments/commit/8996ba2ac4285dde27b37456940c5d06484216a2)) +* write js doc comments for plugin config types ([9a86d6a](https://github.com/focusreactive/payload-plugin-comments/commit/9a86d6a708c1ad6308e8a7aed456a045552af5d9)) diff --git a/packages/payload-plugin-comments/README.md b/packages/payload-plugin-comments/README.md new file mode 100644 index 00000000..dd0fb010 --- /dev/null +++ b/packages/payload-plugin-comments/README.md @@ -0,0 +1,562 @@ +# @focus-reactive/payload-plugin-comments + +[![npm](https://img.shields.io/npm/v/@focus-reactive/payload-plugin-comments)](https://www.npmjs.com/package/@focus-reactive/payload-plugin-comments) + +A collaborative commenting plugin for [Payload CMS](https://payloadcms.com/) v3. Adds a full-featured comments system to the Payload admin panel — supporting document-level and field-level comments on both collections and globals, @mentions with email notifications, comment resolution, multi-tenancy, and locale-aware filtering. + +## Table of Contents + +- [AI Integration Prompt](#ai-integration-prompt) +- [UI Screenshots](#ui-screenshots) +- [Features](#features) +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Setup](#setup) +- [Configuration](#configuration) +- [Translations](#translations) +- [Environment Variables](#environment-variables) +- [Architecture Overview](#architecture-overview) +- [Exports Reference](#exports-reference) +- [Available Scripts](#available-scripts) +- [Contributing](#contributing) +- [License](#license) + +--- + +## AI Integration Prompt + +> Copy and paste this prompt into your AI assistant (Cursor, Claude, etc.) to integrate the plugin into an existing Payload v3 project. + +``` +I want to add a collaborative commenting system to my Payload CMS v3 project using @focus-reactive/payload-plugin-comments. + +## How it works + +The plugin injects into every collection and global: +- A field-level comment badge on every field label — shows comment count, opens a popup to post +- A document-level comments drawer (sidebar) scoped to the current document +- A global comments panel (header button) listing all comments across every document and global + +Comments are stored in an auto-generated `comments` collection (hidden from the sidebar). Each comment records: +- documentId / collectionSlug / globalSlug — what it belongs to +- fieldPath — dot-notation field path (null = document-level) +- locale — for field-level comments (null = shown in all locales) +- text — may contain @(userId) mention tokens +- author, mentions, isResolved, resolvedBy, resolvedAt +- tenant (optional, when multi-tenancy is enabled) + +@mentions use autocomplete from the users collection. Mentioned users receive email notifications via Resend. +Comments are automatically deleted when their parent document is deleted. + +## Installation + +pnpm add @focus-reactive/payload-plugin-comments + +## Step 1 — Register the plugin in payload.config.ts + +import { buildConfig } from 'payload' +import { commentsPlugin } from '@focus-reactive/payload-plugin-comments' + +export default buildConfig({ + plugins: [ + commentsPlugin({ + // Optional: specify which field to use as document title in the UI + collections: [ + { slug: 'pages', titleField: 'title' }, + { slug: 'products', titleField: 'name' }, + ], + // Optional: customize the display name field on users + usernameFieldPath: 'name', // default + }), + ], +}) + +## Step 2 — Import the stylesheet + +In your global CSS or admin layout: + +@import "@focus-reactive/payload-plugin-comments/styles.css"; + +Or in a TypeScript/JS file: + +import "@focus-reactive/payload-plugin-comments/styles.css"; + +## Step 3 — Regenerate the import map + +Run this after adding the plugin so Payload registers the plugin's admin components: + +npx payload generate:importmap + +(Or the equivalent for your package manager: pnpm payload generate:importmap / bunx payload generate:importmap) +If you skip this step the comment badges, drawer, and header button will not appear in the admin UI. + +## Step 4 — Create and run a migration (SQL adapters only) + +The plugin adds a `comments` collection to your database. If you use PostgreSQL or SQLite, create and apply a migration: + +npx payload migrate:create create_comments +npx payload migrate + +Skip this step if you use the MongoDB adapter. + +## Optional: Multi-tenancy + +commentsPlugin({ + tenant: { + enabled: true, + collectionSlug: 'tenants', // default + documentTenantField: 'tenant', // default + }, +}) + +## Optional: Collection overrides + +commentsPlugin({ + overrides: { + access: { /* custom access control */ }, + fields: (defaultFields) => [...defaultFields], + hooks: { + afterChange: [async ({ doc }) => { /* ... */ }], + }, + }, +}) + +## Environment variables (for email notifications) + +RESEND_API_KEY=re_xxxxxxxxxxxxxxxx +RESEND_FROM_EMAIL=comments@yourdomain.com + +## Important notes + +- All collections and globals automatically get field-level comments — `collections` config only sets UI metadata (titleField). +- Comments on globals use globalSlug instead of collectionSlug/documentId. +- Field-level comments are locale-scoped; document-level comments show in all locales. +- The `comments` collection is hidden from the admin sidebar by default. +- Auto-cleanup: when a document is deleted, all its comments are deleted too. +- usernameFieldPath supports dot-notation (e.g. "profile.displayName"). +- If RESEND_FROM_EMAIL is not set, mention emails are silently skipped. +``` + +--- + +## UI Screenshots + +### Global Comments Panel + +A header button in the Payload admin opens a drawer listing all comments across every document and collection. + +![Global comments panel](docs/screenshots/global-comments-panel.png) + +### Document Comments Panel + +When viewing a document, a side panel shows all comments scoped to that document with filter tabs (Open / Resolved / Mentioned me). + +![Document comments panel](docs/screenshots/document-comments-panel.png) + +### Field Comment Popup + +Clicking the comment badge on a field label opens a popup where you can write and post a new comment for that specific field. + +![Field comment popup](docs/screenshots/field-comment-popup.png) + +### Field Label Button — Two States + +The comment button embedded in the field label has two visual states: no comments (inactive, appears on hover) and one or more open comments (active, showing the count badge). + +| Inactive (no comments) | Active (has comments) | +| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| ![Field label button — inactive](docs/screenshots/field-label-button-inactive.png) | ![Field label button — active](docs/screenshots/field-label-button-active.png) | + +--- + +## Features + +- **Document-level comments** — Leave comments on any document in any collection or global +- **Field-level comments** — Comment directly on individual fields; the field label shows a badge with the comment count +- **Global support** — Field-level and document-level comments work on Payload Globals, not just collections +- **@mention users** — Mention other users in comments using `@name` autocomplete +- **Email notifications** — Mentioned users receive email notifications via [Resend](https://resend.com/) +- **Resolve comments** — Mark comments as resolved/unresolved; filter by open, resolved, or mentioned +- **Global comments panel** — A header button opens a drawer showing all comments across all documents and globals +- **Optimistic UI** — Comments appear instantly before the server confirms +- **Multi-tenancy** — Scope comments to tenants via `@payloadcms/plugin-multi-tenant` +- **Locale-aware** — Field-level comments are tied to a locale; document-level comments are shown in all locales +- **Auto-cleanup** — Comments are automatically deleted when their parent document is deleted +- **i18n / Translations** — All UI strings are translatable; ship your own locale overrides alongside the built-in English defaults + +--- + +## Prerequisites + +- Node.js 20 or higher +- A working [Payload CMS v3](https://payloadcms.com/docs/getting-started/installation) project with Next.js +- pnpm (recommended) +- A [Resend](https://resend.com/) account (required only for mention email notifications) + +--- + +## Installation + +```bash +pnpm add @focus-reactive/payload-plugin-comments +``` + +```bash +npm install @focus-reactive/payload-plugin-comments +# or +yarn add @focus-reactive/payload-plugin-comments +``` + +**Peer dependencies:** `payload ^3.0.0`, `@payloadcms/ui ^3.0.0`. `next ^14 || ^15`, `react ^18 || ^19`, `react-dom`, and `@payloadcms/plugin-multi-tenant` are optional. + +--- + +## Setup + +### 1. Add the plugin to your Payload config + +```ts +// payload.config.ts +import { buildConfig } from "payload"; +import { commentsPlugin } from "@focus-reactive/payload-plugin-comments"; + +export default buildConfig({ + plugins: [ + commentsPlugin({ + // Optional: customize which field is used as the document title in the UI + collections: [ + { slug: "pages", titleField: "title" }, + { slug: "products", titleField: "name" }, + ], + }), + ], + // ... rest of your config +}); +``` + +### 2. Import the styles + +Add the plugin's stylesheet to your global CSS or admin layout: + +```css +/* global CSS */ +@import "@focus-reactive/payload-plugin-comments/styles.css"; +``` + +Or import it in a layout/page file: + +```ts +import "@focus-reactive/payload-plugin-comments/styles.css"; +``` + +### 3. Regenerate the import map + +Payload needs its import map regenerated whenever you add or remove a plugin that registers admin components. Run: + +```bash +npx payload generate:importmap +``` + +This command works regardless of your package manager (`npm`, `pnpm`, `yarn`, `bun`). Alternatively use the equivalent for your package manager: + +```bash +pnpm payload generate:importmap +# yarn payload generate:importmap +# bunx payload generate:importmap +``` + +If you skip this step the plugin's admin components (comment badges, drawer, header button) will not appear in the Payload admin UI. + +### 4. Create and run a migration + +The plugin adds a `comments` collection to your database. If you use a SQL adapter (PostgreSQL, SQLite), create and apply a migration: + +```bash +npx payload migrate:create create_comments +npx payload migrate +``` + +Skip this step if you use the MongoDB adapter — Payload creates collections automatically. + +--- + +## Configuration + +The `commentsPlugin` factory accepts an optional `CommentsPluginConfig` object: + +```ts +commentsPlugin(config?: CommentsPluginConfig) +``` + +### `CommentsPluginConfig` + +| Option | Type | Default | Description | +| ------------------- | --------------------- | -------- | ----------------------------------------------------------------------------------- | +| `collections` | `CollectionEntry[]` | — | UI metadata for collections (title field). All collections get comments regardless. | +| `enabled` | `boolean` | `true` | Set to `false` to disable the plugin entirely | +| `tenant` | `TenantPluginConfig` | — | Multi-tenancy settings (see below) | +| `overrides` | `CollectionOverrides` | — | Customize the generated `comments` collection | +| `translations` | `Translations` | — | Override UI strings per locale (see below) | +| `usernameFieldPath` | `string` | `"name"` | Dot-notation path to the display name field on the users collection | + +### `CollectionEntry` + +Each entry in `collections` is an object that provides UI metadata for a specific collection: + +```ts +interface CollectionEntry { + slug: string; + titleField?: string; // Field used as document title in the UI. Default: "id" +} +``` + +> **Note:** You do not need to list every collection. All collections (and all globals) automatically support comments. The `collections` array only sets display metadata like the title field. + +**Examples:** + +```ts +commentsPlugin({ + collections: [ + { slug: "pages", titleField: "title" }, + { slug: "products", titleField: "name" }, + ], +}); +``` + +### `usernameFieldPath` + +Specifies which field on the `users` collection is used as the display name in comment UI and @mention autocomplete. Supports dot-notation for nested fields. + +```ts +commentsPlugin({ + usernameFieldPath: "name", // default + // usernameFieldPath: "firstName", + // usernameFieldPath: "profile.displayName", +}); +``` + +### `TenantPluginConfig` + +Configure multi-tenancy when using `@payloadcms/plugin-multi-tenant`: + +| Option | Type | Default | Description | +| --------------------- | --------- | ----------- | ------------------------------------------------------------- | +| `enabled` | `boolean` | `false` | Enable tenant scoping | +| `collectionSlug` | `string` | `"tenants"` | Slug of the tenants collection | +| `documentTenantField` | `string` | `"tenant"` | Field on document collections that holds the tenant reference | + +**Example:** + +```ts +commentsPlugin({ + tenant: { + enabled: true, + collectionSlug: "tenants", + documentTenantField: "tenant", + }, +}); +``` + +### Collection Overrides + +Use `overrides` to customize the generated `comments` collection — for example, to extend access control or add custom fields: + +```ts +commentsPlugin({ + overrides: { + access: { + // Override the default "authenticated only" access + }, + fields: (defaultFields) => [...defaultFields], + hooks: { + afterChange: [ + async ({ doc }) => { + console.log("Comment changed:", doc.id); + }, + ], + }, + }, +}); +``` + +--- + +## Translations + +Use `translations` to override any UI string for one or more locales. Each key is a locale code; the value is a partial object of the `CommentsTranslations` shape — keys you omit fall back to the built-in English defaults. + +```ts +commentsPlugin({ + translations: { + fr: { + label: "Commentaires", + add: "Ajouter un commentaire", + writeComment: "Écrire un commentaire", + comment: "Commenter", + cancel: "Annuler", + resolve: "Résoudre", + reopen: "Rouvrir", + delete: "Supprimer", + filterOpen: "Ouverts", + filterResolved: "Résolus", + filterMentioned: "Me mentionnent", + }, + }, +}); +``` + +All translatable keys (with their English defaults): + +| Key | Default (English) | +| --------------------- | ------------------------------ | +| `label` | `"Comments"` | +| `openComments_one` | `"{{count}} open comment"` | +| `openComments_other` | `"{{count}} open comments"` | +| `add` | `"Add comment"` | +| `writeComment` | `"Write a comment"` | +| `comment` | `"Comment"` | +| `cancel` | `"Cancel"` | +| `posting` | `"Posting…"` | +| `resolve` | `"Resolve"` | +| `reopen` | `"Reopen"` | +| `delete` | `"Delete"` | +| `general` | `"General"` | +| `close` | `"Close"` | +| `syncingComments` | `"Syncing comments"` | +| `openCommentsAria` | `"Open comments"` | +| `failedToPost` | `"Failed to post comment"` | +| `failedToUpdate` | `"Failed to update comment"` | +| `failedToDelete` | `"Failed to delete comment"` | +| `failedToAdd` | `"Failed to add comment"` | +| `unknownAuthor` | `"Unknown"` | +| `deletedUser` | `"Deleted user"` | +| `noOpenComments` | `"No open comments"` | +| `noResolvedComments` | `"No resolved comments"` | +| `noMentionedComments` | `"No comments mentioning you"` | +| `filterOpen` | `"Open"` | +| `filterResolved` | `"Resolved"` | +| `filterMentioned` | `"Mentioned me"` | +| `noMentionMatches` | `"No matches"` | + +The `CommentsTranslations` type is exported from the package so you can type your translation objects: + +```ts +import type { CommentsTranslations } from "@focus-reactive/payload-plugin-comments"; + +const myTranslations: Record> = { + fr: { label: "Commentaires" }, + de: { label: "Kommentare" }, +}; + +commentsPlugin({ translations: myTranslations }); +``` + +--- + +## Environment Variables + +### Required for email notifications + +| Variable | Description | Example | +| ------------------- | ---------------------------------------------- | ------------------------- | +| `RESEND_API_KEY` | Your Resend API key | `re_xxxxxxxxxxxxxxxx` | +| `RESEND_FROM_EMAIL` | Sender email address for mention notifications | `comments@yourdomain.com` | + +If `RESEND_FROM_EMAIL` is not set, mention email notifications are silently skipped and an error is logged to the console. + +**.env.local example:** + +```env +RESEND_API_KEY=re_xxxxxxxxxxxxxxxx +RESEND_FROM_EMAIL=comments@yourdomain.com +``` + +--- + +## Architecture Overview + +### How It Works + +**Plugin initialization** (`plugin.ts`): + +1. The plugin receives a `CommentsPluginConfig` and returns a standard Payload `Plugin` function. +2. It creates a `comments` collection (hidden from the admin sidebar by default). +3. It patches **every collection** to inject `FieldCommentLabel` into each field's admin label and registers an `afterDelete` hook that cascade-deletes comments when a document is removed. +4. It patches **every global** to inject `FieldCommentLabel` into each field's admin label. +5. It registers two admin providers (`CommentsProviderWrapper`, `GlobalCommentsLoader`) and one admin action (`CommentsHeaderButton`). + +**Data loading** (`GlobalCommentsLoader`): + +- This server component runs on every admin page load. +- It fetches all comments, document titles, mentionable users, field labels, collection labels, and global labels in parallel. +- Results are passed to `GlobalCommentsHydrator` (a client component) which hydrates the `CommentsContext`. + +**State management** (`CommentsProvider`): + +- Holds `allComments` in React state with optimistic updates via `useOptimistic`. +- `visibleComments` is derived: filtered to the current document/collection/global/locale based on the Next.js `pathname`. +- Exposes `addComment`, `removeComment`, `resolveComment`, and `syncComments` mutations. + +**Field-level comments** (`FieldCommentLabel`): + +- The plugin overrides the `Label` component for every named field in every configured collection and global. +- The label reads comments from context and filters by field path, showing a badge with the count. +- Clicking the badge opens the comments drawer pre-scrolled to that field's comment group. + +**Comments collection schema:** + +| Field | Type | Description | +| ---------------- | ----------------------- | ------------------------------------------------------------------ | +| `documentId` | number | ID of the document being commented on (null for globals) | +| `collectionSlug` | text | Slug of the collection (null for global comments) | +| `globalSlug` | text | Slug of the Payload global (null for collection document comments) | +| `fieldPath` | text | Dot-notation path of the field (null = document-level) | +| `locale` | text | Locale of the comment (null = shown in all locales) | +| `text` | textarea | Comment body (may contain `@(userId)` mention tokens) | +| `mentions` | array → relationship | Users mentioned in this comment | +| `author` | relationship → users | Comment author (set automatically) | +| `isResolved` | checkbox | Whether the comment is resolved | +| `resolvedBy` | relationship → users | Who resolved it | +| `resolvedAt` | date | When it was resolved | +| `tenant` | relationship (optional) | Tenant scope (when multi-tenancy is enabled) | + +--- + +## Exports Reference + +| Import path | Exports | +| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `@focus-reactive/payload-plugin-comments` | `commentsPlugin`, `CommentsPluginConfig` (type), `CommentsTranslations` (type), `setPayloadConfig` | +| `@focus-reactive/payload-plugin-comments/styles.css` | Plugin stylesheet (Tailwind-compiled CSS) | + +--- + +## Available Scripts + +Run these from the project root with `pnpm`: + +| Command | Description | +| ------------------- | -------------------------------------------------------------- | +| `pnpm build` | Build the plugin to `dist/` (tsup + Tailwind CSS minification) | +| `pnpm dev` | Build in watch mode — rebuilds on file changes | +| `pnpm lint` | Run ESLint on `src/` | +| `pnpm lint:fix` | Run ESLint with auto-fix | +| `pnpm format` | Format `src/` with Prettier | +| `pnpm format:check` | Check formatting without writing | + +--- + +## Contributing + +1. Fork the repository and create a feature branch. +2. Install dependencies: `pnpm install` +3. Start the build watcher: `pnpm dev` +4. Make your changes in `src/`. +5. Run `pnpm lint` and `pnpm format:check` before submitting. +6. Open a pull request against `main`. + +--- + +## License + +MIT © [Focus Reactive](https://focusreactive.com/) diff --git a/packages/payload-plugin-comments/docs/screenshots/document-comments-panel.png b/packages/payload-plugin-comments/docs/screenshots/document-comments-panel.png new file mode 100644 index 00000000..34c7a00b Binary files /dev/null and b/packages/payload-plugin-comments/docs/screenshots/document-comments-panel.png differ diff --git a/packages/payload-plugin-comments/docs/screenshots/field-comment-popup.png b/packages/payload-plugin-comments/docs/screenshots/field-comment-popup.png new file mode 100644 index 00000000..c218c2eb Binary files /dev/null and b/packages/payload-plugin-comments/docs/screenshots/field-comment-popup.png differ diff --git a/packages/payload-plugin-comments/docs/screenshots/field-label-button-active.png b/packages/payload-plugin-comments/docs/screenshots/field-label-button-active.png new file mode 100644 index 00000000..5db9a4c3 Binary files /dev/null and b/packages/payload-plugin-comments/docs/screenshots/field-label-button-active.png differ diff --git a/packages/payload-plugin-comments/docs/screenshots/field-label-button-inactive.png b/packages/payload-plugin-comments/docs/screenshots/field-label-button-inactive.png new file mode 100644 index 00000000..70d81545 Binary files /dev/null and b/packages/payload-plugin-comments/docs/screenshots/field-label-button-inactive.png differ diff --git a/packages/payload-plugin-comments/docs/screenshots/global-comments-panel.png b/packages/payload-plugin-comments/docs/screenshots/global-comments-panel.png new file mode 100644 index 00000000..7ab19cc2 Binary files /dev/null and b/packages/payload-plugin-comments/docs/screenshots/global-comments-panel.png differ diff --git a/packages/payload-plugin-comments/eslint.config.js b/packages/payload-plugin-comments/eslint.config.js new file mode 100644 index 00000000..e729a211 --- /dev/null +++ b/packages/payload-plugin-comments/eslint.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from "eslint/config"; +import tseslint from 'typescript-eslint' +import prettierConfig from 'eslint-config-prettier' + +export default defineConfig( + { + ignores: ['dist/', 'node_modules/'] + }, + ...tseslint.configs.recommended, + prettierConfig, + { + rules: { + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/consistent-type-imports': 'error', + }, + }, +) diff --git a/packages/payload-plugin-comments/package.json b/packages/payload-plugin-comments/package.json new file mode 100644 index 00000000..049914aa --- /dev/null +++ b/packages/payload-plugin-comments/package.json @@ -0,0 +1,107 @@ +{ + "name": "@focus-reactive/payload-plugin-comments", + "version": "1.1.0", + "license": "MIT", + "author": "Kiryl Pekarski ", + "keywords": [ + "payload", + "payloadcms", + "payload-plugin", + "comments", + "cms", + "plugin", + "collaboration", + "annotations", + "react", + "nextjs", + "typescript" + ], + "publishConfig": { + "access": "restricted" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./components/CommentsHeaderButton": { + "import": "./dist/components/CommentsHeaderButton/index.js", + "types": "./dist/components/CommentsHeaderButton/index.d.ts" + }, + "./components/FieldCommentLabel": { + "import": "./dist/components/FieldCommentLabel/index.js", + "types": "./dist/components/FieldCommentLabel/index.d.ts" + }, + "./providers/CommentsProviderWrapper": { + "import": "./dist/providers/CommentsProviderWrapper/index.js", + "types": "./dist/providers/CommentsProviderWrapper/index.d.ts" + }, + "./providers/GlobalCommentsLoader": { + "import": "./dist/providers/GlobalCommentsLoader/index.js", + "types": "./dist/providers/GlobalCommentsLoader/index.d.ts" + }, + "./styles.css": "./dist/styles.css" + }, + "scripts": { + "build": "node node_modules/tsup/dist/cli-default.js && tsc --emitDeclarationOnly --declarationMap", + "dev": "node node_modules/tsup/dist/cli-default.js --watch", + "prepare": "node node_modules/tsup/dist/cli-default.js && tsc --emitDeclarationOnly --declarationMap", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "format": "prettier --write src/", + "format:check": "prettier --check src/" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.577.0", + "resend": "^4.0.0", + "tailwind-merge": "^3.5.0" + }, + "peerDependencies": { + "@payloadcms/plugin-multi-tenant": "^3.0.0", + "@payloadcms/ui": "^3.0.0", + "next": "^14.0.0 || ^15.0.0", + "payload": "^3.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "next": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "@payloadcms/plugin-multi-tenant": { + "optional": true + } + }, + "devDependencies": { + "@faceless-ui/modal": "^3.0.0", + "@tailwindcss/cli": "^4.2.1", + "@types/node": "^20.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "eslint": "^9.0.0", + "eslint-config-prettier": "^9.0.0", + "next": "15.4.10", + "payload": "3.79.0", + "@payloadcms/ui": "3.79.0", + "@payloadcms/plugin-multi-tenant": "3.79.0", + "prettier": "^3.0.0", + "tailwindcss": "^4.2.1", + "tsup": "^8.0.0", + "typescript": "5.7.3", + "typescript-eslint": "^8.0.0" + } +} diff --git a/packages/payload-plugin-comments/src/collection/access/isAuth.ts b/packages/payload-plugin-comments/src/collection/access/isAuth.ts new file mode 100644 index 00000000..1560a97d --- /dev/null +++ b/packages/payload-plugin-comments/src/collection/access/isAuth.ts @@ -0,0 +1,7 @@ +import type { Access } from "payload"; + +export const isAuth: Access = ({ req: { user } }) => { + if (!user) return false; + + return true; +}; diff --git a/packages/payload-plugin-comments/src/collection/hooks/setAuthorBeforeCreate.ts b/packages/payload-plugin-comments/src/collection/hooks/setAuthorBeforeCreate.ts new file mode 100644 index 00000000..ea0e1436 --- /dev/null +++ b/packages/payload-plugin-comments/src/collection/hooks/setAuthorBeforeCreate.ts @@ -0,0 +1,9 @@ +import type { CollectionBeforeChangeHook } from "payload"; + +export const setAuthorBeforeCreate: CollectionBeforeChangeHook = async ({ data, req, operation }) => { + if (operation === "create" && req.user && !data.author) { + data.author = req.user.id; + } + + return data; +}; diff --git a/packages/payload-plugin-comments/src/collection/hooks/setTenantBeforeCreate.ts b/packages/payload-plugin-comments/src/collection/hooks/setTenantBeforeCreate.ts new file mode 100644 index 00000000..003d912d --- /dev/null +++ b/packages/payload-plugin-comments/src/collection/hooks/setTenantBeforeCreate.ts @@ -0,0 +1,28 @@ +import type { CollectionBeforeChangeHook, CollectionSlug } from "payload"; +import type { CommentsPluginConfigStorage } from "../../types"; + +export const setTenantBeforeCreate: CollectionBeforeChangeHook = async ({ data, req, operation }) => { + if (operation !== "create") return data; + + const pluginConfig = req.payload.config.admin?.custom?.commentsPlugin as CommentsPluginConfigStorage | undefined; + + const tenantConfig = pluginConfig?.tenant; + if (!tenantConfig?.enabled) return data; + + const documentTenantField = tenantConfig.documentTenantField ?? "tenant"; + + try { + const doc = await req.payload.findByID({ + collection: data.collectionSlug as CollectionSlug, + id: data.documentId as string | number, + depth: 0, + req, + }); + data.tenant = (doc as unknown as Record)?.[documentTenantField] ?? null; + } catch (err) { + req.payload.logger.error({ err, msg: "setTenantBeforeCreate: failed to resolve tenant" }); + data.tenant = null; + } + + return data; +}; diff --git a/packages/payload-plugin-comments/src/collection/index.ts b/packages/payload-plugin-comments/src/collection/index.ts new file mode 100644 index 00000000..6aaf64e2 --- /dev/null +++ b/packages/payload-plugin-comments/src/collection/index.ts @@ -0,0 +1,140 @@ +import type { CollectionConfig, CollectionSlug } from "payload"; +import { setAuthorBeforeCreate } from "./hooks/setAuthorBeforeCreate"; +import { setTenantBeforeCreate } from "./hooks/setTenantBeforeCreate"; +import { DEFAULT_COLLECTION_SLUG } from "../constants"; +import type { TenantPluginConfig } from "../types"; +import { isAuth } from "./access/isAuth"; + +export const baseCollection = (tenantConfig?: TenantPluginConfig): CollectionConfig => ({ + slug: DEFAULT_COLLECTION_SLUG, + labels: { + singular: { en: "Comment", es: "Comentario" }, + plural: { en: "Comments", es: "Comentarios" }, + }, + admin: { + hidden: true, + useAsTitle: "text", + defaultColumns: ["author", "collectionSlug", "status", "createdAt"], + }, + access: { + create: isAuth, + read: isAuth, + update: isAuth, + delete: isAuth, + }, + hooks: { + beforeChange: [setAuthorBeforeCreate, ...(tenantConfig?.enabled ? [setTenantBeforeCreate] : [])], + }, + timestamps: true, + fields: [ + { + name: "documentId", + type: "number", + index: true, + }, + { + name: "collectionSlug", + type: "text", + index: true, + }, + { + name: "globalSlug", + type: "text", + index: true, + admin: { + description: "Slug of the Payload global being commented on. Null = collection document comment.", + }, + }, + { + name: "fieldPath", + type: "text", + index: true, + admin: { + description: "Dot-notation path of the field being commented on. Null = document-level.", + }, + }, + { + name: "locale", + type: "text", + required: false, + index: true, + admin: { + description: "Locale for field-level comments. Null = document-level (shown in all locales).", + }, + }, + { + name: "text", + type: "textarea", + required: true, + label: "Comment", + }, + { + name: "mentions", + type: "array", + label: "Mentions", + admin: { + readOnly: true, + }, + fields: [ + { + name: "user", + type: "relationship", + relationTo: "users", + required: true, + }, + ], + }, + { + name: "author", + type: "relationship", + relationTo: "users", + required: true, + label: "Author", + admin: { + position: "sidebar", + }, + }, + { + name: "isResolved", + type: "checkbox", + defaultValue: false, + label: "Resolved", + admin: { + position: "sidebar", + }, + }, + { + name: "resolvedBy", + type: "relationship", + relationTo: "users", + label: "Resolved by", + admin: { + position: "sidebar", + condition: (_data, siblingData) => siblingData?.status === "resolved", + }, + }, + { + name: "resolvedAt", + type: "date", + label: "Resolved at", + admin: { + position: "sidebar", + condition: (_data, siblingData) => siblingData?.status === "resolved", + }, + }, + ...(tenantConfig?.enabled ? + [ + { + name: "tenant", + type: "relationship" as const, + relationTo: (tenantConfig.collectionSlug ?? "tenants") as CollectionSlug, + index: true, + label: "Tenant", + admin: { + position: "sidebar" as const, + }, + }, + ] + : []), + ], +}); diff --git a/packages/payload-plugin-comments/src/components/Avatar/index.tsx b/packages/payload-plugin-comments/src/components/Avatar/index.tsx new file mode 100644 index 00000000..74537554 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/Avatar/index.tsx @@ -0,0 +1,21 @@ +import type { ClientUser } from "payload"; +import type { User } from "../../types"; +import { resolveUsername } from "../../utils/user/resolveUsername"; + +interface Props { + className?: string; + user?: User | ClientUser | null; + usernameFieldPath?: string; + fallbackName: string; +} + +export function Avatar({ user, usernameFieldPath, fallbackName }: Props) { + const userName = resolveUsername(user, usernameFieldPath, fallbackName); + const userInitial = userName.charAt(0).toUpperCase(); + + return ( +
+ {userInitial} +
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentEditor/ActionPanel.tsx b/packages/payload-plugin-comments/src/components/CommentEditor/ActionPanel.tsx new file mode 100644 index 00000000..89de7204 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentEditor/ActionPanel.tsx @@ -0,0 +1,28 @@ +import { AtSign, SendHorizontal } from "lucide-react"; +import { IconButton } from "../IconButton"; +import { useTranslation } from "@payloadcms/ui"; +import { cn } from "../../utils/general/cn"; + +interface Props { + className?: string; + onMention: () => void; + onAddComment: () => void; +} + +export function ActionPanel({ className, onMention, onAddComment }: Props) { + const { t } = useTranslation(); + + return ( +
+ + + + +
+ + + + +
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx b/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx new file mode 100644 index 00000000..0a8bec7b --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx @@ -0,0 +1,310 @@ +"use client"; + +import { useState, useRef, useImperativeHandle, type RefObject, startTransition } from "react"; +import { useAuth, useLocale, useTranslation } from "@payloadcms/ui"; +import { useComments } from "../../providers/CommentsProvider"; +import { MentionDropdown } from "../MentionDropdown"; +import { MentionLabel } from "../MentionLabel"; +import { serializeEditor } from "../../utils/comment/serializeEditor"; +import { isSelfMention } from "../../utils/mention/isSelfMention"; +import { createRoot } from "react-dom/client"; +import type { User } from "../../types"; +import { FALLBACK_USERNAME } from "../../constants"; +import { resolveUsername } from "../../utils/user/resolveUsername"; +import { ActionPanel } from "./ActionPanel"; +import { Avatar } from "../Avatar"; + +export interface CommentEditorHandle { + getValue: () => string; + clear: () => void; + focus: () => void; + insertAt: () => void; +} + +interface CommentEditorProps { + disabled?: boolean; + autoFocus?: boolean; + placeholder?: string; + ref?: RefObject; + fieldPath?: string | null; + documentId?: number; + collectionSlug?: string; + globalSlug?: string; + onSuccessAddComment?: () => void; + onEnterPress?: () => void; + onEscapePress?: () => void; +} + +export function CommentEditor({ + disabled, + autoFocus, + placeholder: placeholderProp, + ref, + fieldPath, + documentId, + collectionSlug, + globalSlug, + onSuccessAddComment, + onEnterPress, + onEscapePress, +}: CommentEditorProps) { + const { mentionUsers: users, addComment } = useComments(); + const { code: locale } = useLocale(); + const { user } = useAuth(); + + const [mentionQuery, setMentionQuery] = useState(null); + const [triggerRange, setTriggerRange] = useState(null); + const [filteredUsers, setFilteredUsers] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + + const { usernameFieldPath } = useComments(); + const { t } = useTranslation(); + + const editorRef = useRef(null); + + const unknownLabel = t("comments:unknownAuthor" as never) ?? FALLBACK_USERNAME; + const currentUserId = user?.id; + const placeholder = placeholderProp ?? t("comments:writeComment" as never) ?? "Add a comment"; + + // Methods + const detectMention = () => { + const sel = window.getSelection(); + + if (!sel || !sel.rangeCount) { + setMentionQuery(null); + + return; + } + + const range = sel.getRangeAt(0); + const node = range.startContainer; + + if (node.nodeType !== Node.TEXT_NODE) { + setMentionQuery(null); + + return; + } + + const text = node.textContent ?? ""; + const offset = range.startOffset; + + let i = offset - 1; + while (i >= 0 && text[i] !== " " && text[i] !== "\n") { + if (text[i] === "@") { + const query = text.slice(i + 1, offset); + if (query.includes(" ")) { + setMentionQuery(null); + return; + } + + const atRange = document.createRange(); + atRange.setStart(node, i); + atRange.setEnd(node, offset); + setTriggerRange(atRange.cloneRange()); + setMentionQuery(query); + setFilteredUsers( + users.filter((u) => + resolveUsername(u, usernameFieldPath, unknownLabel).toLowerCase().includes(query.toLowerCase()), + ), + ); + setSelectedIndex(0); + + return; + } + + i--; + } + + setMentionQuery(null); + }; + + const insertMention = (user: User) => { + if (!triggerRange || !editorRef.current) return; + + const sel = window.getSelection(); + if (!sel) return; + + sel.removeAllRanges(); + sel.addRange(triggerRange); + sel.deleteFromDocument(); + + const { id: userId } = user; + + const mentionLabelContainer = document.createElement("span"); + const mentionLabelContainerRoot = createRoot(mentionLabelContainer); + + mentionLabelContainerRoot.render( + , + ); + + mentionLabelContainer.contentEditable = "false"; + mentionLabelContainer.dataset.mentionId = String(userId); + + const range2 = sel.getRangeAt(0); + range2.insertNode(mentionLabelContainer); + + const zws = document.createTextNode("\u200b"); + mentionLabelContainer.after(zws); + + const newRange = document.createRange(); + newRange.setStartAfter(zws); + newRange.collapse(true); + sel.removeAllRanges(); + sel.addRange(newRange); + + setMentionQuery(null); + setTriggerRange(null); + }; + + const insertAt = () => { + const editor = editorRef.current; + if (!editor) return; + + editor.focus(); + + const sel = window.getSelection(); + if (!sel || !sel.rangeCount) { + const range = document.createRange(); + range.selectNodeContents(editor); + range.collapse(false); + sel?.removeAllRanges(); + sel?.addRange(range); + } + + document.execCommand("insertText", false, "@"); + detectMention(); + }; + + const updateEmptyClass = () => { + const editor = editorRef.current; + if (!editor) return; + + const isEmpty = editor.innerHTML === "" || editor.innerHTML === "
"; + + editor.classList.toggle("is-empty", isEmpty); + }; + + // Editor API + const getEditorValue = () => { + if (!editorRef.current) return ""; + + return serializeEditor(editorRef.current).trim(); + }; + + const clearEditor = () => { + if (editorRef.current) { + editorRef.current.innerHTML = ""; + editorRef.current.classList.add("is-empty"); + } + }; + + const focusEditor = () => { + editorRef.current?.focus(); + }; + + useImperativeHandle(ref, () => ({ + getValue: getEditorValue, + clear: clearEditor, + focus: focusEditor, + insertAt, + })); + + // Handlers + const handleAddComment = () => { + const serialized = getEditorValue(); + if (!serialized) return; + + clearEditor(); + + startTransition(async () => { + const res = await addComment(serialized, fieldPath, documentId, collectionSlug, locale, globalSlug); + + if (res.success) { + onSuccessAddComment?.(); + } + }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (mentionQuery !== null) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % filteredUsers.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedIndex((prev) => (prev - 1 + filteredUsers.length) % filteredUsers.length); + return; + } + if (e.key === "Enter" && filteredUsers.length > 0) { + e.preventDefault(); + const selectedUser = filteredUsers[selectedIndex]; + if (selectedUser) insertMention(selectedUser); + return; + } + if (e.key === "Escape") { + setMentionQuery(null); + setTriggerRange(null); + return; + } + } + + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + + handleAddComment(); + onEnterPress?.(); + } else if (e.key === "Escape") { + onEscapePress?.(); + } + }; + + const handleInput = () => { + detectMention(); + updateEmptyClass(); + }; + + return ( +
+
+ + +
+
+
+
+ + {mentionQuery !== null && ( + + )} + + insertAt()} + onAddComment={handleAddComment} + /> +
+
+
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentItem/ToolsPanel.tsx b/packages/payload-plugin-comments/src/components/CommentItem/ToolsPanel.tsx new file mode 100644 index 00000000..3bac8685 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentItem/ToolsPanel.tsx @@ -0,0 +1,34 @@ +import { CircleCheck, Trash2, Undo2 } from "lucide-react"; +import { useTranslation } from "@payloadcms/ui"; +import { IconButton } from "../IconButton"; + +interface Props { + isResolved: boolean; + canDelete: boolean; + onResolve: () => void; + onDelete: () => void; +} + +export function ToolsPanel({ isResolved, canDelete, onDelete, onResolve }: Props) { + const { t } = useTranslation(); + + return ( +
+ + {isResolved ? + + : } + + + {canDelete && ( + + + + )} +
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentItem/index.tsx b/packages/payload-plugin-comments/src/components/CommentItem/index.tsx new file mode 100644 index 00000000..8a617b44 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentItem/index.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { startTransition } from "react"; +import { useTranslation } from "@payloadcms/ui"; +import { cn } from "../../utils/general/cn"; +import type { Comment } from "../../types"; +import { useComments } from "../../providers/CommentsProvider"; +import { renderCommentText } from "../../utils/comment/renderCommentText"; +import { resolveUsername } from "../../utils/user/resolveUsername"; +import { FALLBACK_DELETED_USERNAME, FALLBACK_USERNAME } from "../../constants"; +import { ToolsPanel } from "./ToolsPanel"; +import { useRelativeDate } from "../../hooks/useRelativeDate"; +import { Avatar } from "../Avatar"; + +interface Props { + comment: Comment; + currentUserId: number | null; +} + +export function CommentItem({ comment, currentUserId }: Props) { + const { resolveComment, removeComment, usernameFieldPath } = useComments(); + const { t } = useTranslation(); + const createdAtRelativeDate = useRelativeDate(comment.createdAt); + + const deletedUserLabel = t("comments:deletedUser" as never) ?? FALLBACK_DELETED_USERNAME; + const unknownLabel = t("comments:unknownAuthor" as never) ?? FALLBACK_USERNAME; + const narrowedAuthor = typeof comment.author === "object" ? comment.author : null; + const authorName = resolveUsername(narrowedAuthor, usernameFieldPath, unknownLabel); + + const isResolved = comment.isResolved ?? false; + + const authorId = + comment.author && typeof comment.author === "object" && "id" in comment.author ? comment.author.id : null; + const canDelete = currentUserId !== null && authorId === currentUserId; + + const handleToggleResolve = () => { + startTransition(async () => { + await resolveComment(comment.id, !isResolved); + }); + }; + + const handleDelete = () => { + startTransition(async () => { + await removeComment(comment.id); + }); + }; + + return ( +
+ + +
+ + +
+
+ {authorName} + {createdAtRelativeDate} +
+ +

+ {renderCommentText({ + text: comment.text, + mentions: comment.mentions, + currentUserId, + usernameFieldPath, + fallbackDeletedUsername: deletedUserLabel, + })} +

+
+
+
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsDrawer/components/Header.tsx b/packages/payload-plugin-comments/src/components/CommentsDrawer/components/Header.tsx new file mode 100644 index 00000000..af5763a8 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsDrawer/components/Header.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { SelectInput, useModal, useTranslation } from "@payloadcms/ui"; +import { useComments } from "../../../providers/CommentsProvider"; +import type { FilterMode } from "../../../types"; +import { LoaderCircleIcon, XIcon } from "lucide-react"; +import type { OptionObject } from "payload"; +import { IconButton } from "../../IconButton"; + +interface HeaderProps { + slug: string; +} + +export function Header({ slug }: HeaderProps) { + const { t } = useTranslation(); + const { closeModal } = useModal(); + const { filter, setFilter, syncCommentsStatus } = useComments(); + + const options = [ + { label: t("comments:filterOpen" as never), value: "open" }, + { label: t("comments:filterResolved" as never), value: "resolved" }, + { label: t("comments:filterMentioned" as never), value: "mentioned" }, + ]; + + return ( +
+

{t("comments:label" as never)}

+ + {syncCommentsStatus === "loading" && ( + + )} + +
+ setFilter((option as OptionObject).value as FilterMode)} + isClearable={false} + /> + + closeModal(slug)} title={t("comments:close" as never)}> + + +
+
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsDrawer/index.tsx b/packages/payload-plugin-comments/src/components/CommentsDrawer/index.tsx new file mode 100644 index 00000000..b98a3e62 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsDrawer/index.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { createPortal } from "react-dom"; +import { CommentsPanel } from "../CommentsPanel"; +import { Header } from "./components/Header"; +import { cn } from "../../utils/general/cn"; +import { Drawer, useModal } from "@payloadcms/ui"; + +interface Props { + slug: string; +} + +export function CommentsDrawer({ slug }: Props) { + const { modalState, closeModal, containerRef } = useModal(); + const isOpen = !!modalState[slug]?.isOpen; + + return ( + <> + {isOpen + && containerRef.current + && createPortal( +
closeModal(slug)} />, + containerRef.current, + )} + }> + + + + ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsHeaderButton/index.tsx b/packages/payload-plugin-comments/src/components/CommentsHeaderButton/index.tsx new file mode 100644 index 00000000..68122383 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsHeaderButton/index.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useTranslation } from "@payloadcms/ui"; +import { MessageSquare } from "lucide-react"; +import { CommentsDrawer } from "../CommentsDrawer"; +import { useCommentsDrawer } from "../../providers/CommentsDrawerProvider"; +import { IconButton } from "../IconButton"; + +export function CommentsHeaderButton() { + const { slug, open } = useCommentsDrawer(); + const { t } = useTranslation(); + + return ( + <> + open()} title={t("comments:openCommentsAria" as never)}> + + + + + + ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/components/CollapsibleGroup.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/components/CollapsibleGroup.tsx new file mode 100644 index 00000000..ba9b1d19 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/CollapsibleGroup.tsx @@ -0,0 +1,71 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; +import { cva } from "class-variance-authority"; +import { useCollapseState } from "../hooks/useCollapseState"; +import { cn } from "../../../utils/general/cn"; + +interface CollapsibleGroupProps { + groupKey: string; + label: string; + children: ReactNode; + level: "collection" | "document" | "field"; +} + +const collapsibleGroupVariants = { + wrapper: cva("flex w-full items-center gap-2 cursor-pointer select-none", { + variants: { + level: { + collection: "py-2", + document: "py-2", + field: "py-1.5", + }, + }, + }), + label: cva("flex-1 truncate", { + variants: { + level: { + collection: "text-[14px] font-semibold text-(--theme-text)", + document: "text-[13px] font-semibold text-(--theme-text)", + field: "text-[12px] font-medium text-(--theme-elevation-600)", + }, + }, + }), + childWrapper: cva("", { + variants: { + level: { + collection: "border-l-2 border-(--theme-elevation-100) pl-3", + document: "border-l-2 border-(--theme-elevation-100) pl-3", + field: "", + }, + }, + }), +}; + +export function CollapsibleGroup({ groupKey, label, children, level }: CollapsibleGroupProps) { + const [isCollapsed, toggle] = useCollapseState(groupKey); + + return ( +
+
(e.key === "Enter" || e.key === " ") && toggle()} + aria-expanded={!isCollapsed} + className={collapsibleGroupVariants.wrapper({ level })}> + {label} + + +
+ + {!isCollapsed &&
{children}
} +
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/components/DocumentView.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/components/DocumentView.tsx new file mode 100644 index 00000000..5fafc908 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/DocumentView.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useTranslation } from "@payloadcms/ui"; +import type { Comment } from "../../../types"; +import { useComments } from "../../../providers/CommentsProvider"; +import { groupCommentsByFieldPath } from "../utils/groupCommentsByFieldPath"; +import { FILTER_NO_COMMENTS_KEYS } from "../constants"; +import { FieldGroupSection } from "./FieldGroupSection"; + +interface Props { + comments: Comment[]; + userId: number | null; + className: string; +} + +export function DocumentView({ comments, userId, className }: Props) { + const { t } = useTranslation(); + const { filter } = useComments(); + + const fields = groupCommentsByFieldPath(comments); + + return ( +
+ {comments.length === 0 && ( +

+ {t(FILTER_NO_COMMENTS_KEYS[filter] as never)} +

+ )} + + +
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx new file mode 100644 index 00000000..c341d1cb --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useTranslation } from "@payloadcms/ui"; +import type { Comment } from "../../../types/comment"; +import { CollapsibleGroup } from "./CollapsibleGroup"; +import { CommentItem } from "../../CommentItem"; +import { createCollapsibleGroupKey } from "../utils/createCollapsibleGroupKey"; +import { resolveFieldLabel } from "../utils/resolveFieldLabel"; +import { useComments } from "../../../providers/CommentsProvider"; +import { CommentEditor } from "../../CommentEditor"; + +interface Props { + fields: Map; + userId: number | null; + collectionSlug?: string; + documentId?: number; + globalSlug?: string; +} + +export function FieldGroupSection({ fields, userId, collectionSlug, documentId, globalSlug }: Props) { + const { t } = useTranslation(); + const { fieldLabelRegistry } = useComments(); + + const generalComments = fields.get(null) ?? []; + const fieldEntries = [...fields.entries()].filter((entry): entry is [string, Comment[]] => entry[0] !== null); + + return ( + <> + +
+ {generalComments.map((comment) => ( + + ))} + + +
+
+ + {fieldEntries.map(([fieldPath, fieldComments]) => ( + +
+ {fieldComments.map((comment) => ( + + ))} + + +
+
+ ))} + + ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalDocumentView.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalDocumentView.tsx new file mode 100644 index 00000000..b7056006 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalDocumentView.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useTranslation } from "@payloadcms/ui"; +import type { Comment } from "../../../types"; +import { useComments } from "../../../providers/CommentsProvider"; +import { groupCommentsByFieldPath } from "../utils/groupCommentsByFieldPath"; +import { FILTER_NO_COMMENTS_KEYS } from "../constants"; +import { FieldGroupSection } from "./FieldGroupSection"; + +interface Props { + comments: Comment[]; + userId: number | null; + className: string; +} + +export function GlobalDocumentView({ comments, userId, className }: Props) { + const { t } = useTranslation(); + const { filter } = useComments(); + + const fields = groupCommentsByFieldPath(comments); + + return ( +
+ {comments.length === 0 && ( +

+ {t(FILTER_NO_COMMENTS_KEYS[filter] as never)} +

+ )} + + +
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx new file mode 100644 index 00000000..e5ffe772 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useLocale, useTranslation } from "@payloadcms/ui"; +import type { Comment } from "../../../types/comment"; +import { useComments } from "../../../providers/CommentsProvider"; +import { groupCommentsGlobally } from "../utils/groupCommentsGlobally"; +import { resolveEntityLabel } from "../utils/resolveEntityLabel"; +import { CollapsibleGroup } from "./CollapsibleGroup"; +import { FILTER_NO_COMMENTS_KEYS } from "../constants"; +import { FieldGroupSection } from "./FieldGroupSection"; +import { createCollapsibleGroupKey } from "../utils/createCollapsibleGroupKey"; + +interface Props { + comments: Comment[]; + userId: number | null; + className: string; +} + +export function GlobalView({ comments, userId, className }: Props) { + const { t } = useTranslation(); + const { documentTitles, filter, collectionLabels, globalLabels } = useComments(); + const { code: locale } = useLocale(); + const groupedComments = groupCommentsGlobally(comments); + + return ( +
+ {comments.length === 0 && ( +

+ {t(FILTER_NO_COMMENTS_KEYS[filter] as never)} +

+ )} + + {groupedComments.map((entry) => { + if (entry.type === "collection") { + const { slug, docs } = entry; + + return ( + + {[...docs.entries()].map(([docId, fields]) => { + const title = documentTitles[slug]?.[String(docId)] ?? String(docId); + const documentId = Number(docId); + + return ( + + + + ); + })} + + ); + } + + const { slug, fields } = entry; + + return ( + + + + ); + })} +
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/constants.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/constants.ts new file mode 100644 index 00000000..61746949 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/constants.ts @@ -0,0 +1,7 @@ +import type { FilterMode } from "../../types"; + +export const FILTER_NO_COMMENTS_KEYS: Record = { + open: "comments:noOpenComments", + resolved: "comments:noResolvedComments", + mentioned: "comments:noMentionedComments", +}; diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/hooks/useCollapseState.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/hooks/useCollapseState.ts new file mode 100644 index 00000000..1ffddf3b --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/hooks/useCollapseState.ts @@ -0,0 +1,51 @@ +"use client"; + +import { useState, useEffect } from "react"; + +const STORAGE_KEY = "payload-comments-collapsed"; + +function readStorage(): Record { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}"); + } catch { + return {}; + } +} + +function writeStorage(data: Record) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch {} +} + +export function useCollapseState(groupKey: string): [isCollapsed: boolean, toggle: () => void] { + const [isCollapsed, setIsCollapsed] = useState(() => { + const stored = readStorage(); + + return stored[groupKey] === true; + }); + + useEffect(() => { + const stored = readStorage(); + + setIsCollapsed(stored[groupKey] === true); + }, [groupKey]); + + const toggle = () => { + setIsCollapsed((prev) => { + const next = !prev; + const stored = readStorage(); + + if (next) { + stored[groupKey] = true; + } else { + delete stored[groupKey]; + } + + writeStorage(stored); + return next; + }); + }; + + return [isCollapsed, toggle]; +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/hooks/useScrollToTargetFieldGroup.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/hooks/useScrollToTargetFieldGroup.ts new file mode 100644 index 00000000..43f1504c --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/hooks/useScrollToTargetFieldGroup.ts @@ -0,0 +1,41 @@ +import { useCommentsDrawer } from "../../../providers/CommentsDrawerProvider"; +import { useLayoutEffect } from "react"; + +export function useScrollToTargetFieldGroup() { + const { scrollTargetPath, setScrollTargetPath } = useCommentsDrawer(); + + useLayoutEffect(() => { + if (!scrollTargetPath) return; + + const id = setTimeout(() => { + const section = document.querySelector(`[data-field-path="${CSS.escape(scrollTargetPath)}"]`); + if (!section) return; + + let scrollContainer: Element | null = section.parentElement; + + while (scrollContainer && scrollContainer !== document.documentElement) { + const { overflow, overflowY } = getComputedStyle(scrollContainer); + if (/auto|scroll/.test(overflow + overflowY)) break; + scrollContainer = scrollContainer.parentElement; + } + + if (!scrollContainer || scrollContainer === document.documentElement) { + section.scrollIntoView({ behavior: "smooth", block: "start" }); + setScrollTargetPath(null); + return; + } + + const header = scrollContainer.querySelector("header"); + const headerHeight = header?.getBoundingClientRect().height ?? 0; + + const sectionTop = section.getBoundingClientRect().top; + const containerTop = scrollContainer.getBoundingClientRect().top; + const scrollOffset = sectionTop - containerTop - headerHeight + scrollContainer.scrollTop; + + scrollContainer.scrollTo({ top: scrollOffset, behavior: "smooth" }); + setScrollTargetPath(null); + }, 300); + + return () => clearTimeout(id); + }, [scrollTargetPath, setScrollTargetPath]); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/index.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/index.tsx new file mode 100644 index 00000000..f8ef5ba1 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/index.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useAuth } from "@payloadcms/ui"; +import { cn } from "../../utils/general/cn"; +import { useComments } from "../../providers/CommentsProvider"; +import { filterComments } from "./utils/filterComments"; +import { DocumentView } from "./components/DocumentView"; +import { GlobalView } from "./components/GlobalView"; +import { GlobalDocumentView } from "./components/GlobalDocumentView"; +import { useScrollToTargetFieldGroup } from "./hooks/useScrollToTargetFieldGroup"; + +interface Props { + className: string; +} + +export const CommentsPanel = ({ className }: Props) => { + const { user } = useAuth(); + const { visibleComments, filter, mode } = useComments(); + + const userId = (user?.id as number) ?? null; + const filteredComments = filterComments({ + comments: visibleComments, + filter, + currentUserId: userId ?? undefined, + }); + + useScrollToTargetFieldGroup(); + + if (mode === "document") { + return ; + } + + if (mode === "global-document") { + return ; + } + + return ; +}; diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/types.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/types.ts new file mode 100644 index 00000000..a644677b --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/types.ts @@ -0,0 +1,5 @@ +import type { Comment } from "../../types"; + +export type FieldPath = string | null; + +export type Group = [FieldPath, Comment[]]; diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/createCollapsibleGroupKey.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/createCollapsibleGroupKey.ts new file mode 100644 index 00000000..85e740c3 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/createCollapsibleGroupKey.ts @@ -0,0 +1,24 @@ +import type { FieldPath } from "../types"; + +interface Props { + collectionSlug?: string; + documentId?: number | null; + globalSlug?: string; + fieldPath?: FieldPath; +} + +export function createCollapsibleGroupKey({ collectionSlug, documentId, globalSlug, fieldPath: fieldPathProp }: Props) { + const fieldPath = fieldPathProp === null ? "__general__" : fieldPathProp; + + const fieldPathSegment = fieldPath === undefined ? "" : `-${fieldPath}`; + + if (collectionSlug && documentId) { + return `${collectionSlug}-${documentId}${fieldPathSegment}`; + } + + if (globalSlug) { + return `${globalSlug}${fieldPathSegment}`; + } + + return ""; +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/filterComments.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/filterComments.ts new file mode 100644 index 00000000..2f87aedf --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/filterComments.ts @@ -0,0 +1,27 @@ +import type { Comment, FilterMode } from "../../../types"; + +interface Props { + comments: Comment[]; + filter: FilterMode; + currentUserId?: number | null; +} + +export function filterComments({ comments, filter, currentUserId }: Props) { + return comments.filter((comment) => { + if (filter === "open") return !comment.isResolved; + + if (filter === "resolved") return comment.isResolved; + + if (filter === "mentioned") { + return ( + comment.mentions?.some(({ user: mentionUser }) => { + if (typeof mentionUser === "number") return mentionUser === currentUserId; + + return mentionUser.id === currentUserId; + }) ?? false + ); + } + + return false; + }); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsByFieldPath.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsByFieldPath.ts new file mode 100644 index 00000000..bf5f5c7e --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsByFieldPath.ts @@ -0,0 +1,17 @@ +import type { Comment } from "../../../types"; +import type { FieldPath } from "../types"; +import { sortGroupsByCreatedAt } from "./sortGroupsByCreatedAt"; + +export function groupCommentsByFieldPath(comments: Comment[]): Map { + const groupMap = new Map(); + + for (const comment of comments) { + const key = comment.fieldPath ?? null; + + if (!groupMap.has(key)) groupMap.set(key, []); + + groupMap.get(key)!.push(comment); + } + + return sortGroupsByCreatedAt(groupMap); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts new file mode 100644 index 00000000..bd4ad6da --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts @@ -0,0 +1,105 @@ +import type { Comment } from "../../../types"; +import { sortGroupsByCreatedAt } from "./sortGroupsByCreatedAt"; +import type { FieldPath } from "../types"; + +type CommentsMap = Map; + +type CollectionCommentsMapByDoc = Map; + +export type CollectionCommentsEntry = { + type: "collection"; + slug: string; + docs: CollectionCommentsMapByDoc; +}; + +export type GlobalCommentsEntry = { + type: "global"; + slug: string; + fields: CommentsMap; +}; + +export type EntityCommentsEntry = CollectionCommentsEntry | GlobalCommentsEntry; + +const extractCommentCreatedAtTime = (c: Comment) => new Date(c.createdAt).getTime(); + +export function groupCommentsGlobally(comments: Comment[]): EntityCommentsEntry[] { + const collections = new Map(); + const globals = new Map(); + + for (const comment of comments) { + if (comment.globalSlug) { + const slug = comment.globalSlug; + const field = comment.fieldPath ?? null; + + if (!globals.has(slug)) globals.set(slug, new Map()); + + const fields = globals.get(slug)!; + + if (!fields.has(field)) fields.set(field, []); + + fields.get(field)!.push(comment); + + continue; + } + + const slug = comment.collectionSlug; + const docId = comment.documentId; + const field = comment.fieldPath ?? null; + if (!slug || !docId) continue; + + if (!collections.has(slug)) collections.set(slug, new Map()); + + const docs = collections.get(slug)!; + + if (!docs.has(docId)) docs.set(docId, new Map()); + + const fields = docs.get(docId)!; + + if (!fields.has(field)) fields.set(field, []); + + fields.get(field)!.push(comment); + } + + // Sort collection inner maps + for (const [slug, docs] of collections.entries()) { + for (const [docId, fields] of docs.entries()) { + docs.set(docId, sortGroupsByCreatedAt(fields)); + } + + const oldestInFields = (fields: Map) => + Math.min(...[...fields.values()].flat().map(extractCommentCreatedAtTime)); + + const sortedDocs = new Map([...docs.entries()].sort(([, a], [, b]) => oldestInFields(a) - oldestInFields(b))); + + collections.set(slug, sortedDocs); + } + + // Sort globals inner maps + for (const [slug, fields] of globals.entries()) { + globals.set(slug, sortGroupsByCreatedAt(fields)); + } + + const oldestInCollectionDocs = (docs: CollectionCommentsMapByDoc) => + Math.min(...[...docs.values()].flatMap((fields) => [...fields.values()].flat()).map(extractCommentCreatedAtTime)); + + const oldestInGlobalFields = (fields: Map) => + Math.min(...[...fields.values()].flat().map(extractCommentCreatedAtTime)); + + const entries: Array<{ entry: EntityCommentsEntry; time: number }> = []; + + for (const [slug, docs] of collections.entries()) { + entries.push({ + entry: { type: "collection", slug, docs }, + time: oldestInCollectionDocs(docs), + }); + } + + for (const [slug, fields] of globals.entries()) { + entries.push({ + entry: { type: "global", slug, fields }, + time: oldestInGlobalFields(fields), + }); + } + + return entries.sort((a, b) => a.time - b.time).map(({ entry }) => entry); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveEntityLabel.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveEntityLabel.ts new file mode 100644 index 00000000..4fd5c84a --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveEntityLabel.ts @@ -0,0 +1,16 @@ +import type { EntityLabel } from "../../../types"; + +export function resolveEntityLabel(label: EntityLabel | undefined, locale: string, fallback: string) { + if (label === undefined) return fallback; + if (typeof label === "string") return label; + + const byLocale = label[locale]; + if (byLocale !== undefined) return byLocale; + + const byEn = label["en"]; + if (byEn !== undefined) return byEn; + + const first = Object.values(label)[0]; + + return first ?? fallback; +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveFieldLabel.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveFieldLabel.ts new file mode 100644 index 00000000..45579188 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveFieldLabel.ts @@ -0,0 +1,17 @@ +import type { GlobalFieldLabelRegistry } from "../../../types"; + +interface Props { + registry: GlobalFieldLabelRegistry; + collectionSlug?: string; + documentId?: number; + globalSlug?: string; + fieldPath: string; +} + +export function resolveFieldLabel({ registry, collectionSlug, documentId, globalSlug, fieldPath }: Props) { + const segments = registry[collectionSlug ?? globalSlug ?? ""]?.[documentId ?? 0]?.[fieldPath]; + + if (!segments) return fieldPath; + + return segments.map((s) => (s.type === "field" ? s.label : `#${s.position + 1}`)).join(" > "); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/sortGroupsByCreatedAt.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/sortGroupsByCreatedAt.ts new file mode 100644 index 00000000..ae783f78 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/sortGroupsByCreatedAt.ts @@ -0,0 +1,13 @@ +import type { Comment } from "../../../types"; +import type { FieldPath } from "../types"; + +export function sortGroupsByCreatedAt(groupMap: Map): Map { + const toTime = (c: Comment) => new Date(c.createdAt).getTime(); + const oldest = (arr: Comment[]) => Math.min(...arr.map(toTime)); + + return new Map( + [...groupMap.entries()] + .sort(([, a], [, b]) => oldest(a) - oldest(b)) + .map(([key, arr]) => [key, [...arr].sort((a, b) => toTime(a) - toTime(b))]), + ); +} diff --git a/packages/payload-plugin-comments/src/components/FieldCommentLabel/AddCommentPopup.tsx b/packages/payload-plugin-comments/src/components/FieldCommentLabel/AddCommentPopup.tsx new file mode 100644 index 00000000..50110847 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/AddCommentPopup.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Popup, useTranslation } from "@payloadcms/ui"; +import { IconButton } from "../IconButton"; +import { cn } from "../../utils/general/cn"; +import { MessageSquarePlus } from "lucide-react"; +import { CommentEditor } from "../CommentEditor"; +import { useComments } from "../../providers/CommentsProvider"; + +interface Props { + fieldPath: string; + showTrigger: boolean; + onToggle: (isOpen: boolean) => void; +} + +export function AddCommentPopup({ fieldPath, showTrigger, onToggle }: Props) { + const { t } = useTranslation(); + const { mode, globalSlug } = useComments(); + + const trigger = ( + + + + ); + + return ( + ( +
+

{t("comments:add" as never)}

+ + +
+ )} + /> + ); +} diff --git a/packages/payload-plugin-comments/src/components/FieldCommentLabel/hooks/useStablePath.ts b/packages/payload-plugin-comments/src/components/FieldCommentLabel/hooks/useStablePath.ts new file mode 100644 index 00000000..e4c67619 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/hooks/useStablePath.ts @@ -0,0 +1,10 @@ +"use client"; + +import { useFormFields } from "@payloadcms/ui"; +import { buildStablePath } from "../utils/buildStablePath"; + +export function useStablePath(positionPath: string) { + return useFormFields(([fields]) => + buildStablePath(positionPath, (idPath) => fields[idPath]?.value as string | undefined), + ); +} diff --git a/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx b/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx new file mode 100644 index 00000000..c085fbcd --- /dev/null +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation, useLocale } from "@payloadcms/ui"; +import { MessageSquareIcon } from "lucide-react"; +import { useComments } from "../../providers/CommentsProvider"; +import type { FieldLabelClientProps } from "payload"; +import type { Label } from "./types"; +import { resolveLabel } from "./utils/resolveLabel"; +import { excludeComments } from "./utils/exludeComments"; +import { useStablePath } from "./hooks/useStablePath"; +import { useCommentsDrawer } from "../../providers/CommentsDrawerProvider"; +import { IconButton } from "../IconButton"; +import { AddCommentPopup } from "./AddCommentPopup"; + +interface Props extends FieldLabelClientProps { + field: FieldLabelClientProps["field"] & { + label?: Label; + required?: boolean; + }; +} + +export function FieldCommentLabel({ field, htmlFor, path: fieldPath }: Props) { + const { label, required } = field; + + const { t } = useTranslation(); + const { code: locale } = useLocale(); + const { open: openDrawer, setScrollTargetPath } = useCommentsDrawer(); + const { visibleComments, setFilter, mode } = useComments(); + + const [isHovered, setIsHovered] = useState(false); + const [isPopupOpen, setIsPopupOpen] = useState(false); + + const resolvedLabel = resolveLabel(label, locale); + const stablePath = useStablePath(fieldPath ?? ""); + const fieldComments = excludeComments(visibleComments, stablePath || undefined, locale); + const openCommentsCount = fieldComments.length; + + const handleOpenDrawer = () => { + setFilter("open"); + setScrollTargetPath(stablePath || null); + openDrawer(); + }; + + const handleToggle = (isOpen: boolean) => { + setIsPopupOpen(isOpen); + }; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)}> + {resolvedLabel && ( + + )} + + {fieldPath && (mode === "document" || mode === "global-document") && ( +
+ {openCommentsCount > 0 ? + + + + {openCommentsCount} + + : } +
+ )} +
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/FieldCommentLabel/types.ts b/packages/payload-plugin-comments/src/components/FieldCommentLabel/types.ts new file mode 100644 index 00000000..4fcbee20 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/types.ts @@ -0,0 +1,3 @@ +type LabelStroke = string | undefined; + +export type Label = LabelStroke | Record; diff --git a/packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/buildStablePath.ts b/packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/buildStablePath.ts new file mode 100644 index 00000000..1ad7c92c --- /dev/null +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/buildStablePath.ts @@ -0,0 +1,19 @@ +export function buildStablePath(positionPath: string, getRowId: (positionalIdPath: string) => string | undefined) { + const segments = positionPath.split("."); + const result: string[] = []; + + for (let i = 0; i < segments.length; i++) { + const seg = segments[i] ?? ""; + + if (/^\d+$/.test(seg)) { + const positionalParent = segments.slice(0, i).join("."); + const idPath = positionalParent ? `${positionalParent}.${seg}.id` : `${seg}.id`; + + result.push(getRowId(idPath) ?? seg); + } else { + result.push(seg); + } + } + + return result.join("."); +} diff --git a/packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/exludeComments.ts b/packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/exludeComments.ts new file mode 100644 index 00000000..f3029cf4 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/exludeComments.ts @@ -0,0 +1,13 @@ +import type { Comment } from "../../../types"; + +export function excludeComments(comments: Comment[] | null, path: string | undefined, currentLocale?: string | null) { + if (!path) return []; + + return (comments ?? []).filter(({ fieldPath, isResolved, locale }) => { + if (fieldPath !== path || isResolved) return false; + + if (locale && currentLocale && locale !== currentLocale) return false; + + return true; + }); +} diff --git a/packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/resolveLabel.ts b/packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/resolveLabel.ts new file mode 100644 index 00000000..21f2f233 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/resolveLabel.ts @@ -0,0 +1,9 @@ +import type { Label } from "../types"; + +export function resolveLabel(label: Label, locale: string) { + if (!label) return null; + + if (typeof label === "string") return label; + + return label[locale] ?? label.en ?? Object.values(label)[0] ?? null; +} diff --git a/packages/payload-plugin-comments/src/components/IconButton/index.tsx b/packages/payload-plugin-comments/src/components/IconButton/index.tsx new file mode 100644 index 00000000..9fb415a3 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/IconButton/index.tsx @@ -0,0 +1,43 @@ +import { cva } from "class-variance-authority"; +import { cn } from "../../utils/general/cn"; + +const variants = cva("flex justify-center items-center p-0 rounded border-none transition-colors cursor-pointer", { + variants: { + variant: { + neutral: "bg-transparent hover:bg-(--theme-elevation-50) text-(--theme-elevation-450) hover:text-(--theme-text)", + neutralSecondary: + "bg-(--theme-elevation-100) hover:bg-(--theme-elevation-150) text-(--theme-elevation-600) hover:text-(--theme-text)", + primary: "bg-(--theme-elevation-1000) hover:bg-(--theme-elevation-800) text-(--theme-elevation-0)", + }, + size: { + sm: "w-[20px] h-[20px]", + md: "w-[24px] h-[24px]", + }, + }, + defaultVariants: { + variant: "neutral", + size: "md", + }, +}); + +interface Props { + className?: string; + title?: string; + children: React.ReactNode; + variant?: "neutral" | "neutralSecondary" | "primary"; + size?: "sm" | "md"; + onClick?: () => void; +} + +export function IconButton({ className, title, children, variant, size, onClick }: Props) { + return ( + + ); +} diff --git a/packages/payload-plugin-comments/src/components/MentionDropdown.tsx b/packages/payload-plugin-comments/src/components/MentionDropdown.tsx new file mode 100644 index 00000000..30041dc2 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/MentionDropdown.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { PopupList, useTranslation } from "@payloadcms/ui"; +import { FALLBACK_USERNAME } from "../constants"; +import { useComments } from "../providers/CommentsProvider"; +import type { User } from "../types"; +import { resolveUsername } from "../utils/user/resolveUsername"; + +interface MentionDropdownProps { + users: User[]; + selectedIndex: number; + onSelect: (user: User) => void; +} + +export function MentionDropdown({ users, selectedIndex, onSelect }: MentionDropdownProps) { + const { usernameFieldPath } = useComments(); + const { t } = useTranslation(); + + const unknownLabel = t("comments:unknownAuthor" as never) ?? FALLBACK_USERNAME; + + return ( +
e.preventDefault()}> + + {users.length === 0 ? +

+ {t("comments:noMentionMatches" as never)} +

+ : users.map((user, index) => ( + onSelect(user)}> + @{resolveUsername(user, usernameFieldPath, unknownLabel)} + + )) + } +
+
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/MentionLabel/index.tsx b/packages/payload-plugin-comments/src/components/MentionLabel/index.tsx new file mode 100644 index 00000000..67b32541 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/MentionLabel/index.tsx @@ -0,0 +1,18 @@ +import { cn } from "../../utils/general/cn"; + +interface Props { + name: string; + isSelf: boolean; +} + +export function MentionLabel({ name, isSelf }: Props) { + return ( + + @{name} + + ); +} diff --git a/packages/payload-plugin-comments/src/config.ts b/packages/payload-plugin-comments/src/config.ts new file mode 100644 index 00000000..f633a9bf --- /dev/null +++ b/packages/payload-plugin-comments/src/config.ts @@ -0,0 +1,20 @@ +import type { SanitizedConfig } from "payload"; +import { PLUGIN_NAME } from "./constants"; + +const GLOBAL_CONFIG_KEY = `__${PLUGIN_NAME}_payload_config__`; + +type GlobalThis = Record; + +export function setPayloadConfig(config: SanitizedConfig) { + (globalThis as GlobalThis)[GLOBAL_CONFIG_KEY] = config; +} + +export function getPayloadConfig(): SanitizedConfig { + const config = (globalThis as GlobalThis)[GLOBAL_CONFIG_KEY] as SanitizedConfig | undefined; + + if (!config) { + throw new Error(`[${PLUGIN_NAME}] Payload config not initialized.`); + } + + return config; +} diff --git a/packages/payload-plugin-comments/src/constants.ts b/packages/payload-plugin-comments/src/constants.ts new file mode 100644 index 00000000..52cb948b --- /dev/null +++ b/packages/payload-plugin-comments/src/constants.ts @@ -0,0 +1,14 @@ +export const DEFAULT_COLLECTION_SLUG = "comments"; +export const COMMENTS_DRAWER_BASE_SLUG = "comments-panel"; + +export const PLUGIN_NAME = "payload-plugin-comments"; +export const PACKAGE_NAME = "@focus-reactive/payload-plugin-comments"; + +export const USERNAME_DEFAULT_FIELD_PATH = "name"; +export const FALLBACK_USERNAME = "Unknown user"; +export const FALLBACK_DELETED_USERNAME = "Deleted user"; + +export const EXCLUDED_ADMIN_ROUTES = [ + '/admin/account', + '/admin/create-first-user', +] diff --git a/packages/payload-plugin-comments/src/hooks/useRelativeDate.ts b/packages/payload-plugin-comments/src/hooks/useRelativeDate.ts new file mode 100644 index 00000000..6a46b826 --- /dev/null +++ b/packages/payload-plugin-comments/src/hooks/useRelativeDate.ts @@ -0,0 +1,10 @@ +"use client"; + +import { useTranslation } from "@payloadcms/ui"; +import { formatRelativeDate } from "../utils/general/formatRelativeDate"; + +export function useRelativeDate(iso: string) { + const { i18n } = useTranslation(); + + return formatRelativeDate(iso, i18n.language); +} diff --git a/packages/payload-plugin-comments/src/index.ts b/packages/payload-plugin-comments/src/index.ts new file mode 100644 index 00000000..4fa40f5c --- /dev/null +++ b/packages/payload-plugin-comments/src/index.ts @@ -0,0 +1,4 @@ +export type { CommentsPluginConfig } from "./types"; +export type { CommentsTranslations } from "./translations/types"; +export { commentsPlugin } from "./plugin"; +export { setPayloadConfig } from "./config"; diff --git a/packages/payload-plugin-comments/src/plugin.ts b/packages/payload-plugin-comments/src/plugin.ts new file mode 100644 index 00000000..f1222ae0 --- /dev/null +++ b/packages/payload-plugin-comments/src/plugin.ts @@ -0,0 +1,71 @@ +import type { Config, Plugin } from "payload"; +import { baseCollection as getBaseCollection } from "./collection"; +import type { CommentsPluginConfig } from "./types"; +import { overrideCommentsCollection } from "./utils/config/overrideCommentsCollection"; +import { getComponentPath } from "./utils/path/getComponentPath"; +import { normalizeCollections } from "./utils/config/normalizeCollections"; +import { mergeTranslations } from "./utils/config/mergeTranslations"; +import { overrideCollections } from "./utils/config/overrideCollections"; +import { overrideGlobals } from "./utils/config/overrideGlobals"; + +export const commentsPlugin = + (config: CommentsPluginConfig = {}): Plugin => + (incomingConfig: Config): Config => { + const { enabled = true, collections: collectionEntries, overrides, usernameFieldPath } = config; + + if (!enabled) { + return incomingConfig; + } + + const baseCollection = getBaseCollection(config.tenant); + const finalCollection = overrideCommentsCollection(baseCollection, overrides); + + const allGlobalSlugs = (incomingConfig.globals ?? []).map((g) => g.slug); + const allCollectionSlugs = (incomingConfig.collections ?? []).map((c) => c.slug); + + const normalizedCollections = normalizeCollections(collectionEntries); + const documentTitleFields = + normalizedCollections ? + Object.fromEntries([...normalizedCollections.entries()].map(([k, v]) => [k, v.titleField])) + : {}; + + const userTranslations = config.translations ?? {}; + const incomingConfigTranslations = (incomingConfig.i18n?.translations as Record | undefined) ?? {}; + + const mergedTranslations = mergeTranslations(incomingConfigTranslations, userTranslations); + + return { + ...incomingConfig, + i18n: { + ...incomingConfig.i18n, + translations: mergedTranslations, + }, + admin: { + ...incomingConfig.admin, + components: { + ...incomingConfig.admin?.components, + providers: [ + ...(incomingConfig.admin?.components?.providers ?? []), + getComponentPath("providers/CommentsProviderWrapper", "CommentsProviderWrapper"), + getComponentPath("providers/GlobalCommentsLoader", "GlobalCommentsLoader"), + ], + actions: [ + ...(incomingConfig.admin?.components?.actions ?? []), + getComponentPath("components/CommentsHeaderButton", "CommentsHeaderButton"), + ], + }, + custom: { + ...incomingConfig.admin?.custom, + commentsPlugin: { + collections: allCollectionSlugs, + documentTitleFields, + globals: allGlobalSlugs, + tenant: config.tenant, + usernameFieldPath, + }, + }, + }, + collections: [...overrideCollections(incomingConfig.collections), finalCollection], + globals: overrideGlobals(incomingConfig.globals), + }; + }; diff --git a/packages/payload-plugin-comments/src/providers/CommentsDrawerProvider/index.tsx b/packages/payload-plugin-comments/src/providers/CommentsDrawerProvider/index.tsx new file mode 100644 index 00000000..8413a586 --- /dev/null +++ b/packages/payload-plugin-comments/src/providers/CommentsDrawerProvider/index.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useDrawerSlug, useModal } from "@payloadcms/ui"; +import { createContext, type ReactNode, useContext, useState } from "react"; +import { COMMENTS_DRAWER_BASE_SLUG } from "../../constants"; +import { useComments } from "../CommentsProvider"; + +interface CommentsDrawerContextProps { + slug: string; + scrollTargetPath: string | null; + open: () => void; + setScrollTargetPath: (path: string | null) => void; +} + +const CommentsDrawerContext = createContext(null); + +interface Props { + children: ReactNode; +} + +export function CommentsDrawerProvider({ children }: Props) { + const [scrollTargetPath, setScrollTargetPath] = useState(null); + const slug = useDrawerSlug(COMMENTS_DRAWER_BASE_SLUG); + const { openModal } = useModal(); + const { syncComments } = useComments(); + + const open = () => { + openModal(slug); + + void syncComments(); + }; + + return ( + + {children} + + ); +} + +export function useCommentsDrawer() { + const context = useContext(CommentsDrawerContext); + + if (!context) throw new Error("useCommentsDrawer must be used within a CommentsProvider"); + + return context; +} diff --git a/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx b/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx new file mode 100644 index 00000000..b370bb82 --- /dev/null +++ b/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx @@ -0,0 +1,353 @@ +"use client"; + +import { + createContext, + type Dispatch, + type ReactNode, + type SetStateAction, + useCallback, + useContext, + useOptimistic, + useState, +} from "react"; +import { useAuth, useLocale } from "@payloadcms/ui"; +import { usePathname } from "next/navigation"; +import type { Comment, EntityLabelsMap, User } from "../../types"; +import { createComment } from "../../services/createComment"; +import { deleteComment } from "../../services/deleteComment"; +import { resolveComment as resolveCommentService } from "../../services/resolveComment"; +import { syncAllCommentsData } from "../../services/syncAllCommentsData"; +import type { FilterMode, GlobalFieldLabelRegistry, LoadingStatus, DocumentTitles, Mode } from "../../types"; +import { parseMentionIds } from "../../utils/mention/parseMentionIds"; +import { defineModeByPathname } from "../../utils/mode/defineModeByPathname"; +import { extractVisibleComments } from "../../utils/comment/extractVisibleComments"; + +type OptimisticAction = + | { type: "add"; comment: Comment } + | { type: "remove"; id: string | number } + | { type: "update"; comment: Comment }; + +function optimisticReducer(state: Comment[], action: OptimisticAction) { + switch (action.type) { + case "add": + return [action.comment, ...state]; + case "remove": + return state.filter(({ id }) => id !== action.id); + case "update": + return state.map((c) => (c.id === action.comment.id ? action.comment : c)); + } +} + +interface MutationResult { + success: boolean; + error?: string; +} + +interface CommentsContextProps { + allComments: Comment[]; + visibleComments: Comment[]; + documentTitles: DocumentTitles; + collectionLabels: EntityLabelsMap; + globalLabels: EntityLabelsMap; + mode: Mode; + collectionSlug: string | null | undefined; + documentId: number | null | undefined; + globalSlug: string | null; + mentionUsers: User[]; + loadError: boolean; + filter: FilterMode; + fieldLabelRegistry: GlobalFieldLabelRegistry; + syncCommentsStatus: LoadingStatus; + usernameFieldPath: string | undefined; + setFilter: Dispatch>; + hydrateComments: ( + incoming?: Comment[], + incomingTitles?: DocumentTitles, + incomingMentionUsers?: User[], + fieldLabels?: GlobalFieldLabelRegistry, + incomingCollectionLabels?: EntityLabelsMap, + nextLoadError?: boolean, + incomingGlobalLabels?: EntityLabelsMap, + ) => void; + syncComments: () => Promise; + addComment: ( + text: string, + fieldPath?: string | null, + documentId?: number, + collectionSlug?: string, + locale?: string | null, + globalSlugOverride?: string, + ) => Promise; + removeComment: (id: string | number) => Promise; + resolveComment: (id: string | number, resolved: boolean) => Promise; + updateDocumentTitle: (collectionSlug: string, documentId: string, value: string) => void; +} + +const CommentsContext = createContext(null); + +interface Props { + children: ReactNode; + usernameFieldPath?: string; +} + +export function CommentsProvider({ children, usernameFieldPath }: Props) { + const { user } = useAuth(); + const pathname = usePathname(); + const { code: currentLocale } = useLocale(); + + const [allComments, setAllComments] = useState([]); + const [optimisticComments, applyOptimistic] = useOptimistic( + allComments, + optimisticReducer, + ); + + const [documentTitles, setDocumentTitles] = useState({}); + const [collectionLabels, setCollectionLabels] = useState({}); + const [globalLabels, setGlobalLabels] = useState({}); + const [mentionUsers, setMentionUsers] = useState([]); + const [fieldLabelRegistry, setFieldLabelRegistry] = useState({}); + const [filter, setFilter] = useState("open"); + const [loadError, setLoadError] = useState(false); + const [syncCommentsStatus, setSyncCommentsStatus] = useState("idle"); + + const { mode, collectionSlug, documentId, globalSlug } = defineModeByPathname(pathname); + + const visibleComments = extractVisibleComments({ + comments: optimisticComments, + mode, + collectionSlug, + documentId, + globalSlug, + currentLocale, + }); + + const hydrateComments = useCallback( + ( + incoming?: Comment[], + incomingTitles?: DocumentTitles, + incomingMentionUsers?: User[], + fieldLabels?: GlobalFieldLabelRegistry, + incomingCollectionLabels?: EntityLabelsMap, + nextLoadError = false, + incomingGlobalLabels?: EntityLabelsMap, + ) => { + if (incoming !== undefined) { + setAllComments(incoming); + } + + if (incomingTitles) { + setDocumentTitles(incomingTitles); + } + + if (incomingMentionUsers) { + setMentionUsers(incomingMentionUsers); + } + + if (fieldLabels) { + setFieldLabelRegistry(fieldLabels); + } + + if (incomingCollectionLabels) { + setCollectionLabels(incomingCollectionLabels); + } + + if (incomingGlobalLabels) { + setGlobalLabels(incomingGlobalLabels); + } + + setLoadError(nextLoadError); + }, + [], + ); + + const addComment = async ( + text: string, + fieldPath?: string | null, + documentIdOverride?: number, + collectionSlugOverride?: string, + locale?: string | null, + globalSlugOverride?: string, + ): Promise => { + const resolvedGlobalSlug = globalSlugOverride ?? (mode === "global-document" ? globalSlug : null); + const resolvedDocId = documentIdOverride ?? documentId; + const resolvedSlug = collectionSlugOverride ?? collectionSlug; + + if (!resolvedGlobalSlug && (!resolvedDocId || !resolvedSlug)) { + return { + success: false, + error: "No document registered", + }; + } + + const tempId = -Date.now(); + + const mentionIds = parseMentionIds(text); + const selectedMentionUsers = mentionUsers.filter(({ id }) => mentionIds.includes(id)); + const mentions = selectedMentionUsers.map((user) => ({ id: null, user })); + + const optimisticComment: Comment = { + id: tempId, + text, + fieldPath, + locale: locale ?? null, + documentId: resolvedGlobalSlug ? null : resolvedDocId, + collectionSlug: resolvedGlobalSlug ? null : resolvedSlug, + globalSlug: resolvedGlobalSlug ?? null, + isResolved: false, + mentions, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + author: user as Comment["author"], + }; + + applyOptimistic({ type: "add", comment: optimisticComment }); + + const result = await createComment({ + documentId: resolvedGlobalSlug ? null : resolvedDocId, + collectionSlug: resolvedGlobalSlug ? null : resolvedSlug, + globalSlug: resolvedGlobalSlug, + text, + fieldPath, + mentionIds, + locale, + }); + + if (result.success) { + applyOptimistic({ type: "remove", id: tempId }); + setAllComments((prev) => [result.data, ...prev.filter((c) => c.id !== tempId)]); + + return { success: true }; + } + + return { + success: false, + error: result.error, + }; + }; + + const removeComment = async (id: string | number): Promise => { + applyOptimistic({ type: "remove", id }); + + const result = await deleteComment(id); + + if (result.success) { + setAllComments((prev) => prev.filter((c) => c.id !== id)); + + return { success: true }; + } + + return { + success: false, + error: result.error, + }; + }; + + const syncComments = async () => { + setSyncCommentsStatus("loading"); + + const allCommentsDataResult = await syncAllCommentsData({ locale: currentLocale }); + + if (!allCommentsDataResult.success) { + setSyncCommentsStatus("error"); + + return; + } + + const { + comments, + documentTitles, + mentionUsers, + fieldLabels, + collectionLabels: syncedLabels, + globalLabels: syncedGlobalLabels, + } = allCommentsDataResult.data; + + setAllComments((prev) => { + const serverIds = new Set(comments.map((c) => c.id)); + const localOnly = prev.filter((c) => c.id > 0 && !serverIds.has(c.id)); + + return [...localOnly, ...comments]; + }); + setDocumentTitles(documentTitles); + setMentionUsers(mentionUsers); + setFieldLabelRegistry(fieldLabels); + setCollectionLabels(syncedLabels); + setGlobalLabels(syncedGlobalLabels); + setSyncCommentsStatus("success"); + }; + + const resolveComment = async (id: string | number, resolved: boolean): Promise => { + const existing = optimisticComments.find((c) => c.id === id); + + if (existing) { + applyOptimistic({ + type: "update", + comment: { + ...existing, + isResolved: resolved, + }, + }); + } + + const result = await resolveCommentService(id, resolved); + + if (result.success) { + setAllComments((prev) => prev.map((c) => (c.id === id ? result.data : c))); + + return { success: true }; + } + + return { + success: false, + error: result.error, + }; + }; + + const updateDocumentTitle = (collectionSlug: string, documentId: string, value: string) => { + setDocumentTitles((prev) => ({ + ...prev, + [collectionSlug]: { + ...prev[collectionSlug], + [documentId]: value, + }, + })); + }; + + return ( + + {children} + + ); +} + +export function useComments() { + const context = useContext(CommentsContext); + + if (!context) throw new Error("useComments must be used within a CommentsProvider"); + + return context; +} diff --git a/packages/payload-plugin-comments/src/providers/CommentsProvider/mergeDocumentTitles.ts b/packages/payload-plugin-comments/src/providers/CommentsProvider/mergeDocumentTitles.ts new file mode 100644 index 00000000..99a47975 --- /dev/null +++ b/packages/payload-plugin-comments/src/providers/CommentsProvider/mergeDocumentTitles.ts @@ -0,0 +1,8 @@ +import type { DocumentTitles } from "../../types"; + +export function mergeDocumentTitles(prev: DocumentTitles, incoming: DocumentTitles): DocumentTitles { + return { + ...prev, + ...Object.fromEntries(Object.entries(incoming).map(([slug, ids]) => [slug, { ...(prev[slug] ?? {}), ...ids }])), + }; +} diff --git a/packages/payload-plugin-comments/src/providers/CommentsProviderWrapper/index.tsx b/packages/payload-plugin-comments/src/providers/CommentsProviderWrapper/index.tsx new file mode 100644 index 00000000..f4215b80 --- /dev/null +++ b/packages/payload-plugin-comments/src/providers/CommentsProviderWrapper/index.tsx @@ -0,0 +1,25 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useConfig } from "@payloadcms/ui"; +import { CommentsDrawerProvider } from "../CommentsDrawerProvider"; +import { CommentsProvider } from "../CommentsProvider"; +import type { CommentsPluginConfigStorage } from "../../types"; + +interface Props { + children: ReactNode; +} + +export function CommentsProviderWrapper({ children }: Props) { + const { config } = useConfig(); + + const pluginConfig = config.admin?.custom?.commentsPlugin as CommentsPluginConfigStorage | undefined; + const usernameFieldRawPath = pluginConfig?.usernameFieldPath; + const usernameFieldPath = usernameFieldRawPath === "" ? undefined : usernameFieldRawPath; + + return ( + + {children} + + ); +} diff --git a/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx new file mode 100644 index 00000000..4bb7f7c3 --- /dev/null +++ b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect } from "react"; +import { useComments } from "../CommentsProvider"; +import type { EntityLabelsMap, Comment, DocumentTitles, GlobalFieldLabelRegistry, User } from "../../types"; + +interface Props { + comments: Comment[]; + documentTitles: DocumentTitles; + mentionUsers: User[]; + fieldLabels: GlobalFieldLabelRegistry; + collectionLabels: EntityLabelsMap; + globalLabels: EntityLabelsMap; + loadError: boolean; +} + +export function GlobalCommentsHydrator({ + comments, + documentTitles, + mentionUsers, + fieldLabels, + collectionLabels, + globalLabels, + loadError, +}: Props) { + const { hydrateComments } = useComments(); + + useEffect(() => { + hydrateComments(comments, documentTitles, mentionUsers, fieldLabels, collectionLabels, loadError, globalLabels); + }, [comments, documentTitles, mentionUsers, fieldLabels, collectionLabels, loadError, globalLabels, hydrateComments]); + + return null; +} diff --git a/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/index.tsx b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/index.tsx new file mode 100644 index 00000000..9b020091 --- /dev/null +++ b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/index.tsx @@ -0,0 +1,39 @@ +import { setPayloadConfig } from "../../config"; +import { GlobalCommentsHydrator } from "./GlobalCommentsHydrator"; +import type { Payload } from "payload"; +import type { ReactNode } from "react"; +import { syncAllCommentsData } from "../../services/syncAllCommentsData"; + +interface Props { + children: ReactNode; + payload: Payload; + locale?: string; +} + +export async function GlobalCommentsLoader({ children, payload, locale }: Props) { + setPayloadConfig(payload.config); + + const res = await syncAllCommentsData({ payload, locale }); + + if (res.success) { + const { comments, documentTitles, mentionUsers, fieldLabels, collectionLabels, globalLabels } = res.data; + + return ( + <> + + + {children} + + ); + } + + return children; +} diff --git a/packages/payload-plugin-comments/src/services/createComment.ts b/packages/payload-plugin-comments/src/services/createComment.ts new file mode 100644 index 00000000..b332bcb8 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/createComment.ts @@ -0,0 +1,101 @@ +"use server"; + +import { headers } from "next/headers"; +import { getDefaultErrorMessage } from "../utils/error/getDefaultErrorMessage"; +import type { Response, Comment, BaseServiceOptions } from "../types"; +import { DEFAULT_COLLECTION_SLUG, FALLBACK_USERNAME } from "../constants"; +import { sendMentionEmails } from "./sendMentionEmails"; +import { extractPayload } from "../utils/payload/extractPayload"; + +interface Props extends BaseServiceOptions { + documentId?: number | null; + collectionSlug?: string | null; + globalSlug?: string | null; + text: string; + fieldPath?: string | null; + mentionIds?: number[]; +} + +export async function createComment({ + documentId, + collectionSlug, + globalSlug, + text, + fieldPath = null, + mentionIds = [], + locale = null, + payload: payloadProp, +}: Props): Promise> { + try { + const payload = await extractPayload(payloadProp); + const { user } = await payload.auth({ headers: await headers() }); + + if (!user) { + return { + success: false, + error: "Unauthorized", + }; + } + + if (!globalSlug && (!documentId || !collectionSlug)) { + return { + success: false, + error: "No document registered", + }; + } + + const mentions = mentionIds.map((id) => ({ user: id })); + + const data = + globalSlug ? + { + globalSlug, + documentId: null, + collectionSlug: null, + fieldPath, + locale, + text, + author: user.id, + isResolved: false, + mentions, + } + : { + documentId, + collectionSlug, + fieldPath, + locale, + text, + author: user.id, + isResolved: false, + mentions, + }; + + const comment = (await payload.create({ + collection: DEFAULT_COLLECTION_SLUG, + data, + overrideAccess: false, + user, + })) as Comment; + + if (mentionIds.length > 0) { + sendMentionEmails({ + mentionIds, + authorName: user.name ?? user.email ?? FALLBACK_USERNAME, + commentText: text, + collectionSlug: collectionSlug ?? globalSlug ?? "", + documentId: documentId, + payload, + }); + } + + return { + success: true, + data: comment, + }; + } catch (err) { + return { + success: false, + error: getDefaultErrorMessage(err), + }; + } +} diff --git a/packages/payload-plugin-comments/src/services/deleteComment.ts b/packages/payload-plugin-comments/src/services/deleteComment.ts new file mode 100644 index 00000000..4ed40667 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/deleteComment.ts @@ -0,0 +1,37 @@ +"use server"; + +import { headers } from "next/headers"; +import { DEFAULT_COLLECTION_SLUG } from "../constants"; +import { getDefaultErrorMessage } from "../utils/error/getDefaultErrorMessage"; +import type { BaseServiceOptions, Response, Comment } from "../types"; +import { extractPayload } from "../utils/payload/extractPayload"; + +export async function deleteComment(id: number | string, options?: BaseServiceOptions): Promise> { + try { + const payload = await extractPayload(options?.payload); + const { user } = await payload.auth({ headers: await headers() }); + + if (!user) { + return { success: false, error: "Unauthorized" }; + } + + const data = (await payload.delete({ + collection: DEFAULT_COLLECTION_SLUG, + id, + overrideAccess: false, + user, + })) as Comment; + + return { + success: true, + data, + }; + } catch (e) { + console.error(`Failed to delete ${id} comment`, e); + + return { + success: false, + error: getDefaultErrorMessage(e), + }; + } +} diff --git a/packages/payload-plugin-comments/src/services/fetchMentionableUsers.ts b/packages/payload-plugin-comments/src/services/fetchMentionableUsers.ts new file mode 100644 index 00000000..3130f008 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/fetchMentionableUsers.ts @@ -0,0 +1,56 @@ +"use server"; + +import type { Where } from "payload"; +import type { BaseServiceOptions, CommentsPluginConfigStorage, Response, User } from "../types"; +import { resolveUsername } from "../utils/user/resolveUsername"; +import { getDefaultErrorMessage } from "../utils/error/getDefaultErrorMessage"; +import { extractPayload } from "../utils/payload/extractPayload"; +import { getCurrentTenantId } from "./getCurrentTenantId"; +import { FALLBACK_USERNAME, USERNAME_DEFAULT_FIELD_PATH } from "../constants"; + +export async function fetchMentionableUsers(options?: BaseServiceOptions): Promise> { + try { + const payload = await extractPayload(options?.payload); + + const pluginConfig = payload.config.admin?.custom?.commentsPlugin as CommentsPluginConfigStorage | undefined; + const usernameFieldPath = pluginConfig?.usernameFieldPath ?? USERNAME_DEFAULT_FIELD_PATH; + + const tenantId = await getCurrentTenantId(payload); + + const where: Where = + tenantId ? + { + or: [{ tenant: { equals: tenantId } }, { tenant: { exists: false } }], + } + : {}; + + const usersRes = await payload.find({ + collection: "users", + overrideAccess: true, + limit: 200, + where: Object.keys(where).length ? where : undefined, + select: { + id: true, + email: true, + [usernameFieldPath]: true, + }, + }); + + const data = usersRes.docs.map((user) => ({ + id: user.id, + [usernameFieldPath]: resolveUsername(user as User, usernameFieldPath, FALLBACK_USERNAME), + })) as User[]; + + return { + success: true, + data, + }; + } catch (e) { + console.error(`Failed to fetch mentionable users`, e); + + return { + success: false, + error: getDefaultErrorMessage(e), + }; + } +} diff --git a/packages/payload-plugin-comments/src/services/fieldLabels/fetchFieldLabels.ts b/packages/payload-plugin-comments/src/services/fieldLabels/fetchFieldLabels.ts new file mode 100644 index 00000000..8825b77e --- /dev/null +++ b/packages/payload-plugin-comments/src/services/fieldLabels/fetchFieldLabels.ts @@ -0,0 +1,114 @@ +"use server"; + +import type { CollectionSlug, Field } from "payload"; +import type { BaseServiceOptions, Comment, GlobalFieldLabelRegistry } from "../../types"; +import { extractPayload } from "../../utils/payload/extractPayload"; +import { groupFieldPathsByDocument } from "./utils/groupFieldPathsByDocument"; +import { needsDocumentFetch } from "./utils/schemaUtils"; +import { resolveFieldPath } from "./utils/resolveFieldPath"; + +type BasePayloadCollections = Record< + string, + { + config: { + fields: Field[]; + }; + } +>; + +type BaseGlobals = Record; + +type BaseDocumentData = Record; + +export async function fetchFieldLabels( + comments: Comment[], + options?: BaseServiceOptions, +): Promise { + const registry: GlobalFieldLabelRegistry = {}; + + const payload = await extractPayload(options?.payload); + + // --- Collections branch --- + const fieldPathsMap = groupFieldPathsByDocument(comments); + const collections = payload.collections as BasePayloadCollections; + + for (const [slug, fieldPathsMapByDocument] of fieldPathsMap.entries()) { + const collectionConfig = collections[slug]?.config; + if (!collectionConfig) continue; + + const schemaFields = collectionConfig.fields; + + for (const [documentId, fieldPathsSet] of fieldPathsMapByDocument.entries()) { + const fieldPaths = Array.from(fieldPathsSet); + if (!fieldPaths.length) continue; + + let docData: BaseDocumentData | null = null; + + if (needsDocumentFetch(fieldPaths, schemaFields)) { + try { + const doc = await payload.findByID({ + collection: slug as CollectionSlug, + id: documentId, + overrideAccess: true, + depth: 5, + }); + + docData = doc as unknown as BaseDocumentData; + } catch {} + } + + registry[slug] ??= {}; + registry[slug][documentId] ??= {}; + + const documentRegistry = registry[slug][documentId]; + + for (const fieldPath of fieldPaths) { + if (!fieldPath) continue; + + documentRegistry[fieldPath] = resolveFieldPath(fieldPath.split("."), schemaFields, docData); + } + } + } + + // --- Globals branch --- + const globalFieldPathsMap = new Map>(); + + for (const { globalSlug, fieldPath } of comments) { + if (!globalSlug || !fieldPath) continue; + if (!globalFieldPathsMap.has(globalSlug)) globalFieldPathsMap.set(globalSlug, new Set()); + globalFieldPathsMap.get(globalSlug)!.add(fieldPath); + } + + const globals = (payload as any).globals as BaseGlobals; + + for (const [slug, fieldPathsSet] of globalFieldPathsMap.entries()) { + const globalConfig = globals?.[slug]?.config; + if (!globalConfig) continue; + + const schemaFields = globalConfig.fields; + const fieldPaths = Array.from(fieldPathsSet); + if (!fieldPaths.length) continue; + + let docData: BaseDocumentData | null = null; + + if (needsDocumentFetch(fieldPaths, schemaFields)) { + try { + const doc = await payload.findGlobal({ slug, overrideAccess: true, depth: 5 }); + docData = doc as unknown as BaseDocumentData; + } catch {} + } + + // Use globalSlug as the first key and sentinel 0 as documentId + registry[slug] ??= {}; + registry[slug][0] ??= {}; + + const documentRegistry = registry[slug][0]; + + for (const fieldPath of fieldPaths) { + if (!fieldPath) continue; + documentRegistry[fieldPath] = resolveFieldPath(fieldPath.split("."), schemaFields, docData); + } + } + + return registry; +} diff --git a/packages/payload-plugin-comments/src/services/fieldLabels/utils/groupFieldPathsByDocument.ts b/packages/payload-plugin-comments/src/services/fieldLabels/utils/groupFieldPathsByDocument.ts new file mode 100644 index 00000000..5ae4f52a --- /dev/null +++ b/packages/payload-plugin-comments/src/services/fieldLabels/utils/groupFieldPathsByDocument.ts @@ -0,0 +1,29 @@ +import type { Comment } from "../../../types"; + +type FieldPathsMap = Map>>; + +export function groupFieldPathsByDocument(comments: Comment[]) { + const fieldPathsMap: FieldPathsMap = new Map(); + + for (const { collectionSlug, documentId, fieldPath } of comments) { + if (!collectionSlug || !documentId || !fieldPath) continue; + + if (!fieldPathsMap.has(collectionSlug)) { + fieldPathsMap.set(collectionSlug, new Map()); + } + + const fieldPathsMapByDocument = fieldPathsMap.get(collectionSlug)!; + + const docId = Number(documentId); + + if (!fieldPathsMapByDocument.has(docId)) { + fieldPathsMapByDocument.set(docId, new Set()); + } + + const fieldPaths = fieldPathsMapByDocument.get(docId)!; + + fieldPaths.add(fieldPath); + } + + return fieldPathsMap; +} diff --git a/packages/payload-plugin-comments/src/services/fieldLabels/utils/resolveFieldPath.ts b/packages/payload-plugin-comments/src/services/fieldLabels/utils/resolveFieldPath.ts new file mode 100644 index 00000000..26d075ab --- /dev/null +++ b/packages/payload-plugin-comments/src/services/fieldLabels/utils/resolveFieldPath.ts @@ -0,0 +1,76 @@ +import type { ArrayField, BlocksField, CollapsibleField, Field, GroupField, RowField, TabsField } from "payload"; +import type { FieldLabelSegment } from "../../../types"; +import { findFieldByName, getLabelString } from "./schemaUtils"; + +export function resolveFieldPath( + segments: string[], + schemaFields: Field[], + docData: Record | null, +): FieldLabelSegment[] { + const result: FieldLabelSegment[] = []; + let currentFields: Field[] = schemaFields; + let currentData: unknown = docData; + let awaitingRowForField: (ArrayField | BlocksField) | null = null; + + for (const segment of segments) { + if (awaitingRowForField) { + const rowId = segment; + let position = 0; + const parentData = currentData as Record | null; + const fieldName = (awaitingRowForField as { name: string }).name; + const arrayData = parentData?.[fieldName]; + + if (Array.isArray(arrayData)) { + const idx = arrayData.findIndex((item: unknown) => String((item as { id?: unknown }).id) === rowId); + if (idx >= 0) { + position = idx; + const rowItem = arrayData[idx] as Record; + currentData = rowItem; + + if (awaitingRowForField.type === "blocks") { + const blockType = rowItem["blockType"] as string | undefined; + if (blockType) { + const blockConfig = (awaitingRowForField as BlocksField).blocks.find((b) => b.slug === blockType); + currentFields = blockConfig?.fields ?? []; + } else { + currentFields = []; + } + } else { + currentFields = (awaitingRowForField as ArrayField).fields; + } + } + } + + result.push({ type: "row", id: rowId, position }); + awaitingRowForField = null; + } else { + const field = findFieldByName(currentFields, segment); + if (!field) { + result.push({ type: "field", label: segment }); + currentFields = []; + continue; + } + + result.push({ type: "field", label: getLabelString(field) }); + + if (field.type === "array") { + awaitingRowForField = field as ArrayField; + } else if (field.type === "blocks") { + awaitingRowForField = field as BlocksField; + } else if (field.type === "group") { + currentFields = (field as GroupField).fields; + if (currentData && typeof currentData === "object") { + currentData = (currentData as Record)[segment]; + } + } else if (field.type === "tabs") { + currentFields = (field as TabsField).tabs.flatMap((t) => t.fields ?? []); + } else if (field.type === "collapsible" || field.type === "row") { + currentFields = (field as CollapsibleField | RowField).fields; + } else { + currentFields = []; + } + } + } + + return result; +} diff --git a/packages/payload-plugin-comments/src/services/fieldLabels/utils/schemaUtils.ts b/packages/payload-plugin-comments/src/services/fieldLabels/utils/schemaUtils.ts new file mode 100644 index 00000000..7fb0e0c2 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/fieldLabels/utils/schemaUtils.ts @@ -0,0 +1,72 @@ +import type { ArrayField, CollapsibleField, Field, GroupField, RowField, TabsField } from "payload"; + +export function flattenFields(fields: Field[]) { + const result: Field[] = []; + + for (const field of fields) { + if ("name" in field) { + result.push(field); + } else if (field.type === "tabs") { + result.push(...flattenFields((field as TabsField).tabs.flatMap((t) => t.fields ?? []))); + } else if (field.type === "collapsible" || field.type === "row") { + result.push(...flattenFields((field as CollapsibleField | RowField).fields)); + } + } + + return result; +} + +export function findFieldByName(fields: Field[], name: string): Field | undefined { + return flattenFields(fields).find((field) => "name" in field && (field as { name: string }).name === name); +} + +export function getLabelString(field: Field) { + const f = field as { + name?: string; + label?: unknown; + }; + + if (typeof f.label === "string") return f.label; + + if (f.label && typeof f.label === "object") { + const loc = f.label as Record; + const val = loc["en"] ?? Object.values(loc)[0]; + + if (typeof val === "string") return val; + } + + return f.name ?? ""; +} + +export function needsDocumentFetch(fieldPaths: string[], schemaFields: Field[]): boolean { + for (const path of fieldPaths) { + let fields = schemaFields; + + let expectingRowId = false; + + for (const segment of path.split(".")) { + if (expectingRowId) return true; + + const field = findFieldByName(fields, segment); + + if (!field) break; + + if (field.type === "array") { + expectingRowId = true; + fields = (field as ArrayField).fields; + } else if (field.type === "blocks") { + expectingRowId = true; + fields = []; + } else if (field.type === "group") { + fields = (field as GroupField).fields; + } else if (field.type === "tabs") { + fields = (field as TabsField).tabs.flatMap((t) => t.fields ?? []); + } else if (field.type === "collapsible" || field.type === "row") { + fields = (field as CollapsibleField | RowField).fields; + } else { + fields = []; + } + } + } + return false; +} diff --git a/packages/payload-plugin-comments/src/services/findAllComments.ts b/packages/payload-plugin-comments/src/services/findAllComments.ts new file mode 100644 index 00000000..53e0b653 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/findAllComments.ts @@ -0,0 +1,62 @@ +"use server"; + +import type { TypedUser, Where } from "payload"; +import { extractPayload } from "../utils/payload/extractPayload"; +import { DEFAULT_COLLECTION_SLUG } from "../constants"; +import { getDefaultErrorMessage } from "../utils/error/getDefaultErrorMessage"; +import type { Response, Comment, BaseServiceOptions } from "../types"; +import { getCurrentTenantId } from "./getCurrentTenantId"; + +interface Props { + enabledCollections?: string[]; + enabledGlobals?: string[]; + user?: TypedUser | null; + options?: BaseServiceOptions; +} + +export async function findAllComments({ enabledCollections, enabledGlobals, options }: Props = {}): Promise> { + try { + const payload = await extractPayload(options?.payload); + + const tenantId = await getCurrentTenantId(payload); + + const where: Where = {}; + + const hasCollections = (enabledCollections?.length ?? 0) > 0; + const hasGlobals = (enabledGlobals?.length ?? 0) > 0; + + if (hasCollections || hasGlobals) { + where.or = [ + ...(hasCollections ? [{ collectionSlug: { in: enabledCollections } }] : []), + ...(hasGlobals ? [{ globalSlug: { in: enabledGlobals } }] : []), + ]; + } + + if (tenantId) { + where.tenant = { + equals: tenantId, + }; + } + + const { docs: comments } = await payload.find({ + collection: DEFAULT_COLLECTION_SLUG, + where: Object.keys(where).length ? where : undefined, + sort: "createdAt", + limit: 200, + depth: 1, + overrideAccess: true, + }); + + return { + success: true, + data: comments as unknown as Comment[], + }; + } catch (e) { + console.error("findAllComments failed:", e); + + return { + success: false, + error: getDefaultErrorMessage(e), + }; + } +} diff --git a/packages/payload-plugin-comments/src/services/getCurrentTenantId.ts b/packages/payload-plugin-comments/src/services/getCurrentTenantId.ts new file mode 100644 index 00000000..96cc44ed --- /dev/null +++ b/packages/payload-plugin-comments/src/services/getCurrentTenantId.ts @@ -0,0 +1,16 @@ +"use server"; + +import type { Payload } from "payload"; +import { headers as getHeaders } from "next/headers"; +import { getTenantFromCookie } from "@payloadcms/plugin-multi-tenant/utilities"; +import type { CommentsPluginConfigStorage } from "../types"; + +export async function getCurrentTenantId(payload: Payload) { + const pluginConfig = payload.config.admin?.custom?.commentsPlugin as CommentsPluginConfigStorage | undefined; + + const tenantConfig = pluginConfig?.tenant; + + if (!tenantConfig?.enabled) return null; + + return getTenantFromCookie(await getHeaders(), payload.db.defaultIDType); +} diff --git a/packages/payload-plugin-comments/src/services/getDocumentTitles.ts b/packages/payload-plugin-comments/src/services/getDocumentTitles.ts new file mode 100644 index 00000000..daac1c5f --- /dev/null +++ b/packages/payload-plugin-comments/src/services/getDocumentTitles.ts @@ -0,0 +1,79 @@ +"use server"; + +import type { CollectionSlug } from "payload"; +import { extractPayload } from "../utils/payload/extractPayload"; +import { getDefaultErrorMessage } from "../utils/error/getDefaultErrorMessage"; +import type { Response, Comment, BaseDocument, DocumentTitles, BaseServiceOptions } from "../types"; + +function buildDocumentTitlesFromDocs(docs: BaseDocument[], titleField: string): Record { + const result: Record = {}; + + for (const doc of docs) { + const key = String(doc.id); + + result[key] = String(doc[titleField]); + } + + return result; +} + +export async function getDocumentTitles( + comments: Comment[], + documentTitleFields: Record, + options?: BaseServiceOptions, +): Promise> { + try { + const payload = await extractPayload(options?.payload); + + const documentIdsMap = new Map>(); + + for (const { collectionSlug, documentId } of comments) { + if (!collectionSlug || documentId == null) continue; + + if (!documentIdsMap.has(collectionSlug)) { + documentIdsMap.set(collectionSlug, new Set()); + } + + documentIdsMap.get(collectionSlug)!.add(documentId); + } + + const documentTitles: DocumentTitles = {}; + + await Promise.all( + [...documentIdsMap.entries()].map(async ([slug, ids]) => { + const titleField = documentTitleFields[slug] ?? "id"; + + try { + const { docs } = await payload.find({ + collection: slug as CollectionSlug, + where: { + id: { + in: [...ids], + }, + }, + limit: ids.size, + depth: 0, + overrideAccess: true, + locale: options?.locale, + }); + + documentTitles[slug] = buildDocumentTitlesFromDocs(docs as unknown as BaseDocument[], titleField); + } catch { + documentTitles[slug] = Object.fromEntries([...ids].map((id) => [String(id), String(id)])); + } + }), + ); + + return { + success: true, + data: documentTitles, + }; + } catch (e) { + console.error(`Failed to fetch document titles`, e); + + return { + success: false, + error: getDefaultErrorMessage(e), + }; + } +} diff --git a/packages/payload-plugin-comments/src/services/getEntitiesLabels.ts b/packages/payload-plugin-comments/src/services/getEntitiesLabels.ts new file mode 100644 index 00000000..8df8d0d5 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/getEntitiesLabels.ts @@ -0,0 +1,16 @@ +import type { EntityLabelsMap, EntityConfig } from "../types"; + +export function getEntitiesLabels(entities: EntityConfig[], enabledSlugs: string[]) { + const slugSet = new Set(enabledSlugs); + const result: EntityLabelsMap = {}; + + for (const entity of entities) { + if (!slugSet.has(entity.slug)) continue; + + const rawLabel = "labels" in entity ? entity.labels?.plural : entity.label; + + result[entity.slug] = typeof rawLabel === "function" || rawLabel === undefined ? entity.slug : rawLabel; + } + + return result; +} diff --git a/packages/payload-plugin-comments/src/services/resolveComment.ts b/packages/payload-plugin-comments/src/services/resolveComment.ts new file mode 100644 index 00000000..f4323ed9 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/resolveComment.ts @@ -0,0 +1,47 @@ +"use server"; + +import type { Response, Comment, BaseServiceOptions } from "../types"; +import { headers } from "next/headers"; +import { DEFAULT_COLLECTION_SLUG } from "../constants"; +import { getDefaultErrorMessage } from "../utils/error/getDefaultErrorMessage"; +import { extractPayload } from "../utils/payload/extractPayload"; + +export async function resolveComment( + id: number | string, + resolved: boolean, + options?: BaseServiceOptions, +): Promise> { + try { + const payload = await extractPayload(options?.payload); + const { user } = await payload.auth({ headers: await headers() }); + + if (!user) { + return { + success: false, + error: "Unauthorized", + }; + } + + const res = await payload.update({ + collection: DEFAULT_COLLECTION_SLUG, + id, + data: { + isResolved: resolved, + resolvedBy: resolved ? user.id : null, + resolvedAt: resolved ? new Date().toISOString() : null, + }, + overrideAccess: false, + user, + }); + + return { + success: true, + data: res as unknown as Comment, + }; + } catch (err) { + return { + success: false, + error: getDefaultErrorMessage(err), + }; + } +} diff --git a/packages/payload-plugin-comments/src/services/sendMentionEmails.ts b/packages/payload-plugin-comments/src/services/sendMentionEmails.ts new file mode 100644 index 00000000..6330fd68 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/sendMentionEmails.ts @@ -0,0 +1,86 @@ +import { Resend } from "resend"; +import { getServerSideURL } from "../utils/general/getURL"; +import type { BaseServiceOptions, CommentsPluginConfigStorage, User } from "../types"; +import { extractPayload } from "../utils/payload/extractPayload"; +import { FALLBACK_USERNAME, USERNAME_DEFAULT_FIELD_PATH } from "../constants"; + +interface SendMentionEmailsProps extends BaseServiceOptions { + mentionIds: number[]; + authorName: string; + commentText: string; + collectionSlug: string; + documentId: number | null | undefined; +} + +export async function sendMentionEmails({ + mentionIds, + authorName, + commentText, + collectionSlug, + documentId, + payload: payloadProp, +}: SendMentionEmailsProps) { + const payload = await extractPayload(payloadProp); + + const pluginConfig = payload.config.admin?.custom?.commentsPlugin as CommentsPluginConfigStorage | undefined; + const usernameFieldPath = pluginConfig?.usernameFieldPath ?? USERNAME_DEFAULT_FIELD_PATH; + + const userDocs = await payload.find({ + collection: "users", + overrideAccess: true, + limit: mentionIds.length, + where: { + id: { in: mentionIds }, + }, + select: { + id: true, + email: true, + [usernameFieldPath]: true, + }, + }); + + const users = userDocs.docs as User[]; + + const userMap: Record = {}; + for (const user of users) { + const { id, email } = user; + const name = user[usernameFieldPath] as string; + userMap[id] = name ?? email ?? FALLBACK_USERNAME; + } + + const resolvedText = commentText.replace(/@\((\d+)\)/g, (_match, id) => { + return `@${userMap[Number(id)]}`; + }); + + const resolvedTextHtml = commentText.replace(/@\((\d+)\)/g, (_match, id) => { + return `@${userMap[Number(id)]}`; + }); + + const adminUrl = `${getServerSideURL()}/admin/collections/${collectionSlug}/${documentId}`; + const resend = new Resend(process.env.RESEND_API_KEY); + const fromEmail = process.env.RESEND_FROM_EMAIL; + + if (!fromEmail) { + console.error( + `[sendMentionEmails] fromEmail parameter wasn't provided. Use RESEND_FROM_EMAIL env to set up fromEmail`, + ); + + return; + } + + for (const u of users) { + if (!u.email) continue; + + try { + await resend.emails.send({ + from: fromEmail, + to: u.email, + subject: `${authorName} mentioned you in a comment`, + text: `${authorName} mentioned you in a comment:\n\n"${resolvedText}"\n\nView document: ${adminUrl}`, + html: `

${authorName} mentioned you in a comment:

${resolvedTextHtml}

View document: ${adminUrl}

`, + }); + } catch (err) { + console.error(`[sendMentionEmails] Failed to send email to user ${u.id}:`, err); + } + } +} diff --git a/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts b/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts new file mode 100644 index 00000000..74d0b755 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts @@ -0,0 +1,74 @@ +"use server"; + +import { findAllComments } from "./findAllComments"; +import { getDocumentTitles } from "./getDocumentTitles"; +import { fetchMentionableUsers } from "./fetchMentionableUsers"; +import { fetchFieldLabels } from "./fieldLabels/fetchFieldLabels"; +import { getEntitiesLabels } from "./getEntitiesLabels"; +import type { + BaseServiceOptions, + EntityLabelsMap, + Comment, + CommentsPluginConfigStorage, + DocumentTitles, + GlobalFieldLabelRegistry, + Response, + User, +} from "../types"; +import { extractPayload } from "../utils/payload/extractPayload"; + +interface SyncResult { + comments: Comment[]; + documentTitles: DocumentTitles; + mentionUsers: User[]; + fieldLabels: GlobalFieldLabelRegistry; + collectionLabels: EntityLabelsMap; + globalLabels: EntityLabelsMap; +} + +const errorResult: Response = { + success: false, + error: "Failed to sync all comments data", +}; + +export async function syncAllCommentsData(options?: BaseServiceOptions): Promise> { + const payload = await extractPayload(options?.payload); + const pluginConfig = payload.config.admin?.custom?.commentsPlugin as CommentsPluginConfigStorage | undefined; + + const commentsResult = await findAllComments({ + enabledCollections: pluginConfig?.collections, + enabledGlobals: pluginConfig?.globals, + options: { + payload, + }, + }); + + if (!commentsResult.success) return errorResult; + + const comments = commentsResult.data; + + const [titlesResult, mentionUsersResult, fieldLabels] = await Promise.all([ + getDocumentTitles(comments, pluginConfig?.documentTitleFields ?? {}, { payload, locale: options?.locale }), + fetchMentionableUsers({ payload }), + fetchFieldLabels(comments), + ]); + + const success = titlesResult.success && mentionUsersResult.success; + + if (!success) return errorResult; + + const collectionLabels = getEntitiesLabels(payload.config.collections, pluginConfig?.collections ?? []); + const globalLabels = getEntitiesLabels(payload.config.globals, pluginConfig?.globals ?? []); + + return { + success: true, + data: { + comments, + documentTitles: titlesResult.data, + mentionUsers: mentionUsersResult.data, + fieldLabels, + collectionLabels, + globalLabels, + }, + }; +} diff --git a/packages/payload-plugin-comments/src/styles.css b/packages/payload-plugin-comments/src/styles.css new file mode 100644 index 00000000..ad821c28 --- /dev/null +++ b/packages/payload-plugin-comments/src/styles.css @@ -0,0 +1,31 @@ +@import "tailwindcss/theme"; +@import "tailwindcss/utilities"; + +@source "./**/*.{ts,tsx}"; + +.comments-drawer .gutter { + padding: 0 20px !important; +} + +.comments-drawer .drawer__content { + --gutter-h: 1px; +} + +.comments-drawer header .rs__control { + min-height: 0; + min-width: 120px; + padding: 2px 4px; + font-size: 13px; +} + +.comments-drawer header .rs__single-value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.comments-drawer header .rs__option { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/payload-plugin-comments/src/translations/en.ts b/packages/payload-plugin-comments/src/translations/en.ts new file mode 100644 index 00000000..bd785609 --- /dev/null +++ b/packages/payload-plugin-comments/src/translations/en.ts @@ -0,0 +1,34 @@ +import type { CommentsTranslations } from "./types"; + +export const en: { comments: CommentsTranslations } = { + comments: { + label: "Comments", + openComments_one: "{{count}} open comment", + openComments_other: "{{count}} open comments", + add: "Add comment", + writeComment: "Write a comment", + comment: "Comment", + cancel: "Cancel", + posting: "Posting…", + resolve: "Resolve", + reopen: "Reopen", + delete: "Delete", + general: "General", + close: "Close", + syncingComments: "Syncing comments", + openCommentsAria: "Open comments", + failedToPost: "Failed to post comment", + failedToUpdate: "Failed to update comment", + failedToDelete: "Failed to delete comment", + failedToAdd: "Failed to add comment", + unknownAuthor: "Unknown", + deletedUser: "Deleted user", + noOpenComments: "No open comments", + noResolvedComments: "No resolved comments", + noMentionedComments: "No comments mentioning you", + filterOpen: "Open", + filterResolved: "Resolved", + filterMentioned: "Mentioned me", + noMentionMatches: "No matches", + }, +}; diff --git a/packages/payload-plugin-comments/src/translations/types.ts b/packages/payload-plugin-comments/src/translations/types.ts new file mode 100644 index 00000000..31b02ea1 --- /dev/null +++ b/packages/payload-plugin-comments/src/translations/types.ts @@ -0,0 +1,32 @@ +export interface CommentsTranslations { + label: string; + openComments_one: string; + openComments_other: string; + add: string; + writeComment: string; + comment: string; + cancel: string; + posting: string; + resolve: string; + reopen: string; + delete: string; + general: string; + close: string; + syncingComments: string; + openCommentsAria: string; + failedToPost: string; + failedToUpdate: string; + failedToDelete: string; + failedToAdd: string; + unknownAuthor: string; + deletedUser: string; + noOpenComments: string; + noResolvedComments: string; + noMentionedComments: string; + filterOpen: string; + filterResolved: string; + filterMentioned: string; + noMentionMatches: string; +} + +export type Translations = Record>; diff --git a/packages/payload-plugin-comments/src/types/base.ts b/packages/payload-plugin-comments/src/types/base.ts new file mode 100644 index 00000000..1e25c1ce --- /dev/null +++ b/packages/payload-plugin-comments/src/types/base.ts @@ -0,0 +1,11 @@ +import type { Payload } from "payload"; + +export interface BaseServiceOptions { + payload?: Payload; + locale?: string | null; +} + +export interface BaseDocument { + id: string | number; + [key: string]: unknown; +} diff --git a/packages/payload-plugin-comments/src/types/collection.ts b/packages/payload-plugin-comments/src/types/collection.ts new file mode 100644 index 00000000..abf85b28 --- /dev/null +++ b/packages/payload-plugin-comments/src/types/collection.ts @@ -0,0 +1 @@ +export type DocumentTitles = Record>; diff --git a/packages/payload-plugin-comments/src/types/comment.ts b/packages/payload-plugin-comments/src/types/comment.ts new file mode 100644 index 00000000..8be0ee2e --- /dev/null +++ b/packages/payload-plugin-comments/src/types/comment.ts @@ -0,0 +1,24 @@ +import type { User } from "./user"; + +export interface CommentMention { + id: string | number | null; + user: number | User; +} + +export interface Comment { + id: number; + documentId?: number | null; + collectionSlug?: string | null; + globalSlug?: string | null; + fieldPath?: string | null; + locale?: string | null; + text: string; + mentions?: CommentMention[] | null; + author: number | User; + isResolved: boolean; + resolvedBy?: number | User | null; + resolvedAt?: string | null; + tenant?: number | string | null; + createdAt: string; + updatedAt: string; +} diff --git a/packages/payload-plugin-comments/src/types/config.ts b/packages/payload-plugin-comments/src/types/config.ts new file mode 100644 index 00000000..18961a0d --- /dev/null +++ b/packages/payload-plugin-comments/src/types/config.ts @@ -0,0 +1,115 @@ +import type { CollectionConfig, Field } from "payload"; +import type { Translations } from "../translations/types"; + +/** + * Specify which field is used as the document title in the comments UI. + * @example + * { + * slug: "pages", + * titleField: "title" + * } + */ +export interface CollectionEntry { + slug: string; + /** @default "id" */ + titleField?: string; +} + +/** + * Multi-tenancy configuration for the comments plugin. + * + * When enabled, comments are scoped to the current tenant so that users + * from different tenants cannot see each other's comments. + */ +export interface TenantPluginConfig { + /** Whether multi-tenancy support is active. + * @default false + */ + enabled?: boolean; + /** + * Slug of the Payload collection that represents tenants. + * @default "tenants" + */ + collectionSlug?: string; + /** + * Name of the relationship field on each document collection that holds + * the tenant reference. This field is used to filter comments by tenant. + * @default "tenant" + */ + documentTenantField?: string; +} + +/** + * Configuration options for the `commentsPlugin`. + * @example + * commentsPlugin({ + * collections: [ + * { + * slug: "pages", + * titleField: "title" + * } + * ], + * tenant: { + * enabled: true, + * collectionSlug: "tenants", + * documentTenantField: "tenant" + * }, + * }) + */ +export interface CommentsPluginConfig { + /** Set to `false` to disable the plugin entirely. + * @default false + */ + enabled?: boolean; + /** + * List of collection config objects to tune collection document comments. In any way all collections have comments. + */ + collections?: CollectionEntry[]; + /** Optional multi-tenancy settings. Omit if your project is single-tenant. */ + tenant?: TenantPluginConfig; + /** + * Fine-grained overrides for the generated `comments` collection. + * + * - `access` / `admin` / `hooks` – merged directly into the collection config. + * - `fields` – receives the default field array and must return the final + * array, allowing you to add, remove, or reorder fields. + */ + overrides?: Partial> & { + fields?: (defaultFields: Field[]) => Field[]; + }; + /** + * Translations for the comments UI per locale. + * + * The key is the locale code (e.g. `"en"`, `"fr"`, `"de"`). + * The value is a partial object matching the comments translation namespace — + * any keys you omit will fall back to the built-in English strings. + * + * @example + * translations: { + * fr: { + * label: "Commentaires", + * add: "Ajouter un commentaire", + * filterOpen: "Ouverts", + * filterResolved: "Résolus", + * filterMentioned: "Me mentionnent", + * } + * } + */ + translations?: Translations; + /** + * Dot-notation path to the field on the users collection used as the + * display name in the comments UI. + * @default "name" + * @example "firstName" + * @example "profile.displayName" + */ + usernameFieldPath?: string; +} + +export interface CommentsPluginConfigStorage { + collections?: string[]; + documentTitleFields?: Record; + globals?: string[]; + tenant?: TenantPluginConfig; + usernameFieldPath?: string; +} diff --git a/packages/payload-plugin-comments/src/types/entity.ts b/packages/payload-plugin-comments/src/types/entity.ts new file mode 100644 index 00000000..0b51cd4e --- /dev/null +++ b/packages/payload-plugin-comments/src/types/entity.ts @@ -0,0 +1,22 @@ +import type { SanitizedCollectionConfig, SanitizedGlobalConfig } from "payload"; + +export type EntityConfig = SanitizedCollectionConfig | SanitizedGlobalConfig; + +export type EntityLabel = string | Record; + +export type EntityLabelsMap = Record; + +export type FieldLabelSegment = + | { + type: "field"; + label: string; + } + | { + type: "row"; + id: string; + position: number; + }; + +export type GlobalFieldLabelRegistry = Record>>; + +export type Mode = "document" | "global-document" | "global"; diff --git a/packages/payload-plugin-comments/src/types/general.ts b/packages/payload-plugin-comments/src/types/general.ts new file mode 100644 index 00000000..0869a26d --- /dev/null +++ b/packages/payload-plugin-comments/src/types/general.ts @@ -0,0 +1,13 @@ +export type FilterMode = "open" | "resolved" | "mentioned"; + +export type LoadingStatus = "idle" | "loading" | "error" | "success"; + +export type Response = + | { + success: true; + data: T; + } + | { + success: false; + error: string; + }; diff --git a/packages/payload-plugin-comments/src/types/index.ts b/packages/payload-plugin-comments/src/types/index.ts new file mode 100644 index 00000000..fc71c872 --- /dev/null +++ b/packages/payload-plugin-comments/src/types/index.ts @@ -0,0 +1,7 @@ +export type { BaseServiceOptions, BaseDocument } from "./base"; +export type { CommentMention, Comment } from "./comment"; +export type { CollectionEntry, TenantPluginConfig, CommentsPluginConfig, CommentsPluginConfigStorage } from "./config"; +export type { User } from "./user"; +export type { FilterMode, LoadingStatus, Response } from "./general"; +export type { DocumentTitles } from "./collection"; +export type { EntityConfig, EntityLabel, EntityLabelsMap, FieldLabelSegment, GlobalFieldLabelRegistry, Mode } from "./entity"; diff --git a/packages/payload-plugin-comments/src/types/user.ts b/packages/payload-plugin-comments/src/types/user.ts new file mode 100644 index 00000000..f0beada9 --- /dev/null +++ b/packages/payload-plugin-comments/src/types/user.ts @@ -0,0 +1,5 @@ +export interface User { + id: number; + email?: string | null; + [key: string]: unknown; +} diff --git a/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts b/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts new file mode 100644 index 00000000..7197881a --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts @@ -0,0 +1,28 @@ +import type { Comment, Mode } from "../../types"; +import type { CollectionSlug } from "payload"; +import { filterCommentsByLocale } from "./filterCommentsByLocale"; + +interface Props { + comments: Comment[]; + mode: Mode; + collectionSlug?: CollectionSlug | null; + documentId?: number | null; + globalSlug?: string | null; + currentLocale?: string | null; +} + +export function extractVisibleComments({ comments, mode, collectionSlug, documentId, globalSlug, currentLocale }: Props) { + const localeFilteredComments = filterCommentsByLocale(comments, currentLocale); + + if (mode === "document" && collectionSlug && documentId) { + return localeFilteredComments.filter( + ({ collectionSlug: slug, documentId: id }) => slug === collectionSlug && id === documentId, + ); + } + + if (mode === "global-document" && globalSlug) { + return localeFilteredComments.filter((c) => c.globalSlug === globalSlug); + } + + return localeFilteredComments; +} diff --git a/packages/payload-plugin-comments/src/utils/comment/filterCommentsByLocale.ts b/packages/payload-plugin-comments/src/utils/comment/filterCommentsByLocale.ts new file mode 100644 index 00000000..e8aab76c --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/comment/filterCommentsByLocale.ts @@ -0,0 +1,13 @@ +import type { Comment } from "../../types"; + +function matchesLocale(comment: Comment, currentLocale?: string | null) { + if (!comment.fieldPath) return true; + + if (!comment.locale) return true; + + return comment.locale === currentLocale; +} + +export function filterCommentsByLocale(comments: Comment[], currentLocale?: string | null) { + return comments.filter((c) => matchesLocale(c, currentLocale)); +} diff --git a/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx b/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx new file mode 100644 index 00000000..9388ecba --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from "react"; +import type { Comment, User } from "../../types"; +import { MentionLabel } from "../../components/MentionLabel"; +import { isSelfMention } from "../mention/isSelfMention"; +import { resolveUsername } from "../user/resolveUsername"; +import { FALLBACK_DELETED_USERNAME } from "../../constants"; + +const MENTION_SPLIT = /(@\(\d+\))/; +const MENTION_EXTRACT = /^@\((\d+)\)$/; + +interface Props { + text: string; + mentions: Comment["mentions"]; + currentUserId?: number | null; + usernameFieldPath?: string; + fallbackDeletedUsername?: string; +} + +export function renderCommentText({ + text, + mentions, + currentUserId, + usernameFieldPath, + fallbackDeletedUsername, +}: Props): ReactNode { + const userMap: Record = {}; + + if (Array.isArray(mentions)) { + for (const mention of mentions) { + const user = mention.user as User; + + userMap[user.id] = user; + } + } + + const parts = text.split(MENTION_SPLIT); + + return ( + <> + {parts.map((part, i) => { + const match = MENTION_EXTRACT.exec(part); + + if (match) { + const userId = Number(match[1]); + const user = userMap[userId]; + const isSelf = isSelfMention(currentUserId, userId); + const name = resolveUsername(user, usernameFieldPath, fallbackDeletedUsername ?? FALLBACK_DELETED_USERNAME); + + return ; + } + + return {part}; + })} + + ); +} diff --git a/packages/payload-plugin-comments/src/utils/comment/serializeEditor.ts b/packages/payload-plugin-comments/src/utils/comment/serializeEditor.ts new file mode 100644 index 00000000..619f57d1 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/comment/serializeEditor.ts @@ -0,0 +1,36 @@ +const ZERO_WIDTH_SPACE = /\u200b/g; + +function serializeNode(node: ChildNode, output: string[]): void { + if (node.nodeType === Node.TEXT_NODE) { + const text = (node.textContent ?? "").replace(ZERO_WIDTH_SPACE, ""); + + output.push(text); + + return; + } + + if (node.nodeType === Node.ELEMENT_NODE) { + const element = node as HTMLElement; + const mentionId = element.dataset.mentionId; + + if (mentionId !== undefined) { + output.push(`@(${mentionId})`); + + return; + } + + for (const child of Array.from(element.childNodes)) { + serializeNode(child, output); + } + } +} + +export function serializeEditor(root: HTMLElement): string { + const parts: string[] = []; + + for (const child of Array.from(root.childNodes)) { + serializeNode(child, parts); + } + + return parts.join(""); +} diff --git a/packages/payload-plugin-comments/src/utils/config/injectFieldCommentComponents.ts b/packages/payload-plugin-comments/src/utils/config/injectFieldCommentComponents.ts new file mode 100644 index 00000000..8f4369cc --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/config/injectFieldCommentComponents.ts @@ -0,0 +1,95 @@ +import type { CollectionConfig, Field, GlobalConfig } from "payload"; +import { getComponentConfig } from "../path/getComponentPath"; + +export type EntityConfig = CollectionConfig | GlobalConfig; + +type BaseField = Field & { + name?: string | null | undefined; +}; + +const LABELED_FIELD_TYPES = new Set([ + "text", + "number", + "email", + "textarea", + "code", + "date", + "upload", + "relationship", + "select", + "radio", + "checkbox", + "richText", + "point", + "json", + "array", + "group", + "blocks", +]); + +function buildPath(parentPath: string, segment: string): string { + return parentPath ? `${parentPath}.${segment}` : segment; +} + +function injectIntoField(field: Field, parentPath: string): Field { + if (field.type === "ui") return field; + + const f = field as BaseField; + const { name } = f; + const currentPath = name ? buildPath(parentPath, name) : parentPath; + + let result: any = { ...f }; + + if (Array.isArray(result.fields)) { + result = { ...result, fields: injectIntoFields(result.fields, currentPath) }; + } + + if (result.type === "tabs" && Array.isArray(result.tabs)) { + result = { + ...result, + tabs: result.tabs.map((tab: { name?: string; fields: Field[] }) => ({ + ...tab, + fields: injectIntoFields(tab.fields, tab.name ? buildPath(currentPath, tab.name) : currentPath), + })), + }; + } + + if (result.type === "blocks" && Array.isArray(result.blocks)) { + result = { + ...result, + blocks: result.blocks.map((block: { fields: Field[] }) => ({ + ...block, + fields: injectIntoFields(block.fields, currentPath), + })), + }; + } + + if (name && LABELED_FIELD_TYPES.has(result.type) && !result.admin?.components?.Label) { + result = { + ...result, + admin: { + ...result.admin, + components: { + ...result.admin?.components, + Label: getComponentConfig({ + componentPath: "components/FieldCommentLabel", + componentName: "FieldCommentLabel", + }), + }, + }, + }; + } + + return result as Field; +} + +function injectIntoFields(fields: Field[], parentPath: string): Field[] { + return fields.map((field) => injectIntoField(field, parentPath)); +} + +export function injectFieldCommentComponents(entity: T): T { + return { + ...entity, + fields: injectIntoFields(entity.fields, ""), + } as T; +} diff --git a/packages/payload-plugin-comments/src/utils/config/mergeTranslations.ts b/packages/payload-plugin-comments/src/utils/config/mergeTranslations.ts new file mode 100644 index 00000000..6f0c6492 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/config/mergeTranslations.ts @@ -0,0 +1,31 @@ +import { en } from "../../translations/en"; +import type { Translations } from "../../translations/types"; + +export function mergeTranslations(incomingConfigTranslations: Record, userTranslations: Translations) { + const mergedTranslations: Record = { + ...incomingConfigTranslations, + en: { + ...((incomingConfigTranslations?.en as Record) ?? {}), + comments: { + ...en.comments, + ...(userTranslations.en ?? {}), + }, + }, + }; + + const existingTranslations = incomingConfigTranslations as Record | undefined; + + for (const [locale, localeTranslations] of Object.entries(userTranslations)) { + if (locale === "en") continue; + + mergedTranslations[locale] = { + ...(existingTranslations?.[locale] ?? {}), + comments: { + ...en.comments, + ...localeTranslations, + }, + }; + } + + return mergedTranslations; +} diff --git a/packages/payload-plugin-comments/src/utils/config/normalizeCollections.ts b/packages/payload-plugin-comments/src/utils/config/normalizeCollections.ts new file mode 100644 index 00000000..5db3fbcf --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/config/normalizeCollections.ts @@ -0,0 +1,19 @@ +import type { CollectionEntry } from "../../types"; + +type NormalizedCollectionConfig = Map; + +const DEFAULT_TITLE_FIELD = "id"; + +export function normalizeCollections(entries?: CollectionEntry[]) { + if (!entries) return null; + + const map = new Map() as NormalizedCollectionConfig; + + for (const entry of entries) { + map.set(entry.slug, { + titleField: entry.titleField ?? DEFAULT_TITLE_FIELD, + }); + } + + return map; +} diff --git a/packages/payload-plugin-comments/src/utils/config/overrideCollections.ts b/packages/payload-plugin-comments/src/utils/config/overrideCollections.ts new file mode 100644 index 00000000..ccfb4d41 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/config/overrideCollections.ts @@ -0,0 +1,38 @@ +import type { CollectionAfterDeleteHook, CollectionConfig } from "payload"; +import { DEFAULT_COLLECTION_SLUG } from "../../constants"; +import { injectFieldCommentComponents } from "./injectFieldCommentComponents"; + +export function overrideCollections(collections?: CollectionConfig[]) { + return (collections ?? []).map((collection) => { + if (collection.slug === DEFAULT_COLLECTION_SLUG) return collection; + + const patchedCollection = injectFieldCommentComponents(collection); + + return { + ...patchedCollection, + hooks: { + ...patchedCollection.hooks, + afterDelete: [ + ...(patchedCollection.hooks?.afterDelete ?? []), + (async ({ doc, req }) => { + await req.payload.delete({ + collection: DEFAULT_COLLECTION_SLUG, + where: { + and: [ + { + collectionSlug: { equals: collection.slug }, + }, + { + documentId: { equals: Number(doc.id) }, + }, + ], + }, + req, + overrideAccess: true, + }); + }) satisfies CollectionAfterDeleteHook, + ], + }, + }; + }); +} diff --git a/packages/payload-plugin-comments/src/utils/config/overrideCommentsCollection.ts b/packages/payload-plugin-comments/src/utils/config/overrideCommentsCollection.ts new file mode 100644 index 00000000..9e12eaab --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/config/overrideCommentsCollection.ts @@ -0,0 +1,17 @@ +import type { CollectionConfig } from "payload"; +import type { CommentsPluginConfig } from "../../types"; + +export function overrideCommentsCollection( + config: CollectionConfig, + overrides: CommentsPluginConfig["overrides"], +): CollectionConfig { + const { access, admin, hooks, fields } = overrides ?? {}; + + return { + ...config, + access: { ...config.access, ...access }, + hooks: { ...config.hooks, ...hooks }, + admin: { ...config.admin, ...admin }, + fields: fields ? fields(config.fields) : config.fields, + }; +} diff --git a/packages/payload-plugin-comments/src/utils/config/overrideGlobals.ts b/packages/payload-plugin-comments/src/utils/config/overrideGlobals.ts new file mode 100644 index 00000000..b36e7cfc --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/config/overrideGlobals.ts @@ -0,0 +1,6 @@ +import type { GlobalConfig } from "payload"; +import { injectFieldCommentComponents } from "./injectFieldCommentComponents"; + +export function overrideGlobals(globals?: GlobalConfig[]) { + return (globals ?? []).map(injectFieldCommentComponents); +} diff --git a/packages/payload-plugin-comments/src/utils/error/getDefaultErrorMessage.ts b/packages/payload-plugin-comments/src/utils/error/getDefaultErrorMessage.ts new file mode 100644 index 00000000..9abcf67d --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/error/getDefaultErrorMessage.ts @@ -0,0 +1,7 @@ +export function getDefaultErrorMessage(err: unknown) { + if (err instanceof Error) return err.message; + + if (typeof err === "string") return err; + + return "An unexpected error occurred"; +} diff --git a/packages/payload-plugin-comments/src/utils/general/cn.ts b/packages/payload-plugin-comments/src/utils/general/cn.ts new file mode 100644 index 00000000..73803b0c --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/general/cn.ts @@ -0,0 +1,7 @@ +import type { ClassValue } from "clsx"; +import clsx from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/packages/payload-plugin-comments/src/utils/general/formatRelativeDate.ts b/packages/payload-plugin-comments/src/utils/general/formatRelativeDate.ts new file mode 100644 index 00000000..c1c50f15 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/general/formatRelativeDate.ts @@ -0,0 +1,30 @@ +export function formatRelativeDate(iso: string, locale: string) { + let date: Date; + + try { + date = new Date(iso); + + if (isNaN(date.getTime())) return iso; + } catch { + return iso; + } + + const elapsedMs = Date.now() - date.getTime(); + const seconds = Math.round(elapsedMs / 1000); + + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + + if (seconds < 45) return rtf.format(0, "second"); + if (seconds < 90) return rtf.format(-1, "minute"); + if (seconds < 45 * 60) return rtf.format(-Math.round(seconds / 60), "minute"); + if (seconds < 90 * 60) return rtf.format(-1, "hour"); + if (seconds < 22 * 3600) return rtf.format(-Math.round(seconds / 3600), "hour"); + if (seconds < 36 * 3600) return rtf.format(-1, "day"); + if (seconds < 6 * 86400) return rtf.format(-Math.round(seconds / 86400), "day"); + if (seconds < 10 * 86400) return rtf.format(-1, "week"); + if (seconds < 45 * 86400) return rtf.format(-Math.round(seconds / (7 * 86400)), "week"); + if (seconds < 345 * 86400) return rtf.format(-Math.round(seconds / (30 * 86400)), "month"); + if (seconds < 545 * 86400) return rtf.format(-1, "year"); + + return rtf.format(-Math.round(seconds / (365 * 86400)), "year"); +} diff --git a/packages/payload-plugin-comments/src/utils/general/getURL.ts b/packages/payload-plugin-comments/src/utils/general/getURL.ts new file mode 100644 index 00000000..69502ee1 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/general/getURL.ts @@ -0,0 +1,11 @@ +export function getServerSideURL(): string { + if (process.env.NEXT_PUBLIC_SERVER_URL) { + return process.env.NEXT_PUBLIC_SERVER_URL; + } + + if (process.env.VERCEL_PROJECT_PRODUCTION_URL) { + return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`; + } + + return "http://localhost:3000"; +} diff --git a/packages/payload-plugin-comments/src/utils/general/getValueByPath.ts b/packages/payload-plugin-comments/src/utils/general/getValueByPath.ts new file mode 100644 index 00000000..2ccab9d4 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/general/getValueByPath.ts @@ -0,0 +1,26 @@ +type Object = Record + +export function getValueByPath(obj: Object, path: string) { + if (!path) return null + + const segments = path.split('.') + let current: unknown = obj + + for (const segment of segments) { + if ( + current === null || + current === undefined || + typeof current !== 'object' || + Array.isArray(current) + ) { + return null + } + current = (current as Object)[segment] + } + + if (current === null || current === undefined) return null + if (typeof current === 'string') return current === '' ? null : current + if (typeof current === 'number' || typeof current === 'boolean') return String(current) + + return null +} diff --git a/packages/payload-plugin-comments/src/utils/mention/isSelfMention.ts b/packages/payload-plugin-comments/src/utils/mention/isSelfMention.ts new file mode 100644 index 00000000..546647e6 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/mention/isSelfMention.ts @@ -0,0 +1,5 @@ +type UserId = number | string | null | undefined; + +export function isSelfMention(currentUserId: UserId, userId: UserId) { + return currentUserId != null && userId === currentUserId; +} diff --git a/packages/payload-plugin-comments/src/utils/mention/parseMentionIds.ts b/packages/payload-plugin-comments/src/utils/mention/parseMentionIds.ts new file mode 100644 index 00000000..dc45a44f --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/mention/parseMentionIds.ts @@ -0,0 +1,12 @@ +export function parseMentionIds(text: string): number[] { + const ids = new Set(); + const pattern = /@\((\d+)\)/g; + + let match: RegExpExecArray | null; + + while ((match = pattern.exec(text)) !== null) { + ids.add(Number(match[1])); + } + + return Array.from(ids); +} diff --git a/packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts b/packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts new file mode 100644 index 00000000..ded5a71e --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts @@ -0,0 +1,48 @@ +import type { CollectionSlug } from "payload"; +import type { Mode } from "../../types"; +import { EXCLUDED_ADMIN_ROUTES } from "../../constants"; + +interface ModeData { + mode: Mode; + collectionSlug: CollectionSlug | null; + documentId: number | null; + globalSlug: string | null; +} + +export function defineModeByPathname(pathname: string): ModeData { + if (EXCLUDED_ADMIN_ROUTES.some((route) => pathname?.startsWith(route))) { + return { + mode: "global", + collectionSlug: null, + documentId: null, + globalSlug: null, + }; + } + + const documentModeMatch = pathname?.match(/\/admin\/collections\/([^/]+)\/(\d+)/); + if (documentModeMatch) { + return { + mode: "document", + collectionSlug: documentModeMatch[1] as CollectionSlug, + documentId: Number(documentModeMatch[2]), + globalSlug: null, + }; + } + + const globalDocumentMatch = pathname?.match(/\/admin\/globals\/([^/]+)$/); + if (globalDocumentMatch) { + return { + mode: "global-document", + globalSlug: globalDocumentMatch[1] ?? null, + collectionSlug: null, + documentId: null, + }; + } + + return { + mode: "global", + collectionSlug: null, + documentId: null, + globalSlug: null, + }; +} diff --git a/packages/payload-plugin-comments/src/utils/path/detectPluginBasePath.ts b/packages/payload-plugin-comments/src/utils/path/detectPluginBasePath.ts new file mode 100644 index 00000000..e437bc8d --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/path/detectPluginBasePath.ts @@ -0,0 +1,5 @@ +import { PACKAGE_NAME } from "../../constants"; + +export function detectPluginBasePath() { + return PACKAGE_NAME; +} diff --git a/packages/payload-plugin-comments/src/utils/path/getComponentPath.ts b/packages/payload-plugin-comments/src/utils/path/getComponentPath.ts new file mode 100644 index 00000000..25986bd3 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/path/getComponentPath.ts @@ -0,0 +1,26 @@ +import { detectPluginBasePath } from "./detectPluginBasePath"; + +export function getComponentPath(componentPath: string, componentName: string) { + return `${detectPluginBasePath()}/${componentPath}#${componentName}`; +} + +interface GetComponentConfigProps { + componentPath: string; + componentName: string; + clientProps?: Record; + serverProps?: Record; +} + +export function getComponentConfig({ + componentPath, + componentName, + clientProps, + serverProps, +}: GetComponentConfigProps) { + return { + path: `${detectPluginBasePath()}/${componentPath}`, + exportName: componentName, + clientProps, + serverProps, + }; +} diff --git a/packages/payload-plugin-comments/src/utils/payload/extractPayload.ts b/packages/payload-plugin-comments/src/utils/payload/extractPayload.ts new file mode 100644 index 00000000..52938547 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/payload/extractPayload.ts @@ -0,0 +1,6 @@ +import { getPayload, type Payload } from "payload"; +import { getPayloadConfig } from "../../config"; + +export async function extractPayload(payload?: Payload): Promise { + return payload ?? (await getPayload({ config: getPayloadConfig() })); +} diff --git a/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts b/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts new file mode 100644 index 00000000..9eabc573 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts @@ -0,0 +1,20 @@ +import type { ClientUser } from "payload"; +import { USERNAME_DEFAULT_FIELD_PATH } from "../../constants"; +import type { User } from "../../types"; +import { getValueByPath } from "../general/getValueByPath"; + +export function resolveUsername( + user: User | ClientUser | null | undefined, + usernameFieldPath: string = USERNAME_DEFAULT_FIELD_PATH, + fallbackLabel: string, +) { + if (!user) return fallbackLabel; + + const customValue = getValueByPath(user, usernameFieldPath); + if (customValue != null) return customValue; + + const { email } = user; + if (email) return email; + + return fallbackLabel; +} diff --git a/packages/payload-plugin-comments/tsconfig.json b/packages/payload-plugin-comments/tsconfig.json new file mode 100644 index 00000000..fffaff02 --- /dev/null +++ b/packages/payload-plugin-comments/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "skipLibCheck": true, + "target": "es2022", + "allowJs": true, + "resolveJsonModule": true, + "moduleDetection": "force", + "isolatedModules": true, + "verbatimModuleSyntax": true, + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "jsx": "react-jsx", + + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + + "declaration": true, + + "declarationMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} \ No newline at end of file diff --git a/packages/payload-plugin-comments/tsup.config.ts b/packages/payload-plugin-comments/tsup.config.ts new file mode 100644 index 00000000..0efd270f --- /dev/null +++ b/packages/payload-plugin-comments/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + onSuccess: 'node_modules/.bin/tailwindcss -i src/styles.css -o dist/styles.css --minify', + entry: ['src/**/*.{ts,tsx}'], + bundle: false, + format: ['esm'], + dts: false, + sourcemap: true, + clean: true, + external: [ + 'payload', + 'react', + 'react-dom', + 'next', + '@payloadcms/ui', + '@payloadcms/plugin-multi-tenant', + 'resend', + ], +}) \ No newline at end of file