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.
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/markdown-toolbar.js +14 -14
- package/admin/js/views/collection-editor.js +5 -3
- package/admin/js/views/collections.js +1 -1
- package/admin/js/views/page-editor.js +27 -27
- package/config/plugins.json +16 -0
- package/config/site.json +1 -1
- package/package.json +2 -2
- package/plugins/analytics/stats.json +1 -1
- package/plugins/contacts/admin/templates/contacts.html +126 -0
- package/plugins/contacts/admin/views/contacts.js +710 -0
- package/plugins/contacts/config.js +6 -0
- package/plugins/contacts/data/contacts.json +20 -0
- package/plugins/contacts/plugin.js +351 -0
- package/plugins/contacts/plugin.json +23 -0
- package/plugins/docs/admin/templates/docs.html +69 -0
- package/plugins/docs/admin/views/docs.js +276 -0
- package/plugins/docs/config.js +8 -0
- package/plugins/docs/data/documents/452f49b7-9c93-4a67-874d-27f882891ad2.json +11 -0
- package/plugins/docs/data/documents/57e003f0-68f2-47dc-9c36-ed4b10ed3deb.json +11 -0
- package/plugins/docs/data/folders.json +9 -0
- package/plugins/docs/data/templates.json +1 -0
- package/plugins/docs/plugin.js +375 -0
- package/plugins/docs/plugin.json +23 -0
- package/plugins/notes/admin/templates/notes.html +92 -0
- package/plugins/notes/admin/views/notes.js +304 -0
- package/plugins/notes/config.js +6 -0
- package/plugins/notes/data/notes.json +1 -0
- package/plugins/notes/plugin.js +177 -0
- package/plugins/notes/plugin.json +23 -0
- package/plugins/todo/admin/templates/todo.html +164 -0
- package/plugins/todo/admin/views/todo.js +328 -0
- package/plugins/todo/config.js +7 -0
- package/plugins/todo/data/todos.json +1 -0
- package/plugins/todo/plugin.js +155 -0
- package/plugins/todo/plugin.json +23 -0
- package/server/routes/api/auth.js +2 -0
- package/server/routes/api/collections.js +55 -0
- package/server/routes/api/forms.js +3 -0
- package/server/routes/api/settings.js +16 -1
- package/server/routes/public.js +2 -0
- package/server/services/markdown.js +169 -8
- 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, '&')
|
|
59
|
+
.replace(/</g, '<')
|
|
60
|
+
.replace(/>/g, '>')
|
|
61
|
+
.replace(/"/g, '"');
|
|
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
|
+
};
|