nodebb-plugin-internalnotes 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ name: Draft release on tag
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - '*'
7
+
8
+ jobs:
9
+ draft-release:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write
13
+ steps:
14
+ - name: Checkout
15
+ uses: actions/checkout@v4
16
+ with:
17
+ fetch-depth: 0
18
+
19
+ - name: Get version from tag
20
+ id: version
21
+ run: |
22
+ TAG="${GITHUB_REF#refs/tags/}"
23
+ # Strip leading 'v' if present (e.g. v1.0.1 -> 1.0.1)
24
+ VERSION="${TAG#v}"
25
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
26
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
27
+
28
+ - name: Extract changelog for version
29
+ id: changelog
30
+ run: |
31
+ VERSION="${{ steps.version.outputs.version }}"
32
+ # Extract from "## [VERSION]" up to (but not including) the next "## ["
33
+ awk '
34
+ /^## \['"$VERSION"'\]/ { found=1; print; next }
35
+ found { if (/^## \[/) exit; print }
36
+ ' CHANGELOG.md > release_notes.md
37
+ echo "---"
38
+ cat release_notes.md
39
+ echo "---"
40
+ # Default body if section missing (e.g. typo in tag)
41
+ if [ ! -s release_notes.md ]; then
42
+ echo "Release ${{ steps.version.outputs.tag }}" > release_notes.md
43
+ fi
44
+
45
+ - name: Create draft release
46
+ uses: softprops/action-gh-release@v2
47
+ with:
48
+ tag_name: ${{ steps.version.outputs.tag }}
49
+ name: ${{ steps.version.outputs.tag }}
50
+ body_path: release_notes.md
51
+ draft: true
52
+ generate_release_notes: false
53
+ env:
54
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -18,7 +18,7 @@ jobs:
18
18
  - name: Setup Node.js
19
19
  uses: actions/setup-node@v4
20
20
  with:
21
- node-version: "20"
21
+ node-version: "24"
22
22
  registry-url: "https://registry.npmjs.org"
23
23
 
24
24
  - name: Install dependencies
package/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [1.0.1] - 2025-02-23
8
+
9
+ ### Added
10
+
11
+ - **Widget "Internal Notes & Assign Topic"** — Add the Internal Notes and Assign Topic buttons via **ACP > Appearance > Widgets** (e.g. to Global Sidebar) for themes that use a different layout. The widget only renders on topic pages.
12
+ - **Right sidebar placement** — Internal Notes and Assign Topic buttons are now injected into the far-right sidebar (`component="sidebar/right"`) at the bottom. When the sidebar is collapsed they show as icon-only; when expanded, icon and label. This placement is only tested with the default theme [nodebb-theme-harmony v2.1.36](https://github.com/NodeBB/nodebb-theme-harmony/tree/v2.1.36).
13
+ - **Translatable "Close"** — Notes panel close control uses a dedicated "Close" button with the new language key `close`.
14
+
15
+ ### Changed
16
+
17
+ - **Button position** — Buttons no longer appear in the topic thread tools menu; they are shown in the right sidebar (or via the new widget). This matches the thin vertical sidebar used for notifications, search, drafts, and chat.
18
+ - **Notes panel close** — Close control moved from a small header link to a visible "Close" button next to "Add Note" in the notes panel for clearer UX.
19
+ - **Docs** — README simplified; detailed setup and technical info moved to DEVELOPMENT.md and TECHNICAL.md. NodeBB 3.x references removed from README.
20
+
21
+ ## [1.0.0] - (initial release)
22
+
23
+ ### Added
24
+
25
+ - Internal staff notes on topics (add, view, delete)
26
+ - Topic assignment (user or group) with notifications for assigned groups
27
+ - "Assign to myself" quick action
28
+ - Permission-based visibility (admin; optional global/category moderators)
29
+ - Right sidebar placement for Internal Notes and Assign Topic buttons
30
+ - Admin settings: allow global moderators, allow category moderators
31
+ - Widget fallback for themes without right sidebar
package/DEVELOPMENT.md ADDED
@@ -0,0 +1,24 @@
1
+ # Development
2
+
3
+ ## Local development setup
4
+
5
+ ```bash
6
+ cd /path/to/nodebb
7
+ npm link /path/to/nodebb-plugin-internalnotes
8
+ ./nodebb build
9
+ ./nodebb dev
10
+ ```
11
+
12
+ ## Standards and linting
13
+
14
+ - The plugin follows [NodeBB plugin standards](https://docs.nodebb.org/development/plugins/); see [NODEBB_STANDARDS_AUDIT.md](NODEBB_STANDARDS_AUDIT.md) for a full audit.
15
+ - Lint: `npm run lint` (ESLint).
16
+
17
+ ## Publishing to npm
18
+
19
+ A GitHub Action (`.github/workflows/publish-npm.yml`) runs lint and publishes to [npm](https://www.npmjs.com/~brutalbirdie) when:
20
+
21
+ - A **release** is published on GitHub, or
22
+ - The workflow is run manually (**Actions → Lint and publish to npm → Run workflow**).
23
+
24
+ **One-time setup:** In this repo, go to **Settings → Secrets and variables → Actions** and add a secret named `NPM_TOKEN` with an [npm access token](https://www.npmjs.com/settings/brutalbirdie/tokens) (Automation type is recommended). The workflow will only publish if `npm run lint` passes.
@@ -1,6 +1,6 @@
1
1
  # NodeBB Plugin Standards Audit
2
2
 
3
- This document audits **nodebb-plugin-internalnotes** against [NodeBB upstream documentation](https://docs.nodebb.org/development/) and the [nodebb-plugin-quickstart](https://github.com/NodeBB/nodebb-plugin-quickstart) template. References: [development](https://docs.nodebb.org/development/), [quickstart](https://docs.nodebb.org/development/quickstart/), [plugins](https://docs.nodebb.org/development/plugins/), [plugin.json](https://docs.nodebb.org/development/plugins/plugin.json/), [hooks](https://docs.nodebb.org/development/plugins/hooks/), [statics](https://docs.nodebb.org/development/plugins/statics/), [libraries](https://docs.nodebb.org/development/plugins/libraries/), [i18n](https://docs.nodebb.org/development/i18n/), [style guide](https://docs.nodebb.org/development/style-guide/).
3
+ This document audits **nodebb-plugin-internalnotes** against [NodeBB upstream documentation](https://docs.nodebb.org/development/) and the [nodebb-plugin-quickstart](https://github.com/NodeBB/nodebb-plugin-quickstart) template. References: [development](https://docs.nodebb.org/development/), [quickstart](https://docs.nodebb.org/development/quickstart/), [plugins](https://docs.nodebb.org/development/plugins/), [plugin.json](https://docs.nodebb.org/development/plugins/plugin.json/), [hooks](https://docs.nodebb.org/development/plugins/hooks/), [statics](https://docs.nodebb.org/development/plugins/statics/), [libraries](https://docs.nodebb.org/development/plugins/libraries/), [i18n](https://docs.nodebb.org/development/i18n/), [style guide](https://docs.nodebb.org/development/style-guide/), [widgets](https://docs.nodebb.org/development/widgets/).
4
4
 
5
5
  ---
6
6
 
@@ -102,6 +102,8 @@ This document audits **nodebb-plugin-internalnotes** against [NodeBB upstream do
102
102
  | **static:api.routes** | REST API routes (notes, assign, group search) | ✅ |
103
103
  | **filter:admin.header.build** | ACP nav item | ✅ |
104
104
  | **filter:navigation.available** | “Assigned” nav item | ✅ |
105
+ | **filter:widgets.getWidgets** | Register "Internal Notes & Assign Topic" widget | ✅ |
106
+ | **filter:widget.render:internalnotes_sidebar** | Render widget HTML (topic page, privileged only) | ✅ |
105
107
  | **filter:topic.get** | Add notes/assignee to single topic | ✅ |
106
108
  | **filter:topics.get** | Add notes/assignee to topic lists | ✅ |
107
109
  | **filter:topic.thread_tools** | Thread tools entries | ✅ |
@@ -111,7 +113,28 @@ This document audits **nodebb-plugin-internalnotes** against [NodeBB upstream do
111
113
 
112
114
  ---
113
115
 
114
- ## 8. Static directories & security
116
+ ## 8. Widgets ([docs](https://docs.nodebb.org/development/widgets/))
117
+
118
+ The plugin provides an optional **Internal Notes & Assign Topic** widget for themes that use a different layout (e.g. when the right sidebar component is not present). Audited against the [Writing Widgets](https://docs.nodebb.org/development/widgets/) documentation.
119
+
120
+ | Requirement | Status | Notes |
121
+ |-------------|--------|--------|
122
+ | **Register widget** | ✅ | Listens to `filter:widgets.getWidgets` with method `defineWidgets`; pushes `{ widget, name, description, content }` into the array and returns it |
123
+ | **Widget namespace** | ✅ | `widget: 'internalnotes_sidebar'` – unique namespace for the render hook |
124
+ | **Render hook** | ✅ | Listens to `filter:widget.render:internalnotes_sidebar` with method `renderInternalNotesWidget`; hook name matches widget namespace |
125
+ | **widget.html** | ✅ | Render method assigns HTML to `widget.html` (buttons for Notes and Assign Topic) |
126
+ | **Async return** | ✅ | `renderInternalNotesWidget` is async and returns `widget` (documented pattern: callback or return widget) |
127
+ | **widget.req** | ✅ | Used for `widget.req.uid` and `widget.req.path` (topic-page and privilege checks) |
128
+ | **widget.area** | ✅ | Used for `widget.area.template` and `widget.area.url` when present (topic-page detection) |
129
+ | **widget.data** | ⚪ | Not used; `content: ''` in registration (no admin form) – valid for a widget with no options |
130
+ | **Nomenclature** | ✅ | Plugin is `nodebb-plugin-internalnotes` (main purpose is internal notes); widget-only packages would use `nodebb-widget-xyz` – correct here |
131
+ | **Privilege / scope** | ✅ | Widget renders empty HTML for non–topic pages and for users without `canViewNotes`; no sensitive data exposed |
132
+
133
+ **Verdict:** Compliant with the widget development docs. Widget is optional fallback; primary UI is thread tools and right-sidebar placement.
134
+
135
+ ---
136
+
137
+ ## 9. Static directories & security
115
138
 
116
139
  - No `staticDirs` – no public static assets. Sensitive data is not exposed via static routes.
117
140
  - API routes use `middleware.ensureLoggedIn` and custom `ensurePrivileged` (notes visibility).
@@ -119,7 +142,7 @@ This document audits **nodebb-plugin-internalnotes** against [NodeBB upstream do
119
142
 
120
143
  ---
121
144
 
122
- ## 9. Database
145
+ ## 10. Database
123
146
 
124
147
  - Uses NodeBB `database` API (`getObject`, `setObject`, `sortedSetAdd`, etc.).
125
148
  - Keys documented in README; no custom migrations yet (no `upgrades` in plugin.json).
@@ -127,11 +150,11 @@ This document audits **nodebb-plugin-internalnotes** against [NodeBB upstream do
127
150
 
128
151
  ---
129
152
 
130
- ## 10. Style guide
153
+ ## 11. Style guide
131
154
 
132
155
  - Core follows Airbnb JS + ESLint; third-party plugins are encouraged but not required to follow.
133
- - This plugin has `"lint": "eslint ."` in package.json but no local eslint config in the repo; lint may rely on global or NodeBB env.
134
- **Verdict:** Optional; adding an `eslint.config.mjs` (or similar) would align with best practice.
156
+ - This plugin has `"lint": "eslint ."` in package.json and a minimal `eslint.config.mjs`; run `npm install -D eslint` for local lint.
157
+ **Verdict:** Compliant.
135
158
 
136
159
  ---
137
160
 
@@ -146,6 +169,7 @@ This document audits **nodebb-plugin-internalnotes** against [NodeBB upstream do
146
169
  | Templates | ✅ Compliant |
147
170
  | i18n | ✅ Compliant |
148
171
  | Hooks | ✅ Compliant |
172
+ | Widgets | ✅ Compliant ([widgets](https://docs.nodebb.org/development/widgets/)) |
149
173
  | Security | ✅ Compliant |
150
174
  | Database | ✅ Compliant |
151
175
  | Style / lint | ✅ eslint.config.mjs added (optional; run `npm install -D eslint` for lint) |
package/README.md CHANGED
@@ -1,8 +1,11 @@
1
+ [![Lint and publish to npm](https://github.com/BrutalBirdie/nodebb-plugin-internalnotes/actions/workflows/publish-npm.yml/badge.svg)](https://github.com/BrutalBirdie/nodebb-plugin-internalnotes/actions/workflows/publish-npm.yml) [![Draft release on tag](https://github.com/BrutalBirdie/nodebb-plugin-internalnotes/actions/workflows/draft-release.yml/badge.svg)](https://github.com/BrutalBirdie/nodebb-plugin-internalnotes/actions/workflows/draft-release.yml)
2
+ ---
3
+
1
4
  # nodebb-plugin-internalnotes
2
5
 
3
6
  A NodeBB plugin that adds **internal staff notes** and **topic assignment** to forum topics. By default only administrators can see and manage notes and assignments; you can optionally allow global moderators and/or category moderators in the plugin settings. They are completely invisible to everyone else.
4
7
 
5
- **Version:** 1.0.0 · **NodeBB:** 4.x (tested on 4.8.1)
8
+ **Version:** 1.0.1 · **NodeBB:** 4.x (tested on 4.8.1)
6
9
 
7
10
  ## Features
8
11
 
@@ -10,7 +13,7 @@ A NodeBB plugin that adds **internal staff notes** and **topic assignment** to f
10
13
  - **Topic Assignment (User or Group)** — Assign a topic to a specific user or an entire group. All members of an assigned group receive a notification.
11
14
  - **"Assign to myself"** — The first option in the assignment modal lets the current user instantly assign the topic to themselves.
12
15
  - **Permission-based visibility** — Notes, assignment badges, and the thread tool buttons are completely invisible to regular users. By default only admins can see them; you can enable global moderators and/or category moderators in the plugin settings. No DOM elements are rendered for unprivileged users.
13
- - **Thread Tools integration** — "Internal Notes" and "Assign Topic" options appear in the topic thread tools dropdown for privileged users only.
16
+ - **Right sidebar placement** — On topic pages, "Internal Notes" and "Assign Topic" buttons are shown in the far-right sidebar (`component="sidebar/right"`). A widget is also available for themes that use a different layout.
14
17
  - **Admin settings page** — Configure who can access notes: allow global moderators and/or category moderators (ACP > Plugins > Internal Notes & Assignments).
15
18
 
16
19
  ## Installation
@@ -22,14 +25,7 @@ npm install nodebb-plugin-internalnotes
22
25
 
23
26
  Then activate the plugin from the **Admin Control Panel > Extend > Plugins**.
24
27
 
25
- ### For development
26
-
27
- ```bash
28
- cd /path/to/nodebb
29
- npm link /path/to/nodebb-plugin-internalnotes
30
- ./nodebb build
31
- ./nodebb dev
32
- ```
28
+ **Where the buttons appear:** On topic pages, the **Internal Notes** and **Assign Topic** buttons are automatically placed in the far-right sidebar (the thin vertical bar on the right edge of the page). No widget setup is required. If your theme does not have this component, you can add the **Internal Notes & Assign Topic** widget to the Global Sidebar in **ACP > Appearance > Widgets** as a fallback.
33
29
 
34
30
  ## Configuration
35
31
 
@@ -41,8 +37,7 @@ Navigate to **ACP > Plugins > Internal Notes & Assignments** to configure:
41
37
  ## Usage
42
38
 
43
39
  1. Navigate to any topic as a user who has access (admin, or global/category moderator if enabled in settings).
44
- 2. Open the **Thread Tools** dropdown (the wrench icon).
45
- 3. Click **Internal Notes** to open the notes side panel, or **Assign Topic** to assign the topic.
40
+ 2. In the **far-right sidebar** (the vertical bar on the right edge of the page), click **Internal Notes** to open the notes side panel, or **Assign Topic** to assign the topic.
46
41
 
47
42
  ### Notes panel
48
43
 
@@ -57,47 +52,16 @@ Navigate to **ACP > Plugins > Internal Notes & Assignments** to configure:
57
52
  - **User tab** — search and select any user by username
58
53
  - **Group tab** — search and select any group by name
59
54
 
60
- ## Database Keys
61
-
62
- | Key | Type | Description |
63
- |-----|------|-------------|
64
- | `internalnote:<noteId>` | Hash | Individual note (noteId, tid, uid, content, timestamp) |
65
- | `internalnotes:tid:<tid>` | Sorted Set | Note IDs for a topic (score = timestamp) |
66
- | `topic:<tid>` → `assignee` | Object Field | UID (for user) or group name (for group) |
67
- | `topic:<tid>` → `assigneeType` | Object Field | `"user"` or `"group"` |
68
- | `global` → `nextInternalNoteId` | Object Field | Auto-incrementing note ID counter |
69
-
70
- ## API Endpoints
71
-
72
- All endpoints require authentication and privileged access.
73
-
74
- | Method | Endpoint | Description |
75
- |--------|----------|-------------|
76
- | `GET` | `/api/v3/plugins/internalnotes/:tid` | Get all notes for a topic |
77
- | `POST` | `/api/v3/plugins/internalnotes/:tid` | Create a note (`{ content }`) |
78
- | `DELETE` | `/api/v3/plugins/internalnotes/:tid/:noteId` | Delete a note |
79
- | `GET` | `/api/v3/plugins/internalnotes/:tid/assign` | Get topic assignee |
80
- | `PUT` | `/api/v3/plugins/internalnotes/:tid/assign` | Assign topic (`{ type: "user"\|"group", id: uid\|groupName }`) |
81
- | `DELETE` | `/api/v3/plugins/internalnotes/:tid/assign` | Unassign topic |
82
- | `GET` | `/api/v3/plugins/internalnotes/groups/search?query=...` | Search groups by name |
83
-
84
55
  ## Compatibility
85
56
 
86
- NodeBB v3.x and v4.x (`nbbpm.compatibility`: `^3.0.0 || ^4.0.0`). Tested on NodeBB 4.8.1.
87
-
88
- ## Development
89
-
90
- - The plugin follows [NodeBB plugin standards](https://docs.nodebb.org/development/plugins/); see [NODEBB_STANDARDS_AUDIT.md](NODEBB_STANDARDS_AUDIT.md) for a full audit.
91
- - Lint: `npm run lint` (ESLint).
92
-
93
- ## Publishing to npm
57
+ NodeBB v4.x. Tested on NodeBB 4.8.1.
94
58
 
95
- A GitHub Action (`.github/workflows/publish-npm.yml`) runs lint and publishes to [npm](https://www.npmjs.com/~brutalbirdie) when:
59
+ The right-sidebar button placement (injection into `component="sidebar/right"`) is only tested with the default theme **nodebb-theme-harmony** [v2.1.36](https://github.com/NodeBB/nodebb-theme-harmony/tree/v2.1.36). Other themes may need the **Internal Notes & Assign Topic** widget in ACP > Appearance > Widgets.
96
60
 
97
- - A **release** is published on GitHub, or
98
- - The workflow is run manually (**Actions → Lint and publish to npm → Run workflow**).
61
+ ## For developers
99
62
 
100
- **One-time setup:** In this repo, go to **Settings → Secrets and variables → Actions** and add a secret named `NPM_TOKEN` with an [npm access token](https://www.npmjs.com/settings/brutalbirdie/tokens) (Automation type is recommended). The workflow will only publish if `npm run lint` passes.
63
+ - **[DEVELOPMENT.md](DEVELOPMENT.md)** Local setup, linting, and publishing to npm.
64
+ - **[TECHNICAL.md](TECHNICAL.md)** — Database keys and API endpoints.
101
65
 
102
66
  ## License
103
67
 
package/TECHNICAL.md ADDED
@@ -0,0 +1,25 @@
1
+ # Technical reference
2
+
3
+ ## Database keys
4
+
5
+ | Key | Type | Description |
6
+ |-----|------|-------------|
7
+ | `internalnote:<noteId>` | Hash | Individual note (noteId, tid, uid, content, timestamp) |
8
+ | `internalnotes:tid:<tid>` | Sorted Set | Note IDs for a topic (score = timestamp) |
9
+ | `topic:<tid>` → `assignee` | Object Field | UID (for user) or group name (for group) |
10
+ | `topic:<tid>` → `assigneeType` | Object Field | `"user"` or `"group"` |
11
+ | `global` → `nextInternalNoteId` | Object Field | Auto-incrementing note ID counter |
12
+
13
+ ## API endpoints
14
+
15
+ All endpoints require authentication and privileged access.
16
+
17
+ | Method | Endpoint | Description |
18
+ |--------|----------|-------------|
19
+ | `GET` | `/api/v3/plugins/internalnotes/:tid` | Get all notes for a topic |
20
+ | `POST` | `/api/v3/plugins/internalnotes/:tid` | Create a note (`{ content }`) |
21
+ | `DELETE` | `/api/v3/plugins/internalnotes/:tid/:noteId` | Delete a note |
22
+ | `GET` | `/api/v3/plugins/internalnotes/:tid/assign` | Get topic assignee |
23
+ | `PUT` | `/api/v3/plugins/internalnotes/:tid/assign` | Assign topic (`{ type: "user"\|"group", id: uid\|groupName }`) |
24
+ | `DELETE` | `/api/v3/plugins/internalnotes/:tid/assign` | Unassign topic |
25
+ | `GET` | `/api/v3/plugins/internalnotes/groups/search?query=...` | Search groups by name |
@@ -3,6 +3,7 @@
3
3
  "panel-title": "Internal Notes",
4
4
  "placeholder": "Write an internal note…",
5
5
  "add-note": "Add Note",
6
+ "close": "Close",
6
7
  "delete-note": "Delete",
7
8
  "no-notes": "No internal notes yet.",
8
9
  "error-loading": "Could not load notes.",
package/library.js CHANGED
@@ -111,6 +111,7 @@ plugin.addInternalNotesToTopic = async (data) => {
111
111
  }
112
112
  const allowed = await canViewNotes(data.uid);
113
113
  data.topic.canViewInternalNotes = allowed;
114
+ data.canViewInternalNotes = allowed; // so client can read from ajaxify.data.canViewInternalNotes
114
115
  if (allowed) {
115
116
  data.topic.assignee = await getAssignee(data.topic.tid);
116
117
  data.topic.internalNoteCount = await db.sortedSetCard(`internalnotes:tid:${data.topic.tid}`);
@@ -137,23 +138,6 @@ plugin.addInternalNotesToTopics = async (data) => {
137
138
  return data;
138
139
  };
139
140
 
140
- plugin.addThreadTools = async (data) => {
141
- const allowed = await canViewNotes(data.uid);
142
- if (allowed) {
143
- data.tools.push({
144
- title: '[[internalnotes:thread-tool-notes]]',
145
- class: 'toggle-internal-notes',
146
- icon: 'fa-sticky-note',
147
- });
148
- data.tools.push({
149
- title: '[[internalnotes:thread-tool-assign]]',
150
- class: 'assign-topic-user',
151
- icon: 'fa-user-plus',
152
- });
153
- }
154
- return data;
155
- };
156
-
157
141
  plugin.purgeTopicNotes = async ({ topic }) => {
158
142
  if (!topic || !topic.tid) {
159
143
  return;
@@ -178,6 +162,61 @@ plugin.addNavigation = (menu) => {
178
162
  return menu;
179
163
  };
180
164
 
165
+ // --- Widget: Internal Notes & Assign Topic in sidebar (topic page only) ---
166
+
167
+ plugin.defineWidgets = (widgets) => {
168
+ widgets.push({
169
+ widget: 'internalnotes_sidebar',
170
+ name: 'Internal Notes & Assign Topic',
171
+ description: 'Shows Internal Notes and Assign Topic buttons for privileged users on topic pages. Add this widget to the Global Sidebar (the right sidebar with notifications, search, drafts, chat).',
172
+ content: '', // no admin config
173
+ });
174
+ return widgets;
175
+ };
176
+
177
+ plugin.renderInternalNotesWidget = async (widget) => {
178
+ // Show only on topic pages. Widget can be in:
179
+ // - Topic template sidebar (template === 'topic'), or
180
+ // - Global Sidebar (template === 'global') — same right sidebar as notifications/search/drafts/chat
181
+ const templateName = (widget.templateData && widget.templateData.template && widget.templateData.template.name) ||
182
+ (widget.area && widget.area.template) || '';
183
+ const path = (widget.req && widget.req.path) ? widget.req.path : (widget.area && widget.area.url) || '';
184
+ const isTopicPage = String(templateName) === 'topic' ||
185
+ (String(templateName) === 'global' && /^\/topic\//.test(String(path)));
186
+ if (!isTopicPage) {
187
+ widget.html = '';
188
+ return widget;
189
+ }
190
+ const uid = widget.req && widget.req.uid ? widget.req.uid : 0;
191
+ const allowed = await canViewNotes(uid);
192
+ if (!allowed) {
193
+ widget.html = '';
194
+ return widget;
195
+ }
196
+ const translator = require.main.require('./src/translator');
197
+ const [notesLabel, assignLabel] = await Promise.all([
198
+ new Promise((resolve) => translator.translate('[[internalnotes:thread-tool-notes]]', resolve)),
199
+ new Promise((resolve) => translator.translate('[[internalnotes:thread-tool-assign]]', resolve)),
200
+ ]);
201
+ const escapeHtml = (str) => {
202
+ if (str == null) return '';
203
+ const s = String(str);
204
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
205
+ };
206
+ widget.html = `
207
+ <div class="internal-notes-sidebar-actions mb-3">
208
+ <div class="btn-group-vertical w-100 d-flex flex-column gap-2" role="group">
209
+ <button type="button" class="btn btn-sm btn-outline-warning toggle-internal-notes w-100 text-start">
210
+ <i class="fa fa-sticky-note me-1"></i> ${escapeHtml(notesLabel)}
211
+ </button>
212
+ <button type="button" class="btn btn-sm btn-outline-primary assign-topic-user w-100 text-start">
213
+ <i class="fa fa-user-plus me-1"></i> ${escapeHtml(assignLabel)}
214
+ </button>
215
+ </div>
216
+ </div>`;
217
+ return widget;
218
+ };
219
+
181
220
  plugin.renderAssignedPage = async (req, res) => {
182
221
  const page = Math.max(1, parseInt(req.query.page, 10) || 1);
183
222
  const uid = req.uid;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-internalnotes",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Add internal staff notes and assignees to topics in NodeBB. Notes are only visible to privileged users (moderators/admins).",
5
5
  "main": "library.js",
6
6
  "author": "BrutalBirdie",
package/plugin.json CHANGED
@@ -19,6 +19,14 @@
19
19
  "hook": "filter:navigation.available",
20
20
  "method": "addNavigation"
21
21
  },
22
+ {
23
+ "hook": "filter:widgets.getWidgets",
24
+ "method": "defineWidgets"
25
+ },
26
+ {
27
+ "hook": "filter:widget.render:internalnotes_sidebar",
28
+ "method": "renderInternalNotesWidget"
29
+ },
22
30
  {
23
31
  "hook": "filter:topic.get",
24
32
  "method": "addInternalNotesToTopic"
@@ -27,10 +35,6 @@
27
35
  "hook": "filter:topics.get",
28
36
  "method": "addInternalNotesToTopics"
29
37
  },
30
- {
31
- "hook": "filter:topic.thread_tools",
32
- "method": "addThreadTools"
33
- },
34
38
  {
35
39
  "hook": "action:topic.purge",
36
40
  "method": "purgeTopicNotes"
@@ -19,10 +19,11 @@
19
19
  notesPanel.remove();
20
20
  }
21
21
 
22
- const [panelTitle, placeholder, addNote] = await Promise.all([
22
+ const [panelTitle, placeholder, addNote, closeLabel] = await Promise.all([
23
23
  t('panel-title'),
24
24
  t('placeholder'),
25
25
  t('add-note'),
26
+ t('close'),
26
27
  ]);
27
28
 
28
29
  const panel = document.createElement('div');
@@ -31,13 +32,15 @@
31
32
  panel.innerHTML = `
32
33
  <div class="internal-notes-header">
33
34
  <h5><i class="fa fa-sticky-note"></i> ${escapeHtml(panelTitle)}</h5>
34
- <button class="btn btn-sm btn-link internal-notes-close" title="Close"><i class="fa fa-times"></i></button>
35
35
  </div>
36
36
  <div class="internal-notes-assignee"></div>
37
37
  <div class="internal-notes-list"></div>
38
38
  <div class="internal-notes-form">
39
39
  <textarea class="form-control internal-notes-input" rows="3" placeholder="${escapeHtml(placeholder)}"></textarea>
40
- <button class="btn btn-primary btn-sm internal-notes-submit mt-2">${escapeHtml(addNote)}</button>
40
+ <div class="internal-notes-form-actions mt-2">
41
+ <button type="button" class="btn btn-primary btn-sm internal-notes-submit">${escapeHtml(addNote)}</button>
42
+ <button type="button" class="btn btn-secondary btn-sm internal-notes-close">${escapeHtml(closeLabel)}</button>
43
+ </div>
41
44
  </div>
42
45
  `;
43
46
  document.body.appendChild(panel);
@@ -494,6 +497,83 @@
494
497
  await loadAssignee(tid);
495
498
  }
496
499
 
500
+ // --- Helpers for badge visibility (topic/list pages) ---
501
+
502
+ function canViewInternalNotesOnPage() {
503
+ return !!(ajaxify.data && (
504
+ ajaxify.data.canViewInternalNotes === true ||
505
+ (ajaxify.data.topic && ajaxify.data.topic.canViewInternalNotes === true)
506
+ ));
507
+ }
508
+
509
+ // --- Buttons in component="sidebar/right" (collapsed = no "open" class = icon only; expanded = "open" = icon + text) ---
510
+
511
+ function isSidebarRightCollapsed(sidebarRight) {
512
+ if (!sidebarRight) return true;
513
+ // Expanded when this element or any ancestor has class "open"
514
+ const hasOpen = sidebarRight.classList.contains('open') ||
515
+ (sidebarRight.parentElement && sidebarRight.parentElement.classList.contains('open'));
516
+ return !hasOpen;
517
+ }
518
+
519
+ function updateSidebarRightCollapsedState(wrap) {
520
+ const sidebarRight = document.querySelector('[component="sidebar/right"]');
521
+ if (!sidebarRight || !wrap) return;
522
+ const collapsed = isSidebarRightCollapsed(sidebarRight);
523
+ wrap.classList.toggle('internal-notes-sidebar-actions--collapsed', collapsed);
524
+ }
525
+
526
+ async function renderSidebarRightButtons() {
527
+ const tid = getTid();
528
+ if (!tid || !canViewInternalNotesOnPage()) {
529
+ return;
530
+ }
531
+ const sidebarRight = document.querySelector('[component="sidebar/right"]');
532
+ if (!sidebarRight) {
533
+ return;
534
+ }
535
+ const existing = sidebarRight.querySelector('.internal-notes-sidebar-actions');
536
+ if (existing) {
537
+ if (existing._internalNotesResizeObserver) existing._internalNotesResizeObserver.disconnect();
538
+ if (existing._internalNotesMutationObserver) existing._internalNotesMutationObserver.disconnect();
539
+ existing.remove();
540
+ }
541
+ const [notesLabel, assignLabel] = await Promise.all([
542
+ t('thread-tool-notes'),
543
+ t('thread-tool-assign'),
544
+ ]);
545
+ const wrap = document.createElement('div');
546
+ wrap.className = 'internal-notes-sidebar-actions';
547
+ wrap.innerHTML = `
548
+ <div class="internal-notes-sidebar-item" role="group">
549
+ <button type="button" class="toggle-internal-notes internal-notes-sidebar-btn" title="${escapeHtml(notesLabel)}">
550
+ <i class="fa fa-sticky-note" aria-hidden="true"></i>
551
+ <span class="internal-notes-sidebar-btn-text">${escapeHtml(notesLabel)}</span>
552
+ </button>
553
+ <button type="button" class="assign-topic-user internal-notes-sidebar-btn" title="${escapeHtml(assignLabel)}">
554
+ <i class="fa fa-user-plus" aria-hidden="true"></i>
555
+ <span class="internal-notes-sidebar-btn-text">${escapeHtml(assignLabel)}</span>
556
+ </button>
557
+ </div>
558
+ `;
559
+ // Append so we're last in DOM; with margin-top: auto we sit at bottom. If theme uses column-reverse, insert first so we render at bottom.
560
+ if (window.getComputedStyle(sidebarRight).flexDirection === 'column-reverse') {
561
+ sidebarRight.insertBefore(wrap, sidebarRight.firstChild);
562
+ } else {
563
+ sidebarRight.appendChild(wrap);
564
+ }
565
+
566
+ updateSidebarRightCollapsedState(wrap);
567
+ requestAnimationFrame(() => updateSidebarRightCollapsedState(wrap));
568
+ const ro = new ResizeObserver(() => updateSidebarRightCollapsedState(wrap));
569
+ ro.observe(sidebarRight);
570
+ wrap._internalNotesResizeObserver = ro;
571
+ const mo = new MutationObserver(() => updateSidebarRightCollapsedState(wrap));
572
+ mo.observe(document.body, { attributes: true, attributeFilter: ['class'] });
573
+ mo.observe(sidebarRight, { attributes: true, attributeFilter: ['class'] });
574
+ wrap._internalNotesMutationObserver = mo;
575
+ }
576
+
497
577
  // --- Page lifecycle ---
498
578
 
499
579
  hooks.on('action:ajaxify.end', () => {
@@ -503,6 +583,8 @@
503
583
  }
504
584
  // Run for both single-topic page and topic list pages (category, recent, etc.)
505
585
  renderBadges();
586
+ // Topic page: inject Internal Notes & Assign Topic into the far-right sidebar (component="sidebar/right")
587
+ renderSidebarRightButtons();
506
588
  });
507
589
 
508
590
  function getTopicDataForTid(tid) {
@@ -538,7 +620,7 @@
538
620
 
539
621
  async function renderBadges() {
540
622
  const headers = document.querySelectorAll('[component="topic/header"]');
541
- const isTopicPage = ajaxify.data.tid && ajaxify.data.canViewInternalNotes;
623
+ const isTopicPage = ajaxify.data.tid && canViewInternalNotesOnPage();
542
624
  // If no headers found but we're on the topic page, use a single "virtual" pass with #content .topic-title
543
625
  const headerList = headers.length ? Array.from(headers) : (isTopicPage ? [document.body] : []);
544
626
 
@@ -112,8 +112,89 @@
112
112
  min-height: 60px;
113
113
  font-size: 0.9rem;
114
114
  }
115
+
116
+ .internal-notes-form-actions {
117
+ display: flex;
118
+ gap: 8px;
119
+ align-items: center;
120
+ }
121
+ }
122
+
123
+ // Internal Notes & Assign Topic in component="sidebar/right" — match theme (grey icons, at bottom)
124
+ [component="sidebar/right"] .internal-notes-sidebar-actions {
125
+ margin-top: auto; /* push to bottom when parent is flex column */
126
+ }
127
+ .internal-notes-sidebar-actions {
128
+ display: flex;
129
+ flex-direction: column;
130
+ gap: 0;
131
+ padding: 0;
132
+ min-width: 0;
133
+
134
+ .internal-notes-sidebar-item {
135
+ display: flex;
136
+ flex-direction: column;
137
+ gap: 0;
138
+ }
139
+
140
+ .internal-notes-sidebar-btn {
141
+ display: flex;
142
+ align-items: center;
143
+ gap: 0.75rem;
144
+ width: 100%;
145
+ padding: 0.5rem 0.75rem;
146
+ border: none;
147
+ background: transparent;
148
+ color: inherit;
149
+ font-size: inherit;
150
+ text-align: left;
151
+ cursor: pointer;
152
+ border-radius: 0;
153
+ transition: background-color 0.15s ease;
154
+
155
+ &:hover {
156
+ background: var(--bs-tertiary-bg, rgba(0, 0, 0, 0.04));
157
+ }
158
+
159
+ &:focus {
160
+ outline: none;
161
+ box-shadow: inset 0 0 0 2px var(--bs-primary, #0d6efd);
162
+ }
163
+
164
+ // Match theme: same grey as other sidebar icons (no custom colors)
165
+ i {
166
+ flex-shrink: 0;
167
+ width: 1em;
168
+ text-align: center;
169
+ color: inherit;
170
+ opacity: inherit;
171
+ }
172
+ }
173
+
174
+ .internal-notes-sidebar-btn-text {
175
+ white-space: nowrap;
176
+ overflow: hidden;
177
+ text-overflow: ellipsis;
178
+ }
179
+
180
+ // Collapsed (no "open" on sidebar): icon-only — hide text
181
+ &.internal-notes-sidebar-actions--collapsed .internal-notes-sidebar-btn-text {
182
+ display: none !important;
183
+ visibility: hidden !important;
184
+ width: 0 !important;
185
+ height: 0 !important;
186
+ overflow: hidden !important;
187
+ position: absolute !important;
188
+ clip: rect(0, 0, 0, 0) !important;
189
+ }
190
+ &.internal-notes-sidebar-actions--collapsed .internal-notes-sidebar-btn {
191
+ justify-content: center;
192
+ padding: 0.5rem 0.75rem;
193
+ min-height: 2.5rem;
194
+ }
115
195
  }
116
196
 
197
+
117
198
  .internal-notes-badge,
118
199
  .assignee-badge {
119
200
  font-size: 0.75rem;