nodebb-plugin-internalnotes 1.0.0
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/publish-npm.yml +33 -0
- package/LICENSE +21 -0
- package/NODEBB_STANDARDS_AUDIT.md +153 -0
- package/README.md +104 -0
- package/eslint.config.mjs +55 -0
- package/languages/en-GB/internalnotes.json +33 -0
- package/lib/controllers.js +9 -0
- package/library.js +434 -0
- package/package.json +32 -0
- package/plugin.json +53 -0
- package/public/lib/acp-main.js +5 -0
- package/public/lib/admin.js +11 -0
- package/public/lib/main.js +631 -0
- package/scss/internalnotes.scss +181 -0
- package/templates/admin/plugins/internalnotes.tpl +44 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
(async () => {
|
|
4
|
+
const hooks = await app.require('hooks');
|
|
5
|
+
const alerts = await app.require('alerts');
|
|
6
|
+
const api = await app.require('api');
|
|
7
|
+
const translator = await app.require('translator');
|
|
8
|
+
|
|
9
|
+
let notesPanel = null;
|
|
10
|
+
|
|
11
|
+
function t(key) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
translator.translate('[[internalnotes:' + key + ']]', resolve);
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function buildNotesPanel() {
|
|
18
|
+
if (notesPanel) {
|
|
19
|
+
notesPanel.remove();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const [panelTitle, placeholder, addNote] = await Promise.all([
|
|
23
|
+
t('panel-title'),
|
|
24
|
+
t('placeholder'),
|
|
25
|
+
t('add-note'),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const panel = document.createElement('div');
|
|
29
|
+
panel.id = 'internal-notes-panel';
|
|
30
|
+
panel.className = 'internal-notes-panel hidden';
|
|
31
|
+
panel.innerHTML = `
|
|
32
|
+
<div class="internal-notes-header">
|
|
33
|
+
<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
|
+
</div>
|
|
36
|
+
<div class="internal-notes-assignee"></div>
|
|
37
|
+
<div class="internal-notes-list"></div>
|
|
38
|
+
<div class="internal-notes-form">
|
|
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>
|
|
41
|
+
</div>
|
|
42
|
+
`;
|
|
43
|
+
document.body.appendChild(panel);
|
|
44
|
+
notesPanel = panel;
|
|
45
|
+
|
|
46
|
+
panel.querySelector('.internal-notes-close').addEventListener('click', () => {
|
|
47
|
+
panel.classList.add('hidden');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
panel.querySelector('.internal-notes-submit').addEventListener('click', () => {
|
|
51
|
+
submitNote();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
panel.querySelector('.internal-notes-input').addEventListener('keydown', (e) => {
|
|
55
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
56
|
+
submitNote();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return panel;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getTid() {
|
|
64
|
+
return ajaxify.data && ajaxify.data.tid;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Notes ---
|
|
68
|
+
|
|
69
|
+
async function submitNote() {
|
|
70
|
+
const tid = getTid();
|
|
71
|
+
if (!tid) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const textarea = notesPanel.querySelector('.internal-notes-input');
|
|
75
|
+
const content = textarea.value.trim();
|
|
76
|
+
if (!content) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
await api.post(`/plugins/internalnotes/${tid}`, { content });
|
|
81
|
+
textarea.value = '';
|
|
82
|
+
await loadNotes(tid);
|
|
83
|
+
alerts.success(await t('note-added'));
|
|
84
|
+
} catch (err) {
|
|
85
|
+
alerts.error(err.message || '[[error:unknown]]');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function loadNotes(tid) {
|
|
90
|
+
if (!notesPanel) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const { notes } = await api.get(`/plugins/internalnotes/${tid}`, {});
|
|
95
|
+
await renderNotes(notes || [], tid);
|
|
96
|
+
} catch (_) {
|
|
97
|
+
const msg = await t('error-loading');
|
|
98
|
+
notesPanel.querySelector('.internal-notes-list').innerHTML =
|
|
99
|
+
'<p class="text-muted p-3">' + escapeHtml(msg) + '</p>';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function renderNotes(notes, tid) {
|
|
104
|
+
const list = notesPanel.querySelector('.internal-notes-list');
|
|
105
|
+
const [noNotes, deleteNoteTitle, confirmDelete, noteDeleted] = await Promise.all([
|
|
106
|
+
t('no-notes'),
|
|
107
|
+
t('delete-note'),
|
|
108
|
+
t('confirm-delete'),
|
|
109
|
+
t('note-deleted'),
|
|
110
|
+
]);
|
|
111
|
+
if (!notes.length) {
|
|
112
|
+
list.innerHTML = '<p class="text-muted p-3">' + escapeHtml(noNotes) + '</p>';
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
list.innerHTML = notes.map(note => `
|
|
116
|
+
<div class="internal-note" data-note-id="${note.noteId}">
|
|
117
|
+
<div class="internal-note-meta">
|
|
118
|
+
<img src="${note.user.picture || ''}" class="avatar avatar-xs" alt="" onerror="this.style.display='none'" />
|
|
119
|
+
<strong>${escapeHtml(note.user.username || 'Unknown')}</strong>
|
|
120
|
+
<span class="timeago text-muted" title="${note.timestampISO}">${note.timestampISO}</span>
|
|
121
|
+
<button class="btn btn-xs btn-link text-danger delete-note" data-note-id="${note.noteId}" title="${escapeHtml(deleteNoteTitle)}">
|
|
122
|
+
<i class="fa fa-trash"></i>
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
<div class="internal-note-content">${escapeHtml(note.content)}</div>
|
|
126
|
+
</div>
|
|
127
|
+
`).join('');
|
|
128
|
+
|
|
129
|
+
list.querySelectorAll('.delete-note').forEach((btn) => {
|
|
130
|
+
btn.addEventListener('click', async () => {
|
|
131
|
+
const noteId = btn.getAttribute('data-note-id');
|
|
132
|
+
if (confirm(confirmDelete)) {
|
|
133
|
+
try {
|
|
134
|
+
await api.del(`/plugins/internalnotes/${tid}/${noteId}`, {});
|
|
135
|
+
await loadNotes(tid);
|
|
136
|
+
alerts.success(noteDeleted);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
alerts.error(err.message || (await t('error-loading')));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
if (jQuery && jQuery.fn.timeago) {
|
|
145
|
+
jQuery('.internal-note .timeago').timeago();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- Assignee display ---
|
|
150
|
+
|
|
151
|
+
async function loadAssignee(tid) {
|
|
152
|
+
if (!notesPanel) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const { assignee } = await api.get(`/plugins/internalnotes/${tid}/assign`, {});
|
|
157
|
+
await renderAssignee(assignee, tid);
|
|
158
|
+
} catch {
|
|
159
|
+
// silently fail
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function renderAssignee(assignee, tid) {
|
|
164
|
+
const container = notesPanel.querySelector('.internal-notes-assignee');
|
|
165
|
+
const [notAssigned, assignChangeLabel, assignedTo, unassignTitle, unassigned] = await Promise.all([
|
|
166
|
+
t('not-assigned'),
|
|
167
|
+
t('assign-change'),
|
|
168
|
+
t('assigned-to'),
|
|
169
|
+
t('unassign'),
|
|
170
|
+
t('unassigned'),
|
|
171
|
+
]);
|
|
172
|
+
if (!assignee) {
|
|
173
|
+
container.innerHTML = `
|
|
174
|
+
<div class="assignee-info">
|
|
175
|
+
<span class="text-muted"><i class="fa fa-user"></i> ${escapeHtml(notAssigned)}</span>
|
|
176
|
+
<button type="button" class="btn btn-xs btn-primary ms-2 assign-from-panel">
|
|
177
|
+
<i class="fa fa-user-plus me-1"></i> ${escapeHtml(assignChangeLabel)}
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
`;
|
|
181
|
+
container.querySelector('.assign-from-panel').addEventListener('click', () => showAssignModal(tid));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let label;
|
|
186
|
+
if (assignee.type === 'group') {
|
|
187
|
+
const g = assignee.group;
|
|
188
|
+
const icon = g.icon ? `<i class="${escapeHtml(g.icon)}"></i> ` : '';
|
|
189
|
+
label = `${icon}<strong>${escapeHtml(g.name)}</strong> <span class="text-muted">(${g.memberCount} members)</span>`;
|
|
190
|
+
} else {
|
|
191
|
+
const u = assignee.user;
|
|
192
|
+
const pic = u.picture ? `<img src="${u.picture}" class="avatar avatar-xs" alt="" onerror="this.style.display='none'" /> ` : '';
|
|
193
|
+
label = `${pic}<strong>${escapeHtml(u.username)}</strong>`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
container.innerHTML = `
|
|
197
|
+
<div class="assignee-info">
|
|
198
|
+
<i class="fa ${assignee.type === 'group' ? 'fa-users' : 'fa-user'}"></i>
|
|
199
|
+
${escapeHtml(assignedTo)} ${label}
|
|
200
|
+
<button type="button" class="btn btn-xs btn-link assign-from-panel" title="${escapeHtml(assignChangeLabel)}">
|
|
201
|
+
<i class="fa fa-pencil"></i>
|
|
202
|
+
</button>
|
|
203
|
+
<button type="button" class="btn btn-xs btn-link text-danger unassign-topic" title="${escapeHtml(unassignTitle)}">
|
|
204
|
+
<i class="fa fa-times"></i>
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
`;
|
|
208
|
+
container.querySelector('.assign-from-panel').addEventListener('click', () => showAssignModal(tid));
|
|
209
|
+
container.querySelector('.unassign-topic').addEventListener('click', async () => {
|
|
210
|
+
try {
|
|
211
|
+
await api.del(`/plugins/internalnotes/${tid}/assign`, {});
|
|
212
|
+
await loadAssignee(tid);
|
|
213
|
+
renderBadges();
|
|
214
|
+
alerts.success(unassigned);
|
|
215
|
+
} catch (err) {
|
|
216
|
+
alerts.error(err.message || (await t('error-loading')));
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// --- Assign modal ---
|
|
222
|
+
|
|
223
|
+
async function showAssignModal(tid) {
|
|
224
|
+
let selectedType = null;
|
|
225
|
+
let selectedId = null;
|
|
226
|
+
|
|
227
|
+
const [
|
|
228
|
+
assignToMyself,
|
|
229
|
+
assignNoOne,
|
|
230
|
+
tabUser,
|
|
231
|
+
tabGroup,
|
|
232
|
+
searchUserPlaceholder,
|
|
233
|
+
searchGroupPlaceholder,
|
|
234
|
+
selectedLabel,
|
|
235
|
+
assignModalTitle,
|
|
236
|
+
cancelLabel,
|
|
237
|
+
assignConfirmLabel,
|
|
238
|
+
selectTargetFirst,
|
|
239
|
+
assignedSuccess,
|
|
240
|
+
unassigned,
|
|
241
|
+
] = await Promise.all([
|
|
242
|
+
t('assign-to-myself'),
|
|
243
|
+
t('assign-no-one'),
|
|
244
|
+
t('tab-user'),
|
|
245
|
+
t('tab-group'),
|
|
246
|
+
t('search-user-placeholder'),
|
|
247
|
+
t('search-group-placeholder'),
|
|
248
|
+
t('selected'),
|
|
249
|
+
t('assign-modal-title'),
|
|
250
|
+
t('cancel'),
|
|
251
|
+
t('assign-confirm'),
|
|
252
|
+
t('select-target-first'),
|
|
253
|
+
t('assigned-success'),
|
|
254
|
+
t('unassigned'),
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
const bodyHtml = `
|
|
258
|
+
<div class="assign-modal-body">
|
|
259
|
+
<button type="button" class="btn btn-outline-primary w-100 mb-2" id="assign-self-btn">
|
|
260
|
+
<i class="fa fa-hand-pointer-o me-1"></i> ${escapeHtml(assignToMyself)}
|
|
261
|
+
</button>
|
|
262
|
+
<button type="button" class="btn btn-outline-secondary w-100 mb-3" id="assign-no-one-btn">
|
|
263
|
+
<i class="fa fa-user-times me-1"></i> ${escapeHtml(assignNoOne)}
|
|
264
|
+
</button>
|
|
265
|
+
<hr />
|
|
266
|
+
<ul class="nav nav-tabs mb-3">
|
|
267
|
+
<li class="nav-item">
|
|
268
|
+
<a href="#" class="nav-link active assign-tab-link" data-pane="pane-user">
|
|
269
|
+
<i class="fa fa-user me-1"></i> ${escapeHtml(tabUser)}
|
|
270
|
+
</a>
|
|
271
|
+
</li>
|
|
272
|
+
<li class="nav-item">
|
|
273
|
+
<a href="#" class="nav-link assign-tab-link" data-pane="pane-group">
|
|
274
|
+
<i class="fa fa-users me-1"></i> ${escapeHtml(tabGroup)}
|
|
275
|
+
</a>
|
|
276
|
+
</li>
|
|
277
|
+
</ul>
|
|
278
|
+
<div id="pane-user" class="assign-tab-pane">
|
|
279
|
+
<div class="mb-3 position-relative">
|
|
280
|
+
<input type="text" class="form-control" id="assign-user-input" placeholder="${escapeHtml(searchUserPlaceholder)}" autocomplete="off" />
|
|
281
|
+
<div id="assign-user-suggestions" class="list-group assign-suggestions"></div>
|
|
282
|
+
</div>
|
|
283
|
+
</div>
|
|
284
|
+
<div id="pane-group" class="assign-tab-pane" style="display:none;">
|
|
285
|
+
<div class="mb-3 position-relative">
|
|
286
|
+
<input type="text" class="form-control" id="assign-group-input" placeholder="${escapeHtml(searchGroupPlaceholder)}" autocomplete="off" />
|
|
287
|
+
<div id="assign-group-suggestions" class="list-group assign-suggestions"></div>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
<div id="assign-selection" class="alert alert-secondary d-none mt-2">
|
|
291
|
+
<small class="text-muted">${escapeHtml(selectedLabel)}:</small>
|
|
292
|
+
<strong id="assign-selection-label"></strong>
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
`;
|
|
296
|
+
|
|
297
|
+
const dialog = bootbox.dialog({
|
|
298
|
+
title: '<i class="fa fa-user-plus me-2"></i> ' + escapeHtml(assignModalTitle),
|
|
299
|
+
message: bodyHtml,
|
|
300
|
+
buttons: {
|
|
301
|
+
cancel: {
|
|
302
|
+
label: cancelLabel,
|
|
303
|
+
className: 'btn-secondary',
|
|
304
|
+
},
|
|
305
|
+
confirm: {
|
|
306
|
+
label: assignConfirmLabel,
|
|
307
|
+
className: 'btn-primary',
|
|
308
|
+
callback: async function () {
|
|
309
|
+
if (!selectedType || !selectedId) {
|
|
310
|
+
alerts.error(selectTargetFirst);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
await api.put(`/plugins/internalnotes/${tid}/assign`, { type: selectedType, id: selectedId });
|
|
315
|
+
await loadAssignee(tid);
|
|
316
|
+
alerts.success(assignedSuccess);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
alerts.error(err.message || (await t('error-loading')));
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
onShown: function () {
|
|
324
|
+
const modalEl = dialog[0] || dialog.get(0);
|
|
325
|
+
|
|
326
|
+
// Tab switching
|
|
327
|
+
modalEl.querySelectorAll('.assign-tab-link').forEach((link) => {
|
|
328
|
+
link.addEventListener('click', (e) => {
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
const targetPane = link.getAttribute('data-pane');
|
|
331
|
+
modalEl.querySelectorAll('.assign-tab-link').forEach(l => l.classList.remove('active'));
|
|
332
|
+
link.classList.add('active');
|
|
333
|
+
modalEl.querySelectorAll('.assign-tab-pane').forEach(p => { p.style.display = 'none'; });
|
|
334
|
+
const pane = modalEl.querySelector('#' + targetPane);
|
|
335
|
+
if (pane) {
|
|
336
|
+
pane.style.display = '';
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
function setSelection(type, id, displayLabel) {
|
|
342
|
+
selectedType = type;
|
|
343
|
+
selectedId = id;
|
|
344
|
+
const label = modalEl.querySelector('#assign-selection-label');
|
|
345
|
+
const box = modalEl.querySelector('#assign-selection');
|
|
346
|
+
if (label) {
|
|
347
|
+
label.textContent = displayLabel;
|
|
348
|
+
}
|
|
349
|
+
if (box) {
|
|
350
|
+
box.classList.remove('d-none');
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// "Assign to myself"
|
|
355
|
+
const selfBtn = modalEl.querySelector('#assign-self-btn');
|
|
356
|
+
if (selfBtn) {
|
|
357
|
+
selfBtn.addEventListener('click', async () => {
|
|
358
|
+
try {
|
|
359
|
+
await api.put(`/plugins/internalnotes/${tid}/assign`, { type: 'user', id: app.user.uid });
|
|
360
|
+
dialog.modal('hide');
|
|
361
|
+
if (notesPanel && !notesPanel.classList.contains('hidden')) {
|
|
362
|
+
await loadAssignee(tid);
|
|
363
|
+
}
|
|
364
|
+
renderBadges();
|
|
365
|
+
alerts.success(assignedSuccess);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
alerts.error(err.message || (await t('error-loading')));
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// "Assign to no one"
|
|
373
|
+
const noOneBtn = modalEl.querySelector('#assign-no-one-btn');
|
|
374
|
+
if (noOneBtn) {
|
|
375
|
+
noOneBtn.addEventListener('click', async () => {
|
|
376
|
+
try {
|
|
377
|
+
await api.del(`/plugins/internalnotes/${tid}/assign`, {});
|
|
378
|
+
dialog.modal('hide');
|
|
379
|
+
if (notesPanel && !notesPanel.classList.contains('hidden')) {
|
|
380
|
+
await loadAssignee(tid);
|
|
381
|
+
}
|
|
382
|
+
renderBadges();
|
|
383
|
+
alerts.success(unassigned);
|
|
384
|
+
} catch (err) {
|
|
385
|
+
alerts.error(err.message || (await t('error-loading')));
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// User search
|
|
391
|
+
setupSearchInput(
|
|
392
|
+
modalEl.querySelector('#assign-user-input'),
|
|
393
|
+
modalEl.querySelector('#assign-user-suggestions'),
|
|
394
|
+
async (query) => {
|
|
395
|
+
const result = await api.get(`/api/users`, { query, section: 'sort-posts' });
|
|
396
|
+
return (result && result.users ? result.users : []).slice(0, 10).map(u => ({
|
|
397
|
+
id: u.uid,
|
|
398
|
+
label: u.username,
|
|
399
|
+
picture: u.picture || '',
|
|
400
|
+
icon: null,
|
|
401
|
+
}));
|
|
402
|
+
},
|
|
403
|
+
(item) => {
|
|
404
|
+
setSelection('user', item.id, item.label);
|
|
405
|
+
}
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
// Group search
|
|
409
|
+
setupSearchInput(
|
|
410
|
+
modalEl.querySelector('#assign-group-input'),
|
|
411
|
+
modalEl.querySelector('#assign-group-suggestions'),
|
|
412
|
+
async (query) => {
|
|
413
|
+
const result = await api.get(`/plugins/internalnotes/groups/search`, { query });
|
|
414
|
+
return (result && result.groups ? result.groups : []).map(g => ({
|
|
415
|
+
id: g.name,
|
|
416
|
+
label: g.name,
|
|
417
|
+
picture: '',
|
|
418
|
+
icon: g.icon || 'fa-users',
|
|
419
|
+
memberCount: g.memberCount,
|
|
420
|
+
}));
|
|
421
|
+
},
|
|
422
|
+
(item) => {
|
|
423
|
+
setSelection('group', item.id, item.label);
|
|
424
|
+
}
|
|
425
|
+
);
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function setupSearchInput(input, suggestionsContainer, searchFn, onSelect) {
|
|
431
|
+
let debounceTimer;
|
|
432
|
+
input.addEventListener('input', () => {
|
|
433
|
+
clearTimeout(debounceTimer);
|
|
434
|
+
debounceTimer = setTimeout(async () => {
|
|
435
|
+
const query = input.value.trim();
|
|
436
|
+
if (query.length < 2) {
|
|
437
|
+
suggestionsContainer.innerHTML = '';
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const items = await searchFn(query);
|
|
442
|
+
suggestionsContainer.innerHTML = items.map(item => {
|
|
443
|
+
let visual;
|
|
444
|
+
if (item.icon) {
|
|
445
|
+
visual = `<i class="fa ${escapeHtml(item.icon)} me-2"></i>`;
|
|
446
|
+
} else if (item.picture) {
|
|
447
|
+
visual = `<img src="${item.picture}" class="avatar avatar-xs me-2" alt="" onerror="this.style.display='none'" />`;
|
|
448
|
+
} else {
|
|
449
|
+
visual = '';
|
|
450
|
+
}
|
|
451
|
+
const extra = item.memberCount !== undefined ? ` <span class="text-muted">(${item.memberCount})</span>` : '';
|
|
452
|
+
return `
|
|
453
|
+
<button type="button" class="list-group-item list-group-item-action suggestion-item" data-id="${escapeHtml(String(item.id))}">
|
|
454
|
+
${visual}${escapeHtml(item.label)}${extra}
|
|
455
|
+
</button>
|
|
456
|
+
`;
|
|
457
|
+
}).join('');
|
|
458
|
+
suggestionsContainer.querySelectorAll('.suggestion-item').forEach((el) => {
|
|
459
|
+
el.addEventListener('click', () => {
|
|
460
|
+
const id = el.getAttribute('data-id');
|
|
461
|
+
const matchedItem = items.find(i => String(i.id) === id);
|
|
462
|
+
if (matchedItem) {
|
|
463
|
+
input.value = matchedItem.label;
|
|
464
|
+
suggestionsContainer.innerHTML = '';
|
|
465
|
+
onSelect(matchedItem);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
} catch {
|
|
470
|
+
suggestionsContainer.innerHTML = '';
|
|
471
|
+
}
|
|
472
|
+
}, 300);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// --- Helpers ---
|
|
477
|
+
|
|
478
|
+
function escapeHtml(str) {
|
|
479
|
+
const div = document.createElement('div');
|
|
480
|
+
div.textContent = str;
|
|
481
|
+
return div.innerHTML;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
async function openNotesPanel() {
|
|
485
|
+
const tid = getTid();
|
|
486
|
+
if (!tid) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (!notesPanel) {
|
|
490
|
+
notesPanel = await buildNotesPanel();
|
|
491
|
+
}
|
|
492
|
+
notesPanel.classList.remove('hidden');
|
|
493
|
+
await loadNotes(tid);
|
|
494
|
+
await loadAssignee(tid);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// --- Page lifecycle ---
|
|
498
|
+
|
|
499
|
+
hooks.on('action:ajaxify.end', () => {
|
|
500
|
+
const tid = getTid();
|
|
501
|
+
if (!tid && notesPanel) {
|
|
502
|
+
notesPanel.classList.add('hidden');
|
|
503
|
+
}
|
|
504
|
+
// Run for both single-topic page and topic list pages (category, recent, etc.)
|
|
505
|
+
renderBadges();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
function getTopicDataForTid(tid) {
|
|
509
|
+
if (ajaxify.data.tid === tid) {
|
|
510
|
+
return ajaxify.data;
|
|
511
|
+
}
|
|
512
|
+
const topics = ajaxify.data.topics || ajaxify.data.category?.topics || [];
|
|
513
|
+
return topics.find(t => t && t.tid === tid) || null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function getBadgeContainer(headerEl, isCurrentTopic) {
|
|
517
|
+
// On topic view, prefer the main .topic-title in #content so badges always show next to the title
|
|
518
|
+
if (isCurrentTopic) {
|
|
519
|
+
const inContent = document.querySelector('#content .topic-title') ||
|
|
520
|
+
document.querySelector('#content [component="topic/header"] .topic-title');
|
|
521
|
+
const titleComponent = document.querySelector('#content [component="topic/title"]');
|
|
522
|
+
if (inContent) {
|
|
523
|
+
return inContent;
|
|
524
|
+
}
|
|
525
|
+
if (titleComponent && titleComponent.parentElement && titleComponent.parentElement.classList.contains('topic-title')) {
|
|
526
|
+
return titleComponent.parentElement;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const titleEl = headerEl.querySelector('[component="topic/title"]') || headerEl.querySelector('.topic-title');
|
|
530
|
+
if (titleEl && titleEl.classList.contains('topic-title')) {
|
|
531
|
+
return titleEl;
|
|
532
|
+
}
|
|
533
|
+
if (titleEl && titleEl.parentElement && titleEl.parentElement.classList.contains('topic-title')) {
|
|
534
|
+
return titleEl.parentElement;
|
|
535
|
+
}
|
|
536
|
+
return headerEl;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function renderBadges() {
|
|
540
|
+
const headers = document.querySelectorAll('[component="topic/header"]');
|
|
541
|
+
const isTopicPage = ajaxify.data.tid && ajaxify.data.canViewInternalNotes;
|
|
542
|
+
// If no headers found but we're on the topic page, use a single "virtual" pass with #content .topic-title
|
|
543
|
+
const headerList = headers.length ? Array.from(headers) : (isTopicPage ? [document.body] : []);
|
|
544
|
+
|
|
545
|
+
headerList.forEach((headerEl) => {
|
|
546
|
+
const row = headerEl.closest && headerEl.closest('[data-tid]');
|
|
547
|
+
// On topic view there may be no [data-tid] parent; use current topic id. On list pages, get tid from row.
|
|
548
|
+
const tid = row ? parseInt(row.getAttribute('data-tid'), 10) : (ajaxify.data.tid || null);
|
|
549
|
+
if (!tid) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const topic = getTopicDataForTid(tid);
|
|
554
|
+
if (!topic || !topic.canViewInternalNotes) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const isCurrentTopic = ajaxify.data.tid === tid;
|
|
559
|
+
const badgeContainer = getBadgeContainer(headerEl, isCurrentTopic);
|
|
560
|
+
if (!badgeContainer) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// Remove any existing badges we added (from this container to avoid duplicates)
|
|
564
|
+
badgeContainer.querySelectorAll('.internal-notes-badge, .assignee-badge').forEach((el) => el.remove());
|
|
565
|
+
|
|
566
|
+
if (topic.internalNoteCount > 0) {
|
|
567
|
+
const badge = document.createElement('span');
|
|
568
|
+
badge.id = 'internal-notes-badge-' + tid;
|
|
569
|
+
badge.className = 'badge bg-warning text-dark ms-2 internal-notes-badge';
|
|
570
|
+
badge.style.cursor = 'pointer';
|
|
571
|
+
badge.innerHTML = '<i class="fa fa-sticky-note"></i> ' + topic.internalNoteCount;
|
|
572
|
+
badge.addEventListener('click', (e) => {
|
|
573
|
+
e.preventDefault();
|
|
574
|
+
if (isCurrentTopic) {
|
|
575
|
+
openNotesPanel();
|
|
576
|
+
} else {
|
|
577
|
+
const path = topic.slug ? 'topic/' + topic.slug : 'topic/' + topic.tid;
|
|
578
|
+
ajaxify.go(path, undefined, true);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
badgeContainer.appendChild(badge);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (topic.assignee) {
|
|
585
|
+
const a = topic.assignee;
|
|
586
|
+
const badge = document.createElement('span');
|
|
587
|
+
badge.id = 'assignee-badge-' + tid;
|
|
588
|
+
badge.className = 'badge bg-info text-dark ms-2 assignee-badge';
|
|
589
|
+
badge.style.cursor = 'pointer';
|
|
590
|
+
if (a.type === 'group') {
|
|
591
|
+
const g = a.group;
|
|
592
|
+
const iconClass = g.icon || 'fa fa-users';
|
|
593
|
+
const iconHtml = g.labelColor
|
|
594
|
+
? '<span class="assignee-badge-icon" style="background:' + escapeHtml(g.labelColor) + ';color:#fff"><i class="' + escapeHtml(iconClass) + '"></i></span> '
|
|
595
|
+
: '<i class="fa ' + (g.icon ? escapeHtml(g.icon.replace(/^fa\s+/, '')) : 'fa-users') + '"></i> ';
|
|
596
|
+
badge.innerHTML = iconHtml + escapeHtml(g.name);
|
|
597
|
+
} else {
|
|
598
|
+
const u = a.user;
|
|
599
|
+
const avatarHtml = u.picture
|
|
600
|
+
? '<img class="assignee-badge-avatar" src="' + escapeHtml(u.picture) + '" alt="" onerror="this.style.display=\'none\';var n=this.nextElementSibling;if(n)n.style.display=\'inline\'">' +
|
|
601
|
+
'<i class="fa fa-user assignee-badge-fallback" style="display:none"></i> '
|
|
602
|
+
: '<i class="fa fa-user"></i> ';
|
|
603
|
+
badge.innerHTML = avatarHtml + escapeHtml(u.username);
|
|
604
|
+
}
|
|
605
|
+
badge.addEventListener('click', (e) => {
|
|
606
|
+
e.preventDefault();
|
|
607
|
+
if (isCurrentTopic) {
|
|
608
|
+
openNotesPanel();
|
|
609
|
+
} else {
|
|
610
|
+
const path = topic.slug ? 'topic/' + topic.slug : 'topic/' + topic.tid;
|
|
611
|
+
ajaxify.go(path, undefined, true);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
badgeContainer.appendChild(badge);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// --- Thread tool click handlers ---
|
|
620
|
+
|
|
621
|
+
$(document).on('click', '.toggle-internal-notes', () => {
|
|
622
|
+
openNotesPanel();
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
$(document).on('click', '.assign-topic-user', () => {
|
|
626
|
+
const tid = getTid();
|
|
627
|
+
if (tid) {
|
|
628
|
+
showAssignModal(tid);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
})();
|