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.
- package/.github/workflows/draft-release.yml +54 -0
- package/.github/workflows/publish-npm.yml +1 -1
- package/CHANGELOG.md +31 -0
- package/DEVELOPMENT.md +24 -0
- package/NODEBB_STANDARDS_AUDIT.md +30 -6
- package/README.md +12 -48
- package/TECHNICAL.md +25 -0
- package/languages/en-GB/internalnotes.json +1 -0
- package/library.js +56 -17
- package/package.json +1 -1
- package/plugin.json +8 -4
- package/public/lib/main.js +86 -4
- package/scss/internalnotes.scss +81 -0
|
@@ -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 }}
|
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.
|
|
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
|
-
##
|
|
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
|
-
##
|
|
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
|
|
134
|
-
**Verdict:**
|
|
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
|
+
[](https://github.com/BrutalBirdie/nodebb-plugin-internalnotes/actions/workflows/publish-npm.yml) [](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.
|
|
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
|
-
- **
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
- The workflow is run manually (**Actions → Lint and publish to npm → Run workflow**).
|
|
61
|
+
## For developers
|
|
99
62
|
|
|
100
|
-
|
|
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 |
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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.
|
|
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"
|
package/public/lib/main.js
CHANGED
|
@@ -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
|
-
<
|
|
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 &&
|
|
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
|
|
package/scss/internalnotes.scss
CHANGED
|
@@ -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;
|