domma-cms 0.66.0 → 0.69.3
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/CLAUDE.md +64 -1
- package/README.md +14 -10
- package/admin/css/admin.css +1 -1
- package/admin/index.html +1 -0
- package/admin/js/api.js +1 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/analytics-shape.js +1 -0
- package/admin/js/lib/contacts-arrange.js +1 -0
- package/admin/js/lib/help-popover.js +1 -0
- package/admin/js/lib/notes-arrange.js +1 -0
- package/admin/js/lib/plugin-accent.js +1 -1
- package/admin/js/lib/plugin-chrome.js +1 -1
- package/admin/js/lib/sidebar-grouping.js +1 -1
- package/admin/js/lib/sidebar-grouping.test.js +1 -1
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/todo-arrange.js +1 -0
- package/admin/js/lib/tool-kit.js +1 -0
- package/admin/js/templates/analytics.html +138 -0
- package/admin/js/templates/contacts.html +156 -0
- package/admin/js/templates/notes.html +137 -0
- package/admin/js/templates/plugin-marketplace.html +17 -4
- package/admin/js/templates/plugins.html +13 -7
- package/admin/js/templates/theme.html +38 -0
- package/admin/js/templates/todo.html +113 -0
- package/admin/js/views/analytics.js +62 -0
- package/admin/js/views/contacts.js +174 -0
- package/admin/js/views/index.js +1 -1
- package/admin/js/views/notes.js +104 -0
- package/admin/js/views/plugin-marketplace.js +1 -1
- package/admin/js/views/plugins.js +24 -22
- package/admin/js/views/theme.js +1 -1
- package/admin/js/views/todo.js +96 -0
- package/bin/cli.js +4 -10
- package/bin/lib/plugin-version.js +28 -0
- package/bin/update.js +14 -0
- package/config/menus/admin-sidebar.json +1 -1
- package/config/server.json +3 -1
- package/package.json +3 -7
- package/plugins/_lib/admin/mail/reader-view.js +20 -0
- package/plugins/_lib/mail/store.js +21 -1
- package/plugins/blog/CLAUDE.md +237 -0
- package/plugins/blog/admin/css/index.css +178 -1
- package/plugins/blog/admin/templates/blog.html +35 -53
- package/plugins/blog/admin/templates/categories.html +19 -6
- package/plugins/blog/admin/templates/comments.html +26 -9
- package/plugins/blog/admin/templates/post-editor.html +36 -13
- package/plugins/blog/admin/templates/settings.html +2 -11
- package/plugins/blog/admin/views/blog.js +288 -162
- package/plugins/blog/admin/views/categories.js +212 -201
- package/plugins/blog/admin/views/comments.js +207 -158
- package/plugins/blog/admin/views/kit.js +372 -0
- package/plugins/blog/admin/views/post-editor.js +269 -21
- package/plugins/blog/admin/views/settings-cog.js +56 -0
- package/plugins/blog/admin/views/settings.js +16 -86
- package/plugins/blog/blocks/blog-card-compact.css +17 -0
- package/plugins/blog/blocks/blog-card-compact.html +8 -0
- package/plugins/blog/blocks/blog-card-row.css +22 -0
- package/plugins/blog/blocks/blog-card-row.html +11 -0
- package/plugins/blog/blocks/blog-card.css +35 -0
- package/plugins/blog/blocks/blog-card.html +16 -0
- package/plugins/blog/blocks/blog-post-classic.css +15 -0
- package/plugins/blog/blocks/blog-post-classic.html +28 -0
- package/plugins/blog/blocks/blog-post-feature.css +20 -0
- package/plugins/blog/blocks/blog-post-feature.html +30 -0
- package/plugins/blog/blocks/blog-post-minimal.css +10 -0
- package/plugins/blog/blocks/blog-post-minimal.html +23 -0
- package/plugins/blog/blocks/blog-post-sidebar.css +22 -0
- package/plugins/blog/blocks/blog-post-sidebar.html +36 -0
- package/plugins/blog/collections/posts/schema.json +3 -1
- package/plugins/blog/config.js +6 -0
- package/plugins/blog/lib/layouts.js +304 -0
- package/plugins/blog/lib/page.js +158 -0
- package/plugins/blog/lib/render.js +119 -0
- package/plugins/blog/lib/samples.js +234 -0
- package/plugins/blog/plugin.js +225 -15
- package/plugins/blog/plugin.json +21 -81
- package/plugins/blog/plugin.public.js +115 -210
- package/plugins/blog/public/blog.css +177 -0
- package/plugins/blog/public/blog.js +586 -0
- package/plugins/blog/public/samples/aurora.svg +7 -0
- package/plugins/blog/public/samples/dusk.svg +7 -0
- package/plugins/blog/public/samples/ember.svg +7 -0
- package/plugins/blog/public/samples/harbour.svg +7 -0
- package/plugins/blog/public/samples/meadow.svg +7 -0
- package/plugins/blog/public/samples/rosewood.svg +7 -0
- package/plugins/blog/templates/index.html +9 -8
- package/plugins/blog/templates/post.html +4 -17
- package/plugins/blog/tests/layouts.test.js +97 -0
- package/plugins/blog/tests/public.test.js +71 -0
- package/plugins/free-tier.lock.json +74 -0
- package/plugins/mail-reader/CLAUDE.md +78 -0
- package/plugins/mail-reader/plugin.json +2 -1
- package/public/css/theme-switcher.css +1 -0
- package/public/js/analytics.js +1 -0
- package/public/js/theme-switcher.js +1 -0
- package/scripts/build.js +41 -2
- package/scripts/run-tests.mjs +92 -0
- package/{plugins/analytics/plugin.js → server/routes/api/analytics.js} +89 -41
- package/{plugins/contacts/plugin.js → server/routes/api/contacts.js} +71 -43
- package/server/routes/api/dashboard.js +8 -2
- package/{plugins/notes/plugin.js → server/routes/api/notes.js} +61 -17
- package/server/routes/api/plugin-marketplace.js +78 -23
- package/server/routes/api/plugins.js +148 -3
- package/server/routes/api/todo.js +178 -0
- package/server/server.js +61 -1
- package/server/services/collections.js +13 -3
- package/server/services/managerClient.js +72 -136
- package/server/services/markdown.js +54 -1
- package/server/services/permissionRegistry.js +50 -0
- package/server/services/pluginEntitlement.js +171 -0
- package/server/services/pluginEnvelope.js +242 -0
- package/server/services/pluginInstaller.js +246 -26
- package/server/services/pluginScaffold.js +5 -0
- package/server/services/plugins.js +117 -7
- package/server/services/presetCollections.js +344 -0
- package/server/services/promoted-plugins-migration.js +254 -0
- package/server/services/publicCors.js +46 -0
- package/server/services/renderer.js +83 -3
- package/server/services/sidebarBadges.js +37 -0
- package/server/services/siteGitignore.js +307 -0
- package/server/services/themeSettings.js +39 -0
- package/config/theme.json +0 -18
- package/plugins/analytics/admin/css/index.css +0 -1
- package/plugins/analytics/admin/templates/analytics.html +0 -70
- package/plugins/analytics/admin/views/analytics.js +0 -176
- package/plugins/analytics/config.js +0 -14
- package/plugins/analytics/plugin.json +0 -60
- package/plugins/analytics/public/inject-body.html +0 -60
- package/plugins/analytics/public/inject-head.html +0 -1
- package/plugins/blog/templates/author.html +0 -9
- package/plugins/blog/templates/category.html +0 -9
- package/plugins/blog/templates/tag.html +0 -9
- package/plugins/contacts/admin/templates/contacts.html +0 -254
- package/plugins/contacts/admin/views/contacts.js +0 -943
- package/plugins/contacts/collections/user-contact-groups/schema.json +0 -35
- package/plugins/contacts/collections/user-contacts/schema.json +0 -179
- package/plugins/contacts/config.js +0 -6
- package/plugins/contacts/plugin.json +0 -36
- package/plugins/demo-viewer/config.js +0 -4
- package/plugins/demo-viewer/plugin.js +0 -36
- package/plugins/demo-viewer/plugin.json +0 -9
- package/plugins/invoice/admin/css/index.css +0 -1
- package/plugins/invoice/admin/templates/editor.html +0 -220
- package/plugins/invoice/admin/templates/index.html +0 -173
- package/plugins/invoice/admin/templates/issuers.html +0 -2
- package/plugins/invoice/admin/templates/receivers.html +0 -2
- package/plugins/invoice/admin/views/contacts-source.js +0 -266
- package/plugins/invoice/admin/views/editor.js +0 -434
- package/plugins/invoice/admin/views/export.js +0 -199
- package/plugins/invoice/admin/views/help-content.js +0 -61
- package/plugins/invoice/admin/views/index.js +0 -643
- package/plugins/invoice/admin/views/issuers.js +0 -30
- package/plugins/invoice/admin/views/media.js +0 -172
- package/plugins/invoice/admin/views/party-view.js +0 -386
- package/plugins/invoice/admin/views/payments.js +0 -127
- package/plugins/invoice/admin/views/print.js +0 -130
- package/plugins/invoice/admin/views/receivers.js +0 -55
- package/plugins/invoice/admin/views/send.js +0 -212
- package/plugins/invoice/admin/views/settings.js +0 -594
- package/plugins/invoice/admin/views/view-lifecycle.js +0 -33
- package/plugins/invoice/collections/invoice-issuers/schema.json +0 -82
- package/plugins/invoice/collections/invoice-receivers/schema.json +0 -16
- package/plugins/invoice/collections/invoices/schema.json +0 -33
- package/plugins/invoice/config.js +0 -37
- package/plugins/invoice/pdf.js +0 -164
- package/plugins/invoice/plugin.js +0 -1456
- package/plugins/invoice/plugin.json +0 -86
- package/plugins/invoice/templates/_base.css +0 -1
- package/plugins/invoice/templates/classic-nologo.html +0 -100
- package/plugins/invoice/templates/classic.html +0 -91
- package/plugins/invoice/templates/invoice-print.html +0 -237
- package/plugins/invoice/templates/minimal.html +0 -99
- package/plugins/invoice/templates/modern-nologo.html +0 -114
- package/plugins/invoice/templates/modern.html +0 -113
- package/plugins/invoice/templates/templates.json +0 -11
- package/plugins/notes/admin/templates/notes.html +0 -83
- package/plugins/notes/admin/views/notes.js +0 -281
- package/plugins/notes/collections/user-notes/schema.json +0 -54
- package/plugins/notes/config.js +0 -6
- package/plugins/notes/plugin.json +0 -33
- package/plugins/surveys/admin/templates/audience.html +0 -47
- package/plugins/surveys/admin/templates/results.html +0 -58
- package/plugins/surveys/admin/templates/survey-editor.html +0 -141
- package/plugins/surveys/admin/templates/surveys.html +0 -25
- package/plugins/surveys/admin/views/audience.js +0 -301
- package/plugins/surveys/admin/views/results.js +0 -172
- package/plugins/surveys/admin/views/survey-editor.js +0 -211
- package/plugins/surveys/admin/views/surveys.js +0 -161
- package/plugins/surveys/collections/survey-contacts/schema.json +0 -13
- package/plugins/surveys/collections/survey-groups/schema.json +0 -11
- package/plugins/surveys/collections/survey-invites/schema.json +0 -16
- package/plugins/surveys/collections/surveys/schema.json +0 -23
- package/plugins/surveys/config.js +0 -8
- package/plugins/surveys/lib/audience.js +0 -37
- package/plugins/surveys/lib/campaigns.js +0 -43
- package/plugins/surveys/lib/ledger.js +0 -110
- package/plugins/surveys/lib/sending.js +0 -106
- package/plugins/surveys/lib/stats.js +0 -62
- package/plugins/surveys/lib/submit.js +0 -95
- package/plugins/surveys/lib/tokens.js +0 -28
- package/plugins/surveys/plugin.js +0 -174
- package/plugins/surveys/plugin.json +0 -65
- package/plugins/surveys/plugin.public.js +0 -149
- package/plugins/surveys/public/dist-shared.mjs +0 -1
- package/plugins/surveys/public/survey.css +0 -1
- package/plugins/surveys/public/survey.mjs +0 -1
- package/plugins/surveys/templates/survey-page.html +0 -63
- package/plugins/theme-switcher/admin/templates/theme-switcher.html +0 -86
- package/plugins/theme-switcher/admin/views/theme-switcher.js +0 -65
- package/plugins/theme-switcher/config.js +0 -10
- package/plugins/theme-switcher/plugin.js +0 -26
- package/plugins/theme-switcher/plugin.json +0 -74
- package/plugins/theme-switcher/public/inject-body.html +0 -153
- package/plugins/theme-switcher/public/inject-head.html +0 -260
- package/plugins/todo/admin/templates/todo.html +0 -158
- package/plugins/todo/admin/views/todo.js +0 -343
- package/plugins/todo/collections/todos/schema.json +0 -60
- package/plugins/todo/config.js +0 -7
- package/plugins/todo/plugin.js +0 -102
- package/plugins/todo/plugin.json +0 -33
- /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, '&')
|
|
122
|
-
.replace(/</g, '<')
|
|
123
|
-
.replace(/>/g, '>')
|
|
124
|
-
.replace(/"/g, '"');
|
|
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
|
-
};
|