domma-cms 0.6.16 → 0.6.21

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.
Files changed (44) hide show
  1. package/admin/js/api.js +1 -1
  2. package/admin/js/app.js +4 -4
  3. package/admin/js/lib/markdown-toolbar.js +14 -14
  4. package/admin/js/views/collection-editor.js +5 -3
  5. package/admin/js/views/collections.js +1 -1
  6. package/admin/js/views/page-editor.js +27 -27
  7. package/config/plugins.json +16 -0
  8. package/config/site.json +1 -1
  9. package/package.json +2 -2
  10. package/plugins/analytics/stats.json +1 -1
  11. package/plugins/contacts/admin/templates/contacts.html +126 -0
  12. package/plugins/contacts/admin/views/contacts.js +710 -0
  13. package/plugins/contacts/config.js +6 -0
  14. package/plugins/contacts/data/contacts.json +20 -0
  15. package/plugins/contacts/plugin.js +351 -0
  16. package/plugins/contacts/plugin.json +23 -0
  17. package/plugins/docs/admin/templates/docs.html +69 -0
  18. package/plugins/docs/admin/views/docs.js +276 -0
  19. package/plugins/docs/config.js +8 -0
  20. package/plugins/docs/data/documents/452f49b7-9c93-4a67-874d-27f882891ad2.json +11 -0
  21. package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +11 -0
  22. package/plugins/docs/data/folders.json +9 -0
  23. package/plugins/docs/data/templates.json +1 -0
  24. package/plugins/docs/plugin.js +375 -0
  25. package/plugins/docs/plugin.json +23 -0
  26. package/plugins/notes/admin/templates/notes.html +92 -0
  27. package/plugins/notes/admin/views/notes.js +304 -0
  28. package/plugins/notes/config.js +6 -0
  29. package/plugins/notes/data/notes.json +1 -0
  30. package/plugins/notes/plugin.js +177 -0
  31. package/plugins/notes/plugin.json +23 -0
  32. package/plugins/todo/admin/templates/todo.html +164 -0
  33. package/plugins/todo/admin/views/todo.js +328 -0
  34. package/plugins/todo/config.js +7 -0
  35. package/plugins/todo/data/todos.json +1 -0
  36. package/plugins/todo/plugin.js +155 -0
  37. package/plugins/todo/plugin.json +23 -0
  38. package/server/routes/api/auth.js +2 -0
  39. package/server/routes/api/collections.js +55 -0
  40. package/server/routes/api/forms.js +3 -0
  41. package/server/routes/api/settings.js +16 -1
  42. package/server/routes/public.js +2 -0
  43. package/server/services/markdown.js +169 -8
  44. package/server/services/plugins.js +3 -2
