From ac12a2ff1b87ed42d2110aea38d698f28860dd3b Mon Sep 17 00:00:00 2001 From: Pekarski Date: Thu, 12 Mar 2026 23:12:29 +0300 Subject: [PATCH 01/23] feat(payload-plugin-comments): set up plugin --- packages/payload-plugin-comments/.gitignore | 3 + .../payload-plugin-comments/.prettierignore | 2 + .../payload-plugin-comments/.prettierrc.json | 25 ++ packages/payload-plugin-comments/CHANGELOG.md | 50 +++ packages/payload-plugin-comments/README.md | 384 ++++++++++++++++++ .../screenshots/document-comments-panel.png | Bin 0 -> 30075 bytes .../docs/screenshots/field-comment-popup.png | Bin 0 -> 19505 bytes .../screenshots/field-label-button-active.png | Bin 0 -> 4861 bytes .../field-label-button-inactive.png | Bin 0 -> 5475 bytes .../screenshots/global-comments-panel.png | Bin 0 -> 37317 bytes .../payload-plugin-comments/eslint.config.js | 17 + packages/payload-plugin-comments/package.json | 106 +++++ .../src/collection/access/isAuth.ts | 7 + .../collection/hooks/setAuthorBeforeCreate.ts | 9 + .../collection/hooks/setTenantBeforeCreate.ts | 28 ++ .../src/collection/index.ts | 156 +++++++ .../src/components/AddCommentForm.tsx | 72 ++++ .../src/components/CommentEditor/index.tsx | 240 +++++++++++ .../src/components/CommentItem.tsx | 129 ++++++ .../CommentsDrawer/components/Header.tsx | 58 +++ .../src/components/CommentsDrawer/index.tsx | 21 + .../components/CommentsHeaderButton/index.tsx | 39 ++ .../components/CollapsibleGroup.tsx | 65 +++ .../CommentsPanel/components/DocumentView.tsx | 44 ++ .../components/FieldGroupSection.tsx | 68 ++++ .../CommentsPanel/components/GlobalView.tsx | 74 ++++ .../src/components/CommentsPanel/constants.ts | 7 + .../CommentsPanel/hooks/useCollapseState.ts | 51 +++ .../hooks/useScrollToTargetFieldGroup.ts | 41 ++ .../src/components/CommentsPanel/index.tsx | 33 ++ .../src/components/CommentsPanel/types.ts | 5 + .../CommentsPanel/utils/filterComments.ts | 27 ++ .../utils/groupCommentsByFieldPath.ts | 17 + .../utils/groupCommentsGlobally.ts | 40 ++ .../utils/resolveCollectionLabel.ts | 20 + .../CommentsPanel/utils/resolveLabel.ts | 14 + .../utils/sortGroupsByCreatedAt.ts | 13 + .../components/CommentsButton.tsx | 22 + .../FieldCommentLabel/hooks/useStablePath.ts | 10 + .../components/FieldCommentLabel/index.tsx | 174 ++++++++ .../src/components/FieldCommentLabel/types.ts | 3 + .../utils/buildStablePath.ts | 19 + .../FieldCommentLabel/utils/exludeComments.ts | 13 + .../FieldCommentLabel/utils/resolveLabel.ts | 9 + .../src/components/MentionDropdown.tsx | 37 ++ .../src/components/MentionLabel/index.tsx | 18 + .../payload-plugin-comments/src/config.ts | 20 + .../payload-plugin-comments/src/constants.ts | 5 + packages/payload-plugin-comments/src/index.ts | 4 + .../payload-plugin-comments/src/plugin.ts | 70 ++++ .../CommentsDrawerProvider/index.tsx | 46 +++ .../src/providers/CommentsProvider/index.tsx | 339 ++++++++++++++++ .../CommentsProvider/mergeDocumentTitles.ts | 8 + .../CommentsProviderWrapper/index.tsx | 15 + .../GlobalCommentsHydrator.tsx | 31 ++ .../providers/GlobalCommentsLoader/index.tsx | 52 +++ .../src/services/createComment.ts | 77 ++++ .../src/services/deleteComment.ts | 37 ++ .../src/services/fetchMentionableUsers.ts | 62 +++ .../services/fieldLabels/fetchFieldLabels.ts | 72 ++++ .../utils/groupFieldPathsByDocument.ts | 27 ++ .../fieldLabels/utils/resolveFieldPath.ts | 76 ++++ .../services/fieldLabels/utils/schemaUtils.ts | 72 ++++ .../src/services/findAllComments.ts | 55 +++ .../src/services/getCollectionLabels.ts | 17 + .../src/services/getCurrentTenantId.ts | 16 + .../src/services/getDocumentTitles.ts | 77 ++++ .../src/services/index.ts | 23 ++ .../src/services/resolveComment.ts | 47 +++ .../src/services/sendMentionEmails.ts | 80 ++++ .../src/services/syncAllCommentsData.ts | 70 ++++ .../payload-plugin-comments/src/styles.css | 12 + .../src/translations/en.ts | 32 ++ .../src/translations/types.ts | 30 ++ packages/payload-plugin-comments/src/types.ts | 177 ++++++++ .../utils/comment/extractVisibleComments.ts | 23 ++ .../utils/comment/filterCommentsByLocale.ts | 13 + .../src/utils/comment/renderCommentText.tsx | 44 ++ .../src/utils/comment/serializeEditor.ts | 36 ++ .../config/injectFieldCommentComponents.ts | 115 ++++++ .../src/utils/config/mergeTranslations.ts | 31 ++ .../src/utils/config/normalizeCollections.ts | 17 + .../src/utils/config/overrideCollection.ts | 17 + .../src/utils/error/getDefaultErrorMessage.ts | 7 + .../src/utils/general/cn.ts | 7 + .../src/utils/general/getURL.ts | 11 + .../src/utils/mention/isSelfMention.ts | 5 + .../src/utils/mention/parseMentionIds.ts | 12 + .../src/utils/mode/defineModeByPathname.ts | 35 ++ .../src/utils/path/detectPluginBasePath.ts | 5 + .../src/utils/path/getComponentPath.ts | 26 ++ .../src/utils/payload/extractPayload.ts | 6 + .../payload-plugin-comments/tsconfig.json | 30 ++ .../payload-plugin-comments/tsup.config.ts | 20 + 94 files changed, 4279 insertions(+) create mode 100644 packages/payload-plugin-comments/.gitignore create mode 100644 packages/payload-plugin-comments/.prettierignore create mode 100644 packages/payload-plugin-comments/.prettierrc.json create mode 100644 packages/payload-plugin-comments/CHANGELOG.md create mode 100644 packages/payload-plugin-comments/README.md create mode 100644 packages/payload-plugin-comments/docs/screenshots/document-comments-panel.png create mode 100644 packages/payload-plugin-comments/docs/screenshots/field-comment-popup.png create mode 100644 packages/payload-plugin-comments/docs/screenshots/field-label-button-active.png create mode 100644 packages/payload-plugin-comments/docs/screenshots/field-label-button-inactive.png create mode 100644 packages/payload-plugin-comments/docs/screenshots/global-comments-panel.png create mode 100644 packages/payload-plugin-comments/eslint.config.js create mode 100644 packages/payload-plugin-comments/package.json create mode 100644 packages/payload-plugin-comments/src/collection/access/isAuth.ts create mode 100644 packages/payload-plugin-comments/src/collection/hooks/setAuthorBeforeCreate.ts create mode 100644 packages/payload-plugin-comments/src/collection/hooks/setTenantBeforeCreate.ts create mode 100644 packages/payload-plugin-comments/src/collection/index.ts create mode 100644 packages/payload-plugin-comments/src/components/AddCommentForm.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentEditor/index.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentItem.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentsDrawer/components/Header.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentsDrawer/index.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentsHeaderButton/index.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/components/CollapsibleGroup.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/components/DocumentView.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/constants.ts create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/hooks/useCollapseState.ts create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/hooks/useScrollToTargetFieldGroup.ts create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/index.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/types.ts create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/utils/filterComments.ts create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsByFieldPath.ts create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveCollectionLabel.ts create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveLabel.ts create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/utils/sortGroupsByCreatedAt.ts create mode 100644 packages/payload-plugin-comments/src/components/FieldCommentLabel/components/CommentsButton.tsx create mode 100644 packages/payload-plugin-comments/src/components/FieldCommentLabel/hooks/useStablePath.ts create mode 100644 packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx create mode 100644 packages/payload-plugin-comments/src/components/FieldCommentLabel/types.ts create mode 100644 packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/buildStablePath.ts create mode 100644 packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/exludeComments.ts create mode 100644 packages/payload-plugin-comments/src/components/FieldCommentLabel/utils/resolveLabel.ts create mode 100644 packages/payload-plugin-comments/src/components/MentionDropdown.tsx create mode 100644 packages/payload-plugin-comments/src/components/MentionLabel/index.tsx create mode 100644 packages/payload-plugin-comments/src/config.ts create mode 100644 packages/payload-plugin-comments/src/constants.ts create mode 100644 packages/payload-plugin-comments/src/index.ts create mode 100644 packages/payload-plugin-comments/src/plugin.ts create mode 100644 packages/payload-plugin-comments/src/providers/CommentsDrawerProvider/index.tsx create mode 100644 packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx create mode 100644 packages/payload-plugin-comments/src/providers/CommentsProvider/mergeDocumentTitles.ts create mode 100644 packages/payload-plugin-comments/src/providers/CommentsProviderWrapper/index.tsx create mode 100644 packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx create mode 100644 packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/index.tsx create mode 100644 packages/payload-plugin-comments/src/services/createComment.ts create mode 100644 packages/payload-plugin-comments/src/services/deleteComment.ts create mode 100644 packages/payload-plugin-comments/src/services/fetchMentionableUsers.ts create mode 100644 packages/payload-plugin-comments/src/services/fieldLabels/fetchFieldLabels.ts create mode 100644 packages/payload-plugin-comments/src/services/fieldLabels/utils/groupFieldPathsByDocument.ts create mode 100644 packages/payload-plugin-comments/src/services/fieldLabels/utils/resolveFieldPath.ts create mode 100644 packages/payload-plugin-comments/src/services/fieldLabels/utils/schemaUtils.ts create mode 100644 packages/payload-plugin-comments/src/services/findAllComments.ts create mode 100644 packages/payload-plugin-comments/src/services/getCollectionLabels.ts create mode 100644 packages/payload-plugin-comments/src/services/getCurrentTenantId.ts create mode 100644 packages/payload-plugin-comments/src/services/getDocumentTitles.ts create mode 100644 packages/payload-plugin-comments/src/services/index.ts create mode 100644 packages/payload-plugin-comments/src/services/resolveComment.ts create mode 100644 packages/payload-plugin-comments/src/services/sendMentionEmails.ts create mode 100644 packages/payload-plugin-comments/src/services/syncAllCommentsData.ts create mode 100644 packages/payload-plugin-comments/src/styles.css create mode 100644 packages/payload-plugin-comments/src/translations/en.ts create mode 100644 packages/payload-plugin-comments/src/translations/types.ts create mode 100644 packages/payload-plugin-comments/src/types.ts create mode 100644 packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts create mode 100644 packages/payload-plugin-comments/src/utils/comment/filterCommentsByLocale.ts create mode 100644 packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx create mode 100644 packages/payload-plugin-comments/src/utils/comment/serializeEditor.ts create mode 100644 packages/payload-plugin-comments/src/utils/config/injectFieldCommentComponents.ts create mode 100644 packages/payload-plugin-comments/src/utils/config/mergeTranslations.ts create mode 100644 packages/payload-plugin-comments/src/utils/config/normalizeCollections.ts create mode 100644 packages/payload-plugin-comments/src/utils/config/overrideCollection.ts create mode 100644 packages/payload-plugin-comments/src/utils/error/getDefaultErrorMessage.ts create mode 100644 packages/payload-plugin-comments/src/utils/general/cn.ts create mode 100644 packages/payload-plugin-comments/src/utils/general/getURL.ts create mode 100644 packages/payload-plugin-comments/src/utils/mention/isSelfMention.ts create mode 100644 packages/payload-plugin-comments/src/utils/mention/parseMentionIds.ts create mode 100644 packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts create mode 100644 packages/payload-plugin-comments/src/utils/path/detectPluginBasePath.ts create mode 100644 packages/payload-plugin-comments/src/utils/path/getComponentPath.ts create mode 100644 packages/payload-plugin-comments/src/utils/payload/extractPayload.ts create mode 100644 packages/payload-plugin-comments/tsconfig.json create mode 100644 packages/payload-plugin-comments/tsup.config.ts 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..d5efa79c --- /dev/null +++ b/packages/payload-plugin-comments/README.md @@ -0,0 +1,384 @@ +# @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, @mentions with email notifications, comment resolution, multi-tenancy, and locale-aware filtering. + +## Table of Contents + +- [UI Screenshots](#ui-screenshots) +- [Features](#features) +- [Prerequisites](#prerequisites) +- [Installation](#installation) +- [Setup](#setup) +- [Configuration](#configuration) +- [Translations](#translations) +- [Environment Variables](#environment-variables) +- [Architecture Overview](#architecture-overview) +- [Available Scripts](#available-scripts) +- [Contributing](#contributing) +- [License](#license) + +--- + +## 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 +- **Field-level comments** — Comment directly on individual fields; the field label shows a badge with the comment count +- **@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 +- **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 +``` + +--- + +## 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({ + // All collections will get comments. You can specify title field for UI. + collections: [ + { + slug: "pages", + titleField: "title", + }, + ], + }), + ], + // ... 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"; +``` + +--- + +## Configuration + +The `commentsPlugin` factory accepts an optional `CommentsPluginConfig` object: + +```ts +commentsPlugin(config?: CommentsPluginConfig) +``` + +### `CommentsPluginConfig` + +| Option | Type | Default | Description | +| -------------- | --------------------- | --------------- | --------------------------------------------- | +| `collections` | `CollectionEntry[]` | all collections | Collections whose documents support comments | +| `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) | + +### `CollectionEntry` + +Each entry in `collections` can be a plain slug string or an object: + +```ts +interface CollectionEntry { + slug: string; + titleField?: string; // Field used as document title in the UI. Default: "id" +} +``` + +**Examples:** + +```ts +commentsPlugin({ + collections: [ + { slug: "pages", titleField: "title" }, // Uses "title" field as display name + { slug: "products", titleField: "name" }, + ], +}); +``` + +### `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"` | +| `noOpenComments` | `"No open comments"` | +| `noResolvedComments` | `"No resolved comments"` | +| `noMentionedComments` | `"No comments mentioning you"` | +| `filterOpen` | `"Open"` | +| `filterResolved` | `"Resolved"` | +| `filterMentioned` | `"Mentioned me"` | + +The `Translations` type is exported from the package so you can type your translation objects: + +```ts +import type { Translations } from "@focus-reactive/payload-plugin-comments"; + +const myTranslations: Translations = { + 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 configured collection to inject `FieldCommentLabel` into each field's admin label — this shows a comment count badge next to each field. +4. 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, and collection 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/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. +- 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 | +| `collectionSlug` | text | Slug of the collection | +| `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) | + +--- + +## 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 0000000000000000000000000000000000000000..40f49eb80dfe21b8b6c43955b773a4030876cd2b GIT binary patch literal 30075 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 vl>YwAQ$i**kGTAi&>Y9Jt@p!6Of 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..000ef8effb8033ced1e1922d03481fb2d933000e GIT binary patch literal 37317 zcmd422UJsC*Di`m6=|ZehKzss8A z)>@?iQs#z+%ov1!0S@bj#HK-N;zcw2F=^99Wo2czseq3-rlFUB4fwb@Rp9RL6Geqb zgolUge@VBu6X50THy+=(e&a@;!qpRw3Aqmx)@g2=C)9#R+#_U_%gox?+6z3RtTjdeqQ`t+&9d0xincuV7WYu0wY zsj)Vq;@Zl}$}$E6Ssl1fNX|Hs+m>|I{pGaozP=o|x&j zJeU;`thyE>llWE0TRO%T}N!QEMxL5m%z~H*gJ>upTq8a_k?`V_) zCMw;NipxxD=`YIYW9Q|FPe@nNs{Ptj5nkY*8CQXP(F1Cb`}qkS+w?eADL^!M`tq}N z(OoJk+fTi{>beE`Eg$Z3_3D6KD*Z3dkNb2Ic4k<0Gh~Sv>8oxr#%vW6y(ga{fOExl^wtDW1i<)13uJP&VEr;?>UjeZSb zx7Dum-2BZOup24Smc$=2>(WLR5*nJ4s^2OYJOL(YFBDZ5sbW#@T_fY@Wl__wUK?0J zv9vCb3~~qf-Mc?ps34!M^P9~zA*#ElRoUf2*0FlJ6m)}Mgu&IQZ*#>MdV$4sBatFB zdca@wtk=9Sjc9&dtG+#%xwj1^7pLv09XuwC0gJ&$lQ}HsjhDKQ$@ohO22d?l)xbKp zneX!p=Xd@=j=b+|9nGiFXF+Zm~w^~R2Y-fZ7V<$xz zs($QdGkh3M)bOk~wn#oV%Z_-Z1}!h1e|ZE#$8OOj_lZmCAXP9)}R zUHgbdAUk4+?Fd$uaFW!m9&MneSlH5epLd1o!KK@^{AO;qko-Niqwnq1(hz<#W;ISH zAEC(c?^?^fnmLFeWkaF#+QX-B<`cVS%;c#v%jW5dR3D$Z36lmuY3$3>?NJwpk8vhp zrWKi*X5r?iX4?J4&`+4yJEF=yIdrd&&Xs5`&r? zv7WsiYzoh56jHLd1Vp^9Hex07Hi&D=-!V;g&M3k`=n%UyTpp3X`3$d%{cuoh*es3^cRK=> zpRm~sjcxDmeSg@c&f%2o+N&;)&l)`I%US@7;=e^~{;T8TZsTHq^J5ZjX3W{PD}Ot= zD%eu>o`VLUO9{xrx$y}EbyAH?%jSGt#C}%#SKg33mEgtcbB$Bf&@cfJQMpCO2f@}P z@es$En!yI!$Ujq+Ty3Fb`9AZt5R5Ke>rNM|=Yr%B!#oA;%Y8FVVO2~=bD^-*Z?=!6 z$4$S`o5+rHdm1WhTH_vHuAtFMf;)k-{0$eeOgN+?clwgd6TR2?S`?0N>SvKh9+BaD zqPn@}Ac%YQ6alqC9aPd2=Q%bfJW=>FFE6|WLOoeE9DNRokf#tB3;3Pb35UEDR@0+< zJ~9ApD4BE>Y&)v#v;-P3ZWj@{!gr#J5uR!aQlN%o4BEkF5|*xSg^H&)C&oK0q?THa z>n_;*(^p@$7lKV z&|RYX{P!IidwLbf2g4mctst2|3iIktzFD!Q@c=e{FSAgX@0V%o8Eon!InGbCN2$l= z0=7Ea&y%Vjg~lS|4@pvyGml6AlsxJ)-8XyJGAJcB4~_DBo+JYkxp8Y0CuleG)6=Bh zqBpxcG8cjywpRh+U$g`#Uo2SIR3brk^4SXS`K>gL%A<|jGO48u3wC`?!TLOZ@v}uP&)nGBZOWQCpe>MsTfTTf*e@?qC7qy zuXBdg(Ninz;K5<3uBABT^kySe9n?#JS%=pA2AMNaoo2_W`<}?_x}FQq>P3@#TyRo7 zN0D!ObobtldS+Y%9q&rnESQzjUl_Rc8O>HeyvzSm`B$pDXSC^R_qrPRG{VL)>ul0^yL7fq?d|$q~EIFr{StZ<753m~T^8)Eg zuj~z3AF^feSZ%$shya&i$vX>OC$>~9mIQO_A4l0YrO7_EaZ(;_4XExoHbS|Btpr`) zmyS`k8XH1r_ZwI{t>rb`Sxi(-Pw~5ploUs-195Msw%n^nZSX$jey;^~qXWAcrsP7E zi4Ldy%5W1fbFOHEZvDN&j2oLbBI>pYMEok!`--1h&xxUgk(QgkqsTIy?om5}!c;$v=Z4k4k6F-r8(P0K8$m@z%Z%DlN@ z19}iub?MKc^8!<$)NU$dSoD+wV9>2>cvY8&xyOqSTY4M&YfP8{-PZ%Tc$~6xFoaq> zcZ#yq-CjG!r1v>y}fGyG+k14k-W1?1z>DjlZJ+dN4&+G zEg?f=pAr*ME2C9e%F{BwKznxEg1EeFDb*wnU=a@ z+3D%*+kW@-h`TKl10`&a9(5Vd=Dv*r_)3~KCnqNW=U=^kEw7;PzSeLGkC&GhkV$g$ zX6yL)0n?x({9n9ANcb@T$p6pcLjPF?mTh8fZO!@2)HI{EXp(@CFeMTodf8-8xPz#z zKYvOK9bSnpBj0SPzTv*-q@-uIe=kGjFH=M&x8CjoX&KyYvK#&??Y_N?4N}*RzhXBp z7{yJy7r&j#(TXW{lhf$9EqmwG{?7W#GnFGH-5TQF#iNH#@20sstpE5+EVC8vCWu|v z(FTe(+Nd925mM#I-yqi`cEi5Xn6a)K!4tB#J1G|5$%N{yKI)u3q|aPQo&EqkrS^pT zM-$iHEy+Dve~8l|7f+@GgyH+}iUz}7E{TA5-%t;GJHT(>)}zruvb{OHAl#w4)5W2> z4f!#;M>xi{F9~t7QBj)?P~3`7)Yg?TR-e=JLKdPMk_MspDYwXf)Mh4W@eUKHWh_Rfm>+Hph`k7Y zjmJ})mswla0d=8-cuf*B;1Rh+p zw^KHHl#`8PcasOsEp;TN8a~5XNb^7D#DRSRo^m{Jf82xivN6zriTfXtrOCs0w z_yvbtzdq!i>?BfiGDnY)sHfv-BfO3Ys>;vp17D4uZQI}B3wduew!d`txHqjiL~{=d-W`;NudSqwTPtB-}Z z00y0{vA4CH7Pm3`S<-1!Ifi;=L)^+~RI2w1Pw+`eh8JS=khX{~;qME$h?miy``sWc zN{+T@n2NqTXk0=4&CVMJRE9M19k&hIUrtrd1cat-BSE1ip=QX>q16tTyz7B+pQ~xN zhSFMw+I}qtGk)W3uCu&;?JTh#CUA4Q_q+GXI@{`-nJaG(W^G{3_*IO=l&Fu{gHAq0 zE@E?UPfEy~FcilE-F=@p+2qX7Z;IN^`|X`<-#OD_Cejk(d(j2SAK!18rJ<{Ox~%_r zpoNGde2Zqg%p?Wu!Wf6>=E+xt3XY)0f`PlbPj{T;^B=+;qKUvIa+j_3)a|c z>&Znno!QOl1g7H<{foR@qNNstPvJhT(MB(~h2JMl27SD^mDM?~*_xtJV026$)1TgQ z{uqx4x_kO~rpixx9T3zfSiI--*k$zPaiz`G82!w5-qik(h}`N!6yxvia>@Gbyrt;G z9~l}De>yqp+MKRr8!uZhUclKPw$JA0)zs@)&Aa^h1#J0qz1+<7%mFvcnf3L?$W0C@ z7f~4YEuPxNxze$)#R;Ezx8jBWwvKU9ZASBqhv;B6sI8gYq&<%h&y3Jytv|QpnA#sV zJ9CtlP71zwPhnb=&k9!=fHf2?jVUK4}M?n{{9Z7#Zc`k>vzT=AwwCH7Rf2sbNu=4JL3)u_pusBtc>336lC^Ar`m2kfYw zkGNg^BD}i)tQGSB%D)@_;l*4A%P-@9dntu20NMY2KFrvEgB&Qj&W47T_AQT{=Va|u z81q$+MDU3!9Xxky{`v*ong&L)|0536e}M+uf6ZqixX9i!C@2Wc228kZ+fB6vc>_+) z?+fnlvkHP~PCP?i%x$i&+0V6>|24~9;(Gemu^pdv7V|}z8ou~~`^l_QQm%{_WUXsg zm7HdjrS%uTs%K4J96N%mPls9dUl;(mdn{(lQ)t$rf~d++{HHXc^|sO%yPxmkqy4>a zQ{1wchCXt8ki~{6Yj^r7vGZUsRuKAV^!40^F=D}|Z*gs&*SxRD0s_G0SP}N-lMSVc z6ib%E2qHrMM2j)0&s`aA5UjY6JG~}PL)b$ab<2s^P40+uu5D(1QNRbj>-U~>)Jq{_ zQJ$(;+CRy)x!d}{*{wDJ%O5?qhr_wQ2S9%~L-p`}aPUn*Qx&7v^m(|&F=ljbgBZIUXE`O;j_NiLM>I^$dY-lNtw-XHJU`4lotX3IP6N|z z|Db<90Z@wds1Zw2q|qk~A^)3!hIzqqHNt)#|ClVa>X%1{bWQ;r$z}E|R+}lDGoS;$ zVwoMiIq9KTE=7~;N-|)?wLtBSi6)xB7|)ICUmV8uHq6C9Op8tZ^yyMv^;~l(Umo+b z38xZg3c5CQnZ@DFF`i__iP(PZI?6eY>}69VXR}#r%2~_I-s)|jN_9|jTZiEJnv|0q zJ21n#*<-dUfk&J9m$>%FN6T_=9?B(m|CtgrEIg}sUy6%7Ej_NkAskwI$dVnGo{Fg9 zZ|l=}`a3I_b~DWN@|*piQhNfmIl&lOCiHB(BO?=iUzubm)r>UUmK5!xfHfV<(Q+5h-h2QERAQv()rnjaRmh2m4?>~L3qZ@ z5=zqg7<6}5D;pVCu=V=;Gt|AQ!Z9lGU^i#g!v~W@>zM6s0^5;e%Gr)FK z_%iJDHX`DALMIc3#gqxU+p={~?Eef-?lCf&lgnpgP25J?4#ZxJ&a9)N;kEWM&2J>t zGH@Z^e(v!RlHrFclmqH0LT`F0Sws*xtxg=dUKu!817hqPV5TLw*)Fu}V}4f9XYGd1dnj~z|LCpx@NUWTYC+Uzn% zRB#F{{MB$4v$m5~y5EudKJz41w+^g_zcCw6bL} zxpQpzoSS&!M=gB_(2=wcVG;Mi|H3{O@Up_5lh;}JbxgyD-Oa`G9L=3I;nq! z7b5swr!htV)T{inJc~4bz4CTXWo#gp2y^nvry(SZfRj;L3%7Owz#K{d=CJvd`wV zJ6MXn4pug4b@Pw^j$m?8Q>CWn)}-Dwh41>55cuPwagb(GY7pc@xp))aOtacU#Sn@r zp^$48`R+(MX7QoD>Y2lh$zfv-vX>Q$R7IwBk+v|CwoIi|`zzI^c+VNlyCcKUcs4Dp zMrr@lt~=KvJ-7h6yj_6x=AFdSZRqdc+P?tP;A>ds+RAGJEZ=Wvl;%3pf=(mQY#$z4 z`GB6v-XpqnWj73Kzb5koZxW#yin8Bsj{L!~I}pc&e}2l_i@CdX zHhzo_rSv9%MWhllWTHx!oz86j^cmY0^m}Ze^1kLNjXaf-_)+}zDtc>J4%mkcbu|nI z#DwIeITX~(6lA7pe{qiyb?0L_);dFDyMLA+_2Mk!?C5MOlbTA?B^r`*NixD-D7R#C11@WJ2)zb*E?W=*w{J)4!>*>c za0=L6dgN$tS{#b^MZB}TBVqRa$t~fI(Us_W-oL*w2=L6S+OB36|L7W}O!looJ@0K7 zkG_n`qCHHwk)x;+5U*w{oJpe_*Jp0sX~+DHsi*P&NA%}^MCpIN)35j3G(7u%RNe|M z9-bn6v4FE0z|nKKHriu6a^@EA{kEI@UBEdF+HC8IR99UG@4o4F#M!oUF`j3MpX_Zj zhH6|yK5}tciPvpDwe}#q?!D5(Lt!By$Mw;mqWd?JeNgV52Wu}!*UB$_;jlW7fy{aC z#$>K^gx849OiQ9HD%Wi~yXAx~6(I7;k=XvQOlrnEU4OMNUE9$|z5tQLpno=C5QZi)|A_BxUDjF9 zk03^6(;g#Vvd>p4^X&>l{Ch3sBY%i6wePGy9}jej*2Am~a^GxPDDP!eytJVW-W$Sh zQ*=h98Bd3rB8x2o`iluO(B;QfFW)+#R@4IRFRz)bzRg=3nABpH@T9#VrXgGU_0^S* zkA&~E0E+1VrL~`yEx4xy(s&42pj7m1e#gZlj-<(iIXhVly^md#{RsUijs9tv#v`L> z-ok%WaG6xx`0k{e6MgThrE!LGwEGjRM~BVKC?rAAcuzwj*y);5yjhW5`5CF6Epz7P zZ_Ae_cTX03Ru=sq&m7*R+e*!7>F*ym#)nFJ_j=sAtZpl`>z~4e`yPmAoY(Psf!rBT z67@R3ICqKjvH9@6Qrt)|N4C!NpFAQxYN!p7 zq><|McV-NEH|wXXd7Dehld(afMgvq=NChzpCIn#*%}z4wc^^L7=9-%y-4j&>_)J&w z#r`4&@PhF>^}0Yt#TQ3^Xg`$jShL~*ZP$*ysT~7jM}>~S`s`?oF=C+`-N#PP}DyTcl@`SIN0PAGm4!0jjlQ0!~Ki%nN*?(H;=Q(7|1q{{G zZedRGm6TF^B7&6s{)m(%m1);KvG)Ygm4@#XIDZ|Vc_H|HZZRvq)d?rH=q-76(<@-{ zqK0(5D%osjJ48>JXRBXhm{+(e@Wbt!#yDQF&L20A?E=5-W?@O@_s2RV-emy&5y}U) zM!-uk^v2y{4akH?idX3`^5eJXx$q*J~8LuPI~$LjVm|jFWWBW zP}rU}QAiFnUGG9qYJKW5t9WP5Tw6MD{%$YrbX2RRNNk#O(G`tK7y!1AXZ62-|5goT zSBg$rPciAwkp5RD}e=u!io_8u*$U=Yx*2YA; zMfCJJ59t&YkXEmkjT1J6^sW=laY$T|OYq*ypS5K+^Th!nf11rY0M4i($GALd#B@Ge zdzAD8>1vW?CjRyb4Zwzz&X&qq2JNE#D;_Y@wj-4~3RENn#kR1l$$Pzyg1%Cb-o6d&h>) z3W<$|=f?h8YUaZ1EjbUeQ%$&)J{D5|?dM}UalL8(f*g!)pX<#1#V_Nt)$;Q^rp{A_ zqM$H?=WKO18UDd9G$t=&3Lb!k%)uI}P=&II#8(jgTVW6M2&IjlvO^s2t=-tK^=VaC z)JSS8Aeuwr+}>!i4YPyye)$$TdX-wG;3{^MB8?`6N&!!NUYtZg`anH{X!I7`OUc;> zzSZ^i%T7qGR%P5UI_?zf^`{*$AeP%+M0U3<|N7+rW&RBT!dv=@6M3jNTI{QR>u`k# zDtsiHFj3F9AEQ}v^d47h`^$Vx$_)hbAY~!KHjh&XYz{uu7kLWLlGNpcjSc&`cd&R{ zcOM!LUa%JP_*dKA+{NiJ+syU&5~ntYL=|&ewnZ;ry4?xX zc2JoLy{y!xo9~$M2ASEAqcXs{CA*&$W`?%;j52&v^eB?1b*9TDVwo(rz#h+qP>mFO zYwY1EmsK0a94YA$}W8Be`CS^Ey#zxhr!Q81U(VRE-N zYcqrQWL50r6+y(iVu%Z|Z6qR_y7KZ;+J(b$PA^;!P}+VorP3fKyP|y0y4-oSLl=V5 zl-4A!5RJb82y>MDJ%gb=^aZN>{c%mBlfcDGkbgY*xa9;(u~*-vM$9bsVP4vUMX>(P z{+8rrj!%RRIJYkqkrZSOuSFSl-cTaaSQ$)C9DubmI?Z>G%rZcosSG0(@6*fDBii4Z$L2M0@+m>PF=wPyZh$o&|fYA$0LlLw>Hod zFn4^HkJ29a{Ffxvdzdc1cVRD3Uc;xYyTvDE(MRO7NmVY)dW^7`Fx?Tp?BhSFQuW1x zf;2K+6+yGa%v62n@sbdCxtlEf@LiNdTaom}wKC9o+PCiEq)BUD$);;%p1XswNI2bH zo4I^IzqBTuS!@pal#=&gchwv7NypsOVT-}7_H(K0i&za!+A5oq1?Pq^mD=h8Qj0(i|l-Z$J`FL(!o$bTHb0qyC zaOiZX-jTa`)h+4xGG6w`_wmNfx&&MH-l)Jm%MHWwcSHf8m_LFTzO}E8=zCz|Ef3jI zn6H;h{!gQRd+dG#Uzr}LttXc|p^>-EHNNG1ls$TX?kOP#YRYdX#)wU0B8o1xM96l+ zQ`+vbFJv**Kbk(g>su%9QrPF833#z;B}U@K-oYj#3olnYQ;#da2tk5&e|?+@m}x3i zjUGwc>6x2jMBloz%BmZ)5a1sayfI~&6v}-WyTSfUe?C_u`H~R#v3QVDYF9 zmR(z_f-_f79gE!XO5A@oBul6PfAg`HtOT<{lw5tr+x=NQS>tC6C!)9ZU; zG9jB`))2ZKwQ{2^5ZQZ{kApfH3&wEaDH{LwCf^$zvyzz-PRop5?XpfZc|KUw*pS#n zq)48XyDI~D_0*d}U1O%az4kayhKgp~=*}J%9uY-jMQmPvCH%kHb`@hgEnRmn-bC3M zNrvX6+EJ59!|A|qf%sf~(lr;;an)tAfr0@e2EdQPn*)T86Y2V=Rn9<=VW zmA51_S~6zTk~|B|v-=iSsSthmG6CuLFS#;cl^0Z{_#MbSwqCJjJpcJIu{ne*^du=&=ZC!+*$NPF12_;cD{qHo3(onr zV763V6cX(z9TWoRM@rtqb zK>9QJ9clS|*oKXVzdo?E>9_P7xNq7BlWF!_r6yDm78Vj|{qi;=ZM3Jc*I5j9fjjTr z*b`T?KnvQU{kHu4K`r-->ZK&zI`Y)MY}{#MEBZ$Cta%as2MXU{Z=(ZMU4zjBAt%J@ zni1|GTT?|`jpZ^HaJhni3$EZ_mI+28#?PM4Gc_>c_U`;UrT3?vLglAlzX&_7xcKg~ z|KV7GzNXCQOa;OV;X=hlO_VP8C>SYr_6r92{<`s(jn^h~OyXPh12Zj+G_(G?(=o66 zc{W#9=8MLJEfs-Z8IH}MZ5^QJ2LzYCGLJyjv#Y((0r@uvC!$?HoX4-E3JCyZ$3Bce@WQxbHctuoJ<)jixxFr^5n*~%c*%%9;`R>W$M-J zo6V;(JYTqb#_7c7;1`XEtKUt{0Z?eE%y?-`R_Hfot?HfC?55DG1o7BYs4=CafeT#w z84{fc6=?){A>iLB7BKTs(P3n=4im07BQU_lhq7z6Vj78zq8I({-Hv-|#qT{loN^O5X)vpG-~&P^5(r zQ~*4_{&Teuz~fI(&0<_J@2)66q@YLmZy?Ivy<6e@1gjZV|GxCn??l|US@PE>`_9{p zBYQo0@H_1(ZwC(QulOW)z^YBr=HE=|*sv0aCnQ59pES?1jgj@de5E1q`tDow471Az!dUKnpsA#f$ zOy`2pHGi`+3ZRGb^zEn&_A93X5cvuJ&!o=(f9mu9zB@~~erah*HE?Ok;>xEsS^mAY zOW^L^yJ=#s@8Tb7zS7n0{Gt6lFE0-dNXn0lW|Cam+>FZ1WJ#CsXzfu;)&a&MMNfxP z^1rwn+>tV|D|Nd4`(EB&=IP{2_rCn83wF`;Nt*96= z9<|EJ$pMmbwO_rm_(wEf47ml6cB%vZ&vU`EW45+M@a4K z*RNfcyQm-l1`SsZ_I99@^f`E2{z7RC5c>&8D@%z4yg~A7E3sZ037H-b& z$6UJRh|V-<>;b>t4e@T?(kEmN5GT&ETtbb{@vUkMZ(c>6PZ>}*#{`uMmL})hS%EeD zZW0+g%dtl^w$$YK`Zwq4+k{HaXBXa=skKu(mUxH|cA4WFsywn^DT0B2&c&@p(TmP7G9hAm* z$@r@3u3*4il(C>s#SdhuGmc@mpogh7O)}k49br^ncj{jCb{RS~Z=~jPAODuiMVcEh zayo?OkS+H#uS?79f~g3-r?}teXv@8}G=!j!(7C2xZ(a2^*;Szx`q?6L!-r|u5kHO2{hQN54x4e>X=p09$7#7K`smsIY@P2>q4 zr^KJ#&KGD+5=1Bl)|Qd`f3u-iv^}MST7zy1c*fuOSe^=R4Sv5x?YNMmKP}!63ql&T})jaaV&iNEYw@!G;ZYvDvTB?5l~j z@pr1{7sWS_c+(R>ozF4uhElsl;~(xQEw~cGPeflS8&GNbovG!Xi%&R(3u`dwu2Xw( zl}3?C3wL#M1qN;%{J6<|V>b7~A(8f7N-E`L%C<RiZZ+46mcp@G!vU;<*swI>i%!QvSP5f5to;V=B#9hNjCcUuA!YDYN2{CT` z2*vw&blkZMHlJLRPbkTlfim$l|p5Y>_r&*Jd|L|6p=rasok-#UDH*>k+OopY#wc zbwmnZY|{68lnTuD^QvDzxi_6Lu1Y6}mops>M$TId>VG+Y^X&nggZ3AU*z%z~m4^pJ0*fKj0^=4~N02S;DlC#}xi%>BAlfRh zfPI-i!JwKva6|qZ>|V>v`-p6tgp^!520K1TBDeLQ;Qb_K@j!T@4K=@X25+jsu${5x zvN39zp3W^1mFDB#FtgoW{&I?b3FS59|AM=^Zp>!1L);_6USg+wtM`Zb9o}DJ!nUSz zWBQ>o>tJx-6Ul2@9y3~WGQ;8mbZQP8>U9_z%7I28yXte`j`--NJ+b*fQh!jm@-*62 zwm^#%ooY~iWU_UVnTHT9hnD|2j4=qo3&u2e995FEzaTb;y^`YBen?$i`B?M`zJT}h zJ6g>j%(c#p90IJktvKR#GR&n7*(W-uSS>{#|2Q%nQxs>0e1zq!C&IX6zoJF%DI00q ze_W%c1s09dddr3{#cP6lz;?Q^A@jZS4X~JI+0`q7)kdf?jkvb(tc>fRsMU1UZ@VhA zw*%SKi`N>O(*JFD?UE5Num}QvO)577v5Q~dS#lStX_de1j`k5f%Bi^H*V=je{AAt1 zh(=8e%5iQls1d0Nej+v4u9R{>_ng*qBRN#^Vuq2enJ;ilC+2f``8H!z_~d*zCWn5T zxBz_0efE8#qd=aEh(4X(em(hw(WG{(zw|79rd-fa%P>#!&$|8 zi-@T7;KFON(ktFPG9!;H&6qrm`uvO*h zsRQ|zS8?u}fbh-9ylr5)v*~D#foS^Y)@JLew4Z2v`uuscJ#07tSwR!N zqV-&HZ+G`TM>feKCvX}~8>NyN7kIu~I=YfWSKz;4z9gEX_4?+?iQvLstrR0Drbi;D z{M3sgZf7#!!|!XC*We9XFEVOGKRm-olz!`9s(mUFd%(66W86D+5&@nQPfq;3G!Zs= z;*ntqlDP=c3^#A_FRv+xjVXS62$gxEKp@DVxTW>{72du8Qm)2+XlG?UpK>VJ0K)e2 zNDyGIfF$#sW6<&ZgN^O&`G&|t0Kv7RpfmLRX__@4@0~BTH42BD(=^V`&i}x^LMb<5 z#PoD(U|_DaeEDMlhW?FDW)DgUgkL*Y9lXJknd@g{fjT)k8OTzi_~)fYDG{J@W3pUf z+z|?e{&Br~72~mbn)u4_8Xy@suWHBv2P&xU+{u2das_tF(3+15;l;(g!1=L035`D} zl$?tTUsF>P`pFf~BHTY=4TV^7b8uuE+}k0-$EUn~`&%p@P$h29j6L~K2h1UWqz6{_ z508(XkLuYqby|0aKY#k9_sX{DlcFurNtLrwBKhX(1k!l@HT1*pVZ;w^-fJeVq?K7F zzGhP%tJgOrQn(c4r#^|$v>QKp@JYA;JocMFX-#MP=9EpUTw zO39hLS*>l}J9RyD10AD$sh?vm!oJ7QRteL(^z}o*b|tknbYi$1(6+Qt=trL(qvv!6 zEdK4^$R-_l@g=IjUEw|6wn)k|GMFwtuI@5QODk^ki22LLj)*5fuY0#WwM_}Vu>(zd zcfL|@fk$$JDnu*#0=6YC$_Y;Aapaco@IIWJW;U}rdg;A`T{45Vt1~xY&7b9Hc))v~ z?mJD3=x?dc86FA2VKH>$V=i|$VM~=d4Egh3JKuXcymsRm&@NhF%QpIv>TR2YLr_?3 zmGA9E7>)tNKgv|iyA*t^TC*D6OtqC3d$8vi(QijOK_9l6!SRNT@V-JCq{rkSbd?4K!l@m}&?=}~f67lq-o5i?I$)I4yRfjHKt zzAiTlX9yQNplYD(!3~ASaaHxi^SeC}s}!GXv|Ko1;z4NF-!nDI9(1+v%$XAS{Va#H znSK1}%k8K1UPoh(r5aM>-rZ7F?Gr9EdE$C(MOlxGp1mQbZ<)hW!S8FGP+QZ2^z^0D z{Kh)=gb}>|A;udCBR0CH=<3nTQ^O2eALjg&5A$W(hJ*Ts`!B*OZURZNAz_e1oke4R z9ZdxB>*@^T@gjU}x`L=LftmZw@$N8sNf-V6o{S$8EY(Lmi>EO}yty$+<=2saSTC8q zPR9B5)cKNOT=llV%9Ivx;NikU|OozsNBy*Ce2)j3MI===-*qs2cD zO)V#~SOyQ8_3XFWzD8U2V8hq9e4^HM9Wj5D%q%BNSW5az^ul;fyoA-yKI^{3KXJEP zWv5id+Myt6Wzx#+3KySPX4UtGxV$$t+w}KarRGkDGnMxv7r^6+E`HNbnnayV%c7QP zGIBX~WZ1s3=cPQgVPHA4r7b=mhPKyoLDx0gyx045&M-gB8;-t%b`OIv`upF_8&VyJ zy+K7wCJ+O5Z=Abu1J_dh@s@R;S?KOmW@zbYgzuy)8qSS}*o0XeW`^#L`QjcEsW!nP zDH5UP1`YmCeRIRn8jec&|4wSvhUqn~R`bk5bDI$CV%x zgiIr9+ps1|3bZ|{v*B)FL0plZ}tc3p3+`qRH3sOf5fJ5AD59i zqoZrPj13|+#ShXcg4&b@Gq|go-FXQBB~FG-E688oRt+RKU6_X5X;PRLbDyYjykF~l z5z(-xa<=RRA#wV3>8t8l(BXST?sPUif1=XdeLxl3%ibdffDWqh|cJU2WgJ$pthc6xR4 z^Q#XI+#24@Tbcmk`Fn=^B$7JKUwm5AgD}W7k_iX?W(kW)eeggoD@o{41X-wMkLslI z=_gu*&v)SUpSi-D6Ppa-=OXA{Gj!jR^phV9Nyfw2nxgGa6$h8m{&|fvpEh)I_eMs= z*HdpI&e#$GQsVnQmFXdm`A+7@CN`16CS*->l@1iqmZs!tWd5Bs?|W#yJ5QkcFTo2c zb>C0VsB^RHnKaiPph{nG_#3?QT{U893neLXWV+@Tv|euGG`|3$`C)J);Ip#Ybf)%o zlXK$iG{;QzBFK$c&&!d5=?3kboZC?Til{mR$DcJ(0mHdFJ(D^X7Vlewg|O+*J}k7AttX-9f; zZ>Kc13Ljwsa56uoRcG^gcm^mod!@*%35m*Fn8-xwC(vIH%kn?%T)uZ^EuA#s5xS1? zltH3WSxn9mV>KNd>aLBh#{C^Skw5aDacU5WPgW+xMIj_Og8<&v`67u} zouku3T75GWGn4aFo6?@(`E~!VOF}N;1?&T({db>7$FDR9QmQ_B5`5o(old+POXW-O zX=`RN>0ZOSaOn9tWzI7d0zUUUye7k+Tl+ovE}BN+Fef5hR2#F#Yr|{a;%zS-{YShU zUfiGk`S8rn{Y)~$cj?B(K{`wIyl}EL)uAzU)-TF47nwJgJ6@j_VyXr4&-GpIG^EL9 zWq-WM%RrMfCO}|Z50CV@8(V#b%=hxmFmf#AdVD$naxW3HD#Ec^Ubc|m(f3}qGadFh#JmH#WIsATn=a{Oo ztSxo&2)6INzPnp+v0v#2tEfYEY#I~JcHBi3=PvC-{dN+~3!vtAC!M|?Y9BexC`0FQ zHNVl$1#bgyd2ZA7e!RAo0~|d63F9As9CAyh459k zQW6>)Gi|I1I|)!9i@~rq56Y?Z-nuZV>BELk_5$xawR~9-MA6;C!C5ucbdPWOM6_ts%i_Wiynf+ErgNJ*#C-5}i{ASK<>(j6)( z(w&2(bV;Yw&`5W~4BZU_`yQX?-D|zqdiTE8S!v4_@jP}BoFxl zI*szxPZn;>lnFR%Ku9CFimFd%j#6^*W^P8$LR(cYsMy>lYUVsel)v-?-?Pxsw*50^V8noR{g;Q)f<7^MdvLhxtEqh8JS7%?69wDY zAO4c{{VKL6=1_k1%+Kv`2Gd-IPe)bqeZPFa9cO)4voPhin?)x?U$;{jTBNH=#2Rl{ zILqnk74L)jWkE5+v*ZCxj4@Pu7FB{=@Iv+ug}$t#~N7`$$CB;d<=gA-J$Bmf%57w^X)4l=LRkt>Ygq35kus6 zA%?8ikzA~X2aR(?y4L$SnWi?Ohwi5xj|iVHk+fca9RlU@8(7V9?S-LG>RnW$L^@=S z$l!AllHv4z$JaWV!&O;_4Q!s3Y4=^!v-|9xGpOEd5+y0(8Rq&~Ir-`W>BN;Fi^D_d&t>Xl^3F9^$NjQgGw2^wcx-3stcduWu)IF;On>4?VkMO>K+0NvtD zeZZ4}j-rCfba3oJZRV@+uG_WZ&i7jg9c+qUEaz{8Th-#u+kAUstn4G%hKz1Fbs324N)F^RdfraHCAu&gdGHg-k`Pg!^b` zMBiPn@lL=iqe#Y<@Ez;2lNDVR8ADhfX#8B2DxCeoDYslt}BInCj$-ScwFWi zNxSm(^}8B6A2!-v+&(>7$(UxVi>g*F^__6G#c({M(P=+WX@x9lVU)YKIviF1Y}7mF zEvvvkfj!)(91dn@rd>Lsb6G3m-*7tL=((+rlVvGe5s+@=#X0feM4fh^oP=_|cM0^x7 z#5g`8CmFCm=6F%L-?w{J#qfFQJR;O}x4*D-*zf-Q1u2Ve&qzT!|1`eOzCs|@ae>)D zxm+!;tv>0S-i7Jej}F1>EK<-joDnXQc;f|=@ry`7q>;OI#FuEjq-a~kb4wEIWG^)9h#tW-mu-_JvIxz(SwsqJ;{rGqGyb##N${8(8F z`M!YWa)Fmfu@_CC@kSppKtDs{1n3NNXQ%XrbE+C~9_h+_E2+M~ z&rU8v&C(ORgn}q_9RsHxuDo=G{SVVi(>NV-lRpOyC3E-R{+&_R?aU3}ZB%wi|16&c zxbHb2eq)UlfX#3U^WmWrK-`@NfMkU!X9%PWz{%X)AS=oLeD2-f#!s2Zm+aHrO_{>p zso`@!hl+U|*RduKeGwZ^?*<1_7OFi@k0Baq^~c%({Vn><4`Adq5NL|VDfUz?P@b0lJs)^?5&YNiN06zgLc3fc2RFP(UodC zrgn30^1@$Vy6tyn8Xs~r>5I$f0}j%w za$xnpDv3wxGjw+F(=xd2e2gi|_9`qGhOYdeIBO*YoyE@@x8*+bS6ad9ys6X~+O~Xom5*QB37Wy8 z)&`1v2^zo8+t;>7J3SlU4Q%gU zgFaUw+FyHfv}E3d>SAiXIhMmrsRe|?F_0XJDPj*-ixbwTAAPOJ+Lq(BG+wgbyv7t= zA-op2Yk#cfkAot?Y6&gv4Bd5(HFxms2(9DP7o=>jvsTv{O}V+W^o!y!m`?yh&Jb@f zFlaj_%PbiTiEac-nwWsQ@%=lcwxVuSKjSWpQwxVVgC!yx%J>L4<)1ukkmJbj^o9+geUTaTa~dW}Zdv zjdiejn6h*?HH8)siUwPieGx9gm>rKQUwv8Sq3g;3m#0GtD5kyw+F{YM%h!-kmsrlQ za|utriez1b1@ZC%^#*oQWPOCEwfO=B8xE_9of$eju52(Av-^l7w&h(m)e$?&v%D7D z12;+%3xm+82HdP9=IBuBZri3Tdd@GT<{~*AvmOoP^R+(WTXB8pKBsK0!goV(>6a{L zkynE}G~V{}uKgkd#F5PvCoCQW6IsT?LeKMHFsujmqDeoUL>qH!~+IDw|Ccg`#_n|}sjyf4=g)}FjyZtb=HEDroSLxf+NvrJAW3vsAn*|$ z9$s`pf|QOMBXLuv83p6A+jv^wsQ745wak&kS|fR8`Z7WO_{RVvHwR*%a*#-ntft zTPmeij{S1PNVnUm*R%(yLwR55_TBY_lf6AyV`QZFKG;gs9`^ncV3yIEHR-77RJbJw6lRPCP`d}UT`)5%iOu%oo;A6XwM?WG43#=|sPuHii}&yU>R*sXQj7EC4Z_ol%xgv=dQbYN;s z0bn7RfYX!C&x1Ia;JSs&9oJjrG7-|tc&i#+nf8(bnud-lF7s~kGK^TPtpnW+$~V7c zgrW)8ue^WL!#hc7Z)qV)Me@8AednOM5=!*ul31{gz4~?hqgB!FsdI!|Yjics9bE2b zy3>d!#bc`tcP~R_bypnK<1~Z&t9JycW9CibdePElJ4Cae*%<04)Hv|~cV~U+FyeGP zq-wHjc`Lc++x&a^6Bv(eZ6A$~dJA)I9NT0tvk5yWkz{}b{4n+r8A&HZ&oD?sek#9v z5O&P~J~Qt^e^GK5+eq~>_*BT^ZeLxd373dQ7|1B4N>|SU@PXMv44@WE8z8-rT%4=GDe&6Zz<=|`{U*Be;2YwNz6lD` z4B-cKU3;fq)_{OFV4Mw~xK$KBh=prW+aKjju<;E$Jt#Ozf?s;1@scBH8iIWtbU+(% z0}%NWo$H@x`r_z^q+Dv;fg~PEpsUsxZkT% zN(Hp}YawuG4x69A9MoNum=uzjXzdYcSi>Q;{*ykx^m2SZsXy1gE{KU7ZOxhYfZ_U6 z9lq}$qYY1S&%?5MFlb-!PnKT1pgCEqoS(TWB)wfXT@CLZj8rmfdvIAx1e6<~{B?h> z@Knu;$r*>Z*()o)8Fh>tN-J^``frr^KaG~rVl>8$VGOdjzwHF}-XJSlZ{Zn)e^ktA zc={b#{ubNYSQJ^{%i8xlfH1~tC>1TX!q6EIc=NURfw;3&Lit1)(?$3cs*#pD&wB?j z@@nK!XeLVUxg6g4MZ1&xeYNXe480o9QAA_s{h_aZJM7IGlPh!-_06KuR7bm0l4WJJ zcjv<@E`PYreRRT4BCUA22Z}JJj<8n0j~Y!quZM2q1HoF+QWSAz4d}`=aYwcS_ibL0 zb!6#M6M+mC!e@_9KU2bLYBYSC;v1xI2i41djh5(D$0r*~;cy#Mv8ymsp!VLF)LFSO zetm)^y?Tfqt7BLY^A!Dl({3;oTWX@*MCV$O3qr0@3d;xjt?Iep;DC z`L?`+C1jMSbpO5OUI_LL3#mpk*jwBNPV}2+F;@;h(*({ z3d|BfyU=(O89~d@K3qiHp{;zPP$G}^!yUDT!cIdYOAp@oiRXCF{_%YkC}(gGaEr5p z=!LpO+rCt6$uwMk9flygT3auF;uTTcd&?mIzTK`&ke0;gI;bv3OT||8rV4NDdIK9Ifsl(F z97)Btj4SuCk@V-K)yvht>0@!eKb=WQN8)elWBZ2Q(C4Tu+ymw{s=VfpuMNJs$^bg> zfl}3TF!J;nb=h|NNFyixg=Xh_&Jcw4iCj+%9crRmThw`s7<-!`z42egCo5} z7c!KlRQ|MKtUgWIIPe_NZ_ubsSDcadynH;RWy|j3sS4G_61}XCKf7uVUCp*|Odbi( z=?(QLI)8@rTK25uLb`0QBZeMu+X&BVlbG$%(M}qi&iw#}srLyZp+%~xy;GF|f_6&G zVntYsNLdskgz0;G10@Cu=73l=V-Y$*sOaTh?*9swvU7O4SO4M=8eL_Af2~D)m}^UR z4i_a7x37QJR)^L55@FF=tO#%L3K4Ur_e2D7BRy0y`LQSd+CP>3+7i(fjP{o(e2J8x8RpI#=tRQjUv6 z=(P+YE-P@;Kx{ibeS3oIPK@4isVwk@|9D3IOwcoTDx{+E74s9&8)QR*0kqwFg${K8 zD~OcA!`ScqDZex^5M`h-ICC}%hdbgm>2N`oje8L?<@iNLH4ghNNe>Cqx1CIp>WCV# zxt^kJ$+{ZV;U+2@AuT9ZhNG^`EufmZa13lC&VL3dtGw!&r&JW!h2;U2(sv}%Ukv*a zdjRP(RMrYtfgu5knbQ;N{#UTxDU5kOX-jFP@N5U%g38pXIx%f7)*cm_3Zaav`88R) zv36~WI6wR+DU6{H?l||jIm7lbeoBb+IKtG}7c^|%nlHn4(Y^?Af)n+2z*4#4hI zR(t5#%@YArnKjVz@J=>1q;%=cAccg!@4Nu0guL31t#a3Ze(t5cUVpeO3=C+#eEaXI zvRXtxi4qNbZey`9VHlQC>5ArJNW>^6Mcd{C7{ZCoF(<1_>i_)obF$CA|;&lRRg!i1lxM*NtaCHkB$^o>>J_hJGiz_oz zV9%}nFA!wzlgmrd+wL1nMsMolfml8#RW8<&QoCbAAIqNSy!$B(%-DdUuIv9YtT;hv2MNQ@YImP{1c)fLyK zSboGha8E>$2@iI|(WUc~84Bdz)s`bK^y`4q0+Rw{T9m-c(7Z&2SET`Ub-ZF?V$9Eb z0KFLsD(YA~JoSr9=afEPvuAobX=P=_A|w9{5ZLkY5gHmAIwt^c%YO0n!S3Yec=7!C z7!%qR5+WkXvuC5S@D)@#ca)Ehls<3)48TI?>>BuWb`NCXpp2#+OXwe?!dlle|Xs(J2e( zR$`bAHW1;G@p;D6k|})O?L`!460fH3rw-?cKWVU)8i(oxbIC1ZpHbpd&`$wg+imXx zqwDzyB4eZkP)PzXbeSal{*Yaa=!&8mha7ir7wionVaFd(O-f|}(ZuBVyIMZ$Od)5` zjezsbZy|0-a=**I`$a!fFI;93Ofwb+djAj)WZsWoARmBji`dp^J zzF_(?BUmJ}1FTg~Uxn7ZNC{)oej)vNkzeC{(9AiUPEMatqNjzVD8J3*Mo`+sppKp` z=2ur)wW%SgQmCgy(mA_r@`^xyST3vX%8L(lBvG@OZ)U3c4t8@cy5ic^0Pz6HK{w7F zkCNL@V4PSve->;ME1%AcoXLJr-Y5!4N|p#zlE2%nmumy@%=|!E9@jZ0UP*QRZ z{Yj-J?H5?tZ+3g8A#NTu;YIw88OIvwvQ#Cx9|lo8EWtBk+j~n`(NZTpE_5Q*Gau7U z!y4S#K)0KG`_a&}C09yy)TZNV08xf?7 zF3wpX2;Ec1i+ML+Jk+4&lN<^CG)K3m+qdybfC5b`RuU)tSlNJ1@rQ2}*1t_tyd#si zdb~2Y%K*T|Up{NwJSQI79k${*uv%iXsGFZQ$5JiQEi~OH3YKcE`tMocxG$T}9jDJ6h-Z z<&=`;Qy-FnHDw;f)O*_q&d22dcj#1JrX>5orrq}4+E;C=|4G-O5Dq z1{V@IUVluur$)7~YcZ~?>sif{0uzqkL`-UzwzIdo-BO-2QjXcr>8@5 zbi3p!1lja3G^nVFPU4TEp~zIb-Cm;CneY#@qMl?Nylc&2U%5@p4f6uDz0lDlKal$$ z4Wp#{4BR?+$wlSXI@|auS1c}64F#TkefTOggH;dfNelg#nF5;>WcVm``t={xZT`mj z|1X9<{R^hxf4hgj?Q!eis5BtUquiinP|9xZ59%MXb1Cv@(bEsIsdzBkeC=^sq_p6{ z6iV7gE-mV8j>g~x!*qIZ&1%frmGXGl;qg+&C{Xt{9U%6(!&|;T7ZebE!1{a(*XBwJ zDg3WEgS6AJ1Q*{;ZgB(8l=cQB{){UG9mib;9$uSlT+lrh5$`>gCj=G#wS;C;TX2_S zo;9|E3|b@_tO9uB)3du4Ak7m&7X*553eY~+uMqfC>^V%YpAX5Ov~kthq@KW<`1Q3d zSb(Ta{_=n0OE&ci`G!`>!})wZkcYRip&?V>4J9&x^}D%IHX* z_wJ(yR5?4-&6fP;l66M!t?^vzb~6iEl!Fnw`LdY+>R0zT*b%djpgGm@aD5Q@(tV`U zsdW4C!~^Aea9E_9I}3Cxs;6*-`Hs(=>ZwRsODQn++2j|>72|KRZOO3iuKSI50-FCkvgZ$&e*M}#ShPWsZKE}HlgTMSet`u*7NMb-fB}x=B&gz7 zX5SKOfFi(ki2cGM(z~0W^MVO~;Z^CZ1ApBj7)vlPzrOR^Bxr6d@5PD7B!vtivib?y z6Lt!?w-?ATRMrkhXN>TuyCwbz`fI}PwAp> z!ru^j@e4YTO6!5*n7ns!rJMKDcRIrnmW!11r7U-rLD$0l9(Gz}Y2#>Hyj<}pT1}bX zeayEGKXNXa>%=xjj;&E%MWQ(+y7XLz(Xbd8!ghG)e&V$Mz78au)4OY{KIW}+v{KXs znulgqzTu?FlQy=sV9#n^J64AmPuY1;YWEk0f^!PKXUDgY=O0_w<`k+#QP`()#L}|{ zcJfQT+*%CZMN+QbgX!Bsu=}Bjyk^OU z_OrJa)G~&Cr{4rAn&!#^9W-O>&Sm;PRed>(J8z}-xOseme8Ru4_f%60ejQWtVkP+7 zm(_lHhZ=)Ohvbl(-H?-yM(lOX^+5(I1x}^{=#2=Nwo0xny~`cUA%fMmJ4QD+y>}$6 z#;w!F*_rd=TMFK@3;W_cgU#6F&h&3sbt!OUzRtRdsaPNj_WMW&RUbQv*4kYM{-%#3 zx`G({c&86}j9KC>`B3ZdF8><6?B6<@vC-oboVfnf6e^;^)KzfD966ZYTUYInbM*F- zGjdY!MCI|l7ehg2Lkvuv?xd6YaJ)>#L1uBwaK%}SSc+F=qrJej4qn-+bPlkeY9hYm zcj^KPn0a07S+V?a2v?uIWdFm!e6WURasA(3tL*<<#1LTQ_-nn8_2Wmco(4ToC=CE3 zk1j5Fw18nWluIrR)dX1En$)mdIWc~bm}nyO>*pmok?pc;QS~lDXGRZMtJh% ziJ7^%s-~vJU-oIdC@d48q5|Af{r`bWQP7; zX!SPH4ZxRf|8VEjIY!m~<#}+nsu8QS)dr^94b4x?XuFSB9&%&dexS_VavCkz3EjzZ zC0(cKPSJ(L;}=($nhrP1-Xs@)O5aG*PC%~ZuY0be{w2R(ZZ!{st+2;$za(rK+&+^s zGpNB0Cw+pD%IdzybS)VzjJ32HzGLF-%y9SvUe@G!XT+@&^EnW1zrU?<(vksb>Rl z)?&)2sYI){QNmFj@7T63UE| zh<>bxeF#QQ+1+n`a1(fP^_>Pr@mKb1j*KV2<@5R9vh^nTdLK7)5rb;(N9W*LUu_WmN_b_l!?!XIUM_TdI}mF((p=w4d~0pMRJ zk<eWoK|*d{Ivfj>`vLYvS0p`jK>>%F1_k6s{mqD1Rv zm`?oRoNS0_i!?HPc`k&IwhT<6JQl<PJqkeOVMP zS@RR1{pS*Io#Q0@9zA|65D91AWZKzzi)DO$r(81uV|ti>CsDA|iPaVE874p_zPU7j zoXowXTE@E|fm5PZ^Y9B4@*D$9x9RRWY^KPZ*1pW^&Zj)CAKopWzov2>b;$-PQF{!V zB(mrX4kcMs3b^q^(Ce#Dp;?>axo&V|f8GY#cu&fWj=1F%z@hDAi=8h+2f zipXJ0A!6fCoY5|xdOmy2RvO@!#Kj}RKhjV^jsyUbn41LH*dD#i9%WW5q3WmnZ{#t) zPcy-J%Eqaq(;}&%eUfANmBH=A+}3un0|Y+sp3jM zM_&;Qti;U@ZG4SR?o6%$tcYeSA_%+BZl~8^Y12Blzh?yIrprctvmtU^u3q7%OY~bl z-Eud_$l+P##0Vd@Lq`w#EF>X>LTapvkYoMHN9-z{_(jh&wH?NB-;)_gJ!`sXbTbey zF}%VLmKJjwqV^r?w(F|+XHF*Bl+yQ2i~5fzR>eh#n?>I$hzTIPS3ryW;~*Dw-H8WG z@{7TorR^sHDFoLk?^p8D-vI{#Q5U~!?nRp2q@|fq>z}p&D z1t))jbwr$vS+9Pi^Umn8QycDxxRp(4)Uwvdb*bX)!raFh3yAW5kNnPXo0y%tw@RssFdbg@!ER$gDlmw zK<6KIOF)ThJQ&t+n{-(k!k9?cf!_G!-VO}z=>}%@uAW?V7(v;&PZZmjGbhtx%G1J>@fPWsUNL+ihEO8@ZbtZ@^$=;XW6_+XiEJ)=)~ zP%;7Uvv8uktL`tC{g=C5b&TlntUJ~Q!GhaZGa%aa%d!AMeRjCKtf$uoS8GSm`^qC; zfWewcNfs3sl_6ov@zScVR@O%X2B0S&?}S)(rF>$6NE>Pl)M8VvlZDYz-*kq&C?9~^x{IK;1>f0#TzuZ!}_1y(sr)@+AWRCTAnB< z1ytrLMS~+abT$p~W2he9-ne*p6I8~7K&((!7KM@=TqPFA?JsU+?&ItG3=`9S%1WM` zoc!~bFURmAz<=hp$>68#=|J0~q#UXW5Cv-6Mn_{uz=NlHbxwPsp_6&QXobAWjpbJZ|h zBsy2iL(&EOFIkdrO!j9X!Vs!s4rrEiUlwvFjL6dr=&=A(UNRZ6!Po0A;@D`z0uy?A z6QtHo&`m&*bX{*>1MdRmvj)ZCh?w~^jT{plMgnYqNA%I}`N6<@YcY6TC3wVu`u~Ua z>t_QL7m=02{Y=j-XW>ABlMKu_YQX2@PGqWZ&kx{J$dZ$6mS$k|l-E7Rc;Y0UVstBS>FprW8oBlpH1pFiJIejY1S%NV=a(lOV@8%H^JS z2}(o4?k9#DB%-{y$Z$8jw5#|Nc=TTlTM1-{xi^K$M4uj?P6; zW0j_5x7I^mw0!&61$v#5#MxkZ3R(Xn6t1LjyYnN24Z`!PO;RAR2-XNFd2f$k*;=tK zGAGE#UZ*Xo6T$<$`{!nWOnx#TxEe2ZXW+#+3XWBePo4*U!B81B?CSU3R+takU8@qe z+IOx*FmZ2zd@M%PnWMwfK#`g-0~K1_^9wAzcL?~N&8hX>s`ndobas92dj)*qn_;?J z=eap|V_o`t3_RyjbXY(p&pT7@p z*Zi+SbxIkc66rH z5D=GAGIG9F*ZFkibco>2#fHDKPRj8Lkp3=-f=HHU0ck97lcdIdPM$>kJaY_YZ}tHy z^IBHbAyUL?U242Nl0YU91-=6Rk(HtGqy0*%n;@4wEgg|I!|NyI2aHaS^CUt%tJ}ku zBT0HHhDndMapX%%InAKeLlcX%5>4afSA=QRpU!-EJX}pr-m>!eC#Z<&JgU1BPBBpN z?P7k)m~}-HqBswr)v~9+osQ8gzdkF(rt#9Ro&Ci1>eI8_dn#-o zVtiOP1ZNsbGq7|Mh~r?{=q4WEFqvPL&3S$}az|#~T+0|WwEg6agjhbXSbyYgE616; zw!03{Oa1(j>ZcKof7cO80vTHR+0v}@vTKH7rq-F4Br-GjtJ;NMa!>JWWWI>-=X$Gt z+015aOemgXmOE=17T-|SrF&)Z6<3}NXILEp?(a5U9Ecx^QU4p!3$lfQWzLrC(ozBh z9DZ<1G%&k82bCbAir9RyY6uHBImsl0JO2Vaon5VEO+QJ@8+6)5hjz z3V`Js8D%y6n9T@31ttM8wcqoq!-1$8ptp}X4f+Sg@Xs~h|F=BBf1^+O7adDzZ0vIY zT6y*QHNg0G=rIG`gc|j~uWdQ4x3~u^NdV<;g~PIl*X4oI<-sCQs=lQG_(?-$BY=XL zKw(Yj@9z@mFZn9EJCngmN)QNS3@9`K;B19|(cojPzTUtyxx=NFzM&y*EnY!jRt!|6 za5-FB{0lHLT5NI$!vA{p7T`zIc=zrD4SawcmcPB&AL$ct20jm*8Wj}n&o}pfMl!uu zvJqWHm6;UlJcxe);4~JJ#PV!MlhK2i~=9))WKq*W=!S0Zu?R(%$|C zD7>Mdpg=7ow4kSlC1|q-BsL9~O2&356rT{`2;!zS=qRJImR6~+_>f?e$!SZY5*a(! za~G*-Z~}=)xn_+^=hPgiYO$16`M!uv zs5q>5TB(;n7@I;d2TC|9HTCQy;7E0BwA*D8oZoNvELHy@@Oqpb$T$Kp3W&>cUlG^z zB-v(ivy` zjhrC3t&J7jdLQlJw>NV1=ss9FG7AqIf1q?+h7-s;Y<6qy0n4=We$dv?g#iiSGp+n95C^7*0f$?8oTk!Zp!CRn-#wRvw)0G8U zG?l0M@Lfu~_pgxzb8w+J#Tu@4M=4YeHN=sWJH9MmcDK>F^n2k~w$7LNi@*@pjwSOY z*%5xUMp62<`{<~LQ+9H44Cl-*)q@^PN{hop9c+3MZHH4;2kUWXcfl3E0lAZ$xwasW(FJjLyrW}QFO;fZ29mLJ}6&SUh%P=ndu zMjP%%0D+By{J$rA(n-+Ql~y@3z?RU?pNKb7i}Ja~vO{hXG%&t)k-8dxg_PO#zK*h!!?Ed*n+Puoe|=q)>3qQ_yPgkwTQHoico1Q2W23ICoA$oh z{#J+R*S?*OPZSoJg5?45H!pO^OoWLEmF+KT!D8Xlu-{JFY(`kss#0n^{R**r1Gcf^ zt054dL=+grS!#da8zaLjFOO@Nm;%{@kVJ}_E5F5Viy~-cIT1ntQ~V+&g#7KtSz4jK z@pqsqkhL*L4I1mMb>0j**%y^NIZ)4FEXoXj#T$g-y0ea-NF>~CZ^e;48uV#WZ~R^P zNgr8;woq* zVXb?dNVwZO{$rilHTFTTU!-#PwYBy=uE$R^LK!G1gro@(A@kjW3_1dW5z+RCuS}UZ zNC*gQ;n?tP;QtS{H0$DnRZK%eL$e`|5D+x&?Cq5kYwlBi-up|W@@FVZQQ_ROAt1CD z0S|zuw@0Z1_1KKe%#&MNKYGC66e8ezYD;Qp;E#xkvT<>F2gJ=@blMzXwcg+Cmeam^ zi8&ut+5iOS-u`Y31+Nnm5sgkv#6J%Ugu!=O#WWB*zWoj`)tsE1?1fD0Ne2@GFUL`= zC(;2YlZh7yJPn}yk6Ry`=LiU2>?6TvnwMDu$elNfzCcF!+_;LJn!0(V-^ANOXq~`c zJJc2;(_+9=V|t-5;M)ZpuX8mTye>B&EQ5fMvI_A~WCgVp^`+?CQW8S?hel;YXIc)} z6CEjEM1fs*rU_EShU_#DV}ngup}u21qGo7iC=UgATSl1euUAB9S!BJ zpUSBe=BhHii0iCu9S@CK`^bkE-n|F^Ot`QyiLQVM=o?z4Bvnjp3Y*|S)mRlh1_FW^ zGG{j;0s?|KaLcyi{n=vwC$`bwZbu#3mdhc`^@(dE8f~I>v~$2bHy(8k z%ZbywHCRXpN6~housq)_62@iKJdn?A)Wq9#M)%>nmAw)~81Ljq<1RNIO`E;*FBjjr z6V53i4wr>CGt7shrgIK9WnfZo_&HU)x4}1gig_@)?NzWK-=lRj{B123HRD?C!yCa7 ztXbAR*`biuQjidILNZGNgM2>9k8_vQM5l#o8`vA;&gAISro_PfLeZ3T(j9buvyS{R zf)Vu(U$ItTcd3&##lCarnn`XM4cWn*T0h69W4LkXolig(_ zXVo2~H`{1Zr>5hLXT`f-o73a5xzq12b|b#Nd}99?kEwX%)z=RMzF!|Zfg7p6L5cLEF=*wTfdM+Om-FBk;4azR#Ukx843 z)3FxW#x2(lpzVo_py`&QK)Fv#hWBljJ;^w_^!JQ@g>DY=fs0d|GP;vA(j{de-*Ej8*OhdQ|w{=@gEEITppr06y5v^9wF>-j~D=%fvP!&g7<{0kD(MjpFD zFUy)G;dehb8Wxvdj~{n2RQ@{O3qVph#1pT_Si z4J?CA7g+N_r^B$yi&qUpCX=EU@R!H1>X=%nR@#a>$!}b*&Z5f}xirOBP8MR}Fg^{T`WJnXhRq4i9T-w7;pwv#gAvev@r<^4f3HqS}L>g3Kh zLEt1Lu?P9KX1BzUp5@B*%0ydCr)bF_pFkaSYp)Gfey9VA!0I>o;j6w=J4h7gX3SQ9 z7%e!^=IEm9@D*_UuHtZ!Z_vzcZWYSR?MB~7XdavURx<7tSYlPKPoJg{XjPSi)^wD8 zbMXs59%S>1W~p9@d`C-)QwWRA6@=}yP|G#EE|=%mAK%%3&v)3GkK0$-y3~6&6aCqp z)+)~Flz>&9j0la1g)A43R2PB zGJnGvMmjk^{q2Ni@7c>I2s0l#P?hE8_9i~j>Ee_&1k%~hgEi^>94J@FYAfnQWSzvx8?3T85g(^saQ^JPZn-pdfJ)Vcg0fx8(*r6R0u5?DXiQW)b;GslyacC zbYm8|p~m7lv;0wlQMn=Q+a|J&E_d?bq4<>K9O_`cYG-ieT%2mNPk@9_!TwnA&dd3O zW`Ezb!qdwSCFiUYzCPuRQM_B4x^ea|OQX56r~Nteh&H#n4-Zt`!?g<949gGNir)@d zSlJqBmoHa}I;Xk>x_Am)5B0ge` z5JpxtaUCtc7hwJ7pzLafRrp13XIu=;{RbA>qu## zllVMr!$CJsmntUO+1Q41i$Ws% zXY^x_0_TK", + "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": { + "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.73.0", + "@payloadcms/ui": "3.73.0", + "@payloadcms/plugin-multi-tenant": "3.73.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..9f11c9bc --- /dev/null +++ b/packages/payload-plugin-comments/src/collection/index.ts @@ -0,0 +1,156 @@ +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", + required: true, + index: true, + }, + { + name: "collectionSlug", + type: "text", + required: true, + index: true, + }, + { + name: "fieldPath", + type: "text", + index: true, + admin: { + description: { + en: "Dot-notation path of the field being commented on. Null = document-level.", + es: "Ruta en notación punto del campo comentado. Null = nivel de documento.", + }, + }, + }, + { + name: "locale", + type: "text", + required: false, + index: true, + admin: { + description: { + en: "Locale for field-level comments. Null = document-level (shown in all locales).", + es: "Locale para comentarios de campo. Null = nivel de documento (visible en todos los locales).", + }, + }, + }, + { + name: "text", + type: "textarea", + required: true, + label: { + en: "Comment", + es: "Comentario", + }, + }, + { + name: "mentions", + type: "array", + label: { en: "Mentions", es: "Menciones" }, + admin: { readOnly: true }, + fields: [ + { + name: "user", + type: "relationship", + relationTo: "users", + required: true, + }, + ], + }, + { + name: "author", + type: "relationship", + relationTo: "users", + required: true, + label: { + en: "Author", + es: "Autor", + }, + admin: { + position: "sidebar", + }, + }, + { + name: "isResolved", + type: "checkbox", + defaultValue: false, + label: { + en: "Resolved", + es: "Resuelto", + }, + admin: { + position: "sidebar", + }, + }, + { + name: "resolvedBy", + type: "relationship", + relationTo: "users", + label: { + en: "Resolved by", + es: "Resuelto por", + }, + admin: { + position: "sidebar", + condition: (_data, siblingData) => siblingData?.status === "resolved", + }, + }, + { + name: "resolvedAt", + type: "date", + label: { + en: "Resolved at", + es: "Resuelto el", + }, + 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: { + en: "Tenant", + es: "Inquilino", + }, + admin: { + position: "sidebar" as const, + }, + }, + ] + : []), + ], +}); diff --git a/packages/payload-plugin-comments/src/components/AddCommentForm.tsx b/packages/payload-plugin-comments/src/components/AddCommentForm.tsx new file mode 100644 index 00000000..b5f5de46 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/AddCommentForm.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useState, useTransition, useRef } from "react"; +import { cn } from "../utils/general/cn"; +import { useComments } from "../providers/CommentsProvider"; +import { useLocale, useTranslation } from "@payloadcms/ui"; +import { CommentEditor } from "./CommentEditor"; +import type { CommentEditorHandle } from "./CommentEditor"; + +interface AddCommentFormProps { + fieldPath?: string | null; + documentId?: number; + collectionSlug?: string; +} + +export function AddCommentForm({ fieldPath, documentId, collectionSlug }: AddCommentFormProps) { + const { addComment } = useComments(); + const { code: locale } = useLocale(); + const { t } = useTranslation(); + + const [error, setError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const editorRef = useRef(null); + + function handleSubmit() { + const serialized = editorRef.current?.getValue() ?? ""; + if (!serialized) return; + + setError(null); + editorRef.current?.clear(); + + startTransition(async () => { + const result = await addComment(serialized, fieldPath, documentId, collectionSlug, locale); + + if (!result.success) { + setError(result.error ?? t("comments:failedToPost" as never)); + } + }); + } + + return ( +
+ + + {error &&

{error}

} + +
+ + + +
+
+ ); +} 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..ff1598dd --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useState, useRef, useImperativeHandle, type RefObject } from "react"; +import { useAuth } 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 { MentionUser } from "../../types"; + +export interface CommentEditorHandle { + getValue: () => string; + clear: () => void; + focus: () => void; + insertAt: () => void; +} + +interface CommentEditorProps { + disabled?: boolean; + autoFocus?: boolean; + placeholder?: string; + ref?: RefObject; + onEnterPress?: () => void; + onEscapePress?: () => void; +} + +export function CommentEditor({ + disabled, + autoFocus, + placeholder = "Add a comment", + ref, + onEnterPress, + onEscapePress, +}: CommentEditorProps) { + const { mentionUsers: users } = useComments(); + const { user } = useAuth(); + + const [mentionQuery, setMentionQuery] = useState(null); + const [triggerRange, setTriggerRange] = useState(null); + const [filteredUsers, setFilteredUsers] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + + const editorRef = useRef(null); + const currentUserId = user?.id; + + 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) => u.name.toLowerCase().includes(query.toLowerCase()))); + setSelectedIndex(0); + + return; + } + + i--; + } + + setMentionQuery(null); + }; + + const insertMention = (user: MentionUser) => { + if (!triggerRange || !editorRef.current) return; + + const sel = window.getSelection(); + if (!sel) return; + + sel.removeAllRanges(); + sel.addRange(triggerRange); + sel.deleteFromDocument(); + + const { id: userId, name } = 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); + }; + + 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(); + onEnterPress?.(); + } else if (e.key === "Escape") { + onEscapePress?.(); + } + }; + + const handleInput = () => { + detectMention(); + updateEmptyClass(); + }; + + useImperativeHandle(ref, () => ({ + getValue() { + if (!editorRef.current) return ""; + + return serializeEditor(editorRef.current).trim(); + }, + clear() { + if (editorRef.current) { + editorRef.current.innerHTML = ""; + editorRef.current.classList.add("is-empty"); + } + }, + focus() { + editorRef.current?.focus(); + }, + insertAt, + })); + + return ( +
+
+ + {mentionQuery !== null && filteredUsers.length > 0 && ( + + )} +
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentItem.tsx b/packages/payload-plugin-comments/src/components/CommentItem.tsx new file mode 100644 index 00000000..faacc300 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentItem.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useState, useTransition } 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"; + +function getAuthorName(author: Comment["author"], unknownLabel: string) { + const defaultValue = unknownLabel; + + if (!author) return defaultValue; + + if (typeof author === "object" && "name" in author) { + return author.name || author.email || defaultValue; + } + + return defaultValue; +} + +function getAuthorInitial(author: Comment["author"], unknownLabel: string) { + return getAuthorName(author, unknownLabel).charAt(0).toUpperCase(); +} + +function formatDate(iso: string) { + try { + return new Intl.DateTimeFormat("en", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(iso)); + } catch { + return iso; + } +} + +interface Props { + comment: Comment; + currentUserId: number | null; +} + +export function CommentItem({ comment, currentUserId }: Props) { + const { resolveComment, removeComment } = useComments(); + const { t } = useTranslation(); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + const unknownLabel = t("comments:unknownAuthor" as never); + const authorName = getAuthorName(comment.author, unknownLabel); + const authorInitial = getAuthorInitial(comment.author, 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; + + function handleToggleResolve() { + setError(null); + startTransition(async () => { + const result = await resolveComment(comment.id, !isResolved); + + if (!result.success) setError(result.error ?? t("comments:failedToUpdate" as never)); + }); + } + + function handleDelete() { + setError(null); + startTransition(async () => { + const result = await removeComment(comment.id); + + if (!result.success) setError(result.error ?? t("comments:failedToDelete" as never)); + }); + } + + return ( +
+
+
+ {authorInitial} +
+ +
+
+ {authorName} + {formatDate(comment.createdAt)} +
+ +

+ {renderCommentText(comment.text, comment.mentions, currentUserId)} +

+ +
+ + + {canDelete && ( + + )} +
+ + {error &&

{error}

} +
+
+
+ ); +} 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..46ab1044 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsDrawer/components/Header.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useModal, useTranslation } from "@payloadcms/ui"; +import { useComments } from "../../../providers/CommentsProvider"; +import type { FilterMode } from "../../../types"; +import { LoaderCircleIcon, XIcon } from "lucide-react"; + +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" && ( + + )} + +
+ + + +
+
+ ); +} 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..53eeb8be --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsDrawer/index.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { CommentsPanel } from "../CommentsPanel"; +import { Header } from "./components/Header"; +import { cn } from "../../utils/general/cn"; +import { Drawer } from "@payloadcms/ui"; + +interface Props { + slug: string; +} + +export function CommentsDrawer({ slug }: Props) { + return ( + }> + + + ); +} 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..d5c1f38d --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsHeaderButton/index.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { Button, useTranslation } from "@payloadcms/ui"; +import { MessageSquare } from "lucide-react"; +import { useComments } from "../../providers/CommentsProvider"; +import { CommentsDrawer } from "../CommentsDrawer"; +import { useCommentsDrawer } from "../../providers/CommentsDrawerProvider"; + +export function CommentsHeaderButton() { + const { allComments, mode, visibleComments } = useComments(); + const { slug, open } = useCommentsDrawer(); + const { t } = useTranslation(); + + const openCount = (mode === "document" ? visibleComments : allComments).filter((c) => !c.isResolved).length; + + const handleOpen = () => { + open(); + }; + + return ( + <> + + + + + ); +} 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..794a6b87 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/CollapsibleGroup.tsx @@ -0,0 +1,65 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ChevronDown } from "lucide-react"; +import { useCollapseState } from "../hooks/useCollapseState"; + +interface CollapsibleGroupProps { + groupKey: string; + label: string; + count: number; + children: ReactNode; + level: "collection" | "document" | "field"; +} + +const levelStyles = { + collection: { + wrapper: "py-3 border-b border-gray-200 dark:border-gray-700", + label: "text-base font-semibold text-gray-900 dark:text-gray-100", + chevron: 16, + childWrapper: "mt-2", + }, + document: { + wrapper: "py-2", + label: "text-sm font-semibold text-gray-700 dark:text-gray-300", + chevron: 14, + childWrapper: "mt-1", + }, + field: { + wrapper: "py-1.5", + label: "text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide", + chevron: 12, + childWrapper: "mt-1", + }, +}; + +export function CollapsibleGroup({ groupKey, label, count, children, level }: CollapsibleGroupProps) { + const [isCollapsed, toggle] = useCollapseState(groupKey); + const styles = levelStyles[level]; + + const isFieldLevel = level === "field"; + + return ( +
+
(e.key === "Enter" || e.key === " ") && toggle()} + aria-expanded={!isCollapsed} + className={`flex w-full items-center gap-2 cursor-pointer select-none ${styles.wrapper}`}> + {label} + {!isFieldLevel && count > 0 && ( + + {count} + + )} + +
+ {!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..7951afbc --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/DocumentView.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useTranslation } from "@payloadcms/ui"; +import type { Comment } from "../../../types"; +import { useComments } from "../../../providers/CommentsProvider"; +import { groupCommentsByFieldPath } from "../utils/groupCommentsByFieldPath"; +import { resolveLabel } from "../utils/resolveLabel"; +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 { fieldLabelRegistry, collectionSlug, documentId, filter } = useComments(); + + const fields = groupCommentsByFieldPath(comments); + + return ( +
+ {comments.length === 0 && ( +

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

+ )} + + + collectionSlug != null && documentId != null ? + resolveLabel(fieldLabelRegistry, collectionSlug, documentId, fp) + : fp + } + generalGroupKey="g::" + fieldGroupKeyPrefix="f::" + /> +
+ ); +} 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..104d9ef5 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useTranslation } from "@payloadcms/ui"; +import type { Comment } from "../../../types"; +import { CollapsibleGroup } from "./CollapsibleGroup"; +import { CommentItem } from "../../CommentItem"; +import { AddCommentForm } from "../../AddCommentForm"; + +interface Props { + fields: Map; + userId: number | null; + collectionSlug?: string; + documentId?: number; + generalGroupKey: string; + fieldGroupKeyPrefix: string; + labelResolver: (fieldPath: string) => string; +} + +export function FieldGroupSection({ + fields, + userId, + collectionSlug, + documentId, + generalGroupKey, + fieldGroupKeyPrefix, + labelResolver, +}: Props) { + const { t } = useTranslation(); + const generalComments = fields.get(null) ?? []; + const fieldEntries = [...fields.entries()].filter((entry): entry is [string, Comment[]] => entry[0] !== null); + + return ( + <> + {fields.has(null) && ( + !c.isResolved).length} + level="field"> +
+ {generalComments.map((comment) => ( + + ))} + + +
+
+ )} + + {fieldEntries.map(([fieldPath, fieldComments]) => ( + !c.isResolved).length} + level="field"> +
+ {fieldComments.map((comment) => ( + + ))} + + +
+
+ ))} + + ); +} 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..52c8c28c --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useLocale, useTranslation } from "@payloadcms/ui"; +import type { Comment } from "../../../types"; +import { useComments } from "../../../providers/CommentsProvider"; +import { groupCommentsGlobally } from "../utils/groupCommentsGlobally"; +import { resolveLabel } from "../utils/resolveLabel"; +import { resolveCollectionLabel } from "../utils/resolveCollectionLabel"; +import { CollapsibleGroup } from "./CollapsibleGroup"; +import { FILTER_NO_COMMENTS_KEYS } from "../constants"; +import { FieldGroupSection } from "./FieldGroupSection"; + +interface Props { + comments: Comment[]; + userId: number | null; + className: string; +} + +export function GlobalView({ comments, userId, className }: Props) { + const { t } = useTranslation(); + const { documentTitles, fieldLabelRegistry, filter, collectionLabels } = useComments(); + const { code: locale } = useLocale(); + const groupedGlobal = groupCommentsGlobally(comments); + + return ( +
+ {comments.length === 0 && ( +

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

+ )} + + {[...groupedGlobal.entries()].map(([slug, docs]) => { + const slugOpenCount = [...docs.values()] + .flatMap((fields) => [...fields.values()].flat()) + .filter((c) => !c.isResolved).length; + + return ( + + {[...docs.entries()].map(([docId, fields]) => { + const title = documentTitles[slug]?.[String(docId)] ?? String(docId); + const docOpenCount = [...fields.values()].flat().filter((c) => !c.isResolved).length; + const documentId = Number(docId); + + return ( + + resolveLabel(fieldLabelRegistry, slug, Number(docId), fp)} + /> + + ); + })} + + ); + })} +
+ ); +} 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..15b2b7be --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/index.tsx @@ -0,0 +1,33 @@ +"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 { 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 ; + } + + 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/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..abd986e0 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts @@ -0,0 +1,40 @@ +import type { Comment } from "../../../types"; +import { sortGroupsByCreatedAt } from "./sortGroupsByCreatedAt"; +import type { FieldPath } from "../types"; + +// Returns: Map>> +export function groupCommentsGlobally(comments: Comment[]): Map>> { + const collections = new Map>>(); + + for (const comment of comments) { + const slug = comment.collectionSlug; + const docId = String(comment.documentId); + const field = comment.fieldPath ?? null; + + 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); + } + + const toTime = (c: Comment) => new Date(c.createdAt).getTime(); + const oldestInFields = (fields: Map) => Math.min(...[...fields.values()].flat().map(toTime)); + + for (const [slug, docs] of collections.entries()) { + for (const [docId, fields] of docs.entries()) { + docs.set(docId, sortGroupsByCreatedAt(fields)); + } + + const sortedDocs = new Map([...docs.entries()].sort(([, a], [, b]) => oldestInFields(a) - oldestInFields(b))); + collections.set(slug, sortedDocs); + } + + const oldestInDocs = (docs: Map>) => + Math.min(...[...docs.values()].map(oldestInFields)); + + return new Map([...collections.entries()].sort(([, a], [, b]) => oldestInDocs(a) - oldestInDocs(b))); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveCollectionLabel.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveCollectionLabel.ts new file mode 100644 index 00000000..31d4b2d9 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveCollectionLabel.ts @@ -0,0 +1,20 @@ +import type { CollectionLabels } from "../../../types"; + +export function resolveCollectionLabel( + label: CollectionLabels[string] | undefined, + locale: string, + fallback: string, +): 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/resolveLabel.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveLabel.ts new file mode 100644 index 00000000..ff733dcc --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveLabel.ts @@ -0,0 +1,14 @@ +import type { GlobalFieldLabelRegistry } from "../../../types"; + +export function resolveLabel( + registry: GlobalFieldLabelRegistry, + collectionSlug: string, + documentId: number, + fieldPath: string, +) { + const segments = registry[collectionSlug]?.[documentId]?.[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/components/CommentsButton.tsx b/packages/payload-plugin-comments/src/components/FieldCommentLabel/components/CommentsButton.tsx new file mode 100644 index 00000000..653381c8 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/components/CommentsButton.tsx @@ -0,0 +1,22 @@ +import { cn } from "../../../utils/general/cn"; +import type { ButtonHTMLAttributes } from "react"; + +interface Props extends ButtonHTMLAttributes { + className?: string; +} + +export function CommentsButton({ className, children, ...restProps }: Props) { + return ( + + ); +} 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..72633d23 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useEffect, useRef, useState, useTransition } from "react"; +import { useTranslation, useLocale } from "@payloadcms/ui"; +import { MessageSquareIcon, MessageSquarePlus } from "lucide-react"; +import { cn } from "../../utils/general/cn"; +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 { CommentsButton } from "./components/CommentsButton"; +import { useStablePath } from "./hooks/useStablePath"; +import { useCommentsDrawer } from "../../providers/CommentsDrawerProvider"; +import { CommentEditor } from "../CommentEditor"; +import type { CommentEditorHandle } from "../CommentEditor"; + +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, addComment, setFilter } = useComments(); + + const [isHovered, setIsHovered] = useState(false); + const [showPopover, setShowPopover] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [isPending, startTransition] = useTransition(); + + const popoverRef = useRef(null); + const commentEditorRef = useRef(null); + + const resolvedLabel = resolveLabel(label, locale); + const stablePath = useStablePath(fieldPath ?? ""); + const fieldComments = excludeComments(visibleComments, stablePath || undefined, locale); + const openCommentsCount = fieldComments.length; + + useEffect(() => { + if (!showPopover) return; + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as Node; + const popover = popoverRef.current; + + if (popover && !popover.contains(target)) { + setShowPopover(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showPopover]); + + const handleOpenDrawer = () => { + setFilter("open"); + setScrollTargetPath(stablePath || null); + openDrawer(); + }; + + const handleSubmit = () => { + const trimmedComment = (commentEditorRef.current?.getValue() ?? "").trim(); + + if (!trimmedComment || isPending) return; + + setSubmitError(null); + + startTransition(async () => { + const result = await addComment( + trimmedComment, + stablePath || undefined, + undefined, + undefined, + stablePath ? locale : null, + ); + + if (result.success) { + setShowPopover(false); + } else { + setSubmitError(result.error ?? t("comments:failedToAdd" as never)); + } + }); + }; + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)}> + {resolvedLabel && ( + + )} + + {fieldPath && ( +
+ {openCommentsCount > 0 ? + + + + {openCommentsCount} + + : setShowPopover(true)}> + + + } + + {showPopover && ( +
+

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

+ + setShowPopover(false)} + placeholder={`${t("comments:writeComment" as never)}…`} + /> + + {submitError &&

{submitError}

} + +
+ + + +
+
+ )} +
+ )} +
+ ); +} 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/MentionDropdown.tsx b/packages/payload-plugin-comments/src/components/MentionDropdown.tsx new file mode 100644 index 00000000..f74d9c12 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/MentionDropdown.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { cn } from "../utils/general/cn"; + +type MentionUser = { id: number; name: string }; + +interface MentionDropdownProps { + users: MentionUser[]; + selectedIndex: number; + onSelect: (user: MentionUser) => void; +} + +export function MentionDropdown({ users, selectedIndex, onSelect }: MentionDropdownProps) { + return ( +
    + {users.map((user, index) => ( +
  • { + e.preventDefault(); + + onSelect(user); + }} + className={cn( + "px-3 py-1.5 text-[13px] text-(--theme-text) cursor-pointer", + index === selectedIndex && "bg-(--theme-elevation-100)", + )}> + @{user.name} +
  • + ))} +
+ ); +} 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..2fcf8bab --- /dev/null +++ b/packages/payload-plugin-comments/src/constants.ts @@ -0,0 +1,5 @@ +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"; 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..7480dd2f --- /dev/null +++ b/packages/payload-plugin-comments/src/plugin.ts @@ -0,0 +1,70 @@ +import type { Config, Plugin } from "payload"; +import { baseCollection as getBaseCollection } from "./collection"; +import type { CommentsPluginConfig } from "./types"; +import { overrideCollection } from "./utils/config/overrideCollection"; +import { getComponentPath } from "./utils/path/getComponentPath"; +import { normalizeCollections } from "./utils/config/normalizeCollections"; +import { injectFieldCommentComponents } from "./utils/config/injectFieldCommentComponents"; +import { mergeTranslations } from "./utils/config/mergeTranslations"; + +export const commentsPlugin = + (config: CommentsPluginConfig = {}): Plugin => + (incomingConfig: Config): Config => { + const { enabled = true, collections: collectionEntries, overrides } = config; + + if (!enabled) { + return incomingConfig; + } + + const normalizedCollections = normalizeCollections(collectionEntries); + const documentTitleFields = + normalizedCollections ? + Object.fromEntries([...normalizedCollections.entries()].map(([k, v]) => [k, v.titleField])) + : {}; + + const baseCollection = getBaseCollection(config.tenant); + const finalCollection = overrideCollection(baseCollection, overrides); + + const allCollectionSlugs = (incomingConfig.collections ?? []).map((c) => c.slug); + + const patchedCollections = (incomingConfig.collections ?? []).map((collection) => + injectFieldCommentComponents(collection), + ); + + 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, + tenant: config.tenant, + }, + }, + }, + collections: [...patchedCollections, finalCollection], + }; + }; 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..8d4898a3 --- /dev/null +++ b/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx @@ -0,0 +1,339 @@ +"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, 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 { + CollectionLabels, + FilterMode, + GlobalFieldLabelRegistry, + LoadingStatus, + DocumentTitles, + MentionUser, + 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: CollectionLabels; + mode: Mode; + collectionSlug: string | null | undefined; + documentId: number | null | undefined; + mentionUsers: MentionUser[]; + loadError: boolean; + filter: FilterMode; + fieldLabelRegistry: GlobalFieldLabelRegistry; + setFilter: Dispatch>; + hydrateComments: ( + incoming?: Comment[], + incomingTitles?: DocumentTitles, + incomingMentionUsers?: MentionUser[], + fieldLabels?: GlobalFieldLabelRegistry, + incomingCollectionLabels?: CollectionLabels, + nextLoadError?: boolean, + ) => void; + syncCommentsStatus: LoadingStatus; + syncComments: () => Promise; + addComment: ( + text: string, + fieldPath?: string | null, + documentId?: number, + collectionSlug?: string, + locale?: string | null, + ) => 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; +} + +export function CommentsProvider({ children }: 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 [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 } = defineModeByPathname(pathname); + + const visibleComments = extractVisibleComments({ + comments: optimisticComments, + mode, + collectionSlug, + documentId, + currentLocale, + }); + + const hydrateComments = useCallback( + ( + incoming?: Comment[], + incomingTitles?: DocumentTitles, + incomingMentionUsers?: MentionUser[], + fieldLabels?: GlobalFieldLabelRegistry, + incomingCollectionLabels?: CollectionLabels, + nextLoadError = false, + ) => { + if (incoming !== undefined) { + setAllComments(incoming); + } + + if (incomingTitles) { + setDocumentTitles(incomingTitles); + } + + if (incomingMentionUsers) { + setMentionUsers(incomingMentionUsers); + } + + if (fieldLabels) { + setFieldLabelRegistry(fieldLabels); + } + + if (incomingCollectionLabels) { + setCollectionLabels(incomingCollectionLabels); + } + + setLoadError(nextLoadError); + }, + [], + ); + + const addComment = async ( + text: string, + fieldPath?: string | null, + documentIdOverride?: number, + collectionSlugOverride?: string, + locale?: string | null, + ): Promise => { + const resolvedDocId = documentIdOverride ?? documentId; + const resolvedSlug = collectionSlugOverride ?? collectionSlug; + + if (!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: user as User })); + + const optimisticComment: Comment = { + id: tempId, + text, + fieldPath, + locale: locale ?? null, + documentId: resolvedDocId, + collectionSlug: resolvedSlug, + isResolved: false, + mentions, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + author: user as unknown as Comment["author"], + }; + + applyOptimistic({ type: "add", comment: optimisticComment }); + + const result = await createComment({ + documentId: resolvedDocId, + collectionSlug: resolvedSlug, + 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, + } = 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); + 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..482b6326 --- /dev/null +++ b/packages/payload-plugin-comments/src/providers/CommentsProviderWrapper/index.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import { CommentsDrawerProvider } from "../CommentsDrawerProvider"; +import { CommentsProvider } from "../CommentsProvider"; + +interface Props { + children: ReactNode; +} + +export function CommentsProviderWrapper({ children }: Props) { + 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..8f5d8920 --- /dev/null +++ b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect } from "react"; +import { useComments } from "../CommentsProvider"; +import type { CollectionLabels, Comment, DocumentTitles, GlobalFieldLabelRegistry, MentionUser } from "../../types"; + +interface Props { + comments: Comment[]; + documentTitles: DocumentTitles; + mentionUsers: MentionUser[]; + fieldLabels: GlobalFieldLabelRegistry; + collectionLabels: CollectionLabels; + loadError: boolean; +} + +export function GlobalCommentsHydrator({ + comments, + documentTitles, + mentionUsers, + fieldLabels, + collectionLabels, + loadError, +}: Props) { + const { hydrateComments } = useComments(); + + useEffect(() => { + hydrateComments(comments, documentTitles, mentionUsers, fieldLabels, collectionLabels, loadError); + }, [comments, documentTitles, mentionUsers, fieldLabels, collectionLabels, loadError, 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..bde13723 --- /dev/null +++ b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/index.tsx @@ -0,0 +1,52 @@ +import { findAllComments } from "../../services/findAllComments"; +import { getDocumentTitles } from "../../services/getDocumentTitles"; +import { fetchMentionableUsers } from "../../services/fetchMentionableUsers"; +import { fetchFieldLabels } from "../../services/fieldLabels/fetchFieldLabels"; +import { getCollectionLabels } from "../../services/getCollectionLabels"; +import { setPayloadConfig } from "../../config"; +import { GlobalCommentsHydrator } from "./GlobalCommentsHydrator"; +import type { Payload } from "payload"; +import type { ReactNode } from "react"; +import type { CommentsPluginConfigStorage } from "../../types"; + +interface Props { + children: ReactNode; + payload: Payload; + locale?: string; +} + +export async function GlobalCommentsLoader({ children, payload, locale }: Props) { + setPayloadConfig(payload.config); + + const pluginConfig = payload?.config.admin?.custom?.commentsPlugin as CommentsPluginConfigStorage | undefined; + + const commentsResult = await findAllComments({ + enabledCollections: pluginConfig?.collections, + options: { payload }, + }); + + const comments = commentsResult.success ? commentsResult.data : []; + + const [titlesResult, mentionUsersResult, fieldLabels] = await Promise.all([ + getDocumentTitles(comments, pluginConfig?.documentTitleFields ?? {}, { payload, locale }), + fetchMentionableUsers({ payload }), + fetchFieldLabels(comments, { payload }), + ]); + + const collectionLabels = getCollectionLabels(payload, pluginConfig?.collections ?? []); + + 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..74a97509 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/createComment.ts @@ -0,0 +1,77 @@ +"use server"; + +import { headers } from "next/headers"; +import { getDefaultErrorMessage } from "../utils/error/getDefaultErrorMessage"; +import type { Response, Comment, BaseServiceOptions } from "../types"; +import { DEFAULT_COLLECTION_SLUG } from "../constants"; +import { sendMentionEmails } from "./sendMentionEmails"; +import { extractPayload } from "../utils/payload/extractPayload"; + +interface Props extends BaseServiceOptions { + documentId: number; + collectionSlug: string; + text: string; + fieldPath?: string | null; + mentionIds?: number[]; +} + +export async function createComment({ + documentId, + collectionSlug, + 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", + }; + } + + const mentions = mentionIds.map((id) => ({ user: id })); + + const comment = (await payload.create({ + collection: DEFAULT_COLLECTION_SLUG, + data: { + documentId, + collectionSlug, + fieldPath, + locale, + text, + author: user.id, + isResolved: false, + mentions, + }, + overrideAccess: false, + user, + })) as Comment; + + if (mentionIds.length > 0) { + sendMentionEmails({ + mentionIds, + authorName: user.name ?? user.email ?? "Someone", + commentText: text, + collectionSlug, + 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..5a742ba1 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/fetchMentionableUsers.ts @@ -0,0 +1,62 @@ +"use server"; + +import type { Where } from "payload"; +import type { BaseServiceOptions, MentionUser, Response } from "../types"; +import { getDefaultErrorMessage } from "../utils/error/getDefaultErrorMessage"; +import { extractPayload } from "../utils/payload/extractPayload"; +import { getCurrentTenantId } from "./getCurrentTenantId"; + +export async function fetchMentionableUsers(options?: BaseServiceOptions): Promise> { + try { + const payload = await extractPayload(options?.payload); + + const tenantId = await getCurrentTenantId(payload); + + const where: Where = + tenantId ? + { + or: [ + { + tenant: { + equals: tenantId, + }, + }, + { + tenant: { + exists: false, + }, + }, + ], + } + : {}; + + const users = await payload.find({ + collection: "users", + overrideAccess: true, + limit: 200, + where: Object.keys(where).length ? where : undefined, + select: { + id: true, + name: true, + email: true, + }, + }); + + const data = users.docs.map(({ id, name, email }) => ({ + id, + name: name ?? email ?? "Unknown", + })) as MentionUser[]; + + 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..ec2723b6 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/fieldLabels/fetchFieldLabels.ts @@ -0,0 +1,72 @@ +"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 BaseDocumentData = Record; + +export async function fetchFieldLabels( + comments: Comment[], + options?: BaseServiceOptions, +): Promise { + const registry: GlobalFieldLabelRegistry = {}; + + const fieldPathsMap = groupFieldPathsByDocument(comments); + if (!fieldPathsMap.size) return registry; + + const payload = await extractPayload(options?.payload); + 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); + } + } + } + + 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..93c14fbb --- /dev/null +++ b/packages/payload-plugin-comments/src/services/fieldLabels/utils/groupFieldPathsByDocument.ts @@ -0,0 +1,27 @@ +import type { Comment, FieldPathsMap } from "../../../types"; + +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..affe3d3a --- /dev/null +++ b/packages/payload-plugin-comments/src/services/findAllComments.ts @@ -0,0 +1,55 @@ +"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[]; + user?: TypedUser | null; + options?: BaseServiceOptions; +} + +export async function findAllComments({ enabledCollections, options }: Props = {}): Promise> { + try { + const payload = await extractPayload(options?.payload); + + const tenantId = await getCurrentTenantId(payload); + + const where: Where = {}; + + if (enabledCollections?.length) { + where.collectionSlug = { in: enabledCollections }; + } + + 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/getCollectionLabels.ts b/packages/payload-plugin-comments/src/services/getCollectionLabels.ts new file mode 100644 index 00000000..fb7ebc43 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/getCollectionLabels.ts @@ -0,0 +1,17 @@ +import type { Payload } from "payload"; +import type { CollectionLabels } from "../types"; + +export function getCollectionLabels(payload: Payload, enabledSlugs: string[]) { + const slugSet = new Set(enabledSlugs); + const result: CollectionLabels = {}; + + for (const collection of payload.config.collections) { + if (!slugSet.has(collection.slug)) continue; + + const rawLabel = collection.labels?.plural; + + result[collection.slug] = typeof rawLabel === "function" || rawLabel === undefined ? collection.slug : rawLabel; + } + + return result; +} 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..ac1c5906 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/getDocumentTitles.ts @@ -0,0 +1,77 @@ +"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 (!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/index.ts b/packages/payload-plugin-comments/src/services/index.ts new file mode 100644 index 00000000..fd4c5f00 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/index.ts @@ -0,0 +1,23 @@ +import { syncAllCommentsData } from "./syncAllCommentsData"; +import { sendMentionEmails } from "./sendMentionEmails"; +import { resolveComment } from "./resolveComment"; +import { getDocumentTitles } from "./getDocumentTitles"; +import { getCurrentTenantId } from "./getCurrentTenantId"; +import { findAllComments } from "./findAllComments"; +import { fetchMentionableUsers } from "./fetchMentionableUsers"; +import { deleteComment } from "./deleteComment"; +import { createComment } from "./createComment"; +import { fetchFieldLabels } from "./fieldLabels/fetchFieldLabels"; + +export { + syncAllCommentsData, + sendMentionEmails, + resolveComment, + getDocumentTitles, + getCurrentTenantId, + findAllComments, + fetchMentionableUsers, + deleteComment, + createComment, + fetchFieldLabels, +}; 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..bcf90171 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/sendMentionEmails.ts @@ -0,0 +1,80 @@ +import { Resend } from "resend"; +import { getServerSideURL } from "../utils/general/getURL"; +import type { BaseServiceOptions, User } from "../types"; +import { extractPayload } from "../utils/payload/extractPayload"; + +interface SendMentionEmailsProps extends BaseServiceOptions { + mentionIds: number[]; + authorName: string; + commentText: string; + collectionSlug: string; + documentId: number; +} + +export async function sendMentionEmails({ + mentionIds, + authorName, + commentText, + collectionSlug, + documentId, + payload: payloadProp, +}: SendMentionEmailsProps) { + const payload = await extractPayload(payloadProp); + + const userDocs = await payload.find({ + collection: "users", + overrideAccess: true, + limit: mentionIds.length, + where: { + id: { in: mentionIds }, + }, + select: { + id: true, + name: true, + email: true, + }, + }); + + const users = userDocs.docs as User[]; + + const userMap: Record = {}; + for (const { id, name, email } of users) { + userMap[Number(id)] = name ?? email ?? "Unknown"; + } + + const resolvedText = commentText.replace(/@\((\d+)\)/g, (_match, id) => { + return `@${userMap[Number(id)] ?? "Unknown"}`; + }); + + const resolvedTextHtml = commentText.replace(/@\((\d+)\)/g, (_match, id) => { + return `@${userMap[Number(id)] ?? "Unknown"}`; + }); + + 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..acd23cd9 --- /dev/null +++ b/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts @@ -0,0 +1,70 @@ +"use server"; + +import { findAllComments } from "./findAllComments"; +import { getDocumentTitles } from "./getDocumentTitles"; +import { fetchMentionableUsers } from "./fetchMentionableUsers"; +import { fetchFieldLabels } from "./fieldLabels/fetchFieldLabels"; +import { getCollectionLabels } from "./getCollectionLabels"; +import type { + BaseServiceOptions, + CollectionLabels, + Comment, + CommentsPluginConfigStorage, + DocumentTitles, + GlobalFieldLabelRegistry, + MentionUser, + Response, +} from "../types"; +import { extractPayload } from "../utils/payload/extractPayload"; + +interface SyncResult { + comments: Comment[]; + documentTitles: DocumentTitles; + mentionUsers: MentionUser[]; + fieldLabels: GlobalFieldLabelRegistry; + collectionLabels: CollectionLabels; +} + +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, + 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 = getCollectionLabels(payload, pluginConfig?.collections ?? []); + + return { + success: true, + data: { + comments, + documentTitles: titlesResult.data, + mentionUsers: mentionUsersResult.data, + fieldLabels, + collectionLabels, + }, + }; +} diff --git a/packages/payload-plugin-comments/src/styles.css b/packages/payload-plugin-comments/src/styles.css new file mode 100644 index 00000000..e263db53 --- /dev/null +++ b/packages/payload-plugin-comments/src/styles.css @@ -0,0 +1,12 @@ +@import "tailwindcss/theme"; +@import "tailwindcss/utilities"; + +@source "./**/*.{ts,tsx}"; + +.comments-drawer .gutter { + padding: 0 20px !important; +} + +.comments-drawer .drawer__content { + --gutter-h: 20px; +} 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..293815e1 --- /dev/null +++ b/packages/payload-plugin-comments/src/translations/en.ts @@ -0,0 +1,32 @@ +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", + noOpenComments: "No open comments", + noResolvedComments: "No resolved comments", + noMentionedComments: "No comments mentioning you", + filterOpen: "Open", + filterResolved: "Resolved", + filterMentioned: "Mentioned me", + }, +}; 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..f50c3730 --- /dev/null +++ b/packages/payload-plugin-comments/src/translations/types.ts @@ -0,0 +1,30 @@ +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; + noOpenComments: string; + noResolvedComments: string; + noMentionedComments: string; + filterOpen: string; + filterResolved: string; + filterMentioned: string; +} + +export type Translations = Record>; diff --git a/packages/payload-plugin-comments/src/types.ts b/packages/payload-plugin-comments/src/types.ts new file mode 100644 index 00000000..6c04884f --- /dev/null +++ b/packages/payload-plugin-comments/src/types.ts @@ -0,0 +1,177 @@ +import type { CollectionConfig, Field, Payload } from "payload"; +import type { Translations } from "./translations/types"; + +export interface BaseServiceOptions { + payload?: Payload; + locale?: string | null; +} + +export type FieldLabelSegment = { type: "field"; label: string } | { type: "row"; id: string; position: number }; + +export type GlobalFieldLabelRegistry = Record>>; + +/** + * 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`. + * + * Pass this object to the plugin factory to customise which collections + * support comments, enable multi-tenancy, and override the generated + * `comments` collection. + * + * @example + * commentsPlugin({ + * collections: [ + * { + * slug: "pages", + * titleField: "title" + * } + * ], + * tenant: { + * enabled: true, + * collectionSlug: "tenants", + * documentTenantField: "tenant" + * }, + * }) + */ +export interface CommentsPluginConfig { + /** + * List of collection slugs (or detailed config objects) whose documents + * should support comments. Each entry creates a comments sidebar for + * documents inside that collection. + * + * By default, all collections have comments. + */ + collections?: CollectionEntry[]; + /** Set to `false` to disable the plugin entirely. + * @default false + */ + enabled?: boolean; + /** 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; +} + +export type NormalizedCollectionConfig = Map; + +export type CommentsPluginConfigOverrides = CommentsPluginConfig["overrides"]; + +export interface MentionUser { + id: number; + name: string; +} + +export interface CommentsPluginConfigStorage { + collections?: string[]; + documentTitleFields?: Record; + tenant?: TenantPluginConfig; +} + +export type DocumentTitles = Record>; + +export type CollectionLabels = Record>; + +export type FieldPathsMap = Map>>; + +export type Mode = "document" | "global" | "create"; + +export type FilterMode = "open" | "resolved" | "mentioned"; + +export interface BaseDocument { + id: string | number; + [key: string]: unknown; +} + +export type LoadingStatus = "idle" | "loading" | "error" | "success"; + +export interface User { + id: number; + name?: string | null; + email?: string | null; +} + +export interface CommentMention { + id: string | number | null; + user: number | User; +} + +export interface Comment { + id: number; + documentId: number; + collectionSlug: string; + 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; +} + +export type Response = { success: true; data: T } | { success: false; error: string }; 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..afac7a5f --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts @@ -0,0 +1,23 @@ +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; + currentLocale?: string | null; +} + +export function extractVisibleComments({ comments, mode, collectionSlug, documentId, currentLocale }: Props) { + if (mode === "create") return []; + + const localeFilteredComments = filterCommentsByLocale(comments, currentLocale); + + return mode === "document" && collectionSlug && documentId ? + localeFilteredComments.filter( + ({ collectionSlug: slug, documentId: id }) => slug === collectionSlug && id === documentId, + ) + : 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..4e1a7946 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; +import type { Comment, User } from "../../types"; +import { MentionLabel } from "../../components/MentionLabel"; +import { isSelfMention } from "../mention/isSelfMention"; + +const MENTION_SPLIT = /(@\(\d+\))/; +const MENTION_EXTRACT = /^@\((\d+)\)$/; + +export function renderCommentText( + text: string, + mentions: Comment["mentions"], + currentUserId?: number | null, +): 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 name = user ? user.name : "deleted user"; + const isSelf = isSelfMention(currentUserId, userId); + + 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..66dea96c --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/config/injectFieldCommentComponents.ts @@ -0,0 +1,115 @@ +import type { CollectionAfterDeleteHook, CollectionConfig, Field } from "payload"; +import { getComponentConfig } from "../path/getComponentPath"; +import { DEFAULT_COLLECTION_SLUG } from "../../constants"; + +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(collection: CollectionConfig): CollectionConfig { + if (collection.slug === DEFAULT_COLLECTION_SLUG) { + return collection; + } + + return { + ...collection, + fields: injectIntoFields(collection.fields, ""), + hooks: { + ...collection.hooks, + afterDelete: [ + ...(collection.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/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..3b0998c5 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/config/normalizeCollections.ts @@ -0,0 +1,17 @@ +import type { CollectionEntry, NormalizedCollectionConfig } from "../../types"; + +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/overrideCollection.ts b/packages/payload-plugin-comments/src/utils/config/overrideCollection.ts new file mode 100644 index 00000000..9ec81b3d --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/config/overrideCollection.ts @@ -0,0 +1,17 @@ +import type { CollectionConfig } from "payload"; +import type { CommentsPluginConfigOverrides } from "../../types"; + +export function overrideCollection( + config: CollectionConfig, + overrides: CommentsPluginConfigOverrides, +): 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/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/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/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..35987249 --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts @@ -0,0 +1,35 @@ +import type { CollectionSlug } from "payload"; +import type { Mode } from "../../types"; + +interface ModeData { + mode: Mode; + collectionSlug: CollectionSlug; + documentId: number | null; +} + +export function defineModeByPathname(pathname: string): ModeData { + const createModeMatch = pathname?.match(/\/admin\/collections\/([^/]+)\/create$/); + + if (createModeMatch) { + const collectionSlug = createModeMatch[1] as CollectionSlug; + + return { + mode: "create", + collectionSlug, + documentId: null, + }; + } + + const documentModeMatch = pathname?.match(/\/admin\/collections\/([^/]+)\/(\d+)/); + + const mode = documentModeMatch ? "document" : "global"; + + const collectionSlug = (documentModeMatch?.[1] as CollectionSlug) ?? null; + const documentId = Number(documentModeMatch?.[2]) || null; + + return { + mode, + collectionSlug, + documentId, + }; +} 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/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 From b57a1c72c2f64450e6fe0d286b95ae19d3a997dc Mon Sep 17 00:00:00 2001 From: Pekarski Date: Fri, 13 Mar 2026 00:49:55 +0300 Subject: [PATCH 02/23] feat(payload-plugin-comments): cerate resolveUserName utility --- .../payload-plugin-comments/src/constants.ts | 2 ++ packages/payload-plugin-comments/src/types.ts | 11 +++++++- .../src/utils/general/getValueByPath.ts | 26 +++++++++++++++++++ .../src/utils/user/resolveUsername.ts | 19 ++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 packages/payload-plugin-comments/src/utils/general/getValueByPath.ts create mode 100644 packages/payload-plugin-comments/src/utils/user/resolveUsername.ts diff --git a/packages/payload-plugin-comments/src/constants.ts b/packages/payload-plugin-comments/src/constants.ts index 2fcf8bab..e5c30809 100644 --- a/packages/payload-plugin-comments/src/constants.ts +++ b/packages/payload-plugin-comments/src/constants.ts @@ -3,3 +3,5 @@ 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"; diff --git a/packages/payload-plugin-comments/src/types.ts b/packages/payload-plugin-comments/src/types.ts index 6c04884f..59d6f9ef 100644 --- a/packages/payload-plugin-comments/src/types.ts +++ b/packages/payload-plugin-comments/src/types.ts @@ -112,6 +112,14 @@ export interface CommentsPluginConfig { * } */ 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 type NormalizedCollectionConfig = Map; @@ -127,6 +135,7 @@ export interface CommentsPluginConfigStorage { collections?: string[]; documentTitleFields?: Record; tenant?: TenantPluginConfig; + usernameFieldPath?: string; } export type DocumentTitles = Record>; @@ -148,8 +157,8 @@ export type LoadingStatus = "idle" | "loading" | "error" | "success"; export interface User { id: number; - name?: string | null; email?: string | null; + [key: string]: unknown; } export interface CommentMention { 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/user/resolveUsername.ts b/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts new file mode 100644 index 00000000..55c9acfe --- /dev/null +++ b/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts @@ -0,0 +1,19 @@ +import { USERNAME_DEFAULT_FIELD_PATH } from "../../constants"; +import type { User } from "../../types"; +import { getValueByPath } from "../general/getValueByPath"; + +export function resolveUsername( + user: User | null | undefined, + usernameFieldPath: string = USERNAME_DEFAULT_FIELD_PATH, + unknownLabel: string, +) { + if (!user) return unknownLabel; + + const customValue = getValueByPath(user, usernameFieldPath); + if (customValue != null) return customValue; + + const { email } = user; + if (email) return email; + + return unknownLabel; +} From 0ddd42b30239f0f9e1e1f51619c5d4d7340f5a76 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Fri, 13 Mar 2026 01:24:12 +0300 Subject: [PATCH 03/23] feat(/payload-plugin-comments): fetch mentionable users considering usernameFieldPath --- .../payload-plugin-comments/src/constants.ts | 1 + .../payload-plugin-comments/src/plugin.ts | 3 +- .../src/providers/CommentsProvider/index.tsx | 20 ++++++----- .../CommentsProviderWrapper/index.tsx | 12 ++++++- .../src/services/fetchMentionableUsers.ts | 34 ++++++++----------- .../src/services/syncAllCommentsData.ts | 4 +-- 6 files changed, 41 insertions(+), 33 deletions(-) diff --git a/packages/payload-plugin-comments/src/constants.ts b/packages/payload-plugin-comments/src/constants.ts index e5c30809..ad9d0240 100644 --- a/packages/payload-plugin-comments/src/constants.ts +++ b/packages/payload-plugin-comments/src/constants.ts @@ -5,3 +5,4 @@ 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"; diff --git a/packages/payload-plugin-comments/src/plugin.ts b/packages/payload-plugin-comments/src/plugin.ts index 7480dd2f..b8b62b8d 100644 --- a/packages/payload-plugin-comments/src/plugin.ts +++ b/packages/payload-plugin-comments/src/plugin.ts @@ -10,7 +10,7 @@ import { mergeTranslations } from "./utils/config/mergeTranslations"; export const commentsPlugin = (config: CommentsPluginConfig = {}): Plugin => (incomingConfig: Config): Config => { - const { enabled = true, collections: collectionEntries, overrides } = config; + const { enabled = true, collections: collectionEntries, overrides, usernameFieldPath } = config; if (!enabled) { return incomingConfig; @@ -62,6 +62,7 @@ export const commentsPlugin = collections: allCollectionSlugs, documentTitleFields, tenant: config.tenant, + usernameFieldPath, }, }, }, diff --git a/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx b/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx index 8d4898a3..f6977c71 100644 --- a/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx +++ b/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx @@ -23,7 +23,6 @@ import type { GlobalFieldLabelRegistry, LoadingStatus, DocumentTitles, - MentionUser, Mode, } from "../../types"; import { parseMentionIds } from "../../utils/mention/parseMentionIds"; @@ -59,20 +58,21 @@ interface CommentsContextProps { mode: Mode; collectionSlug: string | null | undefined; documentId: number | null | undefined; - mentionUsers: MentionUser[]; + mentionUsers: User[]; loadError: boolean; filter: FilterMode; fieldLabelRegistry: GlobalFieldLabelRegistry; + syncCommentsStatus: LoadingStatus; + usernameFieldPath: string | undefined; setFilter: Dispatch>; hydrateComments: ( incoming?: Comment[], incomingTitles?: DocumentTitles, - incomingMentionUsers?: MentionUser[], + incomingMentionUsers?: User[], fieldLabels?: GlobalFieldLabelRegistry, incomingCollectionLabels?: CollectionLabels, nextLoadError?: boolean, ) => void; - syncCommentsStatus: LoadingStatus; syncComments: () => Promise; addComment: ( text: string, @@ -90,9 +90,10 @@ const CommentsContext = createContext(null); interface Props { children: ReactNode; + usernameFieldPath?: string; } -export function CommentsProvider({ children }: Props) { +export function CommentsProvider({ children, usernameFieldPath }: Props) { const { user } = useAuth(); const pathname = usePathname(); const { code: currentLocale } = useLocale(); @@ -105,7 +106,7 @@ export function CommentsProvider({ children }: Props) { const [documentTitles, setDocumentTitles] = useState({}); const [collectionLabels, setCollectionLabels] = useState({}); - const [mentionUsers, setMentionUsers] = useState([]); + const [mentionUsers, setMentionUsers] = useState([]); const [fieldLabelRegistry, setFieldLabelRegistry] = useState({}); const [filter, setFilter] = useState("open"); const [loadError, setLoadError] = useState(false); @@ -125,7 +126,7 @@ export function CommentsProvider({ children }: Props) { ( incoming?: Comment[], incomingTitles?: DocumentTitles, - incomingMentionUsers?: MentionUser[], + incomingMentionUsers?: User[], fieldLabels?: GlobalFieldLabelRegistry, incomingCollectionLabels?: CollectionLabels, nextLoadError = false, @@ -176,7 +177,7 @@ export function CommentsProvider({ children }: Props) { const mentionIds = parseMentionIds(text); const selectedMentionUsers = mentionUsers.filter(({ id }) => mentionIds.includes(id)); - const mentions = selectedMentionUsers.map((user) => ({ id: null, user: user as User })); + const mentions = selectedMentionUsers.map((user) => ({ id: null, user })); const optimisticComment: Comment = { id: tempId, @@ -189,7 +190,7 @@ export function CommentsProvider({ children }: Props) { mentions, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), - author: user as unknown as Comment["author"], + author: user as Comment["author"], }; applyOptimistic({ type: "add", comment: optimisticComment }); @@ -324,6 +325,7 @@ export function CommentsProvider({ children }: Props) { removeComment, resolveComment, updateDocumentTitle, + usernameFieldPath, }}> {children} diff --git a/packages/payload-plugin-comments/src/providers/CommentsProviderWrapper/index.tsx b/packages/payload-plugin-comments/src/providers/CommentsProviderWrapper/index.tsx index 482b6326..f4215b80 100644 --- a/packages/payload-plugin-comments/src/providers/CommentsProviderWrapper/index.tsx +++ b/packages/payload-plugin-comments/src/providers/CommentsProviderWrapper/index.tsx @@ -1,14 +1,24 @@ +"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/services/fetchMentionableUsers.ts b/packages/payload-plugin-comments/src/services/fetchMentionableUsers.ts index 5a742ba1..3130f008 100644 --- a/packages/payload-plugin-comments/src/services/fetchMentionableUsers.ts +++ b/packages/payload-plugin-comments/src/services/fetchMentionableUsers.ts @@ -1,51 +1,45 @@ "use server"; import type { Where } from "payload"; -import type { BaseServiceOptions, MentionUser, Response } from "../types"; +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> { +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, - }, - }, - ], + or: [{ tenant: { equals: tenantId } }, { tenant: { exists: false } }], } : {}; - const users = await payload.find({ + const usersRes = await payload.find({ collection: "users", overrideAccess: true, limit: 200, where: Object.keys(where).length ? where : undefined, select: { id: true, - name: true, email: true, + [usernameFieldPath]: true, }, }); - const data = users.docs.map(({ id, name, email }) => ({ - id, - name: name ?? email ?? "Unknown", - })) as MentionUser[]; + const data = usersRes.docs.map((user) => ({ + id: user.id, + [usernameFieldPath]: resolveUsername(user as User, usernameFieldPath, FALLBACK_USERNAME), + })) as User[]; return { success: true, diff --git a/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts b/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts index acd23cd9..f2b245eb 100644 --- a/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts +++ b/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts @@ -12,15 +12,15 @@ import type { CommentsPluginConfigStorage, DocumentTitles, GlobalFieldLabelRegistry, - MentionUser, Response, + User, } from "../types"; import { extractPayload } from "../utils/payload/extractPayload"; interface SyncResult { comments: Comment[]; documentTitles: DocumentTitles; - mentionUsers: MentionUser[]; + mentionUsers: User[]; fieldLabels: GlobalFieldLabelRegistry; collectionLabels: CollectionLabels; } From f34c059c80ca8d955f82fa91a32f53bfae76ab53 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Fri, 13 Mar 2026 01:55:29 +0300 Subject: [PATCH 04/23] feat(payload-plugin-comments): update CommentItem according to usernameFieldPath --- .../src/components/CommentItem.tsx | 26 +++++-------------- .../payload-plugin-comments/src/constants.ts | 1 + .../src/utils/comment/renderCommentText.tsx | 7 +++-- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/payload-plugin-comments/src/components/CommentItem.tsx b/packages/payload-plugin-comments/src/components/CommentItem.tsx index faacc300..c266e0f8 100644 --- a/packages/payload-plugin-comments/src/components/CommentItem.tsx +++ b/packages/payload-plugin-comments/src/components/CommentItem.tsx @@ -6,22 +6,7 @@ import { cn } from "../utils/general/cn"; import type { Comment } from "../types"; import { useComments } from "../providers/CommentsProvider"; import { renderCommentText } from "../utils/comment/renderCommentText"; - -function getAuthorName(author: Comment["author"], unknownLabel: string) { - const defaultValue = unknownLabel; - - if (!author) return defaultValue; - - if (typeof author === "object" && "name" in author) { - return author.name || author.email || defaultValue; - } - - return defaultValue; -} - -function getAuthorInitial(author: Comment["author"], unknownLabel: string) { - return getAuthorName(author, unknownLabel).charAt(0).toUpperCase(); -} +import { resolveUsername } from "../utils/user/resolveUsername"; function formatDate(iso: string) { try { @@ -43,14 +28,15 @@ interface Props { } export function CommentItem({ comment, currentUserId }: Props) { - const { resolveComment, removeComment } = useComments(); + const { resolveComment, removeComment, usernameFieldPath } = useComments(); const { t } = useTranslation(); const [isPending, startTransition] = useTransition(); const [error, setError] = useState(null); const unknownLabel = t("comments:unknownAuthor" as never); - const authorName = getAuthorName(comment.author, unknownLabel); - const authorInitial = getAuthorInitial(comment.author, unknownLabel); + const narrowedAuthor = typeof comment.author === "object" ? comment.author : null; + const authorName = resolveUsername(narrowedAuthor, usernameFieldPath, unknownLabel); + const authorInitial = authorName.charAt(0).toUpperCase(); const isResolved = comment.isResolved ?? false; const authorId = @@ -93,7 +79,7 @@ export function CommentItem({ comment, currentUserId }: Props) {

- {renderCommentText(comment.text, comment.mentions, currentUserId)} + {renderCommentText(comment.text, comment.mentions, currentUserId, usernameFieldPath)}

diff --git a/packages/payload-plugin-comments/src/constants.ts b/packages/payload-plugin-comments/src/constants.ts index ad9d0240..70f6bb2c 100644 --- a/packages/payload-plugin-comments/src/constants.ts +++ b/packages/payload-plugin-comments/src/constants.ts @@ -6,3 +6,4 @@ 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"; diff --git a/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx b/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx index 4e1a7946..ac181011 100644 --- a/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx +++ b/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx @@ -2,6 +2,8 @@ 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+)\)$/; @@ -10,6 +12,7 @@ export function renderCommentText( text: string, mentions: Comment["mentions"], currentUserId?: number | null, + usernameFieldPath?: string, ): ReactNode { const userMap: Record = {}; @@ -31,10 +34,10 @@ export function renderCommentText( if (match) { const userId = Number(match[1]); const user = userMap[userId]; - const name = user ? user.name : "deleted user"; const isSelf = isSelfMention(currentUserId, userId); + const name = resolveUsername(user, usernameFieldPath, FALLBACK_DELETED_USERNAME); - return ; + return ; } return {part}; From e4d1ba237b975743f2daeb98942f2a66c073aa1d Mon Sep 17 00:00:00 2001 From: Pekarski Date: Fri, 13 Mar 2026 02:26:20 +0300 Subject: [PATCH 05/23] fix(payload-plugin-comments): support username detection by usernameFieldPath everywhere --- .../src/components/CommentEditor/index.tsx | 30 ++++++++++++++----- .../src/components/CommentItem.tsx | 12 ++++++-- .../src/components/MentionDropdown.tsx | 18 +++++++---- .../payload-plugin-comments/src/constants.ts | 4 +-- .../src/providers/CommentsProvider/index.tsx | 4 +-- .../GlobalCommentsHydrator.tsx | 4 +-- .../src/translations/en.ts | 1 + .../src/translations/types.ts | 1 + packages/payload-plugin-comments/src/types.ts | 5 ---- .../src/utils/comment/renderCommentText.tsx | 23 +++++++++----- .../src/utils/user/resolveUsername.ts | 6 ++-- 11 files changed, 73 insertions(+), 35 deletions(-) diff --git a/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx b/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx index ff1598dd..5c83504e 100644 --- a/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx +++ b/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx @@ -1,14 +1,16 @@ "use client"; import { useState, useRef, useImperativeHandle, type RefObject } from "react"; -import { useAuth } from "@payloadcms/ui"; +import { useAuth, 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 { MentionUser } from "../../types"; +import type { User } from "../../types"; +import { FALLBACK_USERNAME } from "../../constants"; +import { resolveUsername } from "../../utils/user/resolveUsername"; export interface CommentEditorHandle { getValue: () => string; @@ -39,9 +41,14 @@ export function CommentEditor({ const [mentionQuery, setMentionQuery] = useState(null); const [triggerRange, setTriggerRange] = useState(null); - const [filteredUsers, setFilteredUsers] = useState([]); + const [filteredUsers, setFilteredUsers] = useState([]); const [selectedIndex, setSelectedIndex] = useState(0); + const { usernameFieldPath } = useComments(); + const { t } = useTranslation(); + + const unknownLabel = t("comments:unknownAuthor" as never) ?? FALLBACK_USERNAME; + const editorRef = useRef(null); const currentUserId = user?.id; @@ -80,7 +87,11 @@ export function CommentEditor({ atRange.setEnd(node, offset); setTriggerRange(atRange.cloneRange()); setMentionQuery(query); - setFilteredUsers(users.filter((u) => u.name.toLowerCase().includes(query.toLowerCase()))); + setFilteredUsers( + users.filter((u) => + resolveUsername(u, usernameFieldPath, unknownLabel).toLowerCase().includes(query.toLowerCase()), + ), + ); setSelectedIndex(0); return; @@ -92,7 +103,7 @@ export function CommentEditor({ setMentionQuery(null); }; - const insertMention = (user: MentionUser) => { + const insertMention = (user: User) => { if (!triggerRange || !editorRef.current) return; const sel = window.getSelection(); @@ -102,12 +113,17 @@ export function CommentEditor({ sel.addRange(triggerRange); sel.deleteFromDocument(); - const { id: userId, name } = user; + const { id: userId } = user; const mentionLabelContainer = document.createElement("span"); const mentionLabelContainerRoot = createRoot(mentionLabelContainer); - mentionLabelContainerRoot.render(); + mentionLabelContainerRoot.render( + , + ); mentionLabelContainer.contentEditable = "false"; mentionLabelContainer.dataset.mentionId = String(userId); diff --git a/packages/payload-plugin-comments/src/components/CommentItem.tsx b/packages/payload-plugin-comments/src/components/CommentItem.tsx index c266e0f8..4a668d42 100644 --- a/packages/payload-plugin-comments/src/components/CommentItem.tsx +++ b/packages/payload-plugin-comments/src/components/CommentItem.tsx @@ -7,6 +7,7 @@ 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"; function formatDate(iso: string) { try { @@ -33,7 +34,8 @@ export function CommentItem({ comment, currentUserId }: Props) { const [isPending, startTransition] = useTransition(); const [error, setError] = useState(null); - const unknownLabel = t("comments:unknownAuthor" as never); + 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 authorInitial = authorName.charAt(0).toUpperCase(); @@ -79,7 +81,13 @@ export function CommentItem({ comment, currentUserId }: Props) {

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

diff --git a/packages/payload-plugin-comments/src/components/MentionDropdown.tsx b/packages/payload-plugin-comments/src/components/MentionDropdown.tsx index f74d9c12..37689835 100644 --- a/packages/payload-plugin-comments/src/components/MentionDropdown.tsx +++ b/packages/payload-plugin-comments/src/components/MentionDropdown.tsx @@ -1,16 +1,24 @@ "use client"; +import { useTranslation } from "@payloadcms/ui"; +import { FALLBACK_USERNAME } from "../constants"; +import { useComments } from "../providers/CommentsProvider"; +import type { User } from "../types"; import { cn } from "../utils/general/cn"; - -type MentionUser = { id: number; name: string }; +import { resolveUsername } from "../utils/user/resolveUsername"; interface MentionDropdownProps { - users: MentionUser[]; + users: User[]; selectedIndex: number; - onSelect: (user: MentionUser) => void; + 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 (
    - @{user.name} + @{resolveUsername(user, usernameFieldPath, unknownLabel)} ))}
diff --git a/packages/payload-plugin-comments/src/constants.ts b/packages/payload-plugin-comments/src/constants.ts index 70f6bb2c..b6e5a41d 100644 --- a/packages/payload-plugin-comments/src/constants.ts +++ b/packages/payload-plugin-comments/src/constants.ts @@ -5,5 +5,5 @@ 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 FALLBACK_USERNAME = "Unknown user"; +export const FALLBACK_DELETED_USERNAME = "Deleted user"; diff --git a/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx b/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx index f6977c71..33e86d02 100644 --- a/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx +++ b/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx @@ -317,15 +317,15 @@ export function CommentsProvider({ children, usernameFieldPath }: Props) { loadError, filter, fieldLabelRegistry, + syncCommentsStatus, + usernameFieldPath, setFilter, hydrateComments, - syncCommentsStatus, syncComments, addComment, removeComment, resolveComment, updateDocumentTitle, - usernameFieldPath, }}> {children} diff --git a/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx index 8f5d8920..c074baf3 100644 --- a/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx +++ b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx @@ -2,12 +2,12 @@ import { useEffect } from "react"; import { useComments } from "../CommentsProvider"; -import type { CollectionLabels, Comment, DocumentTitles, GlobalFieldLabelRegistry, MentionUser } from "../../types"; +import type { CollectionLabels, Comment, DocumentTitles, GlobalFieldLabelRegistry, User } from "../../types"; interface Props { comments: Comment[]; documentTitles: DocumentTitles; - mentionUsers: MentionUser[]; + mentionUsers: User[]; fieldLabels: GlobalFieldLabelRegistry; collectionLabels: CollectionLabels; loadError: boolean; diff --git a/packages/payload-plugin-comments/src/translations/en.ts b/packages/payload-plugin-comments/src/translations/en.ts index 293815e1..279c48c2 100644 --- a/packages/payload-plugin-comments/src/translations/en.ts +++ b/packages/payload-plugin-comments/src/translations/en.ts @@ -22,6 +22,7 @@ export const en: { comments: CommentsTranslations } = { 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", diff --git a/packages/payload-plugin-comments/src/translations/types.ts b/packages/payload-plugin-comments/src/translations/types.ts index f50c3730..9a2d76eb 100644 --- a/packages/payload-plugin-comments/src/translations/types.ts +++ b/packages/payload-plugin-comments/src/translations/types.ts @@ -19,6 +19,7 @@ export interface CommentsTranslations { failedToDelete: string; failedToAdd: string; unknownAuthor: string; + deletedUser: string; noOpenComments: string; noResolvedComments: string; noMentionedComments: string; diff --git a/packages/payload-plugin-comments/src/types.ts b/packages/payload-plugin-comments/src/types.ts index 59d6f9ef..6caf7fdb 100644 --- a/packages/payload-plugin-comments/src/types.ts +++ b/packages/payload-plugin-comments/src/types.ts @@ -126,11 +126,6 @@ export type NormalizedCollectionConfig = Map; export type CommentsPluginConfigOverrides = CommentsPluginConfig["overrides"]; -export interface MentionUser { - id: number; - name: string; -} - export interface CommentsPluginConfigStorage { collections?: string[]; documentTitleFields?: Record; diff --git a/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx b/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx index ac181011..9388ecba 100644 --- a/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx +++ b/packages/payload-plugin-comments/src/utils/comment/renderCommentText.tsx @@ -8,12 +8,21 @@ import { FALLBACK_DELETED_USERNAME } from "../../constants"; const MENTION_SPLIT = /(@\(\d+\))/; const MENTION_EXTRACT = /^@\((\d+)\)$/; -export function renderCommentText( - text: string, - mentions: Comment["mentions"], - currentUserId?: number | null, - usernameFieldPath?: string, -): ReactNode { +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)) { @@ -35,7 +44,7 @@ export function renderCommentText( const userId = Number(match[1]); const user = userMap[userId]; const isSelf = isSelfMention(currentUserId, userId); - const name = resolveUsername(user, usernameFieldPath, FALLBACK_DELETED_USERNAME); + const name = resolveUsername(user, usernameFieldPath, fallbackDeletedUsername ?? FALLBACK_DELETED_USERNAME); return ; } diff --git a/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts b/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts index 55c9acfe..b1ef0df9 100644 --- a/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts +++ b/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts @@ -5,9 +5,9 @@ import { getValueByPath } from "../general/getValueByPath"; export function resolveUsername( user: User | null | undefined, usernameFieldPath: string = USERNAME_DEFAULT_FIELD_PATH, - unknownLabel: string, + fallbackLabel: string, ) { - if (!user) return unknownLabel; + if (!user) return fallbackLabel; const customValue = getValueByPath(user, usernameFieldPath); if (customValue != null) return customValue; @@ -15,5 +15,5 @@ export function resolveUsername( const { email } = user; if (email) return email; - return unknownLabel; + return fallbackLabel; } From 9df90bb22d96de8672d7f79a296091d5b6e628c8 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Fri, 13 Mar 2026 02:45:04 +0300 Subject: [PATCH 06/23] fix(payload-plugin-comments): add usernameFieldPath to sendMentionEmails --- .../src/services/sendMentionEmails.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/payload-plugin-comments/src/services/sendMentionEmails.ts b/packages/payload-plugin-comments/src/services/sendMentionEmails.ts index bcf90171..5c0fd8ee 100644 --- a/packages/payload-plugin-comments/src/services/sendMentionEmails.ts +++ b/packages/payload-plugin-comments/src/services/sendMentionEmails.ts @@ -1,7 +1,8 @@ import { Resend } from "resend"; import { getServerSideURL } from "../utils/general/getURL"; -import type { BaseServiceOptions, User } from "../types"; +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[]; @@ -21,6 +22,9 @@ export async function sendMentionEmails({ }: 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, @@ -30,24 +34,26 @@ export async function sendMentionEmails({ }, select: { id: true, - name: true, email: true, + [usernameFieldPath]: true, }, }); const users = userDocs.docs as User[]; const userMap: Record = {}; - for (const { id, name, email } of users) { - userMap[Number(id)] = name ?? email ?? "Unknown"; + 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)] ?? "Unknown"}`; + return `@${userMap[Number(id)]}`; }); const resolvedTextHtml = commentText.replace(/@\((\d+)\)/g, (_match, id) => { - return `@${userMap[Number(id)] ?? "Unknown"}`; + return `@${userMap[Number(id)]}`; }); const adminUrl = `${getServerSideURL()}/admin/collections/${collectionSlug}/${documentId}`; From d948f675291874e44727d1387c1edab367090818 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Fri, 13 Mar 2026 14:23:10 +0300 Subject: [PATCH 07/23] fix(payload-plugin-comments): hide field comment buttons on create and excluded admin routes --- .../components/FieldCommentLabel/index.tsx | 4 +-- .../payload-plugin-comments/src/constants.ts | 5 +++ packages/payload-plugin-comments/src/types.ts | 2 +- .../utils/comment/extractVisibleComments.ts | 2 -- .../src/utils/mode/defineModeByPathname.ts | 36 ++++++++----------- 5 files changed, 23 insertions(+), 26 deletions(-) diff --git a/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx b/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx index 72633d23..a6942338 100644 --- a/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx @@ -28,7 +28,7 @@ export function FieldCommentLabel({ field, htmlFor, path: fieldPath }: Props) { const { t } = useTranslation(); const { code: locale } = useLocale(); const { open: openDrawer, setScrollTargetPath } = useCommentsDrawer(); - const { visibleComments, addComment, setFilter } = useComments(); + const { visibleComments, addComment, setFilter, mode } = useComments(); const [isHovered, setIsHovered] = useState(false); const [showPopover, setShowPopover] = useState(false); @@ -103,7 +103,7 @@ export function FieldCommentLabel({ field, htmlFor, path: fieldPath }: Props) { )} - {fieldPath && ( + {fieldPath && mode === 'document' && (
{openCommentsCount > 0 ? >; export type FieldPathsMap = Map>>; -export type Mode = "document" | "global" | "create"; +export type Mode = "document" | "global"; export type FilterMode = "open" | "resolved" | "mentioned"; diff --git a/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts b/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts index afac7a5f..ad2caec6 100644 --- a/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts +++ b/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts @@ -11,8 +11,6 @@ interface Props { } export function extractVisibleComments({ comments, mode, collectionSlug, documentId, currentLocale }: Props) { - if (mode === "create") return []; - const localeFilteredComments = filterCommentsByLocale(comments, currentLocale); return mode === "document" && collectionSlug && documentId ? diff --git a/packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts b/packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts index 35987249..b93b4761 100644 --- a/packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts +++ b/packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts @@ -1,35 +1,29 @@ import type { CollectionSlug } from "payload"; import type { Mode } from "../../types"; +import { EXCLUDED_ADMIN_ROUTES } from "../../constants"; interface ModeData { mode: Mode; - collectionSlug: CollectionSlug; + collectionSlug: CollectionSlug | null; documentId: number | null; } export function defineModeByPathname(pathname: string): ModeData { - const createModeMatch = pathname?.match(/\/admin\/collections\/([^/]+)\/create$/); - - if (createModeMatch) { - const collectionSlug = createModeMatch[1] as CollectionSlug; + // 1. Excluded admin routes — return global immediately + if (EXCLUDED_ADMIN_ROUTES.some((route) => pathname?.startsWith(route))) { + return { mode: 'global', collectionSlug: null, documentId: null } + } + // 2. Document mode — /admin/collections/{slug}/{numericId} + const documentModeMatch = pathname?.match(/\/admin\/collections\/([^/]+)\/(\d+)/) + if (documentModeMatch) { return { - mode: "create", - collectionSlug, - documentId: null, - }; + mode: 'document', + collectionSlug: documentModeMatch[1] as CollectionSlug, + documentId: Number(documentModeMatch[2]), + } } - const documentModeMatch = pathname?.match(/\/admin\/collections\/([^/]+)\/(\d+)/); - - const mode = documentModeMatch ? "document" : "global"; - - const collectionSlug = (documentModeMatch?.[1] as CollectionSlug) ?? null; - const documentId = Number(documentModeMatch?.[2]) || null; - - return { - mode, - collectionSlug, - documentId, - }; + // 3. Global fallback (includes /create pages) + return { mode: 'global', collectionSlug: null, documentId: null } } From 281bcb1f1be8988b883df06c712954ceaca8cd77 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Fri, 13 Mar 2026 19:05:41 +0300 Subject: [PATCH 08/23] fix(payload-plugin-comments): display general document comments by default --- .../components/FieldGroupSection.tsx | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx index 104d9ef5..d4afb7ab 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx @@ -31,21 +31,19 @@ export function FieldGroupSection({ return ( <> - {fields.has(null) && ( - !c.isResolved).length} - level="field"> -
- {generalComments.map((comment) => ( - - ))} + !c.isResolved).length} + level="field"> +
+ {generalComments.map((comment) => ( + + ))} - -
-
- )} + +
+
{fieldEntries.map(([fieldPath, fieldComments]) => ( Date: Sat, 14 Mar 2026 15:24:31 +0300 Subject: [PATCH 09/23] feat(payload-plugin-comments): add comments for globals --- .../src/collection/index.ts | 56 +++++++------------ .../components/GlobalDocumentView.tsx | 42 ++++++++++++++ .../CommentsPanel/components/GlobalView.tsx | 38 ++++++++++++- .../src/components/CommentsPanel/index.tsx | 5 ++ .../utils/groupCommentsGlobally.ts | 42 +++++++++++--- .../utils/resolveCollectionLabel.ts | 8 +-- .../components/FieldCommentLabel/index.tsx | 5 +- .../payload-plugin-comments/src/plugin.ts | 24 ++++---- .../src/providers/CommentsProvider/index.tsx | 50 ++++++++++------- .../GlobalCommentsHydrator.tsx | 10 ++-- .../providers/GlobalCommentsLoader/index.tsx | 7 ++- .../src/services/createComment.ts | 56 +++++++++++++------ .../services/fieldLabels/fetchFieldLabels.ts | 48 +++++++++++++++- .../src/services/findAllComments.ts | 13 ++++- .../src/services/getCollectionLabels.ts | 17 ------ .../src/services/getDocumentTitles.ts | 2 + .../src/services/getEntitiesLabels.ts | 16 ++++++ .../src/services/index.ts | 23 -------- .../src/services/sendMentionEmails.ts | 2 +- .../src/services/syncAllCommentsData.ts | 12 ++-- packages/payload-plugin-comments/src/types.ts | 30 +++++----- .../utils/comment/extractVisibleComments.ts | 19 +++++-- .../config/injectFieldCommentComponents.ts | 34 +++-------- .../src/utils/config/overrideCollections.ts | 38 +++++++++++++ ...ction.ts => overrideCommentsCollection.ts} | 2 +- .../src/utils/config/overrideGlobals.ts | 6 ++ .../src/utils/mode/defineModeByPathname.ts | 35 +++++++++--- 27 files changed, 425 insertions(+), 215 deletions(-) create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalDocumentView.tsx delete mode 100644 packages/payload-plugin-comments/src/services/getCollectionLabels.ts create mode 100644 packages/payload-plugin-comments/src/services/getEntitiesLabels.ts delete mode 100644 packages/payload-plugin-comments/src/services/index.ts create mode 100644 packages/payload-plugin-comments/src/utils/config/overrideCollections.ts rename packages/payload-plugin-comments/src/utils/config/{overrideCollection.ts => overrideCommentsCollection.ts} (91%) create mode 100644 packages/payload-plugin-comments/src/utils/config/overrideGlobals.ts diff --git a/packages/payload-plugin-comments/src/collection/index.ts b/packages/payload-plugin-comments/src/collection/index.ts index 9f11c9bc..6aaf64e2 100644 --- a/packages/payload-plugin-comments/src/collection/index.ts +++ b/packages/payload-plugin-comments/src/collection/index.ts @@ -30,24 +30,27 @@ export const baseCollection = (tenantConfig?: TenantPluginConfig): CollectionCon { name: "documentId", type: "number", - required: true, index: true, }, { name: "collectionSlug", type: "text", - required: true, 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: { - en: "Dot-notation path of the field being commented on. Null = document-level.", - es: "Ruta en notación punto del campo comentado. Null = nivel de documento.", - }, + description: "Dot-notation path of the field being commented on. Null = document-level.", }, }, { @@ -56,26 +59,22 @@ export const baseCollection = (tenantConfig?: TenantPluginConfig): CollectionCon required: false, index: true, admin: { - description: { - en: "Locale for field-level comments. Null = document-level (shown in all locales).", - es: "Locale para comentarios de campo. Null = nivel de documento (visible en todos los locales).", - }, + description: "Locale for field-level comments. Null = document-level (shown in all locales).", }, }, { name: "text", type: "textarea", required: true, - label: { - en: "Comment", - es: "Comentario", - }, + label: "Comment", }, { name: "mentions", type: "array", - label: { en: "Mentions", es: "Menciones" }, - admin: { readOnly: true }, + label: "Mentions", + admin: { + readOnly: true, + }, fields: [ { name: "user", @@ -90,10 +89,7 @@ export const baseCollection = (tenantConfig?: TenantPluginConfig): CollectionCon type: "relationship", relationTo: "users", required: true, - label: { - en: "Author", - es: "Autor", - }, + label: "Author", admin: { position: "sidebar", }, @@ -102,10 +98,7 @@ export const baseCollection = (tenantConfig?: TenantPluginConfig): CollectionCon name: "isResolved", type: "checkbox", defaultValue: false, - label: { - en: "Resolved", - es: "Resuelto", - }, + label: "Resolved", admin: { position: "sidebar", }, @@ -114,10 +107,7 @@ export const baseCollection = (tenantConfig?: TenantPluginConfig): CollectionCon name: "resolvedBy", type: "relationship", relationTo: "users", - label: { - en: "Resolved by", - es: "Resuelto por", - }, + label: "Resolved by", admin: { position: "sidebar", condition: (_data, siblingData) => siblingData?.status === "resolved", @@ -126,10 +116,7 @@ export const baseCollection = (tenantConfig?: TenantPluginConfig): CollectionCon { name: "resolvedAt", type: "date", - label: { - en: "Resolved at", - es: "Resuelto el", - }, + label: "Resolved at", admin: { position: "sidebar", condition: (_data, siblingData) => siblingData?.status === "resolved", @@ -142,10 +129,7 @@ export const baseCollection = (tenantConfig?: TenantPluginConfig): CollectionCon type: "relationship" as const, relationTo: (tenantConfig.collectionSlug ?? "tenants") as CollectionSlug, index: true, - label: { - en: "Tenant", - es: "Inquilino", - }, + label: "Tenant", admin: { position: "sidebar" as const, }, 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..7b8d74d4 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalDocumentView.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { useTranslation } from "@payloadcms/ui"; +import type { Comment } from "../../../types"; +import { useComments } from "../../../providers/CommentsProvider"; +import { groupCommentsByFieldPath } from "../utils/groupCommentsByFieldPath"; +import { resolveLabel } from "../utils/resolveLabel"; +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 { fieldLabelRegistry, globalSlug, filter } = useComments(); + + const fields = groupCommentsByFieldPath(comments); + + return ( +
+ {comments.length === 0 && ( +

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

+ )} + + + globalSlug != null ? resolveLabel(fieldLabelRegistry, globalSlug, 0, fp) : fp + } + generalGroupKey="g::" + fieldGroupKeyPrefix="f::" + /> +
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx index 52c8c28c..16abd088 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx @@ -9,6 +9,7 @@ import { resolveCollectionLabel } from "../utils/resolveCollectionLabel"; import { CollapsibleGroup } from "./CollapsibleGroup"; import { FILTER_NO_COMMENTS_KEYS } from "../constants"; import { FieldGroupSection } from "./FieldGroupSection"; +import type { FieldPath } from "../types"; interface Props { comments: Comment[]; @@ -18,9 +19,9 @@ interface Props { export function GlobalView({ comments, userId, className }: Props) { const { t } = useTranslation(); - const { documentTitles, fieldLabelRegistry, filter, collectionLabels } = useComments(); + const { documentTitles, fieldLabelRegistry, filter, collectionLabels, globalLabels } = useComments(); const { code: locale } = useLocale(); - const groupedGlobal = groupCommentsGlobally(comments); + const { collections: groupedCollections, globals: groupedGlobals } = groupCommentsGlobally(comments); return (
@@ -30,7 +31,8 @@ export function GlobalView({ comments, userId, className }: Props) {

)} - {[...groupedGlobal.entries()].map(([slug, docs]) => { + {/* Collection comments */} + {[...groupedCollections.entries()].map(([slug, docs]) => { const slugOpenCount = [...docs.values()] .flatMap((fields) => [...fields.values()].flat()) .filter((c) => !c.isResolved).length; @@ -69,6 +71,36 @@ export function GlobalView({ comments, userId, className }: Props) { ); })} + + {/* Global comments */} + {[...groupedGlobals.entries()].map(([slug, fields]) => { + const openCount = [...fields.values()].flat().filter((c) => !c.isResolved).length; + const rawLabel = globalLabels[slug]; + const label = + typeof rawLabel === "string" ? rawLabel + : typeof rawLabel === "object" && rawLabel !== null ? + (rawLabel as Record)[locale] ?? + (rawLabel as Record)["en"] ?? + slug + : slug; + + return ( + + } + userId={userId} + generalGroupKey={`g:global:${slug}`} + fieldGroupKeyPrefix={`f:global:${slug}:`} + labelResolver={(fp) => resolveLabel(fieldLabelRegistry, slug, 0, fp)} + /> + + ); + })}
); } diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/index.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/index.tsx index 15b2b7be..f8ef5ba1 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/index.tsx +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/index.tsx @@ -6,6 +6,7 @@ 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 { @@ -29,5 +30,9 @@ export const CommentsPanel = ({ className }: Props) => { return ; } + if (mode === "global-document") { + return ; + } + return ; }; diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts index abd986e0..6cb5137f 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts @@ -2,15 +2,36 @@ import type { Comment } from "../../../types"; import { sortGroupsByCreatedAt } from "./sortGroupsByCreatedAt"; import type { FieldPath } from "../types"; -// Returns: Map>> -export function groupCommentsGlobally(comments: Comment[]): Map>> { +export interface GroupedComments { + collections: Map>>; + globals: Map>; +} + +export function groupCommentsGlobally(comments: Comment[]): GroupedComments { const collections = new Map>>(); + const globals = new Map>(); for (const comment of comments) { + // Global comment + 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; + } + + // Collection comment const slug = comment.collectionSlug; const docId = String(comment.documentId); const field = comment.fieldPath ?? null; + if (!slug || !comment.documentId) continue; + if (!collections.has(slug)) collections.set(slug, new Map()); const docs = collections.get(slug)!; @@ -21,20 +42,25 @@ export function groupCommentsGlobally(comments: Comment[]): Map new Date(c.createdAt).getTime(); - const oldestInFields = (fields: Map) => Math.min(...[...fields.values()].flat().map(toTime)); + const oldestInFields = (fields: Map) => + Math.min(...[...fields.values()].flat().map(toTime)); for (const [slug, docs] of collections.entries()) { for (const [docId, fields] of docs.entries()) { docs.set(docId, sortGroupsByCreatedAt(fields)); } - - const sortedDocs = new Map([...docs.entries()].sort(([, a], [, b]) => oldestInFields(a) - oldestInFields(b))); + const sortedDocs = new Map( + [...docs.entries()].sort(([, a], [, b]) => oldestInFields(a) - oldestInFields(b)), + ); collections.set(slug, sortedDocs); } - const oldestInDocs = (docs: Map>) => - Math.min(...[...docs.values()].map(oldestInFields)); + // Sort globals + for (const [slug, fields] of globals.entries()) { + globals.set(slug, sortGroupsByCreatedAt(fields)); + } - return new Map([...collections.entries()].sort(([, a], [, b]) => oldestInDocs(a) - oldestInDocs(b))); + return { collections, globals }; } diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveCollectionLabel.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveCollectionLabel.ts index 31d4b2d9..8079b734 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveCollectionLabel.ts +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveCollectionLabel.ts @@ -1,10 +1,6 @@ -import type { CollectionLabels } from "../../../types"; +import type { EntityLabel } from "../../../types"; -export function resolveCollectionLabel( - label: CollectionLabels[string] | undefined, - locale: string, - fallback: string, -): string { +export function resolveCollectionLabel(label: EntityLabel | undefined, locale: string, fallback: string) { if (label === undefined) return fallback; if (typeof label === "string") return label; diff --git a/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx b/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx index a6942338..f3632ad0 100644 --- a/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx @@ -28,7 +28,7 @@ export function FieldCommentLabel({ field, htmlFor, path: fieldPath }: Props) { const { t } = useTranslation(); const { code: locale } = useLocale(); const { open: openDrawer, setScrollTargetPath } = useCommentsDrawer(); - const { visibleComments, addComment, setFilter, mode } = useComments(); + const { visibleComments, addComment, setFilter, mode, globalSlug } = useComments(); const [isHovered, setIsHovered] = useState(false); const [showPopover, setShowPopover] = useState(false); @@ -80,6 +80,7 @@ export function FieldCommentLabel({ field, htmlFor, path: fieldPath }: Props) { undefined, undefined, stablePath ? locale : null, + mode === 'global-document' ? (globalSlug ?? undefined) : undefined, ); if (result.success) { @@ -103,7 +104,7 @@ export function FieldCommentLabel({ field, htmlFor, path: fieldPath }: Props) { )} - {fieldPath && mode === 'document' && ( + {fieldPath && (mode === 'document' || mode === 'global-document') && (
{openCommentsCount > 0 ? @@ -16,21 +17,18 @@ export const commentsPlugin = 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 baseCollection = getBaseCollection(config.tenant); - const finalCollection = overrideCollection(baseCollection, overrides); - - const allCollectionSlugs = (incomingConfig.collections ?? []).map((c) => c.slug); - - const patchedCollections = (incomingConfig.collections ?? []).map((collection) => - injectFieldCommentComponents(collection), - ); - const userTranslations = config.translations ?? {}; const incomingConfigTranslations = (incomingConfig.i18n?.translations as Record | undefined) ?? {}; @@ -61,11 +59,13 @@ export const commentsPlugin = commentsPlugin: { collections: allCollectionSlugs, documentTitleFields, + globals: allGlobalSlugs, tenant: config.tenant, usernameFieldPath, }, }, }, - collections: [...patchedCollections, finalCollection], + collections: [...overrideCollections(incomingConfig.collections), finalCollection], + globals: overrideGlobals(incomingConfig.globals), }; }; diff --git a/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx b/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx index 33e86d02..b370bb82 100644 --- a/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx +++ b/packages/payload-plugin-comments/src/providers/CommentsProvider/index.tsx @@ -12,19 +12,12 @@ import { } from "react"; import { useAuth, useLocale } from "@payloadcms/ui"; import { usePathname } from "next/navigation"; -import type { Comment, User } from "../../types"; +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 { - CollectionLabels, - FilterMode, - GlobalFieldLabelRegistry, - LoadingStatus, - DocumentTitles, - Mode, -} from "../../types"; +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"; @@ -54,10 +47,12 @@ interface CommentsContextProps { allComments: Comment[]; visibleComments: Comment[]; documentTitles: DocumentTitles; - collectionLabels: CollectionLabels; + collectionLabels: EntityLabelsMap; + globalLabels: EntityLabelsMap; mode: Mode; collectionSlug: string | null | undefined; documentId: number | null | undefined; + globalSlug: string | null; mentionUsers: User[]; loadError: boolean; filter: FilterMode; @@ -70,8 +65,9 @@ interface CommentsContextProps { incomingTitles?: DocumentTitles, incomingMentionUsers?: User[], fieldLabels?: GlobalFieldLabelRegistry, - incomingCollectionLabels?: CollectionLabels, + incomingCollectionLabels?: EntityLabelsMap, nextLoadError?: boolean, + incomingGlobalLabels?: EntityLabelsMap, ) => void; syncComments: () => Promise; addComment: ( @@ -80,6 +76,7 @@ interface CommentsContextProps { documentId?: number, collectionSlug?: string, locale?: string | null, + globalSlugOverride?: string, ) => Promise; removeComment: (id: string | number) => Promise; resolveComment: (id: string | number, resolved: boolean) => Promise; @@ -105,20 +102,22 @@ export function CommentsProvider({ children, usernameFieldPath }: Props) { ); const [documentTitles, setDocumentTitles] = useState({}); - const [collectionLabels, setCollectionLabels] = 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 } = defineModeByPathname(pathname); + const { mode, collectionSlug, documentId, globalSlug } = defineModeByPathname(pathname); const visibleComments = extractVisibleComments({ comments: optimisticComments, mode, collectionSlug, documentId, + globalSlug, currentLocale, }); @@ -128,8 +127,9 @@ export function CommentsProvider({ children, usernameFieldPath }: Props) { incomingTitles?: DocumentTitles, incomingMentionUsers?: User[], fieldLabels?: GlobalFieldLabelRegistry, - incomingCollectionLabels?: CollectionLabels, + incomingCollectionLabels?: EntityLabelsMap, nextLoadError = false, + incomingGlobalLabels?: EntityLabelsMap, ) => { if (incoming !== undefined) { setAllComments(incoming); @@ -151,6 +151,10 @@ export function CommentsProvider({ children, usernameFieldPath }: Props) { setCollectionLabels(incomingCollectionLabels); } + if (incomingGlobalLabels) { + setGlobalLabels(incomingGlobalLabels); + } + setLoadError(nextLoadError); }, [], @@ -162,11 +166,13 @@ export function CommentsProvider({ children, usernameFieldPath }: Props) { 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 (!resolvedDocId || !resolvedSlug) { + if (!resolvedGlobalSlug && (!resolvedDocId || !resolvedSlug)) { return { success: false, error: "No document registered", @@ -184,8 +190,9 @@ export function CommentsProvider({ children, usernameFieldPath }: Props) { text, fieldPath, locale: locale ?? null, - documentId: resolvedDocId, - collectionSlug: resolvedSlug, + documentId: resolvedGlobalSlug ? null : resolvedDocId, + collectionSlug: resolvedGlobalSlug ? null : resolvedSlug, + globalSlug: resolvedGlobalSlug ?? null, isResolved: false, mentions, createdAt: new Date().toISOString(), @@ -196,8 +203,9 @@ export function CommentsProvider({ children, usernameFieldPath }: Props) { applyOptimistic({ type: "add", comment: optimisticComment }); const result = await createComment({ - documentId: resolvedDocId, - collectionSlug: resolvedSlug, + documentId: resolvedGlobalSlug ? null : resolvedDocId, + collectionSlug: resolvedGlobalSlug ? null : resolvedSlug, + globalSlug: resolvedGlobalSlug, text, fieldPath, mentionIds, @@ -251,6 +259,7 @@ export function CommentsProvider({ children, usernameFieldPath }: Props) { mentionUsers, fieldLabels, collectionLabels: syncedLabels, + globalLabels: syncedGlobalLabels, } = allCommentsDataResult.data; setAllComments((prev) => { @@ -263,6 +272,7 @@ export function CommentsProvider({ children, usernameFieldPath }: Props) { setMentionUsers(mentionUsers); setFieldLabelRegistry(fieldLabels); setCollectionLabels(syncedLabels); + setGlobalLabels(syncedGlobalLabels); setSyncCommentsStatus("success"); }; @@ -310,9 +320,11 @@ export function CommentsProvider({ children, usernameFieldPath }: Props) { visibleComments, documentTitles, collectionLabels, + globalLabels, mode, collectionSlug, documentId, + globalSlug, mentionUsers, loadError, filter, diff --git a/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx index c074baf3..4bb7f7c3 100644 --- a/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx +++ b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/GlobalCommentsHydrator.tsx @@ -2,14 +2,15 @@ import { useEffect } from "react"; import { useComments } from "../CommentsProvider"; -import type { CollectionLabels, Comment, DocumentTitles, GlobalFieldLabelRegistry, User } from "../../types"; +import type { EntityLabelsMap, Comment, DocumentTitles, GlobalFieldLabelRegistry, User } from "../../types"; interface Props { comments: Comment[]; documentTitles: DocumentTitles; mentionUsers: User[]; fieldLabels: GlobalFieldLabelRegistry; - collectionLabels: CollectionLabels; + collectionLabels: EntityLabelsMap; + globalLabels: EntityLabelsMap; loadError: boolean; } @@ -19,13 +20,14 @@ export function GlobalCommentsHydrator({ mentionUsers, fieldLabels, collectionLabels, + globalLabels, loadError, }: Props) { const { hydrateComments } = useComments(); useEffect(() => { - hydrateComments(comments, documentTitles, mentionUsers, fieldLabels, collectionLabels, loadError); - }, [comments, documentTitles, mentionUsers, fieldLabels, collectionLabels, loadError, hydrateComments]); + 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 index bde13723..ab1d9cab 100644 --- a/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/index.tsx +++ b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/index.tsx @@ -2,7 +2,7 @@ import { findAllComments } from "../../services/findAllComments"; import { getDocumentTitles } from "../../services/getDocumentTitles"; import { fetchMentionableUsers } from "../../services/fetchMentionableUsers"; import { fetchFieldLabels } from "../../services/fieldLabels/fetchFieldLabels"; -import { getCollectionLabels } from "../../services/getCollectionLabels"; +import { getEntitiesLabels } from "../../services/getEntitiesLabels"; import { setPayloadConfig } from "../../config"; import { GlobalCommentsHydrator } from "./GlobalCommentsHydrator"; import type { Payload } from "payload"; @@ -22,6 +22,7 @@ export async function GlobalCommentsLoader({ children, payload, locale }: Props) const commentsResult = await findAllComments({ enabledCollections: pluginConfig?.collections, + enabledGlobals: pluginConfig?.globals, options: { payload }, }); @@ -33,7 +34,8 @@ export async function GlobalCommentsLoader({ children, payload, locale }: Props) fetchFieldLabels(comments, { payload }), ]); - const collectionLabels = getCollectionLabels(payload, pluginConfig?.collections ?? []); + const collectionLabels = getEntitiesLabels(payload.config.collections, pluginConfig?.collections ?? []); + const globalLabels = getEntitiesLabels(payload.config.globals, pluginConfig?.globals ?? []); return ( <> @@ -43,6 +45,7 @@ export async function GlobalCommentsLoader({ children, payload, locale }: Props) mentionUsers={mentionUsersResult.success ? mentionUsersResult.data : []} fieldLabels={fieldLabels} collectionLabels={collectionLabels} + globalLabels={globalLabels} loadError={!commentsResult.success} /> diff --git a/packages/payload-plugin-comments/src/services/createComment.ts b/packages/payload-plugin-comments/src/services/createComment.ts index 74a97509..b332bcb8 100644 --- a/packages/payload-plugin-comments/src/services/createComment.ts +++ b/packages/payload-plugin-comments/src/services/createComment.ts @@ -3,13 +3,14 @@ import { headers } from "next/headers"; import { getDefaultErrorMessage } from "../utils/error/getDefaultErrorMessage"; import type { Response, Comment, BaseServiceOptions } from "../types"; -import { DEFAULT_COLLECTION_SLUG } from "../constants"; +import { DEFAULT_COLLECTION_SLUG, FALLBACK_USERNAME } from "../constants"; import { sendMentionEmails } from "./sendMentionEmails"; import { extractPayload } from "../utils/payload/extractPayload"; interface Props extends BaseServiceOptions { - documentId: number; - collectionSlug: string; + documentId?: number | null; + collectionSlug?: string | null; + globalSlug?: string | null; text: string; fieldPath?: string | null; mentionIds?: number[]; @@ -18,6 +19,7 @@ interface Props extends BaseServiceOptions { export async function createComment({ documentId, collectionSlug, + globalSlug, text, fieldPath = null, mentionIds = [], @@ -35,20 +37,42 @@ export async function createComment({ }; } + 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: { - documentId, - collectionSlug, - fieldPath, - locale, - text, - author: user.id, - isResolved: false, - mentions, - }, + data, overrideAccess: false, user, })) as Comment; @@ -56,10 +80,10 @@ export async function createComment({ if (mentionIds.length > 0) { sendMentionEmails({ mentionIds, - authorName: user.name ?? user.email ?? "Someone", + authorName: user.name ?? user.email ?? FALLBACK_USERNAME, commentText: text, - collectionSlug, - documentId, + collectionSlug: collectionSlug ?? globalSlug ?? "", + documentId: documentId, payload, }); } diff --git a/packages/payload-plugin-comments/src/services/fieldLabels/fetchFieldLabels.ts b/packages/payload-plugin-comments/src/services/fieldLabels/fetchFieldLabels.ts index ec2723b6..8825b77e 100644 --- a/packages/payload-plugin-comments/src/services/fieldLabels/fetchFieldLabels.ts +++ b/packages/payload-plugin-comments/src/services/fieldLabels/fetchFieldLabels.ts @@ -16,6 +16,8 @@ type BasePayloadCollections = Record< } >; +type BaseGlobals = Record; + type BaseDocumentData = Record; export async function fetchFieldLabels( @@ -24,10 +26,10 @@ export async function fetchFieldLabels( ): Promise { const registry: GlobalFieldLabelRegistry = {}; - const fieldPathsMap = groupFieldPathsByDocument(comments); - if (!fieldPathsMap.size) return registry; - 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()) { @@ -68,5 +70,45 @@ export async function fetchFieldLabels( } } + // --- 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/findAllComments.ts b/packages/payload-plugin-comments/src/services/findAllComments.ts index affe3d3a..53e0b653 100644 --- a/packages/payload-plugin-comments/src/services/findAllComments.ts +++ b/packages/payload-plugin-comments/src/services/findAllComments.ts @@ -9,11 +9,12 @@ import { getCurrentTenantId } from "./getCurrentTenantId"; interface Props { enabledCollections?: string[]; + enabledGlobals?: string[]; user?: TypedUser | null; options?: BaseServiceOptions; } -export async function findAllComments({ enabledCollections, options }: Props = {}): Promise> { +export async function findAllComments({ enabledCollections, enabledGlobals, options }: Props = {}): Promise> { try { const payload = await extractPayload(options?.payload); @@ -21,8 +22,14 @@ export async function findAllComments({ enabledCollections, options }: Props = { const where: Where = {}; - if (enabledCollections?.length) { - where.collectionSlug = { in: enabledCollections }; + 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) { diff --git a/packages/payload-plugin-comments/src/services/getCollectionLabels.ts b/packages/payload-plugin-comments/src/services/getCollectionLabels.ts deleted file mode 100644 index fb7ebc43..00000000 --- a/packages/payload-plugin-comments/src/services/getCollectionLabels.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Payload } from "payload"; -import type { CollectionLabels } from "../types"; - -export function getCollectionLabels(payload: Payload, enabledSlugs: string[]) { - const slugSet = new Set(enabledSlugs); - const result: CollectionLabels = {}; - - for (const collection of payload.config.collections) { - if (!slugSet.has(collection.slug)) continue; - - const rawLabel = collection.labels?.plural; - - result[collection.slug] = typeof rawLabel === "function" || rawLabel === undefined ? collection.slug : rawLabel; - } - - return result; -} diff --git a/packages/payload-plugin-comments/src/services/getDocumentTitles.ts b/packages/payload-plugin-comments/src/services/getDocumentTitles.ts index ac1c5906..daac1c5f 100644 --- a/packages/payload-plugin-comments/src/services/getDocumentTitles.ts +++ b/packages/payload-plugin-comments/src/services/getDocumentTitles.ts @@ -28,6 +28,8 @@ export async function getDocumentTitles( const documentIdsMap = new Map>(); for (const { collectionSlug, documentId } of comments) { + if (!collectionSlug || documentId == null) continue; + if (!documentIdsMap.has(collectionSlug)) { documentIdsMap.set(collectionSlug, new Set()); } 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/index.ts b/packages/payload-plugin-comments/src/services/index.ts deleted file mode 100644 index fd4c5f00..00000000 --- a/packages/payload-plugin-comments/src/services/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { syncAllCommentsData } from "./syncAllCommentsData"; -import { sendMentionEmails } from "./sendMentionEmails"; -import { resolveComment } from "./resolveComment"; -import { getDocumentTitles } from "./getDocumentTitles"; -import { getCurrentTenantId } from "./getCurrentTenantId"; -import { findAllComments } from "./findAllComments"; -import { fetchMentionableUsers } from "./fetchMentionableUsers"; -import { deleteComment } from "./deleteComment"; -import { createComment } from "./createComment"; -import { fetchFieldLabels } from "./fieldLabels/fetchFieldLabels"; - -export { - syncAllCommentsData, - sendMentionEmails, - resolveComment, - getDocumentTitles, - getCurrentTenantId, - findAllComments, - fetchMentionableUsers, - deleteComment, - createComment, - fetchFieldLabels, -}; diff --git a/packages/payload-plugin-comments/src/services/sendMentionEmails.ts b/packages/payload-plugin-comments/src/services/sendMentionEmails.ts index 5c0fd8ee..6330fd68 100644 --- a/packages/payload-plugin-comments/src/services/sendMentionEmails.ts +++ b/packages/payload-plugin-comments/src/services/sendMentionEmails.ts @@ -9,7 +9,7 @@ interface SendMentionEmailsProps extends BaseServiceOptions { authorName: string; commentText: string; collectionSlug: string; - documentId: number; + documentId: number | null | undefined; } export async function sendMentionEmails({ diff --git a/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts b/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts index f2b245eb..74d0b755 100644 --- a/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts +++ b/packages/payload-plugin-comments/src/services/syncAllCommentsData.ts @@ -4,10 +4,10 @@ import { findAllComments } from "./findAllComments"; import { getDocumentTitles } from "./getDocumentTitles"; import { fetchMentionableUsers } from "./fetchMentionableUsers"; import { fetchFieldLabels } from "./fieldLabels/fetchFieldLabels"; -import { getCollectionLabels } from "./getCollectionLabels"; +import { getEntitiesLabels } from "./getEntitiesLabels"; import type { BaseServiceOptions, - CollectionLabels, + EntityLabelsMap, Comment, CommentsPluginConfigStorage, DocumentTitles, @@ -22,7 +22,8 @@ interface SyncResult { documentTitles: DocumentTitles; mentionUsers: User[]; fieldLabels: GlobalFieldLabelRegistry; - collectionLabels: CollectionLabels; + collectionLabels: EntityLabelsMap; + globalLabels: EntityLabelsMap; } const errorResult: Response = { @@ -36,6 +37,7 @@ export async function syncAllCommentsData(options?: BaseServiceOptions): Promise const commentsResult = await findAllComments({ enabledCollections: pluginConfig?.collections, + enabledGlobals: pluginConfig?.globals, options: { payload, }, @@ -55,7 +57,8 @@ export async function syncAllCommentsData(options?: BaseServiceOptions): Promise if (!success) return errorResult; - const collectionLabels = getCollectionLabels(payload, pluginConfig?.collections ?? []); + const collectionLabels = getEntitiesLabels(payload.config.collections, pluginConfig?.collections ?? []); + const globalLabels = getEntitiesLabels(payload.config.globals, pluginConfig?.globals ?? []); return { success: true, @@ -65,6 +68,7 @@ export async function syncAllCommentsData(options?: BaseServiceOptions): Promise mentionUsers: mentionUsersResult.data, fieldLabels, collectionLabels, + globalLabels, }, }; } diff --git a/packages/payload-plugin-comments/src/types.ts b/packages/payload-plugin-comments/src/types.ts index ef6dfcae..bba7c0b6 100644 --- a/packages/payload-plugin-comments/src/types.ts +++ b/packages/payload-plugin-comments/src/types.ts @@ -1,4 +1,4 @@ -import type { CollectionConfig, Field, Payload } from "payload"; +import type { CollectionConfig, Field, Payload, SanitizedCollectionConfig, SanitizedGlobalConfig } from "payload"; import type { Translations } from "./translations/types"; export interface BaseServiceOptions { @@ -69,18 +69,14 @@ export interface TenantPluginConfig { * }) */ export interface CommentsPluginConfig { - /** - * List of collection slugs (or detailed config objects) whose documents - * should support comments. Each entry creates a comments sidebar for - * documents inside that collection. - * - * By default, all collections have comments. - */ - collections?: CollectionEntry[]; /** 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; /** @@ -129,17 +125,16 @@ export type CommentsPluginConfigOverrides = CommentsPluginConfig["overrides"]; export interface CommentsPluginConfigStorage { collections?: string[]; documentTitleFields?: Record; + globals?: string[]; tenant?: TenantPluginConfig; usernameFieldPath?: string; } export type DocumentTitles = Record>; -export type CollectionLabels = Record>; - export type FieldPathsMap = Map>>; -export type Mode = "document" | "global"; +export type Mode = "document" | "global-document" | "global"; export type FilterMode = "open" | "resolved" | "mentioned"; @@ -163,8 +158,9 @@ export interface CommentMention { export interface Comment { id: number; - documentId: number; - collectionSlug: string; + documentId?: number | null; + collectionSlug?: string | null; + globalSlug?: string | null; fieldPath?: string | null; locale?: string | null; text: string; @@ -179,3 +175,9 @@ export interface Comment { } export type Response = { success: true; data: T } | { success: false; error: string }; + +export type EntityConfig = SanitizedCollectionConfig | SanitizedGlobalConfig; + +export type EntityLabel = string | Record; + +export type EntityLabelsMap = Record; diff --git a/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts b/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts index ad2caec6..7197881a 100644 --- a/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts +++ b/packages/payload-plugin-comments/src/utils/comment/extractVisibleComments.ts @@ -7,15 +7,22 @@ interface Props { mode: Mode; collectionSlug?: CollectionSlug | null; documentId?: number | null; + globalSlug?: string | null; currentLocale?: string | null; } -export function extractVisibleComments({ comments, mode, collectionSlug, documentId, currentLocale }: Props) { +export function extractVisibleComments({ comments, mode, collectionSlug, documentId, globalSlug, currentLocale }: Props) { const localeFilteredComments = filterCommentsByLocale(comments, currentLocale); - return mode === "document" && collectionSlug && documentId ? - localeFilteredComments.filter( - ({ collectionSlug: slug, documentId: id }) => slug === collectionSlug && id === documentId, - ) - : localeFilteredComments; + 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/config/injectFieldCommentComponents.ts b/packages/payload-plugin-comments/src/utils/config/injectFieldCommentComponents.ts index 66dea96c..8f4369cc 100644 --- a/packages/payload-plugin-comments/src/utils/config/injectFieldCommentComponents.ts +++ b/packages/payload-plugin-comments/src/utils/config/injectFieldCommentComponents.ts @@ -1,6 +1,7 @@ -import type { CollectionAfterDeleteHook, CollectionConfig, Field } from "payload"; +import type { CollectionConfig, Field, GlobalConfig } from "payload"; import { getComponentConfig } from "../path/getComponentPath"; -import { DEFAULT_COLLECTION_SLUG } from "../../constants"; + +export type EntityConfig = CollectionConfig | GlobalConfig; type BaseField = Field & { name?: string | null | undefined; @@ -34,7 +35,6 @@ 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; @@ -87,29 +87,9 @@ function injectIntoFields(fields: Field[], parentPath: string): Field[] { return fields.map((field) => injectIntoField(field, parentPath)); } -export function injectFieldCommentComponents(collection: CollectionConfig): CollectionConfig { - if (collection.slug === DEFAULT_COLLECTION_SLUG) { - return collection; - } - +export function injectFieldCommentComponents(entity: T): T { return { - ...collection, - fields: injectIntoFields(collection.fields, ""), - hooks: { - ...collection.hooks, - afterDelete: [ - ...(collection.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, - ], - }, - }; + ...entity, + fields: injectIntoFields(entity.fields, ""), + } as T; } 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/overrideCollection.ts b/packages/payload-plugin-comments/src/utils/config/overrideCommentsCollection.ts similarity index 91% rename from packages/payload-plugin-comments/src/utils/config/overrideCollection.ts rename to packages/payload-plugin-comments/src/utils/config/overrideCommentsCollection.ts index 9ec81b3d..788d1929 100644 --- a/packages/payload-plugin-comments/src/utils/config/overrideCollection.ts +++ b/packages/payload-plugin-comments/src/utils/config/overrideCommentsCollection.ts @@ -1,7 +1,7 @@ import type { CollectionConfig } from "payload"; import type { CommentsPluginConfigOverrides } from "../../types"; -export function overrideCollection( +export function overrideCommentsCollection( config: CollectionConfig, overrides: CommentsPluginConfigOverrides, ): CollectionConfig { 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/mode/defineModeByPathname.ts b/packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts index b93b4761..ded5a71e 100644 --- a/packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts +++ b/packages/payload-plugin-comments/src/utils/mode/defineModeByPathname.ts @@ -6,24 +6,43 @@ interface ModeData { mode: Mode; collectionSlug: CollectionSlug | null; documentId: number | null; + globalSlug: string | null; } export function defineModeByPathname(pathname: string): ModeData { - // 1. Excluded admin routes — return global immediately if (EXCLUDED_ADMIN_ROUTES.some((route) => pathname?.startsWith(route))) { - return { mode: 'global', collectionSlug: null, documentId: null } + return { + mode: "global", + collectionSlug: null, + documentId: null, + globalSlug: null, + }; } - // 2. Document mode — /admin/collections/{slug}/{numericId} - const documentModeMatch = pathname?.match(/\/admin\/collections\/([^/]+)\/(\d+)/) + const documentModeMatch = pathname?.match(/\/admin\/collections\/([^/]+)\/(\d+)/); if (documentModeMatch) { return { - mode: 'document', + 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, + }; } - // 3. Global fallback (includes /create pages) - return { mode: 'global', collectionSlug: null, documentId: null } + return { + mode: "global", + collectionSlug: null, + documentId: null, + globalSlug: null, + }; } From bbe1659f1d210f5f44978192b57db87b8ed2ffd2 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Sat, 14 Mar 2026 15:53:23 +0300 Subject: [PATCH 10/23] fix(payload-plugin-comments): sync comments data in GlobalCommentsLoader --- .../providers/GlobalCommentsLoader/index.tsx | 64 +++++++------------ 1 file changed, 24 insertions(+), 40 deletions(-) diff --git a/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/index.tsx b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/index.tsx index ab1d9cab..9b020091 100644 --- a/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/index.tsx +++ b/packages/payload-plugin-comments/src/providers/GlobalCommentsLoader/index.tsx @@ -1,13 +1,8 @@ -import { findAllComments } from "../../services/findAllComments"; -import { getDocumentTitles } from "../../services/getDocumentTitles"; -import { fetchMentionableUsers } from "../../services/fetchMentionableUsers"; -import { fetchFieldLabels } from "../../services/fieldLabels/fetchFieldLabels"; -import { getEntitiesLabels } from "../../services/getEntitiesLabels"; import { setPayloadConfig } from "../../config"; import { GlobalCommentsHydrator } from "./GlobalCommentsHydrator"; import type { Payload } from "payload"; import type { ReactNode } from "react"; -import type { CommentsPluginConfigStorage } from "../../types"; +import { syncAllCommentsData } from "../../services/syncAllCommentsData"; interface Props { children: ReactNode; @@ -18,38 +13,27 @@ interface Props { export async function GlobalCommentsLoader({ children, payload, locale }: Props) { setPayloadConfig(payload.config); - const pluginConfig = payload?.config.admin?.custom?.commentsPlugin as CommentsPluginConfigStorage | undefined; - - const commentsResult = await findAllComments({ - enabledCollections: pluginConfig?.collections, - enabledGlobals: pluginConfig?.globals, - options: { payload }, - }); - - const comments = commentsResult.success ? commentsResult.data : []; - - const [titlesResult, mentionUsersResult, fieldLabels] = await Promise.all([ - getDocumentTitles(comments, pluginConfig?.documentTitleFields ?? {}, { payload, locale }), - fetchMentionableUsers({ payload }), - fetchFieldLabels(comments, { payload }), - ]); - - const collectionLabels = getEntitiesLabels(payload.config.collections, pluginConfig?.collections ?? []); - const globalLabels = getEntitiesLabels(payload.config.globals, pluginConfig?.globals ?? []); - - return ( - <> - - - {children} - - ); + const res = await syncAllCommentsData({ payload, locale }); + + if (res.success) { + const { comments, documentTitles, mentionUsers, fieldLabels, collectionLabels, globalLabels } = res.data; + + return ( + <> + + + {children} + + ); + } + + return children; } From b0320fa47fb452ac3ad1b8105b0ec5aa9216f457 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Sat, 14 Mar 2026 21:07:47 +0300 Subject: [PATCH 11/23] fix(payload-plugin-comments): group comments globally with a single return; add global slug to AddCommentForm --- .../src/components/AddCommentForm.tsx | 5 +- .../components/CollapsibleGroup.tsx | 3 + .../CommentsPanel/components/DocumentView.tsx | 15 +-- .../components/FieldGroupSection.tsx | 43 ++++---- .../components/GlobalDocumentView.tsx | 13 +-- .../CommentsPanel/components/GlobalView.tsx | 103 +++++++----------- .../utils/createCollapsibleGroupKey.ts | 24 ++++ .../utils/groupCommentsGlobally.ts | 83 ++++++++++---- ...llectionLabel.ts => resolveEntityLabel.ts} | 2 +- .../CommentsPanel/utils/resolveFieldLabel.ts | 17 +++ .../CommentsPanel/utils/resolveLabel.ts | 14 --- 11 files changed, 177 insertions(+), 145 deletions(-) create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/utils/createCollapsibleGroupKey.ts rename packages/payload-plugin-comments/src/components/CommentsPanel/utils/{resolveCollectionLabel.ts => resolveEntityLabel.ts} (77%) create mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveFieldLabel.ts delete mode 100644 packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveLabel.ts diff --git a/packages/payload-plugin-comments/src/components/AddCommentForm.tsx b/packages/payload-plugin-comments/src/components/AddCommentForm.tsx index b5f5de46..ca225dd4 100644 --- a/packages/payload-plugin-comments/src/components/AddCommentForm.tsx +++ b/packages/payload-plugin-comments/src/components/AddCommentForm.tsx @@ -11,9 +11,10 @@ interface AddCommentFormProps { fieldPath?: string | null; documentId?: number; collectionSlug?: string; + globalSlug?: string; } -export function AddCommentForm({ fieldPath, documentId, collectionSlug }: AddCommentFormProps) { +export function AddCommentForm({ fieldPath, documentId, collectionSlug, globalSlug }: AddCommentFormProps) { const { addComment } = useComments(); const { code: locale } = useLocale(); const { t } = useTranslation(); @@ -31,7 +32,7 @@ export function AddCommentForm({ fieldPath, documentId, collectionSlug }: AddCom editorRef.current?.clear(); startTransition(async () => { - const result = await addComment(serialized, fieldPath, documentId, collectionSlug, locale); + const result = await addComment(serialized, fieldPath, documentId, collectionSlug, locale, globalSlug); if (!result.success) { setError(result.error ?? t("comments:failedToPost" 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 index 794a6b87..1a826d85 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/components/CollapsibleGroup.tsx +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/CollapsibleGroup.tsx @@ -49,16 +49,19 @@ export function CollapsibleGroup({ groupKey, label, count, children, level }: Co aria-expanded={!isCollapsed} className={`flex w-full items-center gap-2 cursor-pointer select-none ${styles.wrapper}`}> {label} + {!isFieldLevel && count > 0 && ( {count} )} +
+ {!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 index 7951afbc..5fafc908 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/components/DocumentView.tsx +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/DocumentView.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "@payloadcms/ui"; import type { Comment } from "../../../types"; import { useComments } from "../../../providers/CommentsProvider"; import { groupCommentsByFieldPath } from "../utils/groupCommentsByFieldPath"; -import { resolveLabel } from "../utils/resolveLabel"; import { FILTER_NO_COMMENTS_KEYS } from "../constants"; import { FieldGroupSection } from "./FieldGroupSection"; @@ -16,7 +15,7 @@ interface Props { export function DocumentView({ comments, userId, className }: Props) { const { t } = useTranslation(); - const { fieldLabelRegistry, collectionSlug, documentId, filter } = useComments(); + const { filter } = useComments(); const fields = groupCommentsByFieldPath(comments); @@ -28,17 +27,7 @@ export function DocumentView({ comments, userId, className }: Props) {

)} - - collectionSlug != null && documentId != null ? - resolveLabel(fieldLabelRegistry, collectionSlug, documentId, fp) - : fp - } - generalGroupKey="g::" - fieldGroupKeyPrefix="f::" - /> +
); } diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx index d4afb7ab..309b612b 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx @@ -1,55 +1,55 @@ "use client"; import { useTranslation } from "@payloadcms/ui"; -import type { Comment } from "../../../types"; +import type { Comment } from "../../../types/comment"; import { CollapsibleGroup } from "./CollapsibleGroup"; import { CommentItem } from "../../CommentItem"; import { AddCommentForm } from "../../AddCommentForm"; +import { createCollapsibleGroupKey } from "../utils/createCollapsibleGroupKey"; +import { resolveFieldLabel } from "../utils/resolveFieldLabel"; +import { useComments } from "../../../providers/CommentsProvider"; interface Props { fields: Map; userId: number | null; collectionSlug?: string; documentId?: number; - generalGroupKey: string; - fieldGroupKeyPrefix: string; - labelResolver: (fieldPath: string) => string; + globalSlug?: string; } -export function FieldGroupSection({ - fields, - userId, - collectionSlug, - documentId, - generalGroupKey, - fieldGroupKeyPrefix, - labelResolver, -}: Props) { +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 ( <> !c.isResolved).length} level="field"> -
+
{generalComments.map((comment) => ( ))} - +
{fieldEntries.map(([fieldPath, fieldComments]) => ( !c.isResolved).length} level="field">
@@ -57,7 +57,12 @@ export function FieldGroupSection({ ))} - +
))} diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalDocumentView.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalDocumentView.tsx index 7b8d74d4..b7056006 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalDocumentView.tsx +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalDocumentView.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "@payloadcms/ui"; import type { Comment } from "../../../types"; import { useComments } from "../../../providers/CommentsProvider"; import { groupCommentsByFieldPath } from "../utils/groupCommentsByFieldPath"; -import { resolveLabel } from "../utils/resolveLabel"; import { FILTER_NO_COMMENTS_KEYS } from "../constants"; import { FieldGroupSection } from "./FieldGroupSection"; @@ -16,7 +15,7 @@ interface Props { export function GlobalDocumentView({ comments, userId, className }: Props) { const { t } = useTranslation(); - const { fieldLabelRegistry, globalSlug, filter } = useComments(); + const { filter } = useComments(); const fields = groupCommentsByFieldPath(comments); @@ -28,15 +27,7 @@ export function GlobalDocumentView({ comments, userId, className }: Props) {

)} - - globalSlug != null ? resolveLabel(fieldLabelRegistry, globalSlug, 0, fp) : fp - } - generalGroupKey="g::" - fieldGroupKeyPrefix="f::" - /> +
); } diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx index 16abd088..8f3d3da3 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/GlobalView.tsx @@ -1,15 +1,14 @@ "use client"; import { useLocale, useTranslation } from "@payloadcms/ui"; -import type { Comment } from "../../../types"; +import type { Comment } from "../../../types/comment"; import { useComments } from "../../../providers/CommentsProvider"; import { groupCommentsGlobally } from "../utils/groupCommentsGlobally"; -import { resolveLabel } from "../utils/resolveLabel"; -import { resolveCollectionLabel } from "../utils/resolveCollectionLabel"; +import { resolveEntityLabel } from "../utils/resolveEntityLabel"; import { CollapsibleGroup } from "./CollapsibleGroup"; import { FILTER_NO_COMMENTS_KEYS } from "../constants"; import { FieldGroupSection } from "./FieldGroupSection"; -import type { FieldPath } from "../types"; +import { createCollapsibleGroupKey } from "../utils/createCollapsibleGroupKey"; interface Props { comments: Comment[]; @@ -19,9 +18,9 @@ interface Props { export function GlobalView({ comments, userId, className }: Props) { const { t } = useTranslation(); - const { documentTitles, fieldLabelRegistry, filter, collectionLabels, globalLabels } = useComments(); + const { documentTitles, filter, collectionLabels, globalLabels } = useComments(); const { code: locale } = useLocale(); - const { collections: groupedCollections, globals: groupedGlobals } = groupCommentsGlobally(comments); + const groupedComments = groupCommentsGlobally(comments); return (
@@ -31,73 +30,51 @@ export function GlobalView({ comments, userId, className }: Props) {

)} - {/* Collection comments */} - {[...groupedCollections.entries()].map(([slug, docs]) => { - const slugOpenCount = [...docs.values()] - .flatMap((fields) => [...fields.values()].flat()) - .filter((c) => !c.isResolved).length; + {groupedComments.map((entry) => { + if (entry.type === "collection") { + const { slug, docs } = entry; + const slugOpenCount = [...docs.values()] + .flatMap((fields) => [...fields.values()].flat()) + .filter((c) => !c.isResolved).length; - return ( - - {[...docs.entries()].map(([docId, fields]) => { - const title = documentTitles[slug]?.[String(docId)] ?? String(docId); - const docOpenCount = [...fields.values()].flat().filter((c) => !c.isResolved).length; - const documentId = Number(docId); + return ( + + {[...docs.entries()].map(([docId, fields]) => { + const title = documentTitles[slug]?.[String(docId)] ?? String(docId); + const docOpenCount = [...fields.values()].flat().filter((c) => !c.isResolved).length; + const documentId = Number(docId); - return ( - - resolveLabel(fieldLabelRegistry, slug, Number(docId), fp)} - /> - - ); - })} - - ); - })} + return ( + + + + ); + })} + + ); + } - {/* Global comments */} - {[...groupedGlobals.entries()].map(([slug, fields]) => { + const { slug, fields } = entry; const openCount = [...fields.values()].flat().filter((c) => !c.isResolved).length; - const rawLabel = globalLabels[slug]; - const label = - typeof rawLabel === "string" ? rawLabel - : typeof rawLabel === "object" && rawLabel !== null ? - (rawLabel as Record)[locale] ?? - (rawLabel as Record)["en"] ?? - slug - : slug; return ( - } - userId={userId} - generalGroupKey={`g:global:${slug}`} - fieldGroupKeyPrefix={`f:global:${slug}:`} - labelResolver={(fp) => resolveLabel(fieldLabelRegistry, slug, 0, fp)} - /> + ); })} 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/groupCommentsGlobally.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts index 6cb5137f..bd4ad6da 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/groupCommentsGlobally.ts @@ -2,65 +2,104 @@ import type { Comment } from "../../../types"; import { sortGroupsByCreatedAt } from "./sortGroupsByCreatedAt"; import type { FieldPath } from "../types"; -export interface GroupedComments { - collections: Map>>; - globals: Map>; -} +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 function groupCommentsGlobally(comments: Comment[]): GroupedComments { - const collections = new Map>>(); - const globals = new Map>(); +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) { - // Global comment 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; } - // Collection comment const slug = comment.collectionSlug; - const docId = String(comment.documentId); + const docId = comment.documentId; const field = comment.fieldPath ?? null; - - if (!slug || !comment.documentId) continue; + 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 groups - const toTime = (c: Comment) => new Date(c.createdAt).getTime(); - const oldestInFields = (fields: Map) => - Math.min(...[...fields.values()].flat().map(toTime)); - + // Sort collection inner maps for (const [slug, docs] of collections.entries()) { for (const [docId, fields] of docs.entries()) { docs.set(docId, sortGroupsByCreatedAt(fields)); } - const sortedDocs = new Map( - [...docs.entries()].sort(([, a], [, b]) => oldestInFields(a) - oldestInFields(b)), - ); + + 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 + // Sort globals inner maps for (const [slug, fields] of globals.entries()) { globals.set(slug, sortGroupsByCreatedAt(fields)); } - return { collections, globals }; + 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/resolveCollectionLabel.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveEntityLabel.ts similarity index 77% rename from packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveCollectionLabel.ts rename to packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveEntityLabel.ts index 8079b734..4fd5c84a 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveCollectionLabel.ts +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveEntityLabel.ts @@ -1,6 +1,6 @@ import type { EntityLabel } from "../../../types"; -export function resolveCollectionLabel(label: EntityLabel | undefined, locale: string, fallback: string) { +export function resolveEntityLabel(label: EntityLabel | undefined, locale: string, fallback: string) { if (label === undefined) return fallback; if (typeof label === "string") return label; 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/resolveLabel.ts b/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveLabel.ts deleted file mode 100644 index ff733dcc..00000000 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/utils/resolveLabel.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { GlobalFieldLabelRegistry } from "../../../types"; - -export function resolveLabel( - registry: GlobalFieldLabelRegistry, - collectionSlug: string, - documentId: number, - fieldPath: string, -) { - const segments = registry[collectionSlug]?.[documentId]?.[fieldPath]; - - if (!segments) return fieldPath; - - return segments.map((s) => (s.type === "field" ? s.label : `#${s.position + 1}`)).join(" > "); -} From 05da48841fceb9849889e3f0242cd047357442c0 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Sat, 14 Mar 2026 21:09:09 +0300 Subject: [PATCH 12/23] fix(payload-plugin-comments): split types into separate domain files --- .../utils/groupFieldPathsByDocument.ts | 4 +- .../payload-plugin-comments/src/types/base.ts | 11 +++ .../src/types/collection.ts | 1 + .../src/types/comment.ts | 24 ++++++ .../src/{types.ts => types/config.ts} | 80 ++----------------- .../src/types/entity.ts | 22 +++++ .../src/types/general.ts | 13 +++ .../src/types/index.ts | 7 ++ .../payload-plugin-comments/src/types/user.ts | 5 ++ .../src/utils/config/normalizeCollections.ts | 4 +- .../config/overrideCommentsCollection.ts | 4 +- 11 files changed, 97 insertions(+), 78 deletions(-) create mode 100644 packages/payload-plugin-comments/src/types/base.ts create mode 100644 packages/payload-plugin-comments/src/types/collection.ts create mode 100644 packages/payload-plugin-comments/src/types/comment.ts rename packages/payload-plugin-comments/src/{types.ts => types/config.ts} (58%) create mode 100644 packages/payload-plugin-comments/src/types/entity.ts create mode 100644 packages/payload-plugin-comments/src/types/general.ts create mode 100644 packages/payload-plugin-comments/src/types/index.ts create mode 100644 packages/payload-plugin-comments/src/types/user.ts diff --git a/packages/payload-plugin-comments/src/services/fieldLabels/utils/groupFieldPathsByDocument.ts b/packages/payload-plugin-comments/src/services/fieldLabels/utils/groupFieldPathsByDocument.ts index 93c14fbb..5ae4f52a 100644 --- a/packages/payload-plugin-comments/src/services/fieldLabels/utils/groupFieldPathsByDocument.ts +++ b/packages/payload-plugin-comments/src/services/fieldLabels/utils/groupFieldPathsByDocument.ts @@ -1,4 +1,6 @@ -import type { Comment, FieldPathsMap } from "../../../types"; +import type { Comment } from "../../../types"; + +type FieldPathsMap = Map>>; export function groupFieldPathsByDocument(comments: Comment[]) { const fieldPathsMap: FieldPathsMap = new Map(); 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.ts b/packages/payload-plugin-comments/src/types/config.ts similarity index 58% rename from packages/payload-plugin-comments/src/types.ts rename to packages/payload-plugin-comments/src/types/config.ts index bba7c0b6..18961a0d 100644 --- a/packages/payload-plugin-comments/src/types.ts +++ b/packages/payload-plugin-comments/src/types/config.ts @@ -1,20 +1,13 @@ -import type { CollectionConfig, Field, Payload, SanitizedCollectionConfig, SanitizedGlobalConfig } from "payload"; -import type { Translations } from "./translations/types"; - -export interface BaseServiceOptions { - payload?: Payload; - locale?: string | null; -} - -export type FieldLabelSegment = { type: "field"; label: string } | { type: "row"; id: string; position: number }; - -export type GlobalFieldLabelRegistry = Record>>; +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" } + * { + * slug: "pages", + * titleField: "title" + * } */ export interface CollectionEntry { slug: string; @@ -48,11 +41,6 @@ export interface TenantPluginConfig { /** * Configuration options for the `commentsPlugin`. - * - * Pass this object to the plugin factory to customise which collections - * support comments, enable multi-tenancy, and override the generated - * `comments` collection. - * * @example * commentsPlugin({ * collections: [ @@ -118,10 +106,6 @@ export interface CommentsPluginConfig { usernameFieldPath?: string; } -export type NormalizedCollectionConfig = Map; - -export type CommentsPluginConfigOverrides = CommentsPluginConfig["overrides"]; - export interface CommentsPluginConfigStorage { collections?: string[]; documentTitleFields?: Record; @@ -129,55 +113,3 @@ export interface CommentsPluginConfigStorage { tenant?: TenantPluginConfig; usernameFieldPath?: string; } - -export type DocumentTitles = Record>; - -export type FieldPathsMap = Map>>; - -export type Mode = "document" | "global-document" | "global"; - -export type FilterMode = "open" | "resolved" | "mentioned"; - -export interface BaseDocument { - id: string | number; - [key: string]: unknown; -} - -export type LoadingStatus = "idle" | "loading" | "error" | "success"; - -export interface User { - id: number; - email?: string | null; - [key: string]: unknown; -} - -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; -} - -export type Response = { success: true; data: T } | { success: false; error: string }; - -export type EntityConfig = SanitizedCollectionConfig | SanitizedGlobalConfig; - -export type EntityLabel = string | Record; - -export type EntityLabelsMap = Record; 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/config/normalizeCollections.ts b/packages/payload-plugin-comments/src/utils/config/normalizeCollections.ts index 3b0998c5..5db3fbcf 100644 --- a/packages/payload-plugin-comments/src/utils/config/normalizeCollections.ts +++ b/packages/payload-plugin-comments/src/utils/config/normalizeCollections.ts @@ -1,4 +1,6 @@ -import type { CollectionEntry, NormalizedCollectionConfig } from "../../types"; +import type { CollectionEntry } from "../../types"; + +type NormalizedCollectionConfig = Map; const DEFAULT_TITLE_FIELD = "id"; diff --git a/packages/payload-plugin-comments/src/utils/config/overrideCommentsCollection.ts b/packages/payload-plugin-comments/src/utils/config/overrideCommentsCollection.ts index 788d1929..9e12eaab 100644 --- a/packages/payload-plugin-comments/src/utils/config/overrideCommentsCollection.ts +++ b/packages/payload-plugin-comments/src/utils/config/overrideCommentsCollection.ts @@ -1,9 +1,9 @@ import type { CollectionConfig } from "payload"; -import type { CommentsPluginConfigOverrides } from "../../types"; +import type { CommentsPluginConfig } from "../../types"; export function overrideCommentsCollection( config: CollectionConfig, - overrides: CommentsPluginConfigOverrides, + overrides: CommentsPluginConfig["overrides"], ): CollectionConfig { const { access, admin, hooks, fields } = overrides ?? {}; From 178def988c69058c67af2a9b94ac5e08c834d684 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Sun, 15 Mar 2026 01:10:56 +0300 Subject: [PATCH 13/23] feat(payload-plugin-comments): add tools panel to CommentItem --- .../src/components/CommentItem/ToolsPanel.tsx | 44 +++++++++++++++ .../index.tsx} | 56 ++++++------------- 2 files changed, 61 insertions(+), 39 deletions(-) create mode 100644 packages/payload-plugin-comments/src/components/CommentItem/ToolsPanel.tsx rename packages/payload-plugin-comments/src/components/{CommentItem.tsx => CommentItem/index.tsx} (63%) 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..6740a255 --- /dev/null +++ b/packages/payload-plugin-comments/src/components/CommentItem/ToolsPanel.tsx @@ -0,0 +1,44 @@ +import { CircleCheck, Trash2, Undo2 } from "lucide-react"; +import { cn } from "../../utils/general/cn"; +import { useTranslation } from "@payloadcms/ui"; + +interface Props { + isResolved: boolean; + canDelete: boolean; + onResolve: () => void; + onDelete: () => void; +} + +export function ToolsPanel({ isResolved, canDelete, onDelete, onResolve }: Props) { + const { t } = useTranslation(); + + return ( +
+ + + {canDelete && ( + + )} +
+ ); +} diff --git a/packages/payload-plugin-comments/src/components/CommentItem.tsx b/packages/payload-plugin-comments/src/components/CommentItem/index.tsx similarity index 63% rename from packages/payload-plugin-comments/src/components/CommentItem.tsx rename to packages/payload-plugin-comments/src/components/CommentItem/index.tsx index 4a668d42..00022893 100644 --- a/packages/payload-plugin-comments/src/components/CommentItem.tsx +++ b/packages/payload-plugin-comments/src/components/CommentItem/index.tsx @@ -1,13 +1,14 @@ "use client"; -import { useState, useTransition } from "react"; +import { startTransition, useState } 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 { 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"; function formatDate(iso: string) { try { @@ -31,7 +32,6 @@ interface Props { export function CommentItem({ comment, currentUserId }: Props) { const { resolveComment, removeComment, usernameFieldPath } = useComments(); const { t } = useTranslation(); - const [isPending, startTransition] = useTransition(); const [error, setError] = useState(null); const deletedUserLabel = t("comments:deletedUser" as never) ?? FALLBACK_DELETED_USERNAME; @@ -64,11 +64,14 @@ export function CommentItem({ comment, currentUserId }: Props) { } return ( -
+
+ +
{authorInitial} @@ -76,7 +79,7 @@ export function CommentItem({ comment, currentUserId }: Props) {
- {authorName} + {authorName} {formatDate(comment.createdAt)}
@@ -90,31 +93,6 @@ export function CommentItem({ comment, currentUserId }: Props) { })}

-
- - - {canDelete && ( - - )} -
- {error &&

{error}

}
From 9d68ccf024c271ac90b88995594e9e3ac17509f8 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Sun, 15 Mar 2026 02:06:45 +0300 Subject: [PATCH 14/23] feat(payload-plugin-comments): format createdAt label to relative date --- .../src/components/CommentItem/index.tsx | 51 ++++++------------- .../src/hooks/useRelativeDate.ts | 10 ++++ .../src/utils/general/formatRelativeDate.ts | 30 +++++++++++ 3 files changed, 55 insertions(+), 36 deletions(-) create mode 100644 packages/payload-plugin-comments/src/hooks/useRelativeDate.ts create mode 100644 packages/payload-plugin-comments/src/utils/general/formatRelativeDate.ts diff --git a/packages/payload-plugin-comments/src/components/CommentItem/index.tsx b/packages/payload-plugin-comments/src/components/CommentItem/index.tsx index 00022893..87041f9d 100644 --- a/packages/payload-plugin-comments/src/components/CommentItem/index.tsx +++ b/packages/payload-plugin-comments/src/components/CommentItem/index.tsx @@ -1,6 +1,6 @@ "use client"; -import { startTransition, useState } from "react"; +import { startTransition } from "react"; import { useTranslation } from "@payloadcms/ui"; import { cn } from "../../utils/general/cn"; import type { Comment } from "../../types"; @@ -9,20 +9,7 @@ import { renderCommentText } from "../../utils/comment/renderCommentText"; import { resolveUsername } from "../../utils/user/resolveUsername"; import { FALLBACK_DELETED_USERNAME, FALLBACK_USERNAME } from "../../constants"; import { ToolsPanel } from "./ToolsPanel"; - -function formatDate(iso: string) { - try { - return new Intl.DateTimeFormat("en", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }).format(new Date(iso)); - } catch { - return iso; - } -} +import { useRelativeDate } from "../../hooks/useRelativeDate"; interface Props { comment: Comment; @@ -32,7 +19,7 @@ interface Props { export function CommentItem({ comment, currentUserId }: Props) { const { resolveComment, removeComment, usernameFieldPath } = useComments(); const { t } = useTranslation(); - const [error, setError] = useState(null); + const createdAtRelativeDate = useRelativeDate(comment.createdAt); const deletedUserLabel = t("comments:deletedUser" as never) ?? FALLBACK_DELETED_USERNAME; const unknownLabel = t("comments:unknownAuthor" as never) ?? FALLBACK_USERNAME; @@ -45,26 +32,20 @@ export function CommentItem({ comment, currentUserId }: Props) { comment.author && typeof comment.author === "object" && "id" in comment.author ? comment.author.id : null; const canDelete = currentUserId !== null && authorId === currentUserId; - function handleToggleResolve() { - setError(null); + const handleToggleResolve = () => { startTransition(async () => { - const result = await resolveComment(comment.id, !isResolved); - - if (!result.success) setError(result.error ?? t("comments:failedToUpdate" as never)); + await resolveComment(comment.id, !isResolved); }); - } + }; - function handleDelete() { - setError(null); + const handleDelete = () => { startTransition(async () => { - const result = await removeComment(comment.id); - - if (!result.success) setError(result.error ?? t("comments:failedToDelete" as never)); + await removeComment(comment.id); }); - } + }; return ( -
+
-
+
{authorInitial}
-
+
- {authorName} - {formatDate(comment.createdAt)} + {authorName} + {createdAtRelativeDate}
-

+

{renderCommentText({ text: comment.text, mentions: comment.mentions, @@ -92,8 +73,6 @@ export function CommentItem({ comment, currentUserId }: Props) { fallbackDeletedUsername: deletedUserLabel, })}

- - {error &&

{error}

}
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/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"); +} From b264b038a33fd449786b518e33b074955528685a Mon Sep 17 00:00:00 2001 From: Pekarski Date: Sun, 15 Mar 2026 22:23:42 +0300 Subject: [PATCH 15/23] feat(payload-plugin-comments): style CommentsEditor component --- packages/payload-plugin-comments/package.json | 1 + .../src/components/AddCommentForm.tsx | 73 ---------- .../src/components/Avatar/index.tsx | 21 +++ .../components/CommentEditor/ActionPanel.tsx | 28 ++++ .../src/components/CommentEditor/index.tsx | 134 ++++++++++++------ .../src/components/CommentItem/ToolsPanel.tsx | 22 +-- .../src/components/CommentItem/index.tsx | 7 +- .../components/FieldGroupSection.tsx | 6 +- .../components/FieldCommentLabel/index.tsx | 65 +-------- .../src/components/IconButton/index.tsx | 38 +++++ .../src/utils/user/resolveUsername.ts | 3 +- 11 files changed, 201 insertions(+), 197 deletions(-) delete mode 100644 packages/payload-plugin-comments/src/components/AddCommentForm.tsx create mode 100644 packages/payload-plugin-comments/src/components/Avatar/index.tsx create mode 100644 packages/payload-plugin-comments/src/components/CommentEditor/ActionPanel.tsx create mode 100644 packages/payload-plugin-comments/src/components/IconButton/index.tsx diff --git a/packages/payload-plugin-comments/package.json b/packages/payload-plugin-comments/package.json index 6bae9da2..bf40022b 100644 --- a/packages/payload-plugin-comments/package.json +++ b/packages/payload-plugin-comments/package.json @@ -58,6 +58,7 @@ "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", diff --git a/packages/payload-plugin-comments/src/components/AddCommentForm.tsx b/packages/payload-plugin-comments/src/components/AddCommentForm.tsx deleted file mode 100644 index ca225dd4..00000000 --- a/packages/payload-plugin-comments/src/components/AddCommentForm.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import { useState, useTransition, useRef } from "react"; -import { cn } from "../utils/general/cn"; -import { useComments } from "../providers/CommentsProvider"; -import { useLocale, useTranslation } from "@payloadcms/ui"; -import { CommentEditor } from "./CommentEditor"; -import type { CommentEditorHandle } from "./CommentEditor"; - -interface AddCommentFormProps { - fieldPath?: string | null; - documentId?: number; - collectionSlug?: string; - globalSlug?: string; -} - -export function AddCommentForm({ fieldPath, documentId, collectionSlug, globalSlug }: AddCommentFormProps) { - const { addComment } = useComments(); - const { code: locale } = useLocale(); - const { t } = useTranslation(); - - const [error, setError] = useState(null); - const [isPending, startTransition] = useTransition(); - - const editorRef = useRef(null); - - function handleSubmit() { - const serialized = editorRef.current?.getValue() ?? ""; - if (!serialized) return; - - setError(null); - editorRef.current?.clear(); - - startTransition(async () => { - const result = await addComment(serialized, fieldPath, documentId, collectionSlug, locale, globalSlug); - - if (!result.success) { - setError(result.error ?? t("comments:failedToPost" as never)); - } - }); - } - - return ( -
- - - {error &&

{error}

} - -
- - - -
-
- ); -} 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 index 5c83504e..1d1441b1 100644 --- a/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx +++ b/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState, useRef, useImperativeHandle, type RefObject } from "react"; -import { useAuth, useTranslation } from "@payloadcms/ui"; +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"; @@ -11,6 +11,8 @@ 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; @@ -24,6 +26,11 @@ interface CommentEditorProps { autoFocus?: boolean; placeholder?: string; ref?: RefObject; + fieldPath?: string | null; + documentId?: number; + collectionSlug?: string; + globalSlug?: string; + onSuccessAddComment?: () => void; onEnterPress?: () => void; onEscapePress?: () => void; } @@ -31,12 +38,18 @@ interface CommentEditorProps { export function CommentEditor({ disabled, autoFocus, - placeholder = "Add a comment", + placeholder: placeholderProp, ref, + fieldPath, + documentId, + collectionSlug, + globalSlug, + onSuccessAddComment, onEnterPress, onEscapePress, }: CommentEditorProps) { - const { mentionUsers: users } = useComments(); + const { mentionUsers: users, addComment } = useComments(); + const { code: locale } = useLocale(); const { user } = useAuth(); const [mentionQuery, setMentionQuery] = useState(null); @@ -47,11 +60,13 @@ export function CommentEditor({ const { usernameFieldPath } = useComments(); const { t } = useTranslation(); - const unknownLabel = t("comments:unknownAuthor" as never) ?? FALLBACK_USERNAME; - 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(); @@ -172,6 +187,47 @@ export function CommentEditor({ 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") { @@ -199,6 +255,8 @@ export function CommentEditor({ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); + + handleAddComment(); onEnterPress?.(); } else if (e.key === "Escape") { onEscapePress?.(); @@ -210,43 +268,37 @@ export function CommentEditor({ updateEmptyClass(); }; - useImperativeHandle(ref, () => ({ - getValue() { - if (!editorRef.current) return ""; - - return serializeEditor(editorRef.current).trim(); - }, - clear() { - if (editorRef.current) { - editorRef.current.innerHTML = ""; - editorRef.current.classList.add("is-empty"); - } - }, - focus() { - editorRef.current?.focus(); - }, - insertAt, - })); - return (
-
+
+ + +
+
+ + insertAt()} + onAddComment={handleAddComment} + /> +
+
{mentionQuery !== null && filteredUsers.length > 0 && ( diff --git a/packages/payload-plugin-comments/src/components/CommentItem/ToolsPanel.tsx b/packages/payload-plugin-comments/src/components/CommentItem/ToolsPanel.tsx index 6740a255..3bac8685 100644 --- a/packages/payload-plugin-comments/src/components/CommentItem/ToolsPanel.tsx +++ b/packages/payload-plugin-comments/src/components/CommentItem/ToolsPanel.tsx @@ -1,6 +1,6 @@ import { CircleCheck, Trash2, Undo2 } from "lucide-react"; -import { cn } from "../../utils/general/cn"; import { useTranslation } from "@payloadcms/ui"; +import { IconButton } from "../IconButton"; interface Props { isResolved: boolean; @@ -14,30 +14,20 @@ export function ToolsPanel({ isResolved, canDelete, onDelete, onResolve }: Props return (
- + {canDelete && ( - + )}
); diff --git a/packages/payload-plugin-comments/src/components/CommentItem/index.tsx b/packages/payload-plugin-comments/src/components/CommentItem/index.tsx index 87041f9d..886bdea2 100644 --- a/packages/payload-plugin-comments/src/components/CommentItem/index.tsx +++ b/packages/payload-plugin-comments/src/components/CommentItem/index.tsx @@ -10,6 +10,7 @@ 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; @@ -25,7 +26,7 @@ export function CommentItem({ comment, currentUserId }: Props) { 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 authorInitial = authorName.charAt(0).toUpperCase(); + const isResolved = comment.isResolved ?? false; const authorId = @@ -54,9 +55,7 @@ export function CommentItem({ comment, currentUserId }: Props) { />
-
- {authorInitial} -
+
diff --git a/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx b/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx index 309b612b..6dab52ba 100644 --- a/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx +++ b/packages/payload-plugin-comments/src/components/CommentsPanel/components/FieldGroupSection.tsx @@ -4,10 +4,10 @@ import { useTranslation } from "@payloadcms/ui"; import type { Comment } from "../../../types/comment"; import { CollapsibleGroup } from "./CollapsibleGroup"; import { CommentItem } from "../../CommentItem"; -import { AddCommentForm } from "../../AddCommentForm"; import { createCollapsibleGroupKey } from "../utils/createCollapsibleGroupKey"; import { resolveFieldLabel } from "../utils/resolveFieldLabel"; import { useComments } from "../../../providers/CommentsProvider"; +import { CommentEditor } from "../../CommentEditor"; interface Props { fields: Map; @@ -36,7 +36,7 @@ export function FieldGroupSection({ fields, userId, collectionSlug, documentId, ))} - ))} - (null); - const [isPending, startTransition] = useTransition(); const popoverRef = useRef(null); const commentEditorRef = useRef(null); @@ -66,31 +64,6 @@ export function FieldCommentLabel({ field, htmlFor, path: fieldPath }: Props) { openDrawer(); }; - const handleSubmit = () => { - const trimmedComment = (commentEditorRef.current?.getValue() ?? "").trim(); - - if (!trimmedComment || isPending) return; - - setSubmitError(null); - - startTransition(async () => { - const result = await addComment( - trimmedComment, - stablePath || undefined, - undefined, - undefined, - stablePath ? locale : null, - mode === 'global-document' ? (globalSlug ?? undefined) : undefined, - ); - - if (result.success) { - setShowPopover(false); - } else { - setSubmitError(result.error ?? t("comments:failedToAdd" as never)); - } - }); - }; - return (
)} - {fieldPath && (mode === 'document' || mode === 'global-document') && ( + {fieldPath && (mode === "document" || mode === "global-document") && (
{openCommentsCount > 0 ? setShowPopover(false)} onEscapePress={() => setShowPopover(false)} placeholder={`${t("comments:writeComment" as never)}…`} /> - - {submitError &&

{submitError}

} - -
- - - -
)}
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..ebe3d19d --- /dev/null +++ b/packages/payload-plugin-comments/src/components/IconButton/index.tsx @@ -0,0 +1,38 @@ +import { cva } from "class-variance-authority"; +import { cn } from "../../utils/general/cn"; + +const variants = cva( + "flex justify-center items-center p-0 w-[24px] h-[24px] rounded border-none transition-colors cursor-pointer", + { + variants: { + variant: { + neutral: "bg-transparent hover:bg-(--theme-elevation-50) text-(--theme-elevation-450)", + primary: "bg-(--theme-elevation-1000) hover:bg-(--theme-elevation-800) text-(--theme-elevation-0)", + }, + }, + defaultVariants: { + variant: "neutral", + }, + }, +); + +interface Props { + className?: string; + title?: string; + children: React.ReactNode; + variant?: "neutral" | "primary"; + onClick: () => void; +} + +export function IconButton({ className, title, children, variant, onClick }: Props) { + return ( + + ); +} diff --git a/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts b/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts index b1ef0df9..9eabc573 100644 --- a/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts +++ b/packages/payload-plugin-comments/src/utils/user/resolveUsername.ts @@ -1,9 +1,10 @@ +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 | null | undefined, + user: User | ClientUser | null | undefined, usernameFieldPath: string = USERNAME_DEFAULT_FIELD_PATH, fallbackLabel: string, ) { From 35f677bfbdc0462ccdecb7a59d4ef7648607a10c Mon Sep 17 00:00:00 2001 From: Pekarski Date: Sun, 15 Mar 2026 22:57:48 +0300 Subject: [PATCH 16/23] feat(payload-plugin-comments): style CommentsHeaderButton component --- .../components/CommentsHeaderButton/index.tsx | 27 ++++--------------- .../src/components/IconButton/index.tsx | 3 ++- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/packages/payload-plugin-comments/src/components/CommentsHeaderButton/index.tsx b/packages/payload-plugin-comments/src/components/CommentsHeaderButton/index.tsx index d5c1f38d..68122383 100644 --- a/packages/payload-plugin-comments/src/components/CommentsHeaderButton/index.tsx +++ b/packages/payload-plugin-comments/src/components/CommentsHeaderButton/index.tsx @@ -1,37 +1,20 @@ "use client"; -import { Button, useTranslation } from "@payloadcms/ui"; +import { useTranslation } from "@payloadcms/ui"; import { MessageSquare } from "lucide-react"; -import { useComments } from "../../providers/CommentsProvider"; import { CommentsDrawer } from "../CommentsDrawer"; import { useCommentsDrawer } from "../../providers/CommentsDrawerProvider"; +import { IconButton } from "../IconButton"; export function CommentsHeaderButton() { - const { allComments, mode, visibleComments } = useComments(); const { slug, open } = useCommentsDrawer(); const { t } = useTranslation(); - const openCount = (mode === "document" ? visibleComments : allComments).filter((c) => !c.isResolved).length; - - const handleOpen = () => { - open(); - }; - return ( <> - + open()} title={t("comments:openCommentsAria" as never)}> + + diff --git a/packages/payload-plugin-comments/src/components/IconButton/index.tsx b/packages/payload-plugin-comments/src/components/IconButton/index.tsx index ebe3d19d..83bdc398 100644 --- a/packages/payload-plugin-comments/src/components/IconButton/index.tsx +++ b/packages/payload-plugin-comments/src/components/IconButton/index.tsx @@ -7,6 +7,7 @@ const variants = cva( variants: { variant: { neutral: "bg-transparent hover:bg-(--theme-elevation-50) text-(--theme-elevation-450)", + neutralSecondary: "bg-(--theme-elevation-100) hover:bg-(--theme-elevation-150) text-(--theme-elevation-600)", primary: "bg-(--theme-elevation-1000) hover:bg-(--theme-elevation-800) text-(--theme-elevation-0)", }, }, @@ -20,7 +21,7 @@ interface Props { className?: string; title?: string; children: React.ReactNode; - variant?: "neutral" | "primary"; + variant?: "neutral" | "neutralSecondary" | "primary"; onClick: () => void; } From bf40c26df11f8799d5c817c98f2a1714619597e5 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Mon, 16 Mar 2026 00:21:49 +0300 Subject: [PATCH 17/23] feat(payload-plugin-comments): style AddCommentPopup component --- .../FieldCommentLabel/AddCommentPopup.tsx | 49 ++++++++++++ .../components/CommentsButton.tsx | 22 ------ .../components/FieldCommentLabel/index.tsx | 75 ++++--------------- .../src/components/IconButton/index.tsx | 33 ++++---- 4 files changed, 83 insertions(+), 96 deletions(-) create mode 100644 packages/payload-plugin-comments/src/components/FieldCommentLabel/AddCommentPopup.tsx delete mode 100644 packages/payload-plugin-comments/src/components/FieldCommentLabel/components/CommentsButton.tsx 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/components/CommentsButton.tsx b/packages/payload-plugin-comments/src/components/FieldCommentLabel/components/CommentsButton.tsx deleted file mode 100644 index 653381c8..00000000 --- a/packages/payload-plugin-comments/src/components/FieldCommentLabel/components/CommentsButton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { cn } from "../../../utils/general/cn"; -import type { ButtonHTMLAttributes } from "react"; - -interface Props extends ButtonHTMLAttributes { - className?: string; -} - -export function CommentsButton({ className, children, ...restProps }: Props) { - return ( - - ); -} diff --git a/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx b/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx index 96f661d4..c085fbcd 100644 --- a/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx +++ b/packages/payload-plugin-comments/src/components/FieldCommentLabel/index.tsx @@ -1,19 +1,17 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { useTranslation, useLocale } from "@payloadcms/ui"; -import { MessageSquareIcon, MessageSquarePlus } from "lucide-react"; -import { cn } from "../../utils/general/cn"; +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 { CommentsButton } from "./components/CommentsButton"; import { useStablePath } from "./hooks/useStablePath"; import { useCommentsDrawer } from "../../providers/CommentsDrawerProvider"; -import { CommentEditor } from "../CommentEditor"; -import type { CommentEditorHandle } from "../CommentEditor"; +import { IconButton } from "../IconButton"; +import { AddCommentPopup } from "./AddCommentPopup"; interface Props extends FieldLabelClientProps { field: FieldLabelClientProps["field"] & { @@ -28,42 +26,26 @@ export function FieldCommentLabel({ field, htmlFor, path: fieldPath }: Props) { const { t } = useTranslation(); const { code: locale } = useLocale(); const { open: openDrawer, setScrollTargetPath } = useCommentsDrawer(); - const { visibleComments, setFilter, mode, globalSlug } = useComments(); + const { visibleComments, setFilter, mode } = useComments(); const [isHovered, setIsHovered] = useState(false); - const [showPopover, setShowPopover] = useState(false); - - const popoverRef = useRef(null); - const commentEditorRef = useRef(null); + 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; - useEffect(() => { - if (!showPopover) return; - - const handleClickOutside = (e: MouseEvent) => { - const target = e.target as Node; - const popover = popoverRef.current; - - if (popover && !popover.contains(target)) { - setShowPopover(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [showPopover]); - const handleOpenDrawer = () => { setFilter("open"); setScrollTargetPath(stablePath || null); openDrawer(); }; + const handleToggle = (isOpen: boolean) => { + setIsPopupOpen(isOpen); + }; + return (
{openCommentsCount > 0 ? - - + {openCommentsCount} - - : setShowPopover(true)}> - - - } - - {showPopover && ( -
-

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

- - setShowPopover(false)} - onEscapePress={() => setShowPopover(false)} - placeholder={`${t("comments:writeComment" as never)}…`} - /> -
- )} + + : }
)}
diff --git a/packages/payload-plugin-comments/src/components/IconButton/index.tsx b/packages/payload-plugin-comments/src/components/IconButton/index.tsx index 83bdc398..6f375b73 100644 --- a/packages/payload-plugin-comments/src/components/IconButton/index.tsx +++ b/packages/payload-plugin-comments/src/components/IconButton/index.tsx @@ -1,34 +1,37 @@ import { cva } from "class-variance-authority"; import { cn } from "../../utils/general/cn"; -const variants = cva( - "flex justify-center items-center p-0 w-[24px] h-[24px] rounded border-none transition-colors cursor-pointer", - { - variants: { - variant: { - neutral: "bg-transparent hover:bg-(--theme-elevation-50) text-(--theme-elevation-450)", - neutralSecondary: "bg-(--theme-elevation-100) hover:bg-(--theme-elevation-150) text-(--theme-elevation-600)", - primary: "bg-(--theme-elevation-1000) hover:bg-(--theme-elevation-800) text-(--theme-elevation-0)", - }, +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)", + neutralSecondary: "bg-(--theme-elevation-100) hover:bg-(--theme-elevation-150) text-(--theme-elevation-600)", + primary: "bg-(--theme-elevation-1000) hover:bg-(--theme-elevation-800) text-(--theme-elevation-0)", }, - defaultVariants: { - variant: "neutral", + 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"; - onClick: () => void; + size?: "sm" | "md"; + onClick?: () => void; } -export function IconButton({ className, title, children, variant, onClick }: Props) { +export function IconButton({ className, title, children, variant, size, onClick }: Props) { return ( + onChange={(option) => 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/IconButton/index.tsx b/packages/payload-plugin-comments/src/components/IconButton/index.tsx index 6f375b73..9fb415a3 100644 --- a/packages/payload-plugin-comments/src/components/IconButton/index.tsx +++ b/packages/payload-plugin-comments/src/components/IconButton/index.tsx @@ -4,8 +4,9 @@ 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)", - neutralSecondary: "bg-(--theme-elevation-100) hover:bg-(--theme-elevation-150) text-(--theme-elevation-600)", + 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: { From 49829ebe77bfe4a28361a483fd590249d3723d4a Mon Sep 17 00:00:00 2001 From: Pekarski Date: Mon, 16 Mar 2026 03:57:14 +0300 Subject: [PATCH 21/23] feat(payload-plugin-comments): style MentionDropdown component --- .../src/components/CommentEditor/index.tsx | 46 ++++++++++--------- .../src/components/MentionDropdown.tsx | 40 +++++++--------- .../payload-plugin-comments/src/styles.css | 19 ++++++++ .../src/translations/en.ts | 1 + .../src/translations/types.ts | 1 + 5 files changed, 62 insertions(+), 45 deletions(-) diff --git a/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx b/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx index 1d1441b1..0a8bec7b 100644 --- a/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx +++ b/packages/payload-plugin-comments/src/components/CommentEditor/index.tsx @@ -273,24 +273,30 @@ export function CommentEditor({
-
-
+
+
+
+
+ + {mentionQuery !== null && ( + + )}
- - {mentionQuery !== null && filteredUsers.length > 0 && ( - - )}
); } diff --git a/packages/payload-plugin-comments/src/components/MentionDropdown.tsx b/packages/payload-plugin-comments/src/components/MentionDropdown.tsx index 37689835..30041dc2 100644 --- a/packages/payload-plugin-comments/src/components/MentionDropdown.tsx +++ b/packages/payload-plugin-comments/src/components/MentionDropdown.tsx @@ -1,10 +1,9 @@ "use client"; -import { useTranslation } from "@payloadcms/ui"; +import { PopupList, useTranslation } from "@payloadcms/ui"; import { FALLBACK_USERNAME } from "../constants"; import { useComments } from "../providers/CommentsProvider"; import type { User } from "../types"; -import { cn } from "../utils/general/cn"; import { resolveUsername } from "../utils/user/resolveUsername"; interface MentionDropdownProps { @@ -20,26 +19,21 @@ export function MentionDropdown({ users, selectedIndex, onSelect }: MentionDropd const unknownLabel = t("comments:unknownAuthor" as never) ?? FALLBACK_USERNAME; return ( -
    - {users.map((user, index) => ( -
  • { - e.preventDefault(); - - onSelect(user); - }} - className={cn( - "px-3 py-1.5 text-[13px] text-(--theme-text) cursor-pointer", - index === selectedIndex && "bg-(--theme-elevation-100)", - )}> - @{resolveUsername(user, usernameFieldPath, unknownLabel)} -
  • - ))} -
+
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/styles.css b/packages/payload-plugin-comments/src/styles.css index bb977830..ad821c28 100644 --- a/packages/payload-plugin-comments/src/styles.css +++ b/packages/payload-plugin-comments/src/styles.css @@ -10,3 +10,22 @@ .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 index 279c48c2..bd785609 100644 --- a/packages/payload-plugin-comments/src/translations/en.ts +++ b/packages/payload-plugin-comments/src/translations/en.ts @@ -29,5 +29,6 @@ export const en: { comments: CommentsTranslations } = { 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 index 9a2d76eb..31b02ea1 100644 --- a/packages/payload-plugin-comments/src/translations/types.ts +++ b/packages/payload-plugin-comments/src/translations/types.ts @@ -26,6 +26,7 @@ export interface CommentsTranslations { filterOpen: string; filterResolved: string; filterMentioned: string; + noMentionMatches: string; } export type Translations = Record>; From 660f5fe3fde98fa2500176d5fc1dc4b158b1015f Mon Sep 17 00:00:00 2001 From: Pekarski Date: Mon, 16 Mar 2026 10:37:12 +0300 Subject: [PATCH 22/23] fix(payload-plugin-comments): update Payload packages versions --- packages/payload-plugin-comments/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/payload-plugin-comments/package.json b/packages/payload-plugin-comments/package.json index bf40022b..049914aa 100644 --- a/packages/payload-plugin-comments/package.json +++ b/packages/payload-plugin-comments/package.json @@ -95,9 +95,9 @@ "eslint": "^9.0.0", "eslint-config-prettier": "^9.0.0", "next": "15.4.10", - "payload": "3.73.0", - "@payloadcms/ui": "3.73.0", - "@payloadcms/plugin-multi-tenant": "3.73.0", + "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", From d81f325fac06a43f65b93e0fe5052728c2268f06 Mon Sep 17 00:00:00 2001 From: Pekarski Date: Mon, 16 Mar 2026 11:18:50 +0300 Subject: [PATCH 23/23] docs(payload-plugin-comments): update README --- packages/payload-plugin-comments/README.md | 256 +++++++++++++++--- .../screenshots/document-comments-panel.png | Bin 30075 -> 27001 bytes .../docs/screenshots/field-comment-popup.png | Bin 19505 -> 13720 bytes .../screenshots/field-label-button-active.png | Bin 4861 -> 2459 bytes .../field-label-button-inactive.png | Bin 5475 -> 2437 bytes .../screenshots/global-comments-panel.png | Bin 37317 -> 30040 bytes 6 files changed, 217 insertions(+), 39 deletions(-) diff --git a/packages/payload-plugin-comments/README.md b/packages/payload-plugin-comments/README.md index d5efa79c..dd0fb010 100644 --- a/packages/payload-plugin-comments/README.md +++ b/packages/payload-plugin-comments/README.md @@ -2,10 +2,11 @@ [![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, @mentions with email notifications, comment resolution, multi-tenancy, and locale-aware filtering. +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) @@ -15,12 +16,129 @@ A collaborative commenting plugin for [Payload CMS](https://payloadcms.com/) v3. - [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 @@ -53,12 +171,13 @@ The comment button embedded in the field label has two visual states: no comment ## Features -- **Document-level comments** — Leave comments on any document in any collection +- **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 +- **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 @@ -88,6 +207,8 @@ npm install @focus-reactive/payload-plugin-comments 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 @@ -102,12 +223,10 @@ import { commentsPlugin } from "@focus-reactive/payload-plugin-comments"; export default buildConfig({ plugins: [ commentsPlugin({ - // All collections will get comments. You can specify title field for UI. + // Optional: customize which field is used as the document title in the UI collections: [ - { - slug: "pages", - titleField: "title", - }, + { slug: "pages", titleField: "title" }, + { slug: "products", titleField: "name" }, ], }), ], @@ -130,6 +249,35 @@ Or import it in a layout/page file: 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 @@ -142,17 +290,18 @@ commentsPlugin(config?: CommentsPluginConfig) ### `CommentsPluginConfig` -| Option | Type | Default | Description | -| -------------- | --------------------- | --------------- | --------------------------------------------- | -| `collections` | `CollectionEntry[]` | all collections | Collections whose documents support comments | -| `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) | +| 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` can be a plain slug string or an object: +Each entry in `collections` is an object that provides UI metadata for a specific collection: ```ts interface CollectionEntry { @@ -161,17 +310,31 @@ interface CollectionEntry { } ``` +> **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" }, // Uses "title" field as display name + { 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`: @@ -216,7 +379,9 @@ commentsPlugin({ }); ``` -### Translations +--- + +## 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. @@ -264,19 +429,21 @@ All translatable keys (with their English defaults): | `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 `Translations` type is exported from the package so you can type your translation objects: +The `CommentsTranslations` type is exported from the package so you can type your translation objects: ```ts -import type { Translations } from "@focus-reactive/payload-plugin-comments"; +import type { CommentsTranslations } from "@focus-reactive/payload-plugin-comments"; -const myTranslations: Translations = { +const myTranslations: Record> = { fr: { label: "Commentaires" }, de: { label: "Kommentare" }, }; @@ -314,42 +481,53 @@ RESEND_FROM_EMAIL=comments@yourdomain.com 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 configured collection to inject `FieldCommentLabel` into each field's admin label — this shows a comment count badge next to each field. -4. It registers two admin providers (`CommentsProviderWrapper`, `GlobalCommentsLoader`) and one admin action (`CommentsHeaderButton`). +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, and collection labels in parallel. +- 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/locale based on the Next.js `pathname`. +- `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. +- 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 | -| `collectionSlug` | text | Slug of the collection | -| `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) | +| 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) | --- diff --git a/packages/payload-plugin-comments/docs/screenshots/document-comments-panel.png b/packages/payload-plugin-comments/docs/screenshots/document-comments-panel.png index 40f49eb80dfe21b8b6c43955b773a4030876cd2b..34c7a00b364dc0d7b2b0ee9502f4903d3205e28c 100644 GIT binary patch literal 27001 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 AF#rGn literal 30075 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 vl>YwAQ$i**kGTG&8fPLY!%;avM1eI!}l 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 NE@@rNI)Crk{{aRCOoRXc literal 19505 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 literal 5475 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 diff --git a/packages/payload-plugin-comments/docs/screenshots/global-comments-panel.png b/packages/payload-plugin-comments/docs/screenshots/global-comments-panel.png index 000ef8effb8033ced1e1922d03481fb2d933000e..7ab19cc2ad568f41db66ac1407ee3436c312de30 100644 GIT binary patch literal 30040 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 literal 37317 zcmd422UJsC*Di`m6=|ZehKzss8A z)>@?iQs#z+%ov1!0S@bj#HK-N;zcw2F=^99Wo2czseq3-rlFUB4fwb@Rp9RL6Geqb zgolUge@VBu6X50THy+=(e&a@;!qpRw3Aqmx)@g2=C)9#R+#_U_%gox?+6z3RtTjdeqQ`t+&9d0xincuV7WYu0wY zsj)Vq;@Zl}$}$E6Ssl1fNX|Hs+m>|I{pGaozP=o|x&j zJeU;`thyE>llWE0TRO%T}N!QEMxL5m%z~H*gJ>upTq8a_k?`V_) zCMw;NipxxD=`YIYW9Q|FPe@nNs{Ptj5nkY*8CQXP(F1Cb`}qkS+w?eADL^!M`tq}N z(OoJk+fTi{>beE`Eg$Z3_3D6KD*Z3dkNb2Ic4k<0Gh~Sv>8oxr#%vW6y(ga{fOExl^wtDW1i<)13uJP&VEr;?>UjeZSb zx7Dum-2BZOup24Smc$=2>(WLR5*nJ4s^2OYJOL(YFBDZ5sbW#@T_fY@Wl__wUK?0J zv9vCb3~~qf-Mc?ps34!M^P9~zA*#ElRoUf2*0FlJ6m)}Mgu&IQZ*#>MdV$4sBatFB zdca@wtk=9Sjc9&dtG+#%xwj1^7pLv09XuwC0gJ&$lQ}HsjhDKQ$@ohO22d?l)xbKp zneX!p=Xd@=j=b+|9nGiFXF+Zm~w^~R2Y-fZ7V<$xz zs($QdGkh3M)bOk~wn#oV%Z_-Z1}!h1e|ZE#$8OOj_lZmCAXP9)}R zUHgbdAUk4+?Fd$uaFW!m9&MneSlH5epLd1o!KK@^{AO;qko-Niqwnq1(hz<#W;ISH zAEC(c?^?^fnmLFeWkaF#+QX-B<`cVS%;c#v%jW5dR3D$Z36lmuY3$3>?NJwpk8vhp zrWKi*X5r?iX4?J4&`+4yJEF=yIdrd&&Xs5`&r? zv7WsiYzoh56jHLd1Vp^9Hex07Hi&D=-!V;g&M3k`=n%UyTpp3X`3$d%{cuoh*es3^cRK=> zpRm~sjcxDmeSg@c&f%2o+N&;)&l)`I%US@7;=e^~{;T8TZsTHq^J5ZjX3W{PD}Ot= zD%eu>o`VLUO9{xrx$y}EbyAH?%jSGt#C}%#SKg33mEgtcbB$Bf&@cfJQMpCO2f@}P z@es$En!yI!$Ujq+Ty3Fb`9AZt5R5Ke>rNM|=Yr%B!#oA;%Y8FVVO2~=bD^-*Z?=!6 z$4$S`o5+rHdm1WhTH_vHuAtFMf;)k-{0$eeOgN+?clwgd6TR2?S`?0N>SvKh9+BaD zqPn@}Ac%YQ6alqC9aPd2=Q%bfJW=>FFE6|WLOoeE9DNRokf#tB3;3Pb35UEDR@0+< zJ~9ApD4BE>Y&)v#v;-P3ZWj@{!gr#J5uR!aQlN%o4BEkF5|*xSg^H&)C&oK0q?THa z>n_;*(^p@$7lKV z&|RYX{P!IidwLbf2g4mctst2|3iIktzFD!Q@c=e{FSAgX@0V%o8Eon!InGbCN2$l= z0=7Ea&y%Vjg~lS|4@pvyGml6AlsxJ)-8XyJGAJcB4~_DBo+JYkxp8Y0CuleG)6=Bh zqBpxcG8cjywpRh+U$g`#Uo2SIR3brk^4SXS`K>gL%A<|jGO48u3wC`?!TLOZ@v}uP&)nGBZOWQCpe>MsTfTTf*e@?qC7qy zuXBdg(Ninz;K5<3uBABT^kySe9n?#JS%=pA2AMNaoo2_W`<}?_x}FQq>P3@#TyRo7 zN0D!ObobtldS+Y%9q&rnESQzjUl_Rc8O>HeyvzSm`B$pDXSC^R_qrPRG{VL)>ul0^yL7fq?d|$q~EIFr{StZ<753m~T^8)Eg zuj~z3AF^feSZ%$shya&i$vX>OC$>~9mIQO_A4l0YrO7_EaZ(;_4XExoHbS|Btpr`) zmyS`k8XH1r_ZwI{t>rb`Sxi(-Pw~5ploUs-195Msw%n^nZSX$jey;^~qXWAcrsP7E zi4Ldy%5W1fbFOHEZvDN&j2oLbBI>pYMEok!`--1h&xxUgk(QgkqsTIy?om5}!c;$v=Z4k4k6F-r8(P0K8$m@z%Z%DlN@ z19}iub?MKc^8!<$)NU$dSoD+wV9>2>cvY8&xyOqSTY4M&YfP8{-PZ%Tc$~6xFoaq> zcZ#yq-CjG!r1v>y}fGyG+k14k-W1?1z>DjlZJ+dN4&+G zEg?f=pAr*ME2C9e%F{BwKznxEg1EeFDb*wnU=a@ z+3D%*+kW@-h`TKl10`&a9(5Vd=Dv*r_)3~KCnqNW=U=^kEw7;PzSeLGkC&GhkV$g$ zX6yL)0n?x({9n9ANcb@T$p6pcLjPF?mTh8fZO!@2)HI{EXp(@CFeMTodf8-8xPz#z zKYvOK9bSnpBj0SPzTv*-q@-uIe=kGjFH=M&x8CjoX&KyYvK#&??Y_N?4N}*RzhXBp z7{yJy7r&j#(TXW{lhf$9EqmwG{?7W#GnFGH-5TQF#iNH#@20sstpE5+EVC8vCWu|v z(FTe(+Nd925mM#I-yqi`cEi5Xn6a)K!4tB#J1G|5$%N{yKI)u3q|aPQo&EqkrS^pT zM-$iHEy+Dve~8l|7f+@GgyH+}iUz}7E{TA5-%t;GJHT(>)}zruvb{OHAl#w4)5W2> z4f!#;M>xi{F9~t7QBj)?P~3`7)Yg?TR-e=JLKdPMk_MspDYwXf)Mh4W@eUKHWh_Rfm>+Hph`k7Y zjmJ})mswla0d=8-cuf*B;1Rh+p zw^KHHl#`8PcasOsEp;TN8a~5XNb^7D#DRSRo^m{Jf82xivN6zriTfXtrOCs0w z_yvbtzdq!i>?BfiGDnY)sHfv-BfO3Ys>;vp17D4uZQI}B3wduew!d`txHqjiL~{=d-W`;NudSqwTPtB-}Z z00y0{vA4CH7Pm3`S<-1!Ifi;=L)^+~RI2w1Pw+`eh8JS=khX{~;qME$h?miy``sWc zN{+T@n2NqTXk0=4&CVMJRE9M19k&hIUrtrd1cat-BSE1ip=QX>q16tTyz7B+pQ~xN zhSFMw+I}qtGk)W3uCu&;?JTh#CUA4Q_q+GXI@{`-nJaG(W^G{3_*IO=l&Fu{gHAq0 zE@E?UPfEy~FcilE-F=@p+2qX7Z;IN^`|X`<-#OD_Cejk(d(j2SAK!18rJ<{Ox~%_r zpoNGde2Zqg%p?Wu!Wf6>=E+xt3XY)0f`PlbPj{T;^B=+;qKUvIa+j_3)a|c z>&Znno!QOl1g7H<{foR@qNNstPvJhT(MB(~h2JMl27SD^mDM?~*_xtJV026$)1TgQ z{uqx4x_kO~rpixx9T3zfSiI--*k$zPaiz`G82!w5-qik(h}`N!6yxvia>@Gbyrt;G z9~l}De>yqp+MKRr8!uZhUclKPw$JA0)zs@)&Aa^h1#J0qz1+<7%mFvcnf3L?$W0C@ z7f~4YEuPxNxze$)#R;Ezx8jBWwvKU9ZASBqhv;B6sI8gYq&<%h&y3Jytv|QpnA#sV zJ9CtlP71zwPhnb=&k9!=fHf2?jVUK4}M?n{{9Z7#Zc`k>vzT=AwwCH7Rf2sbNu=4JL3)u_pusBtc>336lC^Ar`m2kfYw zkGNg^BD}i)tQGSB%D)@_;l*4A%P-@9dntu20NMY2KFrvEgB&Qj&W47T_AQT{=Va|u z81q$+MDU3!9Xxky{`v*ong&L)|0536e}M+uf6ZqixX9i!C@2Wc228kZ+fB6vc>_+) z?+fnlvkHP~PCP?i%x$i&+0V6>|24~9;(Gemu^pdv7V|}z8ou~~`^l_QQm%{_WUXsg zm7HdjrS%uTs%K4J96N%mPls9dUl;(mdn{(lQ)t$rf~d++{HHXc^|sO%yPxmkqy4>a zQ{1wchCXt8ki~{6Yj^r7vGZUsRuKAV^!40^F=D}|Z*gs&*SxRD0s_G0SP}N-lMSVc z6ib%E2qHrMM2j)0&s`aA5UjY6JG~}PL)b$ab<2s^P40+uu5D(1QNRbj>-U~>)Jq{_ zQJ$(;+CRy)x!d}{*{wDJ%O5?qhr_wQ2S9%~L-p`}aPUn*Qx&7v^m(|&F=ljbgBZIUXE`O;j_NiLM>I^$dY-lNtw-XHJU`4lotX3IP6N|z z|Db<90Z@wds1Zw2q|qk~A^)3!hIzqqHNt)#|ClVa>X%1{bWQ;r$z}E|R+}lDGoS;$ zVwoMiIq9KTE=7~;N-|)?wLtBSi6)xB7|)ICUmV8uHq6C9Op8tZ^yyMv^;~l(Umo+b z38xZg3c5CQnZ@DFF`i__iP(PZI?6eY>}69VXR}#r%2~_I-s)|jN_9|jTZiEJnv|0q zJ21n#*<-dUfk&J9m$>%FN6T_=9?B(m|CtgrEIg}sUy6%7Ej_NkAskwI$dVnGo{Fg9 zZ|l=}`a3I_b~DWN@|*piQhNfmIl&lOCiHB(BO?=iUzubm)r>UUmK5!xfHfV<(Q+5h-h2QERAQv()rnjaRmh2m4?>~L3qZ@ z5=zqg7<6}5D;pVCu=V=;Gt|AQ!Z9lGU^i#g!v~W@>zM6s0^5;e%Gr)FK z_%iJDHX`DALMIc3#gqxU+p={~?Eef-?lCf&lgnpgP25J?4#ZxJ&a9)N;kEWM&2J>t zGH@Z^e(v!RlHrFclmqH0LT`F0Sws*xtxg=dUKu!817hqPV5TLw*)Fu}V}4f9XYGd1dnj~z|LCpx@NUWTYC+Uzn% zRB#F{{MB$4v$m5~y5EudKJz41w+^g_zcCw6bL} zxpQpzoSS&!M=gB_(2=wcVG;Mi|H3{O@Up_5lh;}JbxgyD-Oa`G9L=3I;nq! z7b5swr!htV)T{inJc~4bz4CTXWo#gp2y^nvry(SZfRj;L3%7Owz#K{d=CJvd`wV zJ6MXn4pug4b@Pw^j$m?8Q>CWn)}-Dwh41>55cuPwagb(GY7pc@xp))aOtacU#Sn@r zp^$48`R+(MX7QoD>Y2lh$zfv-vX>Q$R7IwBk+v|CwoIi|`zzI^c+VNlyCcKUcs4Dp zMrr@lt~=KvJ-7h6yj_6x=AFdSZRqdc+P?tP;A>ds+RAGJEZ=Wvl;%3pf=(mQY#$z4 z`GB6v-XpqnWj73Kzb5koZxW#yin8Bsj{L!~I}pc&e}2l_i@CdX zHhzo_rSv9%MWhllWTHx!oz86j^cmY0^m}Ze^1kLNjXaf-_)+}zDtc>J4%mkcbu|nI z#DwIeITX~(6lA7pe{qiyb?0L_);dFDyMLA+_2Mk!?C5MOlbTA?B^r`*NixD-D7R#C11@WJ2)zb*E?W=*w{J)4!>*>c za0=L6dgN$tS{#b^MZB}TBVqRa$t~fI(Us_W-oL*w2=L6S+OB36|L7W}O!looJ@0K7 zkG_n`qCHHwk)x;+5U*w{oJpe_*Jp0sX~+DHsi*P&NA%}^MCpIN)35j3G(7u%RNe|M z9-bn6v4FE0z|nKKHriu6a^@EA{kEI@UBEdF+HC8IR99UG@4o4F#M!oUF`j3MpX_Zj zhH6|yK5}tciPvpDwe}#q?!D5(Lt!By$Mw;mqWd?JeNgV52Wu}!*UB$_;jlW7fy{aC z#$>K^gx849OiQ9HD%Wi~yXAx~6(I7;k=XvQOlrnEU4OMNUE9$|z5tQLpno=C5QZi)|A_BxUDjF9 zk03^6(;g#Vvd>p4^X&>l{Ch3sBY%i6wePGy9}jej*2Am~a^GxPDDP!eytJVW-W$Sh zQ*=h98Bd3rB8x2o`iluO(B;QfFW)+#R@4IRFRz)bzRg=3nABpH@T9#VrXgGU_0^S* zkA&~E0E+1VrL~`yEx4xy(s&42pj7m1e#gZlj-<(iIXhVly^md#{RsUijs9tv#v`L> z-ok%WaG6xx`0k{e6MgThrE!LGwEGjRM~BVKC?rAAcuzwj*y);5yjhW5`5CF6Epz7P zZ_Ae_cTX03Ru=sq&m7*R+e*!7>F*ym#)nFJ_j=sAtZpl`>z~4e`yPmAoY(Psf!rBT z67@R3ICqKjvH9@6Qrt)|N4C!NpFAQxYN!p7 zq><|McV-NEH|wXXd7Dehld(afMgvq=NChzpCIn#*%}z4wc^^L7=9-%y-4j&>_)J&w z#r`4&@PhF>^}0Yt#TQ3^Xg`$jShL~*ZP$*ysT~7jM}>~S`s`?oF=C+`-N#PP}DyTcl@`SIN0PAGm4!0jjlQ0!~Ki%nN*?(H;=Q(7|1q{{G zZedRGm6TF^B7&6s{)m(%m1);KvG)Ygm4@#XIDZ|Vc_H|HZZRvq)d?rH=q-76(<@-{ zqK0(5D%osjJ48>JXRBXhm{+(e@Wbt!#yDQF&L20A?E=5-W?@O@_s2RV-emy&5y}U) zM!-uk^v2y{4akH?idX3`^5eJXx$q*J~8LuPI~$LjVm|jFWWBW zP}rU}QAiFnUGG9qYJKW5t9WP5Tw6MD{%$YrbX2RRNNk#O(G`tK7y!1AXZ62-|5goT zSBg$rPciAwkp5RD}e=u!io_8u*$U=Yx*2YA; zMfCJJ59t&YkXEmkjT1J6^sW=laY$T|OYq*ypS5K+^Th!nf11rY0M4i($GALd#B@Ge zdzAD8>1vW?CjRyb4Zwzz&X&qq2JNE#D;_Y@wj-4~3RENn#kR1l$$Pzyg1%Cb-o6d&h>) z3W<$|=f?h8YUaZ1EjbUeQ%$&)J{D5|?dM}UalL8(f*g!)pX<#1#V_Nt)$;Q^rp{A_ zqM$H?=WKO18UDd9G$t=&3Lb!k%)uI}P=&II#8(jgTVW6M2&IjlvO^s2t=-tK^=VaC z)JSS8Aeuwr+}>!i4YPyye)$$TdX-wG;3{^MB8?`6N&!!NUYtZg`anH{X!I7`OUc;> zzSZ^i%T7qGR%P5UI_?zf^`{*$AeP%+M0U3<|N7+rW&RBT!dv=@6M3jNTI{QR>u`k# zDtsiHFj3F9AEQ}v^d47h`^$Vx$_)hbAY~!KHjh&XYz{uu7kLWLlGNpcjSc&`cd&R{ zcOM!LUa%JP_*dKA+{NiJ+syU&5~ntYL=|&ewnZ;ry4?xX zc2JoLy{y!xo9~$M2ASEAqcXs{CA*&$W`?%;j52&v^eB?1b*9TDVwo(rz#h+qP>mFO zYwY1EmsK0a94YA$}W8Be`CS^Ey#zxhr!Q81U(VRE-N zYcqrQWL50r6+y(iVu%Z|Z6qR_y7KZ;+J(b$PA^;!P}+VorP3fKyP|y0y4-oSLl=V5 zl-4A!5RJb82y>MDJ%gb=^aZN>{c%mBlfcDGkbgY*xa9;(u~*-vM$9bsVP4vUMX>(P z{+8rrj!%RRIJYkqkrZSOuSFSl-cTaaSQ$)C9DubmI?Z>G%rZcosSG0(@6*fDBii4Z$L2M0@+m>PF=wPyZh$o&|fYA$0LlLw>Hod zFn4^HkJ29a{Ffxvdzdc1cVRD3Uc;xYyTvDE(MRO7NmVY)dW^7`Fx?Tp?BhSFQuW1x zf;2K+6+yGa%v62n@sbdCxtlEf@LiNdTaom}wKC9o+PCiEq)BUD$);;%p1XswNI2bH zo4I^IzqBTuS!@pal#=&gchwv7NypsOVT-}7_H(K0i&za!+A5oq1?Pq^mD=h8Qj0(i|l-Z$J`FL(!o$bTHb0qyC zaOiZX-jTa`)h+4xGG6w`_wmNfx&&MH-l)Jm%MHWwcSHf8m_LFTzO}E8=zCz|Ef3jI zn6H;h{!gQRd+dG#Uzr}LttXc|p^>-EHNNG1ls$TX?kOP#YRYdX#)wU0B8o1xM96l+ zQ`+vbFJv**Kbk(g>su%9QrPF833#z;B}U@K-oYj#3olnYQ;#da2tk5&e|?+@m}x3i zjUGwc>6x2jMBloz%BmZ)5a1sayfI~&6v}-WyTSfUe?C_u`H~R#v3QVDYF9 zmR(z_f-_f79gE!XO5A@oBul6PfAg`HtOT<{lw5tr+x=NQS>tC6C!)9ZU; zG9jB`))2ZKwQ{2^5ZQZ{kApfH3&wEaDH{LwCf^$zvyzz-PRop5?XpfZc|KUw*pS#n zq)48XyDI~D_0*d}U1O%az4kayhKgp~=*}J%9uY-jMQmPvCH%kHb`@hgEnRmn-bC3M zNrvX6+EJ59!|A|qf%sf~(lr;;an)tAfr0@e2EdQPn*)T86Y2V=Rn9<=VW zmA51_S~6zTk~|B|v-=iSsSthmG6CuLFS#;cl^0Z{_#MbSwqCJjJpcJIu{ne*^du=&=ZC!+*$NPF12_;cD{qHo3(onr zV763V6cX(z9TWoRM@rtqb zK>9QJ9clS|*oKXVzdo?E>9_P7xNq7BlWF!_r6yDm78Vj|{qi;=ZM3Jc*I5j9fjjTr z*b`T?KnvQU{kHu4K`r-->ZK&zI`Y)MY}{#MEBZ$Cta%as2MXU{Z=(ZMU4zjBAt%J@ zni1|GTT?|`jpZ^HaJhni3$EZ_mI+28#?PM4Gc_>c_U`;UrT3?vLglAlzX&_7xcKg~ z|KV7GzNXCQOa;OV;X=hlO_VP8C>SYr_6r92{<`s(jn^h~OyXPh12Zj+G_(G?(=o66 zc{W#9=8MLJEfs-Z8IH}MZ5^QJ2LzYCGLJyjv#Y((0r@uvC!$?HoX4-E3JCyZ$3Bce@WQxbHctuoJ<)jixxFr^5n*~%c*%%9;`R>W$M-J zo6V;(JYTqb#_7c7;1`XEtKUt{0Z?eE%y?-`R_Hfot?HfC?55DG1o7BYs4=CafeT#w z84{fc6=?){A>iLB7BKTs(P3n=4im07BQU_lhq7z6Vj78zq8I({-Hv-|#qT{loN^O5X)vpG-~&P^5(r zQ~*4_{&Teuz~fI(&0<_J@2)66q@YLmZy?Ivy<6e@1gjZV|GxCn??l|US@PE>`_9{p zBYQo0@H_1(ZwC(QulOW)z^YBr=HE=|*sv0aCnQ59pES?1jgj@de5E1q`tDow471Az!dUKnpsA#f$ zOy`2pHGi`+3ZRGb^zEn&_A93X5cvuJ&!o=(f9mu9zB@~~erah*HE?Ok;>xEsS^mAY zOW^L^yJ=#s@8Tb7zS7n0{Gt6lFE0-dNXn0lW|Cam+>FZ1WJ#CsXzfu;)&a&MMNfxP z^1rwn+>tV|D|Nd4`(EB&=IP{2_rCn83wF`;Nt*96= z9<|EJ$pMmbwO_rm_(wEf47ml6cB%vZ&vU`EW45+M@a4K z*RNfcyQm-l1`SsZ_I99@^f`E2{z7RC5c>&8D@%z4yg~A7E3sZ037H-b& z$6UJRh|V-<>;b>t4e@T?(kEmN5GT&ETtbb{@vUkMZ(c>6PZ>}*#{`uMmL})hS%EeD zZW0+g%dtl^w$$YK`Zwq4+k{HaXBXa=skKu(mUxH|cA4WFsywn^DT0B2&c&@p(TmP7G9hAm* z$@r@3u3*4il(C>s#SdhuGmc@mpogh7O)}k49br^ncj{jCb{RS~Z=~jPAODuiMVcEh zayo?OkS+H#uS?79f~g3-r?}teXv@8}G=!j!(7C2xZ(a2^*;Szx`q?6L!-r|u5kHO2{hQN54x4e>X=p09$7#7K`smsIY@P2>q4 zr^KJ#&KGD+5=1Bl)|Qd`f3u-iv^}MST7zy1c*fuOSe^=R4Sv5x?YNMmKP}!63ql&T})jaaV&iNEYw@!G;ZYvDvTB?5l~j z@pr1{7sWS_c+(R>ozF4uhElsl;~(xQEw~cGPeflS8&GNbovG!Xi%&R(3u`dwu2Xw( zl}3?C3wL#M1qN;%{J6<|V>b7~A(8f7N-E`L%C<RiZZ+46mcp@G!vU;<*swI>i%!QvSP5f5to;V=B#9hNjCcUuA!YDYN2{CT` z2*vw&blkZMHlJLRPbkTlfim$l|p5Y>_r&*Jd|L|6p=rasok-#UDH*>k+OopY#wc zbwmnZY|{68lnTuD^QvDzxi_6Lu1Y6}mops>M$TId>VG+Y^X&nggZ3AU*z%z~m4^pJ0*fKj0^=4~N02S;DlC#}xi%>BAlfRh zfPI-i!JwKva6|qZ>|V>v`-p6tgp^!520K1TBDeLQ;Qb_K@j!T@4K=@X25+jsu${5x zvN39zp3W^1mFDB#FtgoW{&I?b3FS59|AM=^Zp>!1L);_6USg+wtM`Zb9o}DJ!nUSz zWBQ>o>tJx-6Ul2@9y3~WGQ;8mbZQP8>U9_z%7I28yXte`j`--NJ+b*fQh!jm@-*62 zwm^#%ooY~iWU_UVnTHT9hnD|2j4=qo3&u2e995FEzaTb;y^`YBen?$i`B?M`zJT}h zJ6g>j%(c#p90IJktvKR#GR&n7*(W-uSS>{#|2Q%nQxs>0e1zq!C&IX6zoJF%DI00q ze_W%c1s09dddr3{#cP6lz;?Q^A@jZS4X~JI+0`q7)kdf?jkvb(tc>fRsMU1UZ@VhA zw*%SKi`N>O(*JFD?UE5Num}QvO)577v5Q~dS#lStX_de1j`k5f%Bi^H*V=je{AAt1 zh(=8e%5iQls1d0Nej+v4u9R{>_ng*qBRN#^Vuq2enJ;ilC+2f``8H!z_~d*zCWn5T zxBz_0efE8#qd=aEh(4X(em(hw(WG{(zw|79rd-fa%P>#!&$|8 zi-@T7;KFON(ktFPG9!;H&6qrm`uvO*h zsRQ|zS8?u}fbh-9ylr5)v*~D#foS^Y)@JLew4Z2v`uuscJ#07tSwR!N zqV-&HZ+G`TM>feKCvX}~8>NyN7kIu~I=YfWSKz;4z9gEX_4?+?iQvLstrR0Drbi;D z{M3sgZf7#!!|!XC*We9XFEVOGKRm-olz!`9s(mUFd%(66W86D+5&@nQPfq;3G!Zs= z;*ntqlDP=c3^#A_FRv+xjVXS62$gxEKp@DVxTW>{72du8Qm)2+XlG?UpK>VJ0K)e2 zNDyGIfF$#sW6<&ZgN^O&`G&|t0Kv7RpfmLRX__@4@0~BTH42BD(=^V`&i}x^LMb<5 z#PoD(U|_DaeEDMlhW?FDW)DgUgkL*Y9lXJknd@g{fjT)k8OTzi_~)fYDG{J@W3pUf z+z|?e{&Br~72~mbn)u4_8Xy@suWHBv2P&xU+{u2das_tF(3+15;l;(g!1=L035`D} zl$?tTUsF>P`pFf~BHTY=4TV^7b8uuE+}k0-$EUn~`&%p@P$h29j6L~K2h1UWqz6{_ z508(XkLuYqby|0aKY#k9_sX{DlcFurNtLrwBKhX(1k!l@HT1*pVZ;w^-fJeVq?K7F zzGhP%tJgOrQn(c4r#^|$v>QKp@JYA;JocMFX-#MP=9EpUTw zO39hLS*>l}J9RyD10AD$sh?vm!oJ7QRteL(^z}o*b|tknbYi$1(6+Qt=trL(qvv!6 zEdK4^$R-_l@g=IjUEw|6wn)k|GMFwtuI@5QODk^ki22LLj)*5fuY0#WwM_}Vu>(zd zcfL|@fk$$JDnu*#0=6YC$_Y;Aapaco@IIWJW;U}rdg;A`T{45Vt1~xY&7b9Hc))v~ z?mJD3=x?dc86FA2VKH>$V=i|$VM~=d4Egh3JKuXcymsRm&@NhF%QpIv>TR2YLr_?3 zmGA9E7>)tNKgv|iyA*t^TC*D6OtqC3d$8vi(QijOK_9l6!SRNT@V-JCq{rkSbd?4K!l@m}&?=}~f67lq-o5i?I$)I4yRfjHKt zzAiTlX9yQNplYD(!3~ASaaHxi^SeC}s}!GXv|Ko1;z4NF-!nDI9(1+v%$XAS{Va#H znSK1}%k8K1UPoh(r5aM>-rZ7F?Gr9EdE$C(MOlxGp1mQbZ<)hW!S8FGP+QZ2^z^0D z{Kh)=gb}>|A;udCBR0CH=<3nTQ^O2eALjg&5A$W(hJ*Ts`!B*OZURZNAz_e1oke4R z9ZdxB>*@^T@gjU}x`L=LftmZw@$N8sNf-V6o{S$8EY(Lmi>EO}yty$+<=2saSTC8q zPR9B5)cKNOT=llV%9Ivx;NikU|OozsNBy*Ce2)j3MI===-*qs2cD zO)V#~SOyQ8_3XFWzD8U2V8hq9e4^HM9Wj5D%q%BNSW5az^ul;fyoA-yKI^{3KXJEP zWv5id+Myt6Wzx#+3KySPX4UtGxV$$t+w}KarRGkDGnMxv7r^6+E`HNbnnayV%c7QP zGIBX~WZ1s3=cPQgVPHA4r7b=mhPKyoLDx0gyx045&M-gB8;-t%b`OIv`upF_8&VyJ zy+K7wCJ+O5Z=Abu1J_dh@s@R;S?KOmW@zbYgzuy)8qSS}*o0XeW`^#L`QjcEsW!nP zDH5UP1`YmCeRIRn8jec&|4wSvhUqn~R`bk5bDI$CV%x zgiIr9+ps1|3bZ|{v*B)FL0plZ}tc3p3+`qRH3sOf5fJ5AD59i zqoZrPj13|+#ShXcg4&b@Gq|go-FXQBB~FG-E688oRt+RKU6_X5X;PRLbDyYjykF~l z5z(-xa<=RRA#wV3>8t8l(BXST?sPUif1=XdeLxl3%ibdffDWqh|cJU2WgJ$pthc6xR4 z^Q#XI+#24@Tbcmk`Fn=^B$7JKUwm5AgD}W7k_iX?W(kW)eeggoD@o{41X-wMkLslI z=_gu*&v)SUpSi-D6Ppa-=OXA{Gj!jR^phV9Nyfw2nxgGa6$h8m{&|fvpEh)I_eMs= z*HdpI&e#$GQsVnQmFXdm`A+7@CN`16CS*->l@1iqmZs!tWd5Bs?|W#yJ5QkcFTo2c zb>C0VsB^RHnKaiPph{nG_#3?QT{U893neLXWV+@Tv|euGG`|3$`C)J);Ip#Ybf)%o zlXK$iG{;QzBFK$c&&!d5=?3kboZC?Til{mR$DcJ(0mHdFJ(D^X7Vlewg|O+*J}k7AttX-9f; zZ>Kc13Ljwsa56uoRcG^gcm^mod!@*%35m*Fn8-xwC(vIH%kn?%T)uZ^EuA#s5xS1? zltH3WSxn9mV>KNd>aLBh#{C^Skw5aDacU5WPgW+xMIj_Og8<&v`67u} zouku3T75GWGn4aFo6?@(`E~!VOF}N;1?&T({db>7$FDR9QmQ_B5`5o(old+POXW-O zX=`RN>0ZOSaOn9tWzI7d0zUUUye7k+Tl+ovE}BN+Fef5hR2#F#Yr|{a;%zS-{YShU zUfiGk`S8rn{Y)~$cj?B(K{`wIyl}EL)uAzU)-TF47nwJgJ6@j_VyXr4&-GpIG^EL9 zWq-WM%RrMfCO}|Z50CV@8(V#b%=hxmFmf#AdVD$naxW3HD#Ec^Ubc|m(f3}qGadFh#JmH#WIsATn=a{Oo ztSxo&2)6INzPnp+v0v#2tEfYEY#I~JcHBi3=PvC-{dN+~3!vtAC!M|?Y9BexC`0FQ zHNVl$1#bgyd2ZA7e!RAo0~|d63F9As9CAyh459k zQW6>)Gi|I1I|)!9i@~rq56Y?Z-nuZV>BELk_5$xawR~9-MA6;C!C5ucbdPWOM6_ts%i_Wiynf+ErgNJ*#C-5}i{ASK<>(j6)( z(w&2(bV;Yw&`5W~4BZU_`yQX?-D|zqdiTE8S!v4_@jP}BoFxl zI*szxPZn;>lnFR%Ku9CFimFd%j#6^*W^P8$LR(cYsMy>lYUVsel)v-?-?Pxsw*50^V8noR{g;Q)f<7^MdvLhxtEqh8JS7%?69wDY zAO4c{{VKL6=1_k1%+Kv`2Gd-IPe)bqeZPFa9cO)4voPhin?)x?U$;{jTBNH=#2Rl{ zILqnk74L)jWkE5+v*ZCxj4@Pu7FB{=@Iv+ug}$t#~N7`$$CB;d<=gA-J$Bmf%57w^X)4l=LRkt>Ygq35kus6 zA%?8ikzA~X2aR(?y4L$SnWi?Ohwi5xj|iVHk+fca9RlU@8(7V9?S-LG>RnW$L^@=S z$l!AllHv4z$JaWV!&O;_4Q!s3Y4=^!v-|9xGpOEd5+y0(8Rq&~Ir-`W>BN;Fi^D_d&t>Xl^3F9^$NjQgGw2^wcx-3stcduWu)IF;On>4?VkMO>K+0NvtD zeZZ4}j-rCfba3oJZRV@+uG_WZ&i7jg9c+qUEaz{8Th-#u+kAUstn4G%hKz1Fbs324N)F^RdfraHCAu&gdGHg-k`Pg!^b` zMBiPn@lL=iqe#Y<@Ez;2lNDVR8ADhfX#8B2DxCeoDYslt}BInCj$-ScwFWi zNxSm(^}8B6A2!-v+&(>7$(UxVi>g*F^__6G#c({M(P=+WX@x9lVU)YKIviF1Y}7mF zEvvvkfj!)(91dn@rd>Lsb6G3m-*7tL=((+rlVvGe5s+@=#X0feM4fh^oP=_|cM0^x7 z#5g`8CmFCm=6F%L-?w{J#qfFQJR;O}x4*D-*zf-Q1u2Ve&qzT!|1`eOzCs|@ae>)D zxm+!;tv>0S-i7Jej}F1>EK<-joDnXQc;f|=@ry`7q>;OI#FuEjq-a~kb4wEIWG^)9h#tW-mu-_JvIxz(SwsqJ;{rGqGyb##N${8(8F z`M!YWa)Fmfu@_CC@kSppKtDs{1n3NNXQ%XrbE+C~9_h+_E2+M~ z&rU8v&C(ORgn}q_9RsHxuDo=G{SVVi(>NV-lRpOyC3E-R{+&_R?aU3}ZB%wi|16&c zxbHb2eq)UlfX#3U^WmWrK-`@NfMkU!X9%PWz{%X)AS=oLeD2-f#!s2Zm+aHrO_{>p zso`@!hl+U|*RduKeGwZ^?*<1_7OFi@k0Baq^~c%({Vn><4`Adq5NL|VDfUz?P@b0lJs)^?5&YNiN06zgLc3fc2RFP(UodC zrgn30^1@$Vy6tyn8Xs~r>5I$f0}j%w za$xnpDv3wxGjw+F(=xd2e2gi|_9`qGhOYdeIBO*YoyE@@x8*+bS6ad9ys6X~+O~Xom5*QB37Wy8 z)&`1v2^zo8+t;>7J3SlU4Q%gU zgFaUw+FyHfv}E3d>SAiXIhMmrsRe|?F_0XJDPj*-ixbwTAAPOJ+Lq(BG+wgbyv7t= zA-op2Yk#cfkAot?Y6&gv4Bd5(HFxms2(9DP7o=>jvsTv{O}V+W^o!y!m`?yh&Jb@f zFlaj_%PbiTiEac-nwWsQ@%=lcwxVuSKjSWpQwxVVgC!yx%J>L4<)1ukkmJbj^o9+geUTaTa~dW}Zdv zjdiejn6h*?HH8)siUwPieGx9gm>rKQUwv8Sq3g;3m#0GtD5kyw+F{YM%h!-kmsrlQ za|utriez1b1@ZC%^#*oQWPOCEwfO=B8xE_9of$eju52(Av-^l7w&h(m)e$?&v%D7D z12;+%3xm+82HdP9=IBuBZri3Tdd@GT<{~*AvmOoP^R+(WTXB8pKBsK0!goV(>6a{L zkynE}G~V{}uKgkd#F5PvCoCQW6IsT?LeKMHFsujmqDeoUL>qH!~+IDw|Ccg`#_n|}sjyf4=g)}FjyZtb=HEDroSLxf+NvrJAW3vsAn*|$ z9$s`pf|QOMBXLuv83p6A+jv^wsQ745wak&kS|fR8`Z7WO_{RVvHwR*%a*#-ntft zTPmeij{S1PNVnUm*R%(yLwR55_TBY_lf6AyV`QZFKG;gs9`^ncV3yIEHR-77RJbJw6lRPCP`d}UT`)5%iOu%oo;A6XwM?WG43#=|sPuHii}&yU>R*sXQj7EC4Z_ol%xgv=dQbYN;s z0bn7RfYX!C&x1Ia;JSs&9oJjrG7-|tc&i#+nf8(bnud-lF7s~kGK^TPtpnW+$~V7c zgrW)8ue^WL!#hc7Z)qV)Me@8AednOM5=!*ul31{gz4~?hqgB!FsdI!|Yjics9bE2b zy3>d!#bc`tcP~R_bypnK<1~Z&t9JycW9CibdePElJ4Cae*%<04)Hv|~cV~U+FyeGP zq-wHjc`Lc++x&a^6Bv(eZ6A$~dJA)I9NT0tvk5yWkz{}b{4n+r8A&HZ&oD?sek#9v z5O&P~J~Qt^e^GK5+eq~>_*BT^ZeLxd373dQ7|1B4N>|SU@PXMv44@WE8z8-rT%4=GDe&6Zz<=|`{U*Be;2YwNz6lD` z4B-cKU3;fq)_{OFV4Mw~xK$KBh=prW+aKjju<;E$Jt#Ozf?s;1@scBH8iIWtbU+(% z0}%NWo$H@x`r_z^q+Dv;fg~PEpsUsxZkT% zN(Hp}YawuG4x69A9MoNum=uzjXzdYcSi>Q;{*ykx^m2SZsXy1gE{KU7ZOxhYfZ_U6 z9lq}$qYY1S&%?5MFlb-!PnKT1pgCEqoS(TWB)wfXT@CLZj8rmfdvIAx1e6<~{B?h> z@Knu;$r*>Z*()o)8Fh>tN-J^``frr^KaG~rVl>8$VGOdjzwHF}-XJSlZ{Zn)e^ktA zc={b#{ubNYSQJ^{%i8xlfH1~tC>1TX!q6EIc=NURfw;3&Lit1)(?$3cs*#pD&wB?j z@@nK!XeLVUxg6g4MZ1&xeYNXe480o9QAA_s{h_aZJM7IGlPh!-_06KuR7bm0l4WJJ zcjv<@E`PYreRRT4BCUA22Z}JJj<8n0j~Y!quZM2q1HoF+QWSAz4d}`=aYwcS_ibL0 zb!6#M6M+mC!e@_9KU2bLYBYSC;v1xI2i41djh5(D$0r*~;cy#Mv8ymsp!VLF)LFSO zetm)^y?Tfqt7BLY^A!Dl({3;oTWX@*MCV$O3qr0@3d;xjt?Iep;DC z`L?`+C1jMSbpO5OUI_LL3#mpk*jwBNPV}2+F;@;h(*({ z3d|BfyU=(O89~d@K3qiHp{;zPP$G}^!yUDT!cIdYOAp@oiRXCF{_%YkC}(gGaEr5p z=!LpO+rCt6$uwMk9flygT3auF;uTTcd&?mIzTK`&ke0;gI;bv3OT||8rV4NDdIK9Ifsl(F z97)Btj4SuCk@V-K)yvht>0@!eKb=WQN8)elWBZ2Q(C4Tu+ymw{s=VfpuMNJs$^bg> zfl}3TF!J;nb=h|NNFyixg=Xh_&Jcw4iCj+%9crRmThw`s7<-!`z42egCo5} z7c!KlRQ|MKtUgWIIPe_NZ_ubsSDcadynH;RWy|j3sS4G_61}XCKf7uVUCp*|Odbi( z=?(QLI)8@rTK25uLb`0QBZeMu+X&BVlbG$%(M}qi&iw#}srLyZp+%~xy;GF|f_6&G zVntYsNLdskgz0;G10@Cu=73l=V-Y$*sOaTh?*9swvU7O4SO4M=8eL_Af2~D)m}^UR z4i_a7x37QJR)^L55@FF=tO#%L3K4Ur_e2D7BRy0y`LQSd+CP>3+7i(fjP{o(e2J8x8RpI#=tRQjUv6 z=(P+YE-P@;Kx{ibeS3oIPK@4isVwk@|9D3IOwcoTDx{+E74s9&8)QR*0kqwFg${K8 zD~OcA!`ScqDZex^5M`h-ICC}%hdbgm>2N`oje8L?<@iNLH4ghNNe>Cqx1CIp>WCV# zxt^kJ$+{ZV;U+2@AuT9ZhNG^`EufmZa13lC&VL3dtGw!&r&JW!h2;U2(sv}%Ukv*a zdjRP(RMrYtfgu5knbQ;N{#UTxDU5kOX-jFP@N5U%g38pXIx%f7)*cm_3Zaav`88R) zv36~WI6wR+DU6{H?l||jIm7lbeoBb+IKtG}7c^|%nlHn4(Y^?Af)n+2z*4#4hI zR(t5#%@YArnKjVz@J=>1q;%=cAccg!@4Nu0guL31t#a3Ze(t5cUVpeO3=C+#eEaXI zvRXtxi4qNbZey`9VHlQC>5ArJNW>^6Mcd{C7{ZCoF(<1_>i_)obF$CA|;&lRRg!i1lxM*NtaCHkB$^o>>J_hJGiz_oz zV9%}nFA!wzlgmrd+wL1nMsMolfml8#RW8<&QoCbAAIqNSy!$B(%-DdUuIv9YtT;hv2MNQ@YImP{1c)fLyK zSboGha8E>$2@iI|(WUc~84Bdz)s`bK^y`4q0+Rw{T9m-c(7Z&2SET`Ub-ZF?V$9Eb z0KFLsD(YA~JoSr9=afEPvuAobX=P=_A|w9{5ZLkY5gHmAIwt^c%YO0n!S3Yec=7!C z7!%qR5+WkXvuC5S@D)@#ca)Ehls<3)48TI?>>BuWb`NCXpp2#+OXwe?!dlle|Xs(J2e( zR$`bAHW1;G@p;D6k|})O?L`!460fH3rw-?cKWVU)8i(oxbIC1ZpHbpd&`$wg+imXx zqwDzyB4eZkP)PzXbeSal{*Yaa=!&8mha7ir7wionVaFd(O-f|}(ZuBVyIMZ$Od)5` zjezsbZy|0-a=**I`$a!fFI;93Ofwb+djAj)WZsWoARmBji`dp^J zzF_(?BUmJ}1FTg~Uxn7ZNC{)oej)vNkzeC{(9AiUPEMatqNjzVD8J3*Mo`+sppKp` z=2ur)wW%SgQmCgy(mA_r@`^xyST3vX%8L(lBvG@OZ)U3c4t8@cy5ic^0Pz6HK{w7F zkCNL@V4PSve->;ME1%AcoXLJr-Y5!4N|p#zlE2%nmumy@%=|!E9@jZ0UP*QRZ z{Yj-J?H5?tZ+3g8A#NTu;YIw88OIvwvQ#Cx9|lo8EWtBk+j~n`(NZTpE_5Q*Gau7U z!y4S#K)0KG`_a&}C09yy)TZNV08xf?7 zF3wpX2;Ec1i+ML+Jk+4&lN<^CG)K3m+qdybfC5b`RuU)tSlNJ1@rQ2}*1t_tyd#si zdb~2Y%K*T|Up{NwJSQI79k${*uv%iXsGFZQ$5JiQEi~OH3YKcE`tMocxG$T}9jDJ6h-Z z<&=`;Qy-FnHDw;f)O*_q&d22dcj#1JrX>5orrq}4+E;C=|4G-O5Dq z1{V@IUVluur$)7~YcZ~?>sif{0uzqkL`-UzwzIdo-BO-2QjXcr>8@5 zbi3p!1lja3G^nVFPU4TEp~zIb-Cm;CneY#@qMl?Nylc&2U%5@p4f6uDz0lDlKal$$ z4Wp#{4BR?+$wlSXI@|auS1c}64F#TkefTOggH;dfNelg#nF5;>WcVm``t={xZT`mj z|1X9<{R^hxf4hgj?Q!eis5BtUquiinP|9xZ59%MXb1Cv@(bEsIsdzBkeC=^sq_p6{ z6iV7gE-mV8j>g~x!*qIZ&1%frmGXGl;qg+&C{Xt{9U%6(!&|;T7ZebE!1{a(*XBwJ zDg3WEgS6AJ1Q*{;ZgB(8l=cQB{){UG9mib;9$uSlT+lrh5$`>gCj=G#wS;C;TX2_S zo;9|E3|b@_tO9uB)3du4Ak7m&7X*553eY~+uMqfC>^V%YpAX5Ov~kthq@KW<`1Q3d zSb(Ta{_=n0OE&ci`G!`>!})wZkcYRip&?V>4J9&x^}D%IHX* z_wJ(yR5?4-&6fP;l66M!t?^vzb~6iEl!Fnw`LdY+>R0zT*b%djpgGm@aD5Q@(tV`U zsdW4C!~^Aea9E_9I}3Cxs;6*-`Hs(=>ZwRsODQn++2j|>72|KRZOO3iuKSI50-FCkvgZ$&e*M}#ShPWsZKE}HlgTMSet`u*7NMb-fB}x=B&gz7 zX5SKOfFi(ki2cGM(z~0W^MVO~;Z^CZ1ApBj7)vlPzrOR^Bxr6d@5PD7B!vtivib?y z6Lt!?w-?ATRMrkhXN>TuyCwbz`fI}PwAp> z!ru^j@e4YTO6!5*n7ns!rJMKDcRIrnmW!11r7U-rLD$0l9(Gz}Y2#>Hyj<}pT1}bX zeayEGKXNXa>%=xjj;&E%MWQ(+y7XLz(Xbd8!ghG)e&V$Mz78au)4OY{KIW}+v{KXs znulgqzTu?FlQy=sV9#n^J64AmPuY1;YWEk0f^!PKXUDgY=O0_w<`k+#QP`()#L}|{ zcJfQT+*%CZMN+QbgX!Bsu=}Bjyk^OU z_OrJa)G~&Cr{4rAn&!#^9W-O>&Sm;PRed>(J8z}-xOseme8Ru4_f%60ejQWtVkP+7 zm(_lHhZ=)Ohvbl(-H?-yM(lOX^+5(I1x}^{=#2=Nwo0xny~`cUA%fMmJ4QD+y>}$6 z#;w!F*_rd=TMFK@3;W_cgU#6F&h&3sbt!OUzRtRdsaPNj_WMW&RUbQv*4kYM{-%#3 zx`G({c&86}j9KC>`B3ZdF8><6?B6<@vC-oboVfnf6e^;^)KzfD966ZYTUYInbM*F- zGjdY!MCI|l7ehg2Lkvuv?xd6YaJ)>#L1uBwaK%}SSc+F=qrJej4qn-+bPlkeY9hYm zcj^KPn0a07S+V?a2v?uIWdFm!e6WURasA(3tL*<<#1LTQ_-nn8_2Wmco(4ToC=CE3 zk1j5Fw18nWluIrR)dX1En$)mdIWc~bm}nyO>*pmok?pc;QS~lDXGRZMtJh% ziJ7^%s-~vJU-oIdC@d48q5|Af{r`bWQP7; zX!SPH4ZxRf|8VEjIY!m~<#}+nsu8QS)dr^94b4x?XuFSB9&%&dexS_VavCkz3EjzZ zC0(cKPSJ(L;}=($nhrP1-Xs@)O5aG*PC%~ZuY0be{w2R(ZZ!{st+2;$za(rK+&+^s zGpNB0Cw+pD%IdzybS)VzjJ32HzGLF-%y9SvUe@G!XT+@&^EnW1zrU?<(vksb>Rl z)?&)2sYI){QNmFj@7T63UE| zh<>bxeF#QQ+1+n`a1(fP^_>Pr@mKb1j*KV2<@5R9vh^nTdLK7)5rb;(N9W*LUu_WmN_b_l!?!XIUM_TdI}mF((p=w4d~0pMRJ zk<eWoK|*d{Ivfj>`vLYvS0p`jK>>%F1_k6s{mqD1Rv zm`?oRoNS0_i!?HPc`k&IwhT<6JQl<PJqkeOVMP zS@RR1{pS*Io#Q0@9zA|65D91AWZKzzi)DO$r(81uV|ti>CsDA|iPaVE874p_zPU7j zoXowXTE@E|fm5PZ^Y9B4@*D$9x9RRWY^KPZ*1pW^&Zj)CAKopWzov2>b;$-PQF{!V zB(mrX4kcMs3b^q^(Ce#Dp;?>axo&V|f8GY#cu&fWj=1F%z@hDAi=8h+2f zipXJ0A!6fCoY5|xdOmy2RvO@!#Kj}RKhjV^jsyUbn41LH*dD#i9%WW5q3WmnZ{#t) zPcy-J%Eqaq(;}&%eUfANmBH=A+}3un0|Y+sp3jM zM_&;Qti;U@ZG4SR?o6%$tcYeSA_%+BZl~8^Y12Blzh?yIrprctvmtU^u3q7%OY~bl z-Eud_$l+P##0Vd@Lq`w#EF>X>LTapvkYoMHN9-z{_(jh&wH?NB-;)_gJ!`sXbTbey zF}%VLmKJjwqV^r?w(F|+XHF*Bl+yQ2i~5fzR>eh#n?>I$hzTIPS3ryW;~*Dw-H8WG z@{7TorR^sHDFoLk?^p8D-vI{#Q5U~!?nRp2q@|fq>z}p&D z1t))jbwr$vS+9Pi^Umn8QycDxxRp(4)Uwvdb*bX)!raFh3yAW5kNnPXo0y%tw@RssFdbg@!ER$gDlmw zK<6KIOF)ThJQ&t+n{-(k!k9?cf!_G!-VO}z=>}%@uAW?V7(v;&PZZmjGbhtx%G1J>@fPWsUNL+ihEO8@ZbtZ@^$=;XW6_+XiEJ)=)~ zP%;7Uvv8uktL`tC{g=C5b&TlntUJ~Q!GhaZGa%aa%d!AMeRjCKtf$uoS8GSm`^qC; zfWewcNfs3sl_6ov@zScVR@O%X2B0S&?}S)(rF>$6NE>Pl)M8VvlZDYz-*kq&C?9~^x{IK;1>f0#TzuZ!}_1y(sr)@+AWRCTAnB< z1ytrLMS~+abT$p~W2he9-ne*p6I8~7K&((!7KM@=TqPFA?JsU+?&ItG3=`9S%1WM` zoc!~bFURmAz<=hp$>68#=|J0~q#UXW5Cv-6Mn_{uz=NlHbxwPsp_6&QXobAWjpbJZ|h zBsy2iL(&EOFIkdrO!j9X!Vs!s4rrEiUlwvFjL6dr=&=A(UNRZ6!Po0A;@D`z0uy?A z6QtHo&`m&*bX{*>1MdRmvj)ZCh?w~^jT{plMgnYqNA%I}`N6<@YcY6TC3wVu`u~Ua z>t_QL7m=02{Y=j-XW>ABlMKu_YQX2@PGqWZ&kx{J$dZ$6mS$k|l-E7Rc;Y0UVstBS>FprW8oBlpH1pFiJIejY1S%NV=a(lOV@8%H^JS z2}(o4?k9#DB%-{y$Z$8jw5#|Nc=TTlTM1-{xi^K$M4uj?P6; zW0j_5x7I^mw0!&61$v#5#MxkZ3R(Xn6t1LjyYnN24Z`!PO;RAR2-XNFd2f$k*;=tK zGAGE#UZ*Xo6T$<$`{!nWOnx#TxEe2ZXW+#+3XWBePo4*U!B81B?CSU3R+takU8@qe z+IOx*FmZ2zd@M%PnWMwfK#`g-0~K1_^9wAzcL?~N&8hX>s`ndobas92dj)*qn_;?J z=eap|V_o`t3_RyjbXY(p&pT7@p z*Zi+SbxIkc66rH z5D=GAGIG9F*ZFkibco>2#fHDKPRj8Lkp3=-f=HHU0ck97lcdIdPM$>kJaY_YZ}tHy z^IBHbAyUL?U242Nl0YU91-=6Rk(HtGqy0*%n;@4wEgg|I!|NyI2aHaS^CUt%tJ}ku zBT0HHhDndMapX%%InAKeLlcX%5>4afSA=QRpU!-EJX}pr-m>!eC#Z<&JgU1BPBBpN z?P7k)m~}-HqBswr)v~9+osQ8gzdkF(rt#9Ro&Ci1>eI8_dn#-o zVtiOP1ZNsbGq7|Mh~r?{=q4WEFqvPL&3S$}az|#~T+0|WwEg6agjhbXSbyYgE616; zw!03{Oa1(j>ZcKof7cO80vTHR+0v}@vTKH7rq-F4Br-GjtJ;NMa!>JWWWI>-=X$Gt z+015aOemgXmOE=17T-|SrF&)Z6<3}NXILEp?(a5U9Ecx^QU4p!3$lfQWzLrC(ozBh z9DZ<1G%&k82bCbAir9RyY6uHBImsl0JO2Vaon5VEO+QJ@8+6)5hjz z3V`Js8D%y6n9T@31ttM8wcqoq!-1$8ptp}X4f+Sg@Xs~h|F=BBf1^+O7adDzZ0vIY zT6y*QHNg0G=rIG`gc|j~uWdQ4x3~u^NdV<;g~PIl*X4oI<-sCQs=lQG_(?-$BY=XL zKw(Yj@9z@mFZn9EJCngmN)QNS3@9`K;B19|(cojPzTUtyxx=NFzM&y*EnY!jRt!|6 za5-FB{0lHLT5NI$!vA{p7T`zIc=zrD4SawcmcPB&AL$ct20jm*8Wj}n&o}pfMl!uu zvJqWHm6;UlJcxe);4~JJ#PV!MlhK2i~=9))WKq*W=!S0Zu?R(%$|C zD7>Mdpg=7ow4kSlC1|q-BsL9~O2&356rT{`2;!zS=qRJImR6~+_>f?e$!SZY5*a(! za~G*-Z~}=)xn_+^=hPgiYO$16`M!uv zs5q>5TB(;n7@I;d2TC|9HTCQy;7E0BwA*D8oZoNvELHy@@Oqpb$T$Kp3W&>cUlG^z zB-v(ivy` zjhrC3t&J7jdLQlJw>NV1=ss9FG7AqIf1q?+h7-s;Y<6qy0n4=We$dv?g#iiSGp+n95C^7*0f$?8oTk!Zp!CRn-#wRvw)0G8U zG?l0M@Lfu~_pgxzb8w+J#Tu@4M=4YeHN=sWJH9MmcDK>F^n2k~w$7LNi@*@pjwSOY z*%5xUMp62<`{<~LQ+9H44Cl-*)q@^PN{hop9c+3MZHH4;2kUWXcfl3E0lAZ$xwasW(FJjLyrW}QFO;fZ29mLJ}6&SUh%P=ndu zMjP%%0D+By{J$rA(n-+Ql~y@3z?RU?pNKb7i}Ja~vO{hXG%&t)k-8dxg_PO#zK*h!!?Ed*n+Puoe|=q)>3qQ_yPgkwTQHoico1Q2W23ICoA$oh z{#J+R*S?*OPZSoJg5?45H!pO^OoWLEmF+KT!D8Xlu-{JFY(`kss#0n^{R**r1Gcf^ zt054dL=+grS!#da8zaLjFOO@Nm;%{@kVJ}_E5F5Viy~-cIT1ntQ~V+&g#7KtSz4jK z@pqsqkhL*L4I1mMb>0j**%y^NIZ)4FEXoXj#T$g-y0ea-NF>~CZ^e;48uV#WZ~R^P zNgr8;woq* zVXb?dNVwZO{$rilHTFTTU!-#PwYBy=uE$R^LK!G1gro@(A@kjW3_1dW5z+RCuS}UZ zNC*gQ;n?tP;QtS{H0$DnRZK%eL$e`|5D+x&?Cq5kYwlBi-up|W@@FVZQQ_ROAt1CD z0S|zuw@0Z1_1KKe%#&MNKYGC66e8ezYD;Qp;E#xkvT<>F2gJ=@blMzXwcg+Cmeam^ zi8&ut+5iOS-u`Y31+Nnm5sgkv#6J%Ugu!=O#WWB*zWoj`)tsE1?1fD0Ne2@GFUL`= zC(;2YlZh7yJPn}yk6Ry`=LiU2>?6TvnwMDu$elNfzCcF!+_;LJn!0(V-^ANOXq~`c zJJc2;(_+9=V|t-5;M)ZpuX8mTye>B&EQ5fMvI_A~WCgVp^`+?CQW8S?hel;YXIc)} z6CEjEM1fs*rU_EShU_#DV}ngup}u21qGo7iC=UgATSl1euUAB9S!BJ zpUSBe=BhHii0iCu9S@CK`^bkE-n|F^Ot`QyiLQVM=o?z4Bvnjp3Y*|S)mRlh1_FW^ zGG{j;0s?|KaLcyi{n=vwC$`bwZbu#3mdhc`^@(dE8f~I>v~$2bHy(8k z%ZbywHCRXpN6~housq)_62@iKJdn?A)Wq9#M)%>nmAw)~81Ljq<1RNIO`E;*FBjjr z6V53i4wr>CGt7shrgIK9WnfZo_&HU)x4}1gig_@)?NzWK-=lRj{B123HRD?C!yCa7 ztXbAR*`biuQjidILNZGNgM2>9k8_vQM5l#o8`vA;&gAISro_PfLeZ3T(j9buvyS{R zf)Vu(U$ItTcd3&##lCarnn`XM4cWn*T0h69W4LkXolig(_ zXVo2~H`{1Zr>5hLXT`f-o73a5xzq12b|b#Nd}99?kEwX%)z=RMzF!|Zfg7p6L5cLEF=*wTfdM+Om-FBk;4azR#Ukx843 z)3FxW#x2(lpzVo_py`&QK)Fv#hWBljJ;^w_^!JQ@g>DY=fs0d|GP;vA(j{de-*Ej8*OhdQ|w{=@gEEITppr06y5v^9wF>-j~D=%fvP!&g7<{0kD(MjpFD zFUy)G;dehb8Wxvdj~{n2RQ@{O3qVph#1pT_Si z4J?CA7g+N_r^B$yi&qUpCX=EU@R!H1>X=%nR@#a>$!}b*&Z5f}xirOBP8MR}Fg^{T`WJnXhRq4i9T-w7;pwv#gAvev@r<^4f3HqS}L>g3Kh zLEt1Lu?P9KX1BzUp5@B*%0ydCr)bF_pFkaSYp)Gfey9VA!0I>o;j6w=J4h7gX3SQ9 z7%e!^=IEm9@D*_UuHtZ!Z_vzcZWYSR?MB~7XdavURx<7tSYlPKPoJg{XjPSi)^wD8 zbMXs59%S>1W~p9@d`C-)QwWRA6@=}yP|G#EE|=%mAK%%3&v)3GkK0$-y3~6&6aCqp z)+)~Flz>&9j0la1g)A43R2PB zGJnGvMmjk^{q2Ni@7c>I2s0l#P?hE8_9i~j>Ee_&1k%~hgEi^>94J@FYAfnQWSzvx8?3T85g(^saQ^JPZn-pdfJ)Vcg0fx8(*r6R0u5?DXiQW)b;GslyacC zbYm8|p~m7lv;0wlQMn=Q+a|J&E_d?bq4<>K9O_`cYG-ieT%2mNPk@9_!TwnA&dd3O zW`Ezb!qdwSCFiUYzCPuRQM_B4x^ea|OQX56r~Nteh&H#n4-Zt`!?g<949gGNir)@d zSlJqBmoHa}I;Xk>x_Am)5B0ge` z5JpxtaUCtc7hwJ7pzLafRrp13XIu=;{RbA>qu## zllVMr!$CJsmntUO+1Q41i$Ws% zXY^x_0_TK