domma-cms 0.66.0 → 0.69.4

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 (221) hide show
  1. package/CLAUDE.md +64 -1
  2. package/README.md +14 -10
  3. package/admin/css/admin.css +1 -1
  4. package/admin/index.html +1 -0
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +4 -4
  7. package/admin/js/lib/analytics-shape.js +1 -0
  8. package/admin/js/lib/contacts-arrange.js +1 -0
  9. package/admin/js/lib/help-popover.js +1 -0
  10. package/admin/js/lib/notes-arrange.js +1 -0
  11. package/admin/js/lib/plugin-accent.js +1 -1
  12. package/admin/js/lib/plugin-chrome.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.js +1 -1
  14. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/todo-arrange.js +1 -0
  17. package/admin/js/lib/tool-kit.js +1 -0
  18. package/admin/js/templates/analytics.html +138 -0
  19. package/admin/js/templates/contacts.html +156 -0
  20. package/admin/js/templates/notes.html +137 -0
  21. package/admin/js/templates/plugin-marketplace.html +17 -4
  22. package/admin/js/templates/plugins.html +13 -7
  23. package/admin/js/templates/theme.html +38 -0
  24. package/admin/js/templates/todo.html +113 -0
  25. package/admin/js/views/analytics.js +62 -0
  26. package/admin/js/views/contacts.js +174 -0
  27. package/admin/js/views/index.js +1 -1
  28. package/admin/js/views/notes.js +104 -0
  29. package/admin/js/views/plugin-marketplace.js +1 -1
  30. package/admin/js/views/plugins.js +24 -22
  31. package/admin/js/views/theme.js +1 -1
  32. package/admin/js/views/todo.js +96 -0
  33. package/bin/cli.js +4 -10
  34. package/bin/lib/plugin-version.js +28 -0
  35. package/bin/update.js +14 -0
  36. package/config/menus/admin-sidebar.json +1 -1
  37. package/config/server.json +3 -1
  38. package/package.json +3 -7
  39. package/plugins/_lib/admin/mail/reader-view.js +20 -0
  40. package/plugins/_lib/mail/store.js +21 -1
  41. package/plugins/blog/CLAUDE.md +237 -0
  42. package/plugins/blog/admin/css/index.css +178 -1
  43. package/plugins/blog/admin/templates/blog.html +35 -53
  44. package/plugins/blog/admin/templates/categories.html +19 -6
  45. package/plugins/blog/admin/templates/comments.html +26 -9
  46. package/plugins/blog/admin/templates/post-editor.html +36 -13
  47. package/plugins/blog/admin/templates/settings.html +2 -11
  48. package/plugins/blog/admin/views/blog.js +288 -162
  49. package/plugins/blog/admin/views/categories.js +212 -201
  50. package/plugins/blog/admin/views/comments.js +207 -158
  51. package/plugins/blog/admin/views/kit.js +372 -0
  52. package/plugins/blog/admin/views/post-editor.js +269 -21
  53. package/plugins/blog/admin/views/settings-cog.js +56 -0
  54. package/plugins/blog/admin/views/settings.js +16 -86
  55. package/plugins/blog/blocks/blog-card-compact.css +17 -0
  56. package/plugins/blog/blocks/blog-card-compact.html +8 -0
  57. package/plugins/blog/blocks/blog-card-row.css +22 -0
  58. package/plugins/blog/blocks/blog-card-row.html +11 -0
  59. package/plugins/blog/blocks/blog-card.css +35 -0
  60. package/plugins/blog/blocks/blog-card.html +16 -0
  61. package/plugins/blog/blocks/blog-post-classic.css +15 -0
  62. package/plugins/blog/blocks/blog-post-classic.html +28 -0
  63. package/plugins/blog/blocks/blog-post-feature.css +20 -0
  64. package/plugins/blog/blocks/blog-post-feature.html +30 -0
  65. package/plugins/blog/blocks/blog-post-minimal.css +10 -0
  66. package/plugins/blog/blocks/blog-post-minimal.html +23 -0
  67. package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
  68. package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
  69. package/plugins/blog/collections/posts/schema.json +3 -1
  70. package/plugins/blog/config.js +6 -0
  71. package/plugins/blog/lib/layouts.js +304 -0
  72. package/plugins/blog/lib/page.js +158 -0
  73. package/plugins/blog/lib/render.js +119 -0
  74. package/plugins/blog/lib/samples.js +234 -0
  75. package/plugins/blog/plugin.js +225 -15
  76. package/plugins/blog/plugin.json +21 -81
  77. package/plugins/blog/plugin.public.js +115 -210
  78. package/plugins/blog/public/blog.css +177 -0
  79. package/plugins/blog/public/blog.js +586 -0
  80. package/plugins/blog/public/samples/aurora.svg +7 -0
  81. package/plugins/blog/public/samples/dusk.svg +7 -0
  82. package/plugins/blog/public/samples/ember.svg +7 -0
  83. package/plugins/blog/public/samples/harbour.svg +7 -0
  84. package/plugins/blog/public/samples/meadow.svg +7 -0
  85. package/plugins/blog/public/samples/rosewood.svg +7 -0
  86. package/plugins/blog/templates/index.html +9 -8
  87. package/plugins/blog/templates/post.html +4 -17
  88. package/plugins/blog/tests/layouts.test.js +97 -0
  89. package/plugins/blog/tests/public.test.js +71 -0
  90. package/plugins/free-tier.lock.json +74 -0
  91. package/plugins/mail-reader/CLAUDE.md +78 -0
  92. package/plugins/mail-reader/plugin.json +2 -1
  93. package/public/css/theme-switcher.css +1 -0
  94. package/public/js/analytics.js +1 -0
  95. package/public/js/theme-switcher.js +1 -0
  96. package/scripts/build.js +41 -2
  97. package/scripts/run-tests.mjs +94 -0
  98. package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
  99. package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
  100. package/server/routes/api/dashboard.js +8 -2
  101. package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
  102. package/server/routes/api/plugin-marketplace.js +78 -23
  103. package/server/routes/api/plugins.js +148 -3
  104. package/server/routes/api/todo.js +178 -0
  105. package/server/server.js +61 -1
  106. package/server/services/collections.js +13 -3
  107. package/server/services/managerClient.js +72 -136
  108. package/server/services/markdown.js +54 -1
  109. package/server/services/permissionRegistry.js +50 -0
  110. package/server/services/pluginEntitlement.js +171 -0
  111. package/server/services/pluginEnvelope.js +242 -0
  112. package/server/services/pluginInstaller.js +246 -26
  113. package/server/services/pluginScaffold.js +5 -0
  114. package/server/services/plugins.js +117 -7
  115. package/server/services/presetCollections.js +344 -0
  116. package/server/services/promoted-plugins-migration.js +254 -0
  117. package/server/services/publicCors.js +46 -0
  118. package/server/services/renderer.js +83 -3
  119. package/server/services/sidebarBadges.js +37 -0
  120. package/server/services/siteGitignore.js +310 -0
  121. package/server/services/themeSettings.js +39 -0
  122. package/config/theme.json +0 -18
  123. package/plugins/analytics/admin/css/index.css +0 -1
  124. package/plugins/analytics/admin/templates/analytics.html +0 -70
  125. package/plugins/analytics/admin/views/analytics.js +0 -176
  126. package/plugins/analytics/config.js +0 -14
  127. package/plugins/analytics/plugin.json +0 -60
  128. package/plugins/analytics/public/inject-body.html +0 -60
  129. package/plugins/analytics/public/inject-head.html +0 -1
  130. package/plugins/blog/templates/author.html +0 -9
  131. package/plugins/blog/templates/category.html +0 -9
  132. package/plugins/blog/templates/tag.html +0 -9
  133. package/plugins/contacts/admin/templates/contacts.html +0 -254
  134. package/plugins/contacts/admin/views/contacts.js +0 -943
  135. package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
  136. package/plugins/contacts/collections/user-contacts/schema.json +0 -179
  137. package/plugins/contacts/config.js +0 -6
  138. package/plugins/contacts/plugin.json +0 -36
  139. package/plugins/demo-viewer/config.js +0 -4
  140. package/plugins/demo-viewer/plugin.js +0 -36
  141. package/plugins/demo-viewer/plugin.json +0 -9
  142. package/plugins/invoice/admin/css/index.css +0 -1
  143. package/plugins/invoice/admin/templates/editor.html +0 -220
  144. package/plugins/invoice/admin/templates/index.html +0 -173
  145. package/plugins/invoice/admin/templates/issuers.html +0 -2
  146. package/plugins/invoice/admin/templates/receivers.html +0 -2
  147. package/plugins/invoice/admin/views/contacts-source.js +0 -266
  148. package/plugins/invoice/admin/views/editor.js +0 -434
  149. package/plugins/invoice/admin/views/export.js +0 -199
  150. package/plugins/invoice/admin/views/help-content.js +0 -61
  151. package/plugins/invoice/admin/views/index.js +0 -643
  152. package/plugins/invoice/admin/views/issuers.js +0 -30
  153. package/plugins/invoice/admin/views/media.js +0 -172
  154. package/plugins/invoice/admin/views/party-view.js +0 -386
  155. package/plugins/invoice/admin/views/payments.js +0 -127
  156. package/plugins/invoice/admin/views/print.js +0 -130
  157. package/plugins/invoice/admin/views/receivers.js +0 -55
  158. package/plugins/invoice/admin/views/send.js +0 -212
  159. package/plugins/invoice/admin/views/settings.js +0 -594
  160. package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
  161. package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
  162. package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
  163. package/plugins/invoice/collections/invoices/schema.json +0 -33
  164. package/plugins/invoice/config.js +0 -37
  165. package/plugins/invoice/pdf.js +0 -164
  166. package/plugins/invoice/plugin.js +0 -1456
  167. package/plugins/invoice/plugin.json +0 -86
  168. package/plugins/invoice/templates/_base.css +0 -1
  169. package/plugins/invoice/templates/classic-nologo.html +0 -100
  170. package/plugins/invoice/templates/classic.html +0 -91
  171. package/plugins/invoice/templates/invoice-print.html +0 -237
  172. package/plugins/invoice/templates/minimal.html +0 -99
  173. package/plugins/invoice/templates/modern-nologo.html +0 -114
  174. package/plugins/invoice/templates/modern.html +0 -113
  175. package/plugins/invoice/templates/templates.json +0 -11
  176. package/plugins/notes/admin/templates/notes.html +0 -83
  177. package/plugins/notes/admin/views/notes.js +0 -281
  178. package/plugins/notes/collections/user-notes/schema.json +0 -54
  179. package/plugins/notes/config.js +0 -6
  180. package/plugins/notes/plugin.json +0 -33
  181. package/plugins/surveys/admin/templates/audience.html +0 -47
  182. package/plugins/surveys/admin/templates/results.html +0 -58
  183. package/plugins/surveys/admin/templates/survey-editor.html +0 -141
  184. package/plugins/surveys/admin/templates/surveys.html +0 -25
  185. package/plugins/surveys/admin/views/audience.js +0 -301
  186. package/plugins/surveys/admin/views/results.js +0 -172
  187. package/plugins/surveys/admin/views/survey-editor.js +0 -211
  188. package/plugins/surveys/admin/views/surveys.js +0 -161
  189. package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
  190. package/plugins/surveys/collections/survey-groups/schema.json +0 -11
  191. package/plugins/surveys/collections/survey-invites/schema.json +0 -16
  192. package/plugins/surveys/collections/surveys/schema.json +0 -23
  193. package/plugins/surveys/config.js +0 -8
  194. package/plugins/surveys/lib/audience.js +0 -37
  195. package/plugins/surveys/lib/campaigns.js +0 -43
  196. package/plugins/surveys/lib/ledger.js +0 -110
  197. package/plugins/surveys/lib/sending.js +0 -106
  198. package/plugins/surveys/lib/stats.js +0 -62
  199. package/plugins/surveys/lib/submit.js +0 -95
  200. package/plugins/surveys/lib/tokens.js +0 -28
  201. package/plugins/surveys/plugin.js +0 -174
  202. package/plugins/surveys/plugin.json +0 -65
  203. package/plugins/surveys/plugin.public.js +0 -149
  204. package/plugins/surveys/public/dist-shared.mjs +0 -1
  205. package/plugins/surveys/public/survey.css +0 -1
  206. package/plugins/surveys/public/survey.mjs +0 -1
  207. package/plugins/surveys/templates/survey-page.html +0 -63
  208. package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
  209. package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
  210. package/plugins/theme-switcher/config.js +0 -10
  211. package/plugins/theme-switcher/plugin.js +0 -26
  212. package/plugins/theme-switcher/plugin.json +0 -74
  213. package/plugins/theme-switcher/public/inject-body.html +0 -153
  214. package/plugins/theme-switcher/public/inject-head.html +0 -260
  215. package/plugins/todo/admin/templates/todo.html +0 -158
  216. package/plugins/todo/admin/views/todo.js +0 -343
  217. package/plugins/todo/collections/todos/schema.json +0 -60
  218. package/plugins/todo/config.js +0 -7
  219. package/plugins/todo/plugin.js +0 -102
  220. package/plugins/todo/plugin.json +0 -33
  221. /package/{plugins/contacts/admin/css/index.css → admin/css/contacts.css} +0 -0