@@ -0,0 +1,710 @@
1
+ /**
2
+ * Contacts plugin admin view.
3
+ *
4
+ * @module contacts/admin/views/contacts
5
+ */
6
+
7
+ const BASE = '/api/plugins/contacts';
8
+
9
+ async function api(url, method = 'GET', body) {
10
+ const opts = {method, headers: {'Authorization': 'Bearer ' + (S.get('auth_token') || '')}};
11
+ if (body !== undefined) {
12
+ opts.headers['Content-Type'] = 'application/json';
13
+ opts.body = JSON.stringify(body);
14
+ }
15
+ const res = await fetch(url, opts);
16
+ if (!res.ok) {
17
+ const err = await res.json().catch(() => ({error: res.statusText}));
18
+ throw new Error(err.error || res.statusText);
19
+ }
20
+ const text = await res.text();
21
+ return text ? JSON.parse(text) : {};
22
+ }
23
+
24
+ export const contactsView = {
25
+ templateUrl: '/plugins/contacts/admin/templates/contacts.html',
26
+
27
+ /**
28
+ * Mount the contacts view.
29
+ *
30
+ * @param {object} $container - Domma-wrapped container element
31
+ * @returns {Promise<void>}
32
+ */
33
+ async onMount($container) {
34
+ // ---------------------------------------------------------------
35
+ // State
36
+ // ---------------------------------------------------------------
37
+ let allContacts = [];
38
+ let groups = [];
39
+ let currentEditId = null;
40
+ let selectedGroups = [];
41
+ let showFavsOnly = false;
42
+ let currentGroupFilter = '';
43
+ let contactsTable = null;
44
+ let selectedIds = new Set();
45
+
46
+ // ---------------------------------------------------------------
47
+ // Helpers
48
+ // ---------------------------------------------------------------
49
+
50
+ /**
51
+ * Escape HTML special characters to prevent XSS.
52
+ *
53
+ * @param {string} str
54
+ * @returns {string}
55
+ */
56
+ function esc(str) {
57
+ return String(str ?? '')
58
+ .replace(/&/g, '&amp;')
59
+ .replace(/</g, '&lt;')
60
+ .replace(/>/g, '&gt;')
61
+ .replace(/"/g, '&quot;');
62
+ }
63
+
64
+ // ---------------------------------------------------------------
65
+ // Data loading
66
+ // ---------------------------------------------------------------
67
+
68
+ async function loadGroups() {
69
+ try {
70
+ groups = await api(`${BASE}/groups`);
71
+ } catch {
72
+ groups = [];
73
+ }
74
+ }
75
+
76
+ async function loadContacts() {
77
+ try {
78
+ const params = new URLSearchParams();
79
+ if (currentGroupFilter) params.set('group', currentGroupFilter);
80
+ if (showFavsOnly) params.set('favourites', 'true');
81
+ const qs = params.toString();
82
+ allContacts = await api(`${BASE}/contacts${qs ? '?' + qs : ''}`);
83
+ } catch {
84
+ allContacts = [];
85
+ }
86
+ }
87
+
88
+ // ---------------------------------------------------------------
89
+ // Stats
90
+ // ---------------------------------------------------------------
91
+
92
+ function updateStats() {
93
+ const totalEl = $container.find('#stat-total').get(0);
94
+ const groupsEl = $container.find('#stat-groups').get(0);
95
+ const favsEl = $container.find('#stat-favs').get(0);
96
+ if (totalEl) totalEl.textContent = allContacts.length;
97
+ if (groupsEl) groupsEl.textContent = groups.length;
98
+ if (favsEl) favsEl.textContent = allContacts.filter((c) => c.favourite).length;
99
+ }
100
+
101
+ // ---------------------------------------------------------------
102
+ // Group filter dropdown (select)
103
+ // ---------------------------------------------------------------
104
+
105
+ function populateGroupFilter() {
106
+ const sel = $container.find('#group-filter').get(0);
107
+ if (!sel) return;
108
+ const current = sel.value;
109
+ while (sel.options.length > 1) sel.remove(1);
110
+ groups.forEach((g) => {
111
+ const opt = document.createElement('option');
112
+ opt.value = g;
113
+ opt.textContent = g;
114
+ if (g === current) opt.selected = true;
115
+ sel.appendChild(opt);
116
+ });
117
+ }
118
+
119
+ // ---------------------------------------------------------------
120
+ // Table
121
+ // ---------------------------------------------------------------
122
+
123
+ function buildTable() {
124
+ const tableEl = $container.find('#contacts-table').get(0);
125
+ const emptyEl = $container.find('#contacts-empty').get(0);
126
+
127
+ if (!allContacts.length) {
128
+ if (tableEl) tableEl.style.display = 'none';
129
+ if (emptyEl) emptyEl.style.display = 'block';
130
+ return;
131
+ }
132
+
133
+ if (emptyEl) emptyEl.style.display = 'none';
134
+ if (tableEl) tableEl.style.display = '';
135
+
136
+ if (contactsTable) {
137
+ contactsTable.setData(allContacts);
138
+ return;
139
+ }
140
+
141
+ // Render functions return HTML strings — values are escaped via esc()
142
+ // T.create passes these through Domma's DOMPurify sanitiser
143
+ contactsTable = T.create('#contacts-table', {
144
+ data: allContacts,
145
+ selectable: true,
146
+ columns: [
147
+ {
148
+ key: 'name',
149
+ title: 'Name',
150
+ sortable: true,
151
+ render: (v) => `<strong>${esc(v)}</strong>`
152
+ },
153
+ {
154
+ key: 'email',
155
+ title: 'Email',
156
+ sortable: true,
157
+ render: (v) => v ? `<a href="mailto:${esc(v)}">${esc(v)}</a>` : '\u2014'
158
+ },
159
+ {
160
+ key: 'phone',
161
+ title: 'Phone',
162
+ sortable: false,
163
+ render: (v) => esc(v) || '\u2014'
164
+ },
165
+ {
166
+ key: 'groups',
167
+ title: 'Groups',
168
+ sortable: false,
169
+ render: (v) => {
170
+ if (!Array.isArray(v) || v.length === 0) return '\u2014';
171
+ return v.map((g) => `<span class="badge badge-primary" style="margin-right:2px;">${esc(g)}</span>`).join('');
172
+ }
173
+ },
174
+ {
175
+ key: 'favourite',
176
+ title: '',
177
+ sortable: false,
178
+ render: (v) => v
179
+ ? '<span data-icon="star" style="color:var(--dm-warning);"></span>'
180
+ : '<span data-icon="star" style="opacity:0.2;"></span>'
181
+ },
182
+ {
183
+ key: 'id',
184
+ title: 'Actions',
185
+ sortable: false,
186
+ render: (id) =>
187
+ `<button class="btn btn-sm btn-outline contact-edit-btn" data-id="${esc(id)}" style="margin-right:4px;">` +
188
+ `<span data-icon="edit" data-icon-size="14"></span> Edit</button>` +
189
+ `<button class="btn btn-sm btn-danger contact-delete-btn" data-id="${esc(id)}">` +
190
+ `<span data-icon="trash" data-icon-size="14"></span> Delete</button>`
191
+ }
192
+ ],
193
+ emptyMessage: 'No contacts match your filters.'
194
+ });
195
+
196
+ // Selection change handler
197
+ if (contactsTable && typeof contactsTable.on === 'function') {
198
+ contactsTable.on('selectionChange', (rows) => {
199
+ selectedIds = new Set(rows.map((r) => r.id));
200
+ const bulkEl = $container.find('#bulk-actions').get(0);
201
+ const countEl = $container.find('#selected-count').get(0);
202
+ if (bulkEl) bulkEl.style.display = selectedIds.size > 0 ? 'inline-flex' : 'none';
203
+ if (countEl) countEl.textContent = selectedIds.size;
204
+ });
205
+ }
206
+
207
+ // Event delegation on table container — avoids namespaced delegation issues
208
+ const tableContainer = $container.find('#contacts-table').get(0);
209
+ if (tableContainer) {
210
+ tableContainer.addEventListener('click', async (e) => {
211
+ const editBtn = e.target.closest('.contact-edit-btn');
212
+ const deleteBtn = e.target.closest('.contact-delete-btn');
213
+
214
+ if (editBtn) {
215
+ openEditor(editBtn.dataset.id);
216
+ }
217
+
218
+ if (deleteBtn) {
219
+ const id = deleteBtn.dataset.id;
220
+ const contact = allContacts.find((c) => c.id === id);
221
+ if (!contact) return;
222
+ const ok = await E.confirm(`Delete "${contact.name}"?`);
223
+ if (!ok) return;
224
+ try {
225
+ await api(`${BASE}/contacts/${id}`, 'DELETE');
226
+ E.toast('Contact deleted.', { type: 'success' });
227
+ await refresh();
228
+ } catch {
229
+ E.toast('Failed to delete contact.', { type: 'error' });
230
+ }
231
+ }
232
+ });
233
+ }
234
+
235
+ Domma.icons.scan();
236
+ }
237
+
238
+ // ---------------------------------------------------------------
239
+ // Editor
240
+ // ---------------------------------------------------------------
241
+
242
+ function openEditor(id) {
243
+ currentEditId = id ?? null;
244
+ selectedGroups = [];
245
+
246
+ const editorEl = $container.find('#contact-editor').get(0);
247
+ if (!editorEl) return;
248
+
249
+ const titleEl = $container.find('#editor-title').get(0);
250
+ const saveLbl = $container.find('#editor-save-label').get(0);
251
+ const deleteBtn = $container.find('#editor-delete-btn').get(0);
252
+
253
+ if (id) {
254
+ const c = allContacts.find((x) => x.id === id);
255
+ if (!c) return;
256
+ if (titleEl) titleEl.textContent = 'Edit Contact';
257
+ if (saveLbl) saveLbl.textContent = 'Update Contact';
258
+ if (deleteBtn) deleteBtn.style.display = 'inline-flex';
259
+
260
+ const nameEl = $container.find('#contact-name').get(0);
261
+ const emailEl = $container.find('#contact-email').get(0);
262
+ const phoneEl = $container.find('#contact-phone').get(0);
263
+ const notesEl = $container.find('#contact-notes').get(0);
264
+ const favEl = $container.find('#contact-favourite').get(0);
265
+
266
+ if (nameEl) nameEl.value = c.name ?? '';
267
+ if (emailEl) emailEl.value = c.email ?? '';
268
+ if (phoneEl) phoneEl.value = c.phone ?? '';
269
+ if (notesEl) notesEl.value = c.notes ?? '';
270
+ if (favEl) favEl.checked = c.favourite === true;
271
+ selectedGroups = Array.isArray(c.groups) ? [...c.groups] : [];
272
+ } else {
273
+ if (titleEl) titleEl.textContent = 'Add Contact';
274
+ if (saveLbl) saveLbl.textContent = 'Save Contact';
275
+ if (deleteBtn) deleteBtn.style.display = 'none';
276
+
277
+ const fields = ['#contact-name', '#contact-email', '#contact-phone'];
278
+ fields.forEach((sel) => {
279
+ const el = $container.find(sel).get(0);
280
+ if (el) el.value = '';
281
+ });
282
+ const notesEl = $container.find('#contact-notes').get(0);
283
+ const favEl = $container.find('#contact-favourite').get(0);
284
+ if (notesEl) notesEl.value = '';
285
+ if (favEl) favEl.checked = false;
286
+ selectedGroups = [];
287
+ }
288
+
289
+ updateGroupsDisplay();
290
+ editorEl.style.display = '';
291
+ editorEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
292
+ }
293
+
294
+ function closeEditor() {
295
+ const editorEl = $container.find('#contact-editor').get(0);
296
+ if (editorEl) editorEl.style.display = 'none';
297
+ currentEditId = null;
298
+ selectedGroups = [];
299
+ }
300
+
301
+ /**
302
+ * Render selected group tags inside the groups display area using DOM APIs.
303
+ */
304
+ function updateGroupsDisplay() {
305
+ const display = $container.find('#contact-groups-display').get(0);
306
+ const placeholder = $container.find('#groups-placeholder').get(0);
307
+ if (!display) return;
308
+
309
+ // Remove existing tag badges
310
+ Array.from(display.querySelectorAll('.group-tag-badge')).forEach((el) => el.remove());
311
+
312
+ if (selectedGroups.length === 0) {
313
+ if (placeholder) placeholder.style.display = '';
314
+ } else {
315
+ if (placeholder) placeholder.style.display = 'none';
316
+ selectedGroups.forEach((g) => {
317
+ const span = document.createElement('span');
318
+ span.className = 'badge badge-primary group-tag-badge';
319
+ span.style.marginRight = '2px';
320
+ span.textContent = g; // textContent — safe, no XSS
321
+ display.appendChild(span);
322
+ });
323
+ }
324
+ }
325
+
326
+ async function saveContact() {
327
+ const nameEl = $container.find('#contact-name').get(0);
328
+ const name = (nameEl?.value ?? '').trim();
329
+ if (!name) {
330
+ E.toast('Name is required.', { type: 'error' });
331
+ return;
332
+ }
333
+
334
+ const body = {
335
+ name,
336
+ email: ($container.find('#contact-email').get(0)?.value ?? '').trim(),
337
+ phone: ($container.find('#contact-phone').get(0)?.value ?? '').trim(),
338
+ notes: ($container.find('#contact-notes').get(0)?.value ?? '').trim(),
339
+ favourite: $container.find('#contact-favourite').get(0)?.checked === true,
340
+ groups: selectedGroups
341
+ };
342
+
343
+ try {
344
+ if (currentEditId) {
345
+ await api(`${BASE}/contacts/${currentEditId}`, 'PUT', body);
346
+ E.toast('Contact updated.', { type: 'success' });
347
+ } else {
348
+ await api(`${BASE}/contacts`, 'POST', body);
349
+ E.toast('Contact created.', { type: 'success' });
350
+ }
351
+ closeEditor();
352
+ await refresh();
353
+ } catch {
354
+ E.toast('Failed to save contact.', { type: 'error' });
355
+ }
356
+ }
357
+
358
+ // ---------------------------------------------------------------
359
+ // Groups panel
360
+ // ---------------------------------------------------------------
361
+
362
+ /**
363
+ * Render the groups management list using DOM APIs (no innerHTML for interactive elements).
364
+ */
365
+ function renderGroupsList() {
366
+ const listEl = $container.find('#groups-list').get(0);
367
+ if (!listEl) return;
368
+
369
+ // Clear existing content safely
370
+ while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
371
+
372
+ if (groups.length === 0) {
373
+ const p = document.createElement('p');
374
+ p.className = 'text-muted';
375
+ p.textContent = 'No groups yet. Add one above.';
376
+ listEl.appendChild(p);
377
+ return;
378
+ }
379
+
380
+ groups.forEach((group) => {
381
+ const row = document.createElement('div');
382
+ row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;' +
383
+ 'padding:0.6rem 0.75rem;border:1px solid var(--dm-border);' +
384
+ 'border-radius:var(--dm-radius);margin-bottom:0.4rem;';
385
+
386
+ const label = document.createElement('span');
387
+ label.className = 'badge badge-primary';
388
+ label.textContent = group; // textContent — safe
389
+ row.appendChild(label);
390
+
391
+ const actions = document.createElement('div');
392
+ actions.style.display = 'flex';
393
+ actions.style.gap = '0.4rem';
394
+
395
+ const editBtn = document.createElement('button');
396
+ editBtn.className = 'btn btn-sm btn-outline';
397
+ editBtn.textContent = ' Edit';
398
+ const editIcon = document.createElement('span');
399
+ editIcon.setAttribute('data-icon', 'edit');
400
+ editIcon.setAttribute('data-icon-size', '14');
401
+ editBtn.prepend(editIcon);
402
+ editBtn.addEventListener('click', async () => {
403
+ const newName = await E.prompt(`Rename group "${group}":`, { inputValue: group });
404
+ if (!newName || newName.trim() === group) return;
405
+ try {
406
+ await api(`${BASE}/groups/${encodeURIComponent(group)}`, 'PUT', {newName: newName.trim()});
407
+ E.toast('Group renamed.', { type: 'success' });
408
+ await refresh();
409
+ } catch (err) {
410
+ E.toast(err?.message ?? 'Failed to rename group.', { type: 'error' });
411
+ }
412
+ });
413
+
414
+ const delBtn = document.createElement('button');
415
+ delBtn.className = 'btn btn-sm btn-danger';
416
+ delBtn.textContent = ' Delete';
417
+ const delIcon = document.createElement('span');
418
+ delIcon.setAttribute('data-icon', 'trash');
419
+ delIcon.setAttribute('data-icon-size', '14');
420
+ delBtn.prepend(delIcon);
421
+ delBtn.addEventListener('click', async () => {
422
+ const ok = await E.confirm(`Delete group "${group}"? It will be removed from all contacts.`);
423
+ if (!ok) return;
424
+ try {
425
+ await api(`${BASE}/groups/${encodeURIComponent(group)}`, 'DELETE');
426
+ E.toast('Group deleted.', { type: 'success' });
427
+ await refresh();
428
+ } catch {
429
+ E.toast('Failed to delete group.', { type: 'error' });
430
+ }
431
+ });
432
+
433
+ actions.appendChild(editBtn);
434
+ actions.appendChild(delBtn);
435
+ row.appendChild(actions);
436
+ listEl.appendChild(row);
437
+ });
438
+
439
+ Domma.icons.scan();
440
+ }
441
+
442
+ // ---------------------------------------------------------------
443
+ // Groups dropdown inside the editor (inline custom dropdown)
444
+ // ---------------------------------------------------------------
445
+
446
+ function renderGroupsDropdown() {
447
+ const itemsEl = $container.find('#groups-dropdown-items').get(0);
448
+ if (!itemsEl) return;
449
+ while (itemsEl.firstChild) itemsEl.removeChild(itemsEl.firstChild);
450
+ groups.forEach((g) => {
451
+ const item = document.createElement('div');
452
+ item.style.cssText = 'padding:0.4rem 0.6rem;cursor:pointer;border-radius:var(--dm-radius);';
453
+ if (selectedGroups.includes(g)) {
454
+ item.style.background = 'var(--dm-primary-light, rgba(99,102,241,0.1))';
455
+ item.style.fontWeight = '600';
456
+ }
457
+ item.textContent = g; // textContent — safe
458
+ item.addEventListener('click', () => {
459
+ const idx = selectedGroups.indexOf(g);
460
+ if (idx === -1) {
461
+ selectedGroups.push(g);
462
+ } else {
463
+ selectedGroups.splice(idx, 1);
464
+ }
465
+ updateGroupsDisplay();
466
+ renderGroupsDropdown();
467
+ });
468
+ itemsEl.appendChild(item);
469
+ });
470
+ }
471
+
472
+ // ---------------------------------------------------------------
473
+ // Client-side search filter
474
+ // ---------------------------------------------------------------
475
+
476
+ function applySearch(query) {
477
+ if (!contactsTable) return;
478
+ if (!query) {
479
+ contactsTable.setData(allContacts);
480
+ return;
481
+ }
482
+ const q = query.toLowerCase();
483
+ const filtered = allContacts.filter((c) =>
484
+ (c.name && c.name.toLowerCase().includes(q)) ||
485
+ (c.email && c.email.toLowerCase().includes(q)) ||
486
+ (c.phone && c.phone.includes(q))
487
+ );
488
+ contactsTable.setData(filtered);
489
+ }
490
+
491
+ // ---------------------------------------------------------------
492
+ // Import / Export
493
+ // ---------------------------------------------------------------
494
+
495
+ async function exportContacts() {
496
+ try {
497
+ const data = await api(`${BASE}/export`);
498
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
499
+ const url = URL.createObjectURL(blob);
500
+ const a = document.createElement('a');
501
+ a.href = url;
502
+ a.download = 'contacts.json';
503
+ document.body.appendChild(a);
504
+ a.click();
505
+ document.body.removeChild(a);
506
+ URL.revokeObjectURL(url);
507
+ E.toast('Contacts exported.', { type: 'success' });
508
+ } catch {
509
+ E.toast('Export failed.', { type: 'error' });
510
+ }
511
+ }
512
+
513
+ async function importContacts(file) {
514
+ try {
515
+ const text = await file.text();
516
+ const parsed = JSON.parse(text);
517
+ const contacts = Array.isArray(parsed) ? parsed : parsed.contacts;
518
+ if (!Array.isArray(contacts)) throw new Error('Invalid format');
519
+ const result = await api(`${BASE}/import`, 'POST', {contacts});
520
+ E.toast(`Imported ${result.imported} contact(s).`, { type: 'success' });
521
+ await refresh();
522
+ } catch {
523
+ E.toast('Import failed. Ensure the file is valid JSON.', { type: 'error' });
524
+ }
525
+ }
526
+
527
+ // ---------------------------------------------------------------
528
+ // Bulk delete
529
+ // ---------------------------------------------------------------
530
+
531
+ async function bulkDelete() {
532
+ if (selectedIds.size === 0) return;
533
+ const ok = await E.confirm(`Delete ${selectedIds.size} selected contact(s)?`);
534
+ if (!ok) return;
535
+ try {
536
+ await api(`${BASE}/contacts/bulk`, 'DELETE', {ids: [...selectedIds]});
537
+ E.toast(`${selectedIds.size} contact(s) deleted.`, { type: 'success' });
538
+ selectedIds.clear();
539
+ await refresh();
540
+ } catch {
541
+ E.toast('Bulk delete failed.', { type: 'error' });
542
+ }
543
+ }
544
+
545
+ // ---------------------------------------------------------------
546
+ // Full refresh
547
+ // ---------------------------------------------------------------
548
+
549
+ async function refresh() {
550
+ await Promise.all([loadGroups(), loadContacts()]);
551
+ updateStats();
552
+ populateGroupFilter();
553
+ renderGroupsList();
554
+ buildTable();
555
+ }
556
+
557
+ // ---------------------------------------------------------------
558
+ // Wire up events
559
+ // ---------------------------------------------------------------
560
+
561
+ // Add contact button
562
+ const addBtn = $container.find('#add-contact-btn').get(0);
563
+ if (addBtn) addBtn.addEventListener('click', () => openEditor(null));
564
+
565
+ // Editor save
566
+ const editorSaveBtn = $container.find('#editor-save-btn').get(0);
567
+ if (editorSaveBtn) editorSaveBtn.addEventListener('click', saveContact);
568
+
569
+ // Editor cancel (two cancel buttons)
570
+ [$container.find('#editor-cancel-btn').get(0), $container.find('#editor-cancel-btn2').get(0)]
571
+ .filter(Boolean)
572
+ .forEach((btn) => btn.addEventListener('click', closeEditor));
573
+
574
+ // Editor delete
575
+ const editorDeleteBtn = $container.find('#editor-delete-btn').get(0);
576
+ if (editorDeleteBtn) {
577
+ editorDeleteBtn.addEventListener('click', async () => {
578
+ if (!currentEditId) return;
579
+ const contact = allContacts.find((c) => c.id === currentEditId);
580
+ const ok = await E.confirm(`Delete "${contact?.name ?? 'this contact'}"?`);
581
+ if (!ok) return;
582
+ try {
583
+ await api(`${BASE}/contacts/${currentEditId}`, 'DELETE');
584
+ E.toast('Contact deleted.', { type: 'success' });
585
+ closeEditor();
586
+ await refresh();
587
+ } catch {
588
+ E.toast('Failed to delete contact.', { type: 'error' });
589
+ }
590
+ });
591
+ }
592
+
593
+ // Favourites toggle
594
+ const favToggle = $container.find('#fav-toggle').get(0);
595
+ if (favToggle) {
596
+ favToggle.addEventListener('click', async () => {
597
+ showFavsOnly = !showFavsOnly;
598
+ favToggle.classList.toggle('btn-warning', showFavsOnly);
599
+ favToggle.classList.toggle('btn-outline', !showFavsOnly);
600
+ await loadContacts();
601
+ updateStats();
602
+ buildTable();
603
+ });
604
+ }
605
+
606
+ // Group filter
607
+ const groupFilterSel = $container.find('#group-filter').get(0);
608
+ if (groupFilterSel) {
609
+ groupFilterSel.addEventListener('change', async () => {
610
+ currentGroupFilter = groupFilterSel.value;
611
+ await loadContacts();
612
+ updateStats();
613
+ buildTable();
614
+ });
615
+ }
616
+
617
+ // Search
618
+ const searchInput = $container.find('#contacts-search').get(0);
619
+ if (searchInput) {
620
+ let searchTimer = null;
621
+ searchInput.addEventListener('input', () => {
622
+ clearTimeout(searchTimer);
623
+ searchTimer = setTimeout(() => applySearch(searchInput.value.trim()), 250);
624
+ });
625
+ }
626
+
627
+ // Export
628
+ const exportBtn = $container.find('#export-btn').get(0);
629
+ if (exportBtn) exportBtn.addEventListener('click', exportContacts);
630
+
631
+ // Import
632
+ const importBtn = $container.find('#import-btn').get(0);
633
+ const importFile = $container.find('#import-file').get(0);
634
+ if (importBtn && importFile) {
635
+ importBtn.addEventListener('click', () => importFile.click());
636
+ importFile.addEventListener('change', async () => {
637
+ const file = importFile.files?.[0];
638
+ if (file) {
639
+ await importContacts(file);
640
+ importFile.value = '';
641
+ }
642
+ });
643
+ }
644
+
645
+ // Bulk delete
646
+ const bulkDeleteBtn = $container.find('#bulk-delete-btn').get(0);
647
+ if (bulkDeleteBtn) bulkDeleteBtn.addEventListener('click', bulkDelete);
648
+
649
+ // Add group
650
+ const addGroupBtn = $container.find('#add-group-btn').get(0);
651
+ const newGroupInput = $container.find('#new-group-input').get(0);
652
+ if (addGroupBtn) {
653
+ addGroupBtn.addEventListener('click', async () => {
654
+ const name = newGroupInput?.value?.trim();
655
+ if (!name) return;
656
+ try {
657
+ await api(`${BASE}/groups`, 'POST', {name});
658
+ if (newGroupInput) newGroupInput.value = '';
659
+ E.toast('Group added.', { type: 'success' });
660
+ await refresh();
661
+ } catch (err) {
662
+ E.toast(err?.message ?? 'Failed to add group.', { type: 'error' });
663
+ }
664
+ });
665
+ }
666
+
667
+ if (newGroupInput) {
668
+ newGroupInput.addEventListener('keydown', (e) => {
669
+ if (e.key === 'Enter') addGroupBtn?.click();
670
+ });
671
+ }
672
+
673
+ // Groups panel collapse toggle
674
+ const panelToggle = $container.find('#groups-panel-toggle').get(0);
675
+ const panelBody = $container.find('#groups-panel-body').get(0);
676
+ const chevron = $container.find('#groups-chevron').get(0);
677
+ if (panelToggle && panelBody) {
678
+ panelToggle.addEventListener('click', () => {
679
+ const open = panelBody.style.display !== 'none';
680
+ panelBody.style.display = open ? 'none' : '';
681
+ if (chevron) chevron.setAttribute('data-icon', open ? 'chevron-down' : 'chevron-up');
682
+ Domma.icons.scan();
683
+ });
684
+ }
685
+
686
+ // Groups picker toggle inside editor
687
+ const groupsDisplay = $container.find('#contact-groups-display').get(0);
688
+ const groupsDropdownList = $container.find('#groups-dropdown-list').get(0);
689
+ if (groupsDisplay && groupsDropdownList) {
690
+ groupsDisplay.addEventListener('click', (e) => {
691
+ e.stopPropagation();
692
+ const open = groupsDropdownList.style.display !== 'none';
693
+ if (!open) renderGroupsDropdown();
694
+ groupsDropdownList.style.display = open ? 'none' : '';
695
+ });
696
+
697
+ document.addEventListener('click', (e) => {
698
+ if (!groupsDisplay.contains(e.target) && !groupsDropdownList.contains(e.target)) {
699
+ groupsDropdownList.style.display = 'none';
700
+ }
701
+ });
702
+ }
703
+
704
+ // ---------------------------------------------------------------
705
+ // Initial load
706
+ // ---------------------------------------------------------------
707
+ await refresh();
708
+ Domma.icons.scan();
709
+ }
710
+ };