@@ -1,943 +0,0 @@
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
- /**
25
- * The billing fields, as a map of contact key to the input that edits it.
26
- *
27
- * One list rather than a load/save pair spelled out twice - the way a field
28
- * gets added to the form and quietly never saved.
29
- */
30
- const BILLING_FIELDS = {
31
- company: '#contact-company',
32
- jobTitle: '#contact-job-title',
33
- website: '#contact-website',
34
- billingEmail: '#contact-billing-email',
35
- vatNumber: '#contact-vat-number',
36
- companyNumber: '#contact-company-number',
37
- reference: '#contact-reference',
38
- paymentTerms: '#contact-payment-terms',
39
- currency: '#contact-currency',
40
- accountName: '#contact-account-name',
41
- bankName: '#contact-bank-name',
42
- sortCode: '#contact-sort-code',
43
- accountNumber: '#contact-account-number',
44
- iban: '#contact-iban',
45
- bic: '#contact-bic'
46
- };
47
-
48
- /**
49
- * The repeatable lists, and the columns each row is made of.
50
- *
51
- * `wide` gets a full-width input; `check` is the single radio-ish flag that
52
- * says which address an invoice should use.
53
- */
54
- const REPEATERS = {
55
- addresses: {
56
- listId: 'addresses-list',
57
- emptyId: 'addresses-empty',
58
- columns: [
59
- {key: 'label', placeholder: 'Label (Office, Home…)'},
60
- {key: 'line1', placeholder: 'Address line 1', wide: true},
61
- {key: 'line2', placeholder: 'Address line 2', wide: true},
62
- {key: 'city', placeholder: 'City'},
63
- {key: 'region', placeholder: 'County / Region'},
64
- {key: 'postcode', placeholder: 'Postcode'},
65
- {key: 'country', placeholder: 'Country'},
66
- {key: 'isBilling', label: 'Use for invoices', check: true}
67
- ]
68
- },
69
- emails: {
70
- listId: 'emails-list',
71
- emptyId: 'emails-empty',
72
- columns: [
73
- {key: 'label', placeholder: 'Label (Accounts, Personal…)'},
74
- {key: 'address', placeholder: 'name@example.com', wide: true}
75
- ]
76
- },
77
- phones: {
78
- listId: 'phones-list',
79
- emptyId: 'phones-empty',
80
- columns: [
81
- {key: 'label', placeholder: 'Label (Mobile, Office…)'},
82
- {key: 'number', placeholder: '+44 …', wide: true}
83
- ]
84
- }
85
- };
86
-
87
- export const contactsView = {
88
- templateUrl: '/plugins/contacts/admin/templates/contacts.html',
89
-
90
- /**
91
- * Mount the contacts view.
92
- *
93
- * @param {object} $container - Domma-wrapped container element
94
- * @returns {Promise<void>}
95
- */
96
- async onMount($container) {
97
- // ---------------------------------------------------------------
98
- // State
99
- // ---------------------------------------------------------------
100
- let allContacts = [];
101
- let groups = [];
102
- let currentEditId = null;
103
- let selectedGroups = [];
104
- let showFavsOnly = false;
105
- let currentGroupFilter = '';
106
- let contactsTable = null;
107
- let selectedIds = new Set();
108
-
109
- // ---------------------------------------------------------------
110
- // Helpers
111
- // ---------------------------------------------------------------
112
-
113
- /**
114
- * Escape HTML special characters to prevent XSS.
115
- *
116
- * @param {string} str
117
- * @returns {string}
118
- */
119
- function esc(str) {
120
- return String(str ?? '')
121
- .replace(/&/g, '&amp;')
122
- .replace(/</g, '&lt;')
123
- .replace(/>/g, '&gt;')
124
- .replace(/"/g, '&quot;');
125
- }
126
-
127
- // ---------------------------------------------------------------
128
- // Data loading
129
- // ---------------------------------------------------------------
130
-
131
- async function loadGroups() {
132
- try {
133
- groups = await api(`${BASE}/groups`);
134
- } catch {
135
- groups = [];
136
- }
137
- }
138
-
139
- async function loadContacts() {
140
- try {
141
- const params = new URLSearchParams();
142
- if (currentGroupFilter) params.set('group', currentGroupFilter);
143
- if (showFavsOnly) params.set('favourites', 'true');
144
- const qs = params.toString();
145
- allContacts = await api(`${BASE}/contacts${qs ? '?' + qs : ''}`);
146
- } catch {
147
- allContacts = [];
148
- }
149
- }
150
-
151
- // ---------------------------------------------------------------
152
- // Stats
153
- // ---------------------------------------------------------------
154
-
155
- function updateStats() {
156
- const totalEl = $container.find('#stat-total').get(0);
157
- const groupsEl = $container.find('#stat-groups').get(0);
158
- const favsEl = $container.find('#stat-favs').get(0);
159
- if (totalEl) totalEl.textContent = allContacts.length;
160
- if (groupsEl) groupsEl.textContent = groups.length;
161
- if (favsEl) favsEl.textContent = allContacts.filter((c) => c.favourite).length;
162
- }
163
-
164
- // ---------------------------------------------------------------
165
- // Group filter dropdown (select)
166
- // ---------------------------------------------------------------
167
-
168
- function populateGroupFilter() {
169
- const sel = $container.find('#group-filter').get(0);
170
- if (!sel) return;
171
- const current = sel.value;
172
- while (sel.options.length > 1) sel.remove(1);
173
- groups.forEach((g) => {
174
- const opt = document.createElement('option');
175
- opt.value = g;
176
- opt.textContent = g;
177
- if (g === current) opt.selected = true;
178
- sel.appendChild(opt);
179
- });
180
- }
181
-
182
- // ---------------------------------------------------------------
183
- // Table
184
- // ---------------------------------------------------------------
185
-
186
- function buildTable() {
187
- const tableEl = $container.find('#contacts-table').get(0);
188
- const emptyEl = $container.find('#contacts-empty').get(0);
189
-
190
- if (!allContacts.length) {
191
- if (tableEl) tableEl.style.display = 'none';
192
- if (emptyEl) emptyEl.style.display = 'block';
193
- return;
194
- }
195
-
196
- if (emptyEl) emptyEl.style.display = 'none';
197
- if (tableEl) tableEl.style.display = '';
198
-
199
- if (contactsTable) {
200
- contactsTable.setData(allContacts);
201
- return;
202
- }
203
-
204
- // Render functions return HTML strings - values are escaped via esc()
205
- // T.create passes these through Domma's DOMPurify sanitiser
206
- contactsTable = T.create('#contacts-table', {
207
- data: allContacts,
208
- selectable: true,
209
- columns: [
210
- {
211
- key: 'name',
212
- title: 'Name',
213
- sortable: true,
214
- render: (v) => `<strong>${esc(v)}</strong>`
215
- },
216
- {
217
- key: 'email',
218
- title: 'Email',
219
- sortable: true,
220
- render: (v) => v ? `<a href="mailto:${esc(v)}">${esc(v)}</a>` : '-'
221
- },
222
- {
223
- key: 'phone',
224
- title: 'Phone',
225
- sortable: false,
226
- render: (v) => esc(v) || '-'
227
- },
228
- {
229
- key: 'groups',
230
- title: 'Groups',
231
- sortable: false,
232
- render: (v) => {
233
- if (!Array.isArray(v) || v.length === 0) return '-';
234
- return v.map((g) => `<span class="badge badge-primary" style="margin-right:2px;">${esc(g)}</span>`).join('');
235
- }
236
- },
237
- {
238
- key: 'favourite',
239
- title: '',
240
- sortable: false,
241
- render: (v) => v
242
- ? '<span data-icon="star" style="color:var(--dm-warning);"></span>'
243
- : '<span data-icon="star" style="opacity:0.2;"></span>'
244
- },
245
- {
246
- key: 'id',
247
- title: 'Actions',
248
- sortable: false,
249
- render: (id) =>
250
- `<button class="btn btn-sm btn-outline contact-edit-btn" data-id="${esc(id)}" style="margin-right:4px;">` +
251
- `<span data-icon="edit" data-icon-size="14"></span> Edit</button>` +
252
- `<button class="btn btn-sm btn-danger contact-delete-btn" data-id="${esc(id)}">` +
253
- `<span data-icon="trash" data-icon-size="14"></span> Delete</button>`
254
- }
255
- ],
256
- emptyMessage: 'No contacts match your filters.'
257
- });
258
-
259
- // Selection change handler
260
- if (contactsTable && typeof contactsTable.on === 'function') {
261
- contactsTable.on('selectionChange', (rows) => {
262
- selectedIds = new Set(rows.map((r) => r.id));
263
- const bulkEl = $container.find('#bulk-actions').get(0);
264
- const countEl = $container.find('#selected-count').get(0);
265
- if (bulkEl) bulkEl.style.display = selectedIds.size > 0 ? 'inline-flex' : 'none';
266
- if (countEl) countEl.textContent = selectedIds.size;
267
- });
268
- }
269
-
270
- // Event delegation on table container - avoids namespaced delegation issues
271
- const tableContainer = $container.find('#contacts-table').get(0);
272
- if (tableContainer) {
273
- tableContainer.addEventListener('click', async (e) => {
274
- const editBtn = e.target.closest('.contact-edit-btn');
275
- const deleteBtn = e.target.closest('.contact-delete-btn');
276
-
277
- if (editBtn) {
278
- openEditor(editBtn.dataset.id);
279
- }
280
-
281
- if (deleteBtn) {
282
- const id = deleteBtn.dataset.id;
283
- const contact = allContacts.find((c) => c.id === id);
284
- if (!contact) return;
285
- const ok = await E.confirm(`Delete "${contact.name}"?`);
286
- if (!ok) return;
287
- try {
288
- await api(`${BASE}/contacts/${id}`, 'DELETE');
289
- E.toast('Contact deleted.', { type: 'success' });
290
- await refresh();
291
- } catch {
292
- E.toast('Failed to delete contact.', { type: 'error' });
293
- }
294
- }
295
- });
296
- }
297
-
298
- Domma.icons.scan();
299
- }
300
-
301
- // ---------------------------------------------------------------
302
- // Editor
303
- // ---------------------------------------------------------------
304
-
305
- /**
306
- * Draw one repeatable list.
307
- *
308
- * Built with DOM APIs rather than innerHTML because these rows carry
309
- * inputs, and Domma's .html() sanitises interactive elements away.
310
- *
311
- * @param {string} field addresses | emails | phones
312
- * @param {object[]} rows
313
- */
314
- function renderRepeater(field, rows) {
315
- const spec = REPEATERS[field];
316
- const listEl = $container.find('#' + spec.listId).get(0);
317
- const emptyEl = $container.find('#' + spec.emptyId).get(0);
318
- if (!listEl) return;
319
-
320
- while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
321
- if (emptyEl) emptyEl.style.display = rows.length ? 'none' : '';
322
-
323
- rows.forEach((row, index) => {
324
- const rowEl = document.createElement('div');
325
- rowEl.className = 'contacts-row';
326
- rowEl.dataset.index = String(index);
327
-
328
- for (const col of spec.columns) {
329
- if (col.check) {
330
- const wrap = document.createElement('label');
331
- wrap.className = 'form-check contacts-row-check';
332
- const box = document.createElement('input');
333
- box.type = 'checkbox';
334
- box.className = 'form-check-input';
335
- box.dataset.key = col.key;
336
- box.checked = row[col.key] === true;
337
- // Only one address can be the billing one.
338
- box.addEventListener('change', () => {
339
- if (!box.checked) return;
340
- listEl.querySelectorAll(`input[data-key="${col.key}"]`).forEach((other) => {
341
- if (other !== box) other.checked = false;
342
- });
343
- });
344
- const text = document.createElement('span');
345
- text.className = 'form-check-label';
346
- text.textContent = col.label;
347
- wrap.appendChild(box);
348
- wrap.appendChild(text);
349
- rowEl.appendChild(wrap);
350
- continue;
351
- }
352
-
353
- const input = document.createElement('input');
354
- input.type = 'text';
355
- input.className = 'form-input' + (col.wide ? ' contacts-row-wide' : '');
356
- input.dataset.key = col.key;
357
- input.placeholder = col.placeholder ?? '';
358
- input.value = row[col.key] ?? '';
359
- rowEl.appendChild(input);
360
- }
361
-
362
- const remove = document.createElement('button');
363
- remove.type = 'button';
364
- remove.className = 'btn btn-sm btn-ghost contacts-row-remove';
365
- remove.title = 'Remove';
366
- remove.textContent = '\u00d7';
367
- remove.addEventListener('click', () => {
368
- const next = readRepeater(field);
369
- next.splice(index, 1);
370
- renderRepeater(field, next);
371
- updateBillingSummary();
372
- });
373
- rowEl.appendChild(remove);
374
-
375
- listEl.appendChild(rowEl);
376
- });
377
- }
378
-
379
- /**
380
- * Read a repeatable list back out of the DOM.
381
- *
382
- * @param {string} field
383
- * @returns {object[]}
384
- */
385
- function readRepeater(field) {
386
- const spec = REPEATERS[field];
387
- const listEl = $container.find('#' + spec.listId).get(0);
388
- if (!listEl) return [];
389
-
390
- return [...listEl.querySelectorAll('.contacts-row')].map((rowEl) => {
391
- const row = {};
392
- for (const col of spec.columns) {
393
- const el = rowEl.querySelector(`[data-key="${col.key}"]`);
394
- row[col.key] = col.check ? el?.checked === true : (el?.value ?? '').trim();
395
- }
396
- return row;
397
- }).filter((row) => Object.entries(row).some(([k, v]) => k !== 'label' && v !== '' && v !== false));
398
- }
399
-
400
- /** A one-line "what is in here" for the folded billing section. */
401
- function updateBillingSummary() {
402
- const el = $container.find('#billing-summary').get(0);
403
- if (!el) return;
404
- const bits = [];
405
- const company = $container.find('#contact-company').get(0)?.value?.trim();
406
- if (company) bits.push(company);
407
- const addresses = readRepeater('addresses').length;
408
- if (addresses) bits.push(`${addresses} address${addresses === 1 ? '' : 'es'}`);
409
- const extras = readRepeater('emails').length + readRepeater('phones').length;
410
- if (extras) bits.push(`${extras} more contact${extras === 1 ? '' : 's'}`);
411
- if ($container.find('#contact-sort-code').get(0)?.value?.trim()
412
- || $container.find('#contact-iban').get(0)?.value?.trim()) bits.push('bank details');
413
- el.textContent = bits.length ? bits.join(' \u00b7 ') : '';
414
- }
415
-
416
- /** Fill the billing inputs from a contact, or clear them for a new one. */
417
- function loadBilling(contact) {
418
- for (const [key, sel] of Object.entries(BILLING_FIELDS)) {
419
- const el = $container.find(sel).get(0);
420
- if (!el) continue;
421
- const value = contact?.[key];
422
- el.value = value === null || value === undefined ? '' : String(value);
423
- }
424
- for (const field of Object.keys(REPEATERS)) {
425
- renderRepeater(field, Array.isArray(contact?.[field]) ? contact[field] : []);
426
- }
427
- updateBillingSummary();
428
- }
429
-
430
- /** Read the billing inputs back into the body a save posts. */
431
- function readBilling() {
432
- const body = {};
433
- for (const [key, sel] of Object.entries(BILLING_FIELDS)) {
434
- body[key] = ($container.find(sel).get(0)?.value ?? '').trim();
435
- }
436
- for (const field of Object.keys(REPEATERS)) body[field] = readRepeater(field);
437
- return body;
438
- }
439
-
440
- function openEditor(id) {
441
- currentEditId = id ?? null;
442
- selectedGroups = [];
443
-
444
- const editorEl = $container.find('#contact-editor').get(0);
445
- if (!editorEl) return;
446
-
447
- const titleEl = $container.find('#editor-title').get(0);
448
- const saveLbl = $container.find('#editor-save-label').get(0);
449
- const deleteBtn = $container.find('#editor-delete-btn').get(0);
450
-
451
- if (id) {
452
- const c = allContacts.find((x) => x.id === id);
453
- if (!c) return;
454
- if (titleEl) titleEl.textContent = 'Edit Contact';
455
- if (saveLbl) saveLbl.textContent = 'Update Contact';
456
- if (deleteBtn) deleteBtn.style.display = 'inline-flex';
457
-
458
- const nameEl = $container.find('#contact-name').get(0);
459
- const emailEl = $container.find('#contact-email').get(0);
460
- const phoneEl = $container.find('#contact-phone').get(0);
461
- const notesEl = $container.find('#contact-notes').get(0);
462
- const favEl = $container.find('#contact-favourite').get(0);
463
-
464
- if (nameEl) nameEl.value = c.name ?? '';
465
- if (emailEl) emailEl.value = c.email ?? '';
466
- if (phoneEl) phoneEl.value = c.phone ?? '';
467
- if (notesEl) notesEl.value = c.notes ?? '';
468
- if (favEl) favEl.checked = c.favourite === true;
469
- selectedGroups = Array.isArray(c.groups) ? [...c.groups] : [];
470
- loadBilling(c);
471
- } else {
472
- if (titleEl) titleEl.textContent = 'Add Contact';
473
- if (saveLbl) saveLbl.textContent = 'Save Contact';
474
- if (deleteBtn) deleteBtn.style.display = 'none';
475
-
476
- const fields = ['#contact-name', '#contact-email', '#contact-phone'];
477
- fields.forEach((sel) => {
478
- const el = $container.find(sel).get(0);
479
- if (el) el.value = '';
480
- });
481
- const notesEl = $container.find('#contact-notes').get(0);
482
- const favEl = $container.find('#contact-favourite').get(0);
483
- if (notesEl) notesEl.value = '';
484
- if (favEl) favEl.checked = false;
485
- selectedGroups = [];
486
- loadBilling(null);
487
- }
488
-
489
- updateGroupsDisplay();
490
- editorEl.style.display = '';
491
- editorEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
492
- }
493
-
494
- function closeEditor() {
495
- const editorEl = $container.find('#contact-editor').get(0);
496
- if (editorEl) editorEl.style.display = 'none';
497
- currentEditId = null;
498
- selectedGroups = [];
499
- }
500
-
501
- /**
502
- * Render selected group tags inside the groups display area using DOM APIs.
503
- */
504
- function updateGroupsDisplay() {
505
- const display = $container.find('#contact-groups-display').get(0);
506
- const placeholder = $container.find('#groups-placeholder').get(0);
507
- if (!display) return;
508
-
509
- // Remove existing tag badges
510
- Array.from(display.querySelectorAll('.group-tag-badge')).forEach((el) => el.remove());
511
-
512
- if (selectedGroups.length === 0) {
513
- if (placeholder) placeholder.style.display = '';
514
- } else {
515
- if (placeholder) placeholder.style.display = 'none';
516
- selectedGroups.forEach((g) => {
517
- const span = document.createElement('span');
518
- span.className = 'badge badge-primary group-tag-badge';
519
- span.style.marginRight = '2px';
520
- span.textContent = g; // textContent - safe, no XSS
521
- display.appendChild(span);
522
- });
523
- }
524
- }
525
-
526
- async function saveContact() {
527
- const nameEl = $container.find('#contact-name').get(0);
528
- const name = (nameEl?.value ?? '').trim();
529
- if (!name) {
530
- E.toast('Name is required.', { type: 'error' });
531
- return;
532
- }
533
-
534
- const body = {
535
- name,
536
- email: ($container.find('#contact-email').get(0)?.value ?? '').trim(),
537
- phone: ($container.find('#contact-phone').get(0)?.value ?? '').trim(),
538
- notes: ($container.find('#contact-notes').get(0)?.value ?? '').trim(),
539
- favourite: $container.find('#contact-favourite').get(0)?.checked === true,
540
- groups: selectedGroups,
541
- ...readBilling()
542
- };
543
-
544
- try {
545
- if (currentEditId) {
546
- await api(`${BASE}/contacts/${currentEditId}`, 'PUT', body);
547
- E.toast('Contact updated.', { type: 'success' });
548
- } else {
549
- await api(`${BASE}/contacts`, 'POST', body);
550
- E.toast('Contact created.', { type: 'success' });
551
- }
552
- closeEditor();
553
- await refresh();
554
- } catch {
555
- E.toast('Failed to save contact.', { type: 'error' });
556
- }
557
- }
558
-
559
- // ---------------------------------------------------------------
560
- // Groups panel
561
- // ---------------------------------------------------------------
562
-
563
- /**
564
- * Render the groups management list using DOM APIs (no innerHTML for interactive elements).
565
- */
566
- function renderGroupsList() {
567
- const listEl = $container.find('#groups-list').get(0);
568
- if (!listEl) return;
569
-
570
- // Clear existing content safely
571
- while (listEl.firstChild) listEl.removeChild(listEl.firstChild);
572
-
573
- if (groups.length === 0) {
574
- const p = document.createElement('p');
575
- p.className = 'text-muted';
576
- p.textContent = 'No groups yet. Add one above.';
577
- listEl.appendChild(p);
578
- return;
579
- }
580
-
581
- groups.forEach((group) => {
582
- const row = document.createElement('div');
583
- row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;' +
584
- 'padding:0.6rem 0.75rem;border:1px solid var(--dm-border);' +
585
- 'border-radius:var(--dm-radius);margin-bottom:0.4rem;';
586
-
587
- const label = document.createElement('span');
588
- label.className = 'badge badge-primary';
589
- label.textContent = group; // textContent - safe
590
- row.appendChild(label);
591
-
592
- const actions = document.createElement('div');
593
- actions.style.display = 'flex';
594
- actions.style.gap = '0.4rem';
595
-
596
- const editBtn = document.createElement('button');
597
- editBtn.className = 'btn btn-sm btn-outline';
598
- editBtn.textContent = ' Edit';
599
- const editIcon = document.createElement('span');
600
- editIcon.setAttribute('data-icon', 'edit');
601
- editIcon.setAttribute('data-icon-size', '14');
602
- editBtn.prepend(editIcon);
603
- editBtn.addEventListener('click', async () => {
604
- const newName = await E.prompt(`Rename group "${group}":`, { inputValue: group });
605
- if (!newName || newName.trim() === group) return;
606
- try {
607
- await api(`${BASE}/groups/${encodeURIComponent(group)}`, 'PUT', {newName: newName.trim()});
608
- E.toast('Group renamed.', { type: 'success' });
609
- await refresh();
610
- } catch (err) {
611
- E.toast(err?.message ?? 'Failed to rename group.', { type: 'error' });
612
- }
613
- });
614
-
615
- const delBtn = document.createElement('button');
616
- delBtn.className = 'btn btn-sm btn-danger';
617
- delBtn.textContent = ' Delete';
618
- const delIcon = document.createElement('span');
619
- delIcon.setAttribute('data-icon', 'trash');
620
- delIcon.setAttribute('data-icon-size', '14');
621
- delBtn.prepend(delIcon);
622
- delBtn.addEventListener('click', async () => {
623
- const ok = await E.confirm(`Delete group "${group}"? It will be removed from all contacts.`);
624
- if (!ok) return;
625
- try {
626
- await api(`${BASE}/groups/${encodeURIComponent(group)}`, 'DELETE');
627
- E.toast('Group deleted.', { type: 'success' });
628
- await refresh();
629
- } catch {
630
- E.toast('Failed to delete group.', { type: 'error' });
631
- }
632
- });
633
-
634
- actions.appendChild(editBtn);
635
- actions.appendChild(delBtn);
636
- row.appendChild(actions);
637
- listEl.appendChild(row);
638
- });
639
-
640
- Domma.icons.scan();
641
- }
642
-
643
- // ---------------------------------------------------------------
644
- // Groups dropdown inside the editor (inline custom dropdown)
645
- // ---------------------------------------------------------------
646
-
647
- function renderGroupsDropdown() {
648
- const itemsEl = $container.find('#groups-dropdown-items').get(0);
649
- if (!itemsEl) return;
650
- while (itemsEl.firstChild) itemsEl.removeChild(itemsEl.firstChild);
651
- groups.forEach((g) => {
652
- const item = document.createElement('div');
653
- item.style.cssText = 'padding:0.4rem 0.6rem;cursor:pointer;border-radius:var(--dm-radius);';
654
- if (selectedGroups.includes(g)) {
655
- item.style.background = 'var(--dm-primary-light, rgba(99,102,241,0.1))';
656
- item.style.fontWeight = '600';
657
- }
658
- item.textContent = g; // textContent - safe
659
- item.addEventListener('click', () => {
660
- const idx = selectedGroups.indexOf(g);
661
- if (idx === -1) {
662
- selectedGroups.push(g);
663
- } else {
664
- selectedGroups.splice(idx, 1);
665
- }
666
- updateGroupsDisplay();
667
- renderGroupsDropdown();
668
- });
669
- itemsEl.appendChild(item);
670
- });
671
- }
672
-
673
- // ---------------------------------------------------------------
674
- // Client-side search filter
675
- // ---------------------------------------------------------------
676
-
677
- function applySearch(query) {
678
- if (!contactsTable) return;
679
- if (!query) {
680
- contactsTable.setData(allContacts);
681
- return;
682
- }
683
- const q = query.toLowerCase();
684
- const filtered = allContacts.filter((c) =>
685
- (c.name && c.name.toLowerCase().includes(q)) ||
686
- (c.email && c.email.toLowerCase().includes(q)) ||
687
- (c.phone && c.phone.includes(q))
688
- );
689
- contactsTable.setData(filtered);
690
- }
691
-
692
- // ---------------------------------------------------------------
693
- // Import / Export
694
- // ---------------------------------------------------------------
695
-
696
- async function exportContacts() {
697
- try {
698
- const data = await api(`${BASE}/export`);
699
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
700
- const url = URL.createObjectURL(blob);
701
- const a = document.createElement('a');
702
- a.href = url;
703
- a.download = 'contacts.json';
704
- document.body.appendChild(a);
705
- a.click();
706
- document.body.removeChild(a);
707
- URL.revokeObjectURL(url);
708
- E.toast('Contacts exported.', { type: 'success' });
709
- } catch {
710
- E.toast('Export failed.', { type: 'error' });
711
- }
712
- }
713
-
714
- async function importContacts(file) {
715
- try {
716
- const text = await file.text();
717
- const parsed = JSON.parse(text);
718
- const contacts = Array.isArray(parsed) ? parsed : parsed.contacts;
719
- if (!Array.isArray(contacts)) throw new Error('Invalid format');
720
- const result = await api(`${BASE}/import`, 'POST', {contacts});
721
- E.toast(`Imported ${result.imported} contact(s).`, { type: 'success' });
722
- await refresh();
723
- } catch {
724
- E.toast('Import failed. Ensure the file is valid JSON.', { type: 'error' });
725
- }
726
- }
727
-
728
- // ---------------------------------------------------------------
729
- // Bulk delete
730
- // ---------------------------------------------------------------
731
-
732
- async function bulkDelete() {
733
- if (selectedIds.size === 0) return;
734
- const ok = await E.confirm(`Delete ${selectedIds.size} selected contact(s)?`);
735
- if (!ok) return;
736
- try {
737
- await api(`${BASE}/contacts/bulk`, 'DELETE', {ids: [...selectedIds]});
738
- E.toast(`${selectedIds.size} contact(s) deleted.`, { type: 'success' });
739
- selectedIds.clear();
740
- await refresh();
741
- } catch {
742
- E.toast('Bulk delete failed.', { type: 'error' });
743
- }
744
- }
745
-
746
- // ---------------------------------------------------------------
747
- // Full refresh
748
- // ---------------------------------------------------------------
749
-
750
- async function refresh() {
751
- await Promise.all([loadGroups(), loadContacts()]);
752
- updateStats();
753
- populateGroupFilter();
754
- renderGroupsList();
755
- buildTable();
756
- }
757
-
758
- // ---------------------------------------------------------------
759
- // Wire up events
760
- // ---------------------------------------------------------------
761
-
762
- // Add contact button
763
- const addBtn = $container.find('#add-contact-btn').get(0);
764
- if (addBtn) addBtn.addEventListener('click', () => openEditor(null));
765
-
766
- // Billing section: fold, unfold, and the three Add buttons.
767
- const billingToggle = $container.find('#billing-toggle').get(0);
768
- const billingBody = $container.find('#billing-body').get(0);
769
- if (billingToggle && billingBody) {
770
- billingToggle.addEventListener('click', () => {
771
- const open = billingBody.classList.toggle('is-open');
772
- // The chevron is ROTATED rather than swapped for a different
773
- // icon: I.scan replaces the span with a fresh <svg>, so the
774
- // element you held a reference to is gone and setting
775
- // data-icon on it changes nothing. A CSS transform needs no
776
- // element identity at all, and animates for free.
777
- billingToggle.classList.toggle('is-open', open);
778
- });
779
- }
780
-
781
- for (const [field, btnId] of [['addresses', '#add-address-btn'], ['emails', '#add-email-btn'], ['phones', '#add-phone-btn']]) {
782
- const btn = $container.find(btnId).get(0);
783
- if (!btn) continue;
784
- btn.addEventListener('click', () => {
785
- renderRepeater(field, [...readRepeater(field), {}]);
786
- updateBillingSummary();
787
- });
788
- }
789
-
790
- // The folded summary should follow what is typed above it.
791
- for (const sel of ['#contact-company', '#contact-sort-code', '#contact-iban']) {
792
- const el = $container.find(sel).get(0);
793
- if (el) el.addEventListener('input', updateBillingSummary);
794
- }
795
-
796
- // Editor save
797
- const editorSaveBtn = $container.find('#editor-save-btn').get(0);
798
- if (editorSaveBtn) editorSaveBtn.addEventListener('click', saveContact);
799
-
800
- // Editor cancel (two cancel buttons)
801
- [$container.find('#editor-cancel-btn').get(0), $container.find('#editor-cancel-btn2').get(0)]
802
- .filter(Boolean)
803
- .forEach((btn) => btn.addEventListener('click', closeEditor));
804
-
805
- // Editor delete
806
- const editorDeleteBtn = $container.find('#editor-delete-btn').get(0);
807
- if (editorDeleteBtn) {
808
- editorDeleteBtn.addEventListener('click', async () => {
809
- if (!currentEditId) return;
810
- const contact = allContacts.find((c) => c.id === currentEditId);
811
- const ok = await E.confirm(`Delete "${contact?.name ?? 'this contact'}"?`);
812
- if (!ok) return;
813
- try {
814
- await api(`${BASE}/contacts/${currentEditId}`, 'DELETE');
815
- E.toast('Contact deleted.', { type: 'success' });
816
- closeEditor();
817
- await refresh();
818
- } catch {
819
- E.toast('Failed to delete contact.', { type: 'error' });
820
- }
821
- });
822
- }
823
-
824
- // Favourites toggle
825
- const favToggle = $container.find('#fav-toggle').get(0);
826
- if (favToggle) {
827
- favToggle.addEventListener('click', async () => {
828
- showFavsOnly = !showFavsOnly;
829
- favToggle.classList.toggle('btn-warning', showFavsOnly);
830
- favToggle.classList.toggle('btn-outline', !showFavsOnly);
831
- await loadContacts();
832
- updateStats();
833
- buildTable();
834
- });
835
- }
836
-
837
- // Group filter
838
- const groupFilterSel = $container.find('#group-filter').get(0);
839
- if (groupFilterSel) {
840
- groupFilterSel.addEventListener('change', async () => {
841
- currentGroupFilter = groupFilterSel.value;
842
- await loadContacts();
843
- updateStats();
844
- buildTable();
845
- });
846
- }
847
-
848
- // Search
849
- const searchInput = $container.find('#contacts-search').get(0);
850
- if (searchInput) {
851
- let searchTimer = null;
852
- searchInput.addEventListener('input', () => {
853
- clearTimeout(searchTimer);
854
- searchTimer = setTimeout(() => applySearch(searchInput.value.trim()), 250);
855
- });
856
- }
857
-
858
- // Export
859
- const exportBtn = $container.find('#export-btn').get(0);
860
- if (exportBtn) exportBtn.addEventListener('click', exportContacts);
861
-
862
- // Import
863
- const importBtn = $container.find('#import-btn').get(0);
864
- const importFile = $container.find('#import-file').get(0);
865
- if (importBtn && importFile) {
866
- importBtn.addEventListener('click', () => importFile.click());
867
- importFile.addEventListener('change', async () => {
868
- const file = importFile.files?.[0];
869
- if (file) {
870
- await importContacts(file);
871
- importFile.value = '';
872
- }
873
- });
874
- }
875
-
876
- // Bulk delete
877
- const bulkDeleteBtn = $container.find('#bulk-delete-btn').get(0);
878
- if (bulkDeleteBtn) bulkDeleteBtn.addEventListener('click', bulkDelete);
879
-
880
- // Add group
881
- const addGroupBtn = $container.find('#add-group-btn').get(0);
882
- const newGroupInput = $container.find('#new-group-input').get(0);
883
- if (addGroupBtn) {
884
- addGroupBtn.addEventListener('click', async () => {
885
- const name = newGroupInput?.value?.trim();
886
- if (!name) return;
887
- try {
888
- await api(`${BASE}/groups`, 'POST', {name});
889
- if (newGroupInput) newGroupInput.value = '';
890
- E.toast('Group added.', { type: 'success' });
891
- await refresh();
892
- } catch (err) {
893
- E.toast(err?.message ?? 'Failed to add group.', { type: 'error' });
894
- }
895
- });
896
- }
897
-
898
- if (newGroupInput) {
899
- newGroupInput.addEventListener('keydown', (e) => {
900
- if (e.key === 'Enter') addGroupBtn?.click();
901
- });
902
- }
903
-
904
- // Groups panel collapse toggle
905
- const panelToggle = $container.find('#groups-panel-toggle').get(0);
906
- const panelBody = $container.find('#groups-panel-body').get(0);
907
- if (panelToggle && panelBody) {
908
- panelToggle.addEventListener('click', () => {
909
- const open = panelBody.style.display !== 'none';
910
- panelBody.style.display = open ? 'none' : '';
911
- // Rotated, not swapped: I.scan replaces the icon span with a
912
- // fresh <svg> and does not carry the id across, so the
913
- // reference this held was stale after the first scan and the
914
- // chevron never turned.
915
- panelToggle.classList.toggle('is-open', !open);
916
- });
917
- }
918
-
919
- // Groups picker toggle inside editor
920
- const groupsDisplay = $container.find('#contact-groups-display').get(0);
921
- const groupsDropdownList = $container.find('#groups-dropdown-list').get(0);
922
- if (groupsDisplay && groupsDropdownList) {
923
- groupsDisplay.addEventListener('click', (e) => {
924
- e.stopPropagation();
925
- const open = groupsDropdownList.style.display !== 'none';
926
- if (!open) renderGroupsDropdown();
927
- groupsDropdownList.style.display = open ? 'none' : '';
928
- });
929
-
930
- document.addEventListener('click', (e) => {
931
- if (!groupsDisplay.contains(e.target) && !groupsDropdownList.contains(e.target)) {
932
- groupsDropdownList.style.display = 'none';
933
- }
934
- });
935
- }
936
-
937
- // ---------------------------------------------------------------
938
- // Initial load
939
- // ---------------------------------------------------------------
940
- await refresh();
941
- Domma.icons.scan();
942
- }
943
- };