domma-cms 0.55.1 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (211) hide show
  1. package/CLAUDE.md +130 -2
  2. package/README.md +4 -4
  3. package/admin/css/admin.css +1 -1
  4. package/admin/dist/domma/domma-tools.css +3 -3
  5. package/admin/dist/domma/domma-tools.min.js +3 -3
  6. package/admin/js/api.js +1 -1
  7. package/admin/js/app.js +3 -3
  8. package/admin/js/lib/page-picker.js +1 -0
  9. package/admin/js/lib/plugin-accent.js +1 -0
  10. package/admin/js/lib/plugin-chrome.js +1 -0
  11. package/admin/js/lib/shortcode-context-menu.js +2 -2
  12. package/admin/js/lib/sidebar-grouping.js +1 -1
  13. package/admin/js/lib/sidebar-grouping.test.js +1 -1
  14. package/admin/js/lib/sidebar-renderer.js +4 -4
  15. package/admin/js/lib/slideover-resizable.js +1 -0
  16. package/admin/js/templates/context-menu-editor.html +212 -0
  17. package/admin/js/templates/context-menus.html +16 -0
  18. package/admin/js/templates/plugin-code.html +1 -1
  19. package/{plugins/site-search/admin/templates/site-search.html → admin/js/templates/search.html} +50 -4
  20. package/admin/js/templates/settings.html +16 -97
  21. package/admin/js/templates/theme.html +173 -0
  22. package/admin/js/views/context-menu-editor.js +55 -0
  23. package/admin/js/views/context-menus.js +5 -0
  24. package/admin/js/views/form-editor.js +7 -7
  25. package/admin/js/views/index.js +1 -1
  26. package/admin/js/views/plugin-marketplace.js +1 -1
  27. package/admin/js/views/plugins.js +25 -23
  28. package/admin/js/views/search.js +1 -0
  29. package/admin/js/views/settings.js +3 -3
  30. package/admin/js/views/theme.js +1 -0
  31. package/bin/cli.js +2 -0
  32. package/bin/update.js +13 -2
  33. package/config/menus/admin-sidebar.json +129 -23
  34. package/config/plugins.json +5 -5
  35. package/config/search.json +13 -0
  36. package/config/theme.json +18 -0
  37. package/package.json +12 -4
  38. package/plugins/_lib/admin/mail/compose-window.js +914 -0
  39. package/plugins/_lib/admin/mail/contacts.js +301 -0
  40. package/plugins/_lib/admin/mail/diagnostics-section.js +133 -0
  41. package/plugins/_lib/admin/mail/folder-tree.js +254 -0
  42. package/plugins/_lib/admin/mail/identity.js +480 -0
  43. package/plugins/_lib/admin/mail/image-senders-section.js +131 -0
  44. package/plugins/_lib/admin/mail/keyboard.js +136 -0
  45. package/plugins/_lib/admin/mail/mail.css +1 -0
  46. package/plugins/_lib/admin/mail/mail.html +71 -0
  47. package/plugins/_lib/admin/mail/panes.js +253 -0
  48. package/plugins/_lib/admin/mail/reader-view.js +4453 -0
  49. package/plugins/_lib/admin/mail/resizable.js +26 -0
  50. package/plugins/_lib/admin/mail/rules.js +343 -0
  51. package/plugins/_lib/admin/mail/scheduling.js +203 -0
  52. package/plugins/_lib/admin/mail/section-kit.js +277 -0
  53. package/plugins/_lib/admin/mail/templates.js +238 -0
  54. package/plugins/_lib/admin/mail/threads.js +200 -0
  55. package/plugins/_lib/admin/mail/vacation.js +269 -0
  56. package/plugins/_lib/admin/ui/help.css +1 -0
  57. package/plugins/_lib/admin/ui/help.js +174 -0
  58. package/plugins/_lib/admin/ui/resizable.js +151 -0
  59. package/plugins/_lib/dataStore.js +117 -0
  60. package/plugins/_lib/mail/accounts.js +919 -0
  61. package/plugins/_lib/mail/bodyTokens.js +101 -0
  62. package/plugins/_lib/mail/compose.js +256 -0
  63. package/plugins/_lib/mail/defaults.js +57 -0
  64. package/plugins/_lib/mail/diagnostics.js +154 -0
  65. package/plugins/_lib/mail/envelope.js +161 -0
  66. package/plugins/_lib/mail/folders.js +192 -0
  67. package/plugins/_lib/mail/handoff.js +274 -0
  68. package/plugins/_lib/mail/imapPool.js +293 -0
  69. package/plugins/_lib/mail/mbox.js +74 -0
  70. package/plugins/_lib/mail/pollSchedule.js +79 -0
  71. package/plugins/_lib/mail/poller.js +135 -0
  72. package/plugins/_lib/mail/priority.js +138 -0
  73. package/plugins/_lib/mail/readRoutes.js +680 -0
  74. package/plugins/_lib/mail/render.js +291 -0
  75. package/plugins/_lib/mail/ruleRunner.js +152 -0
  76. package/plugins/_lib/mail/scheduler.js +254 -0
  77. package/plugins/_lib/mail/secretbox.js +229 -0
  78. package/plugins/_lib/mail/send.js +396 -0
  79. package/plugins/_lib/mail/store.js +1002 -0
  80. package/plugins/_lib/mail/sync.js +292 -0
  81. package/plugins/_lib/mail/syncPlan.js +126 -0
  82. package/plugins/_lib/mail/syncSelection.js +82 -0
  83. package/plugins/_lib/mail/unsubscribe.js +183 -0
  84. package/plugins/_lib/mail/vacationRunner.js +114 -0
  85. package/plugins/_lib/mail/write.js +473 -0
  86. package/plugins/_lib/schemaSync.js +83 -0
  87. package/plugins/_template/admin/css/index.css +0 -0
  88. package/plugins/_template/admin/templates/index.html +4 -4
  89. package/plugins/_template/admin/views/index.js +7 -0
  90. package/plugins/analytics/admin/css/index.css +1 -0
  91. package/plugins/analytics/admin/templates/analytics.html +22 -13
  92. package/plugins/analytics/plugin.json +3 -0
  93. package/plugins/blog/admin/css/index.css +1 -0
  94. package/plugins/blog/admin/templates/blog.html +30 -18
  95. package/plugins/blog/admin/templates/categories.html +2 -2
  96. package/plugins/blog/admin/templates/comments.html +2 -2
  97. package/plugins/blog/admin/templates/post-editor.html +34 -34
  98. package/plugins/blog/admin/templates/settings.html +6 -3
  99. package/plugins/blog/admin/views/blog.js +8 -5
  100. package/plugins/blog/admin/views/categories.js +5 -10
  101. package/plugins/blog/admin/views/comments.js +5 -5
  102. package/plugins/blog/admin/views/post-editor.js +39 -20
  103. package/plugins/blog/admin/views/settings.js +52 -50
  104. package/plugins/blog/collections/categories/schema.json +7 -6
  105. package/plugins/blog/collections/comments/schema.json +11 -10
  106. package/plugins/blog/collections/posts/schema.json +14 -13
  107. package/plugins/blog/plugin.js +36 -13
  108. package/plugins/blog/plugin.json +13 -5
  109. package/plugins/blog/plugin.public.js +312 -0
  110. package/plugins/contacts/admin/css/index.css +1 -0
  111. package/plugins/contacts/admin/templates/contacts.html +128 -0
  112. package/plugins/contacts/admin/views/contacts.js +237 -4
  113. package/plugins/contacts/collections/user-contacts/schema.json +108 -0
  114. package/plugins/contacts/plugin.js +214 -27
  115. package/plugins/contacts/plugin.json +4 -1
  116. package/plugins/invoice/admin/css/index.css +1 -0
  117. package/plugins/invoice/admin/templates/editor.html +140 -49
  118. package/plugins/invoice/admin/templates/index.html +153 -23
  119. package/plugins/invoice/admin/templates/issuers.html +2 -5
  120. package/plugins/invoice/admin/templates/receivers.html +2 -5
  121. package/plugins/invoice/admin/views/contacts-source.js +266 -0
  122. package/plugins/invoice/admin/views/editor.js +366 -199
  123. package/plugins/invoice/admin/views/export.js +199 -0
  124. package/plugins/invoice/admin/views/help-content.js +61 -0
  125. package/plugins/invoice/admin/views/index.js +582 -94
  126. package/plugins/invoice/admin/views/issuers.js +24 -17
  127. package/plugins/invoice/admin/views/media.js +172 -0
  128. package/plugins/invoice/admin/views/party-view.js +305 -67
  129. package/plugins/invoice/admin/views/payments.js +127 -0
  130. package/plugins/invoice/admin/views/print.js +130 -0
  131. package/plugins/invoice/admin/views/receivers.js +49 -16
  132. package/plugins/invoice/admin/views/send.js +212 -0
  133. package/plugins/invoice/admin/views/settings.js +594 -0
  134. package/plugins/invoice/admin/views/view-lifecycle.js +33 -0
  135. package/plugins/invoice/collections/invoice-issuers/schema.json +77 -11
  136. package/plugins/invoice/collections/invoice-receivers/schema.json +10 -9
  137. package/plugins/invoice/collections/invoices/schema.json +19 -13
  138. package/plugins/invoice/config.js +27 -6
  139. package/plugins/invoice/pdf.js +164 -0
  140. package/plugins/invoice/plugin.js +1217 -44
  141. package/plugins/invoice/plugin.json +10 -9
  142. package/plugins/invoice/templates/_base.css +1 -0
  143. package/plugins/invoice/templates/classic-nologo.html +100 -0
  144. package/plugins/invoice/templates/classic.html +91 -0
  145. package/plugins/invoice/templates/invoice-print.html +24 -0
  146. package/plugins/invoice/templates/minimal.html +99 -0
  147. package/plugins/invoice/templates/modern-nologo.html +114 -0
  148. package/plugins/invoice/templates/modern.html +113 -0
  149. package/plugins/invoice/templates/templates.json +11 -0
  150. package/plugins/mail-reader/admin/views/mail.js +19 -0
  151. package/plugins/mail-reader/config.js +7 -0
  152. package/plugins/mail-reader/plugin.js +48 -0
  153. package/plugins/mail-reader/plugin.json +33 -0
  154. package/plugins/notes/admin/views/notes.js +1 -1
  155. package/plugins/notes/plugin.json +2 -2
  156. package/plugins/surveys/lib/audience.js +37 -0
  157. package/plugins/surveys/lib/campaigns.js +43 -0
  158. package/plugins/surveys/lib/ledger.js +110 -0
  159. package/plugins/surveys/lib/sending.js +106 -0
  160. package/plugins/surveys/lib/stats.js +62 -0
  161. package/plugins/surveys/lib/submit.js +95 -0
  162. package/plugins/surveys/lib/tokens.js +28 -0
  163. package/plugins/surveys/plugin.public.js +149 -0
  164. package/plugins/theme-switcher/admin/templates/theme-switcher.html +1 -1
  165. package/public/css/forms.css +1 -1
  166. package/public/css/search.css +1 -0
  167. package/public/css/site.css +1 -1
  168. package/public/js/collection-context.js +2 -2
  169. package/public/js/context-menus.js +1 -0
  170. package/public/js/form-logic-engine.js +1 -1
  171. package/public/js/forms.js +2 -2
  172. package/public/js/search.js +1 -0
  173. package/public/js/site.js +1 -1
  174. package/scripts/build.js +37 -3
  175. package/scripts/copy-domma.js +48 -0
  176. package/scripts/seed.js +1996 -0
  177. package/server/routes/api/collections.js +34 -0
  178. package/server/routes/api/context-menus.js +104 -0
  179. package/server/routes/api/forms.js +42 -3
  180. package/server/routes/api/notifications.js +69 -19
  181. package/server/routes/api/plugins.js +50 -6
  182. package/server/routes/api/search.js +43 -0
  183. package/server/routes/api/theme.js +69 -0
  184. package/server/routes/public.js +42 -7
  185. package/server/server.js +74 -0
  186. package/server/services/adapters/FileAdapter.js +6 -1
  187. package/server/services/content.js +26 -0
  188. package/server/services/contextMenus.js +477 -0
  189. package/server/services/markdown.js +70 -9
  190. package/server/services/permissionRegistry.js +24 -0
  191. package/server/services/pluginFiles.js +52 -11
  192. package/server/services/plugins.js +229 -6
  193. package/server/services/renderer.js +144 -22
  194. package/server/services/roles.js +1 -1
  195. package/server/services/search-migration.js +82 -0
  196. package/server/services/search.js +413 -0
  197. package/server/services/sidebar-migration.js +1 -0
  198. package/server/services/themeSettings.js +541 -0
  199. package/server/services/users.js +8 -0
  200. package/server/templates/page.html +4 -2
  201. package/plugins/contacts/data/contacts.json +0 -20
  202. package/plugins/notes/data/notes.json +0 -1
  203. package/plugins/site-search/admin/views/site-search.js +0 -116
  204. package/plugins/site-search/config.js +0 -15
  205. package/plugins/site-search/plugin.js +0 -188
  206. package/plugins/site-search/plugin.json +0 -40
  207. package/plugins/site-search/public/inject-body.html +0 -17
  208. package/plugins/site-search/public/inject-head.html +0 -1
  209. package/plugins/site-search/public/search.css +0 -1
  210. package/plugins/site-search/public/search.js +0 -1
  211. package/plugins/todo/data/todos.json +0 -1
@@ -1,23 +1,30 @@
1
1
  /**
2
- * Invoice plugin - issuers CRUD view.
2
+ * Invoice plugin - issuers screen.
3
+ *
4
+ * The same panel the settings slideover puts in its Issuers tab. This route
5
+ * stays because the sidebar and old bookmarks point at it, and because a
6
+ * fifteen-field issuer form is more comfortable on a full screen than in a
7
+ * slideover.
3
8
  */
4
- import {mountPartyView} from './party-view.js?v=1';
9
+ import {ISSUER_FIELDS, mountPartyPanel} from './party-view.js?v=8';
10
+ import {disposeOnLeave} from './view-lifecycle.js?v=8';
5
11
 
6
12
  export const issuersView = {
7
13
  templateUrl: '/plugins/invoice/admin/templates/issuers.html',
8
- onMount: ($container) => mountPartyView($container, {
9
- kind: 'issuer',
10
- endpoint: '/api/plugins/invoice/issuers',
11
- label: 'Issuer',
12
- fields: [
13
- { key: 'name', label: 'Trading Name', required: true, type: 'text' },
14
- { key: 'email', label: 'Email', type: 'text' },
15
- { key: 'phone', label: 'Phone', type: 'text' },
16
- { key: 'website', label: 'Website', type: 'text' },
17
- { key: 'vatNumber', label: 'VAT Number', type: 'text' },
18
- { key: 'logoUrl', label: 'Logo URL', type: 'text' },
19
- { key: 'address', label: 'Address', type: 'textarea' },
20
- { key: 'bankDetails', label: 'Bank Details', type: 'textarea' }
21
- ]
22
- })
14
+
15
+ onMount($container) {
16
+ const host = $container.find('#party-host').get(0);
17
+ if (!host) return;
18
+
19
+ const panel = mountPartyPanel(host, {
20
+ kind: 'issuer',
21
+ endpoint: '/api/plugins/invoice/issuers',
22
+ label: 'Issuer',
23
+ intro: 'An issuer is you - a trading name, its address, VAT number and the account an invoice is paid into. Add one profile per business you invoice as.',
24
+ fields: ISSUER_FIELDS
25
+ });
26
+
27
+ disposeOnLeave(host, panel.dispose);
28
+ Domma.icons.scan($container.get(0));
29
+ }
23
30
  };
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Invoice plugin - choosing or uploading a logo.
3
+ *
4
+ * A logo is a file, not a URL somebody types, so every place that holds one
5
+ * (the Branding setting, an issuer's own override) opens this: the media
6
+ * library as a grid, with an Upload button that adds to it.
7
+ *
8
+ * Upload goes through a raw `fetch` rather than `H`, and deliberately. `H`
9
+ * sets `Content-Type: application/json` and `JSON.stringify`s its body, so a
10
+ * FormData posted through it arrives as `{}` - the file never leaves the
11
+ * browser and the server answers "No file uploaded". The Media screen in the
12
+ * admin uses the same raw call for the same reason; the token comes out of
13
+ * Domma storage, which is where the admin's interceptor reads it from too.
14
+ */
15
+ const IMAGE = /\.(png|jpe?g|gif|webp|svg)$/i;
16
+
17
+ /**
18
+ * Upload one file to the media library.
19
+ *
20
+ * @param {File} file
21
+ * @returns {Promise<{name: string, url: string}>}
22
+ */
23
+ async function uploadMedia(file) {
24
+ const body = new FormData();
25
+ body.append('file', file);
26
+
27
+ const res = await fetch('/api/media', {
28
+ method: 'POST',
29
+ body,
30
+ headers: { Authorization: 'Bearer ' + (S.get('auth_token') || '') }
31
+ });
32
+
33
+ let payload = null;
34
+ try {
35
+ payload = await res.json();
36
+ } catch { /* a non-JSON body means the status is all we have */ }
37
+
38
+ if (!res.ok) throw new Error(payload?.error || `Upload failed (${res.status}).`);
39
+ // POST /api/media answers with an array when several files went up.
40
+ return Array.isArray(payload) ? payload[0] : payload;
41
+ }
42
+
43
+ /**
44
+ * Open the picker.
45
+ *
46
+ * @param {(url: string) => void} onPick called with the chosen media path
47
+ * @returns {Promise<void>}
48
+ */
49
+ export async function openMediaPicker(onPick) {
50
+ const slideover = E.slideover({
51
+ title: 'Choose a logo',
52
+ size: 'md',
53
+ position: 'right',
54
+ onClose: () => setTimeout(() => {
55
+ slideover.destroy();
56
+ slideover.element?.remove();
57
+ }, 350)
58
+ });
59
+
60
+ const wrapper = document.createElement('div');
61
+ wrapper.className = 'invoice-party-form';
62
+
63
+ const bar = document.createElement('div');
64
+ bar.className = 'invoice-panel-head';
65
+
66
+ const uploadBtn = document.createElement('button');
67
+ uploadBtn.type = 'button';
68
+ uploadBtn.className = 'btn btn-sm btn-primary';
69
+ uploadBtn.textContent = 'Upload…';
70
+
71
+ const fileInput = document.createElement('input');
72
+ fileInput.type = 'file';
73
+ fileInput.accept = 'image/*';
74
+ fileInput.className = 'invoice-hidden';
75
+
76
+ const clearBtn = document.createElement('button');
77
+ clearBtn.type = 'button';
78
+ clearBtn.className = 'btn btn-sm btn-ghost';
79
+ clearBtn.textContent = 'No logo';
80
+ clearBtn.title = 'Clear the logo';
81
+
82
+ bar.appendChild(clearBtn);
83
+ bar.appendChild(uploadBtn);
84
+ bar.appendChild(fileInput);
85
+ wrapper.appendChild(bar);
86
+
87
+ const status = document.createElement('p');
88
+ status.className = 'text-sm text-muted';
89
+ wrapper.appendChild(status);
90
+
91
+ const grid = document.createElement('div');
92
+ grid.className = 'invoice-media-grid';
93
+ wrapper.appendChild(grid);
94
+
95
+ slideover.element.appendChild(wrapper);
96
+
97
+ /**
98
+ * @param {string} url
99
+ * @returns {void}
100
+ */
101
+ function choose(url) {
102
+ onPick(url);
103
+ slideover.close();
104
+ }
105
+
106
+ /**
107
+ * @returns {Promise<void>}
108
+ */
109
+ async function refresh() {
110
+ status.textContent = 'Loading…';
111
+ let media = [];
112
+ try {
113
+ const res = await H.get('/api/media');
114
+ media = (res.data ?? res ?? []).filter((m) => IMAGE.test(m.name || m.url || ''));
115
+ } catch {
116
+ status.textContent = 'The media library could not be read.';
117
+ return;
118
+ }
119
+
120
+ grid.textContent = '';
121
+ status.textContent = media.length
122
+ ? ''
123
+ : 'No images yet. Upload one and it will appear here.';
124
+
125
+ for (const item of media) {
126
+ const tile = document.createElement('button');
127
+ tile.type = 'button';
128
+ tile.className = 'invoice-media-tile';
129
+ tile.title = item.name;
130
+
131
+ const img = document.createElement('img');
132
+ img.src = item.url;
133
+ img.alt = item.name;
134
+ img.loading = 'lazy';
135
+ tile.appendChild(img);
136
+
137
+ const name = document.createElement('span');
138
+ name.textContent = item.name;
139
+ tile.appendChild(name);
140
+
141
+ tile.addEventListener('click', () => choose(item.url));
142
+ grid.appendChild(tile);
143
+ }
144
+ }
145
+
146
+ uploadBtn.addEventListener('click', () => fileInput.click());
147
+ clearBtn.addEventListener('click', () => choose(''));
148
+
149
+ fileInput.addEventListener('change', async () => {
150
+ const file = fileInput.files?.[0];
151
+ if (!file) return;
152
+ uploadBtn.disabled = true;
153
+ status.textContent = 'Uploading…';
154
+ try {
155
+ const saved = await uploadMedia(file);
156
+ E.toast('Uploaded.', { type: 'success' });
157
+ // Straight to the newly uploaded file: somebody who has just
158
+ // chosen it in a file dialog has already picked it.
159
+ if (saved?.url) { choose(saved.url); return; }
160
+ await refresh();
161
+ } catch (err) {
162
+ status.textContent = err?.message ?? 'Upload failed.';
163
+ E.toast(err?.message ?? 'Upload failed.', { type: 'error' });
164
+ } finally {
165
+ uploadBtn.disabled = false;
166
+ fileInput.value = '';
167
+ }
168
+ });
169
+
170
+ slideover.open();
171
+ await refresh();
172
+ }
@@ -1,27 +1,96 @@
1
1
  /**
2
- * Shared CRUD view for issuers and receivers.
3
- * Renders a sortable table and an edit slideover driven by a fields[] config.
2
+ * Shared CRUD panel for issuers and receivers.
3
+ *
4
+ * A sortable Domma table, and a slideover whose form is generated by F.create
5
+ * from the same fields[] config the caller already supplies - the fields were
6
+ * being assembled by hand with document.createElement, which is the thing
7
+ * F.create exists to replace.
8
+ *
9
+ * It mounts into an ELEMENT rather than a route view, because there are now two
10
+ * places that want it: the Issuers and Receivers screens, and two tabs of the
11
+ * settings slideover. Nothing here knows which it is in, and nothing here
12
+ * subscribes to the router - the caller owns the teardown, since a panel in a
13
+ * slideover dies when the slideover closes and a panel in a view dies when the
14
+ * route changes.
15
+ *
16
+ * The slideover takes the form's container as an ELEMENT. E.slideover's own
17
+ * setContent sanitises, which strips inputs; passing an element has it appended
18
+ * instead (Slideover.create, elements.js), which is the light-DOM route the
19
+ * root CLAUDE.md prescribes.
4
20
  */
21
+ import {openMediaPicker} from './media.js?v=8';
22
+ import {scanHelp} from '/plugins/_lib/admin/ui/help.js?v=8';
23
+
5
24
  function escapeHtml(s) {
6
25
  return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
7
26
  }
8
27
 
9
- export async function mountPartyView($container, config) {
10
- let parties = [];
28
+ /**
29
+ * Draw a party panel into `host`.
30
+ *
31
+ * @param {HTMLElement} host
32
+ * @param {object} config - {kind, endpoint, label, fields[], intro?}
33
+ * @returns {{reload: Function, dispose: Function, parties: object}}
34
+ */
35
+ export function mountPartyPanel(host, config) {
36
+ // One observable holds the list; the table follows it, so a create, an edit
37
+ // and a delete each write once and nothing calls a reload by hand.
38
+ const parties = M.observable([]);
11
39
  let table = null;
40
+ let menu = null;
41
+
42
+ host.textContent = '';
43
+ host.classList.add('invoice-party-panel');
44
+
45
+ if (config.intro) {
46
+ const intro = document.createElement('p');
47
+ intro.className = 'text-sm text-muted mb-2';
48
+ intro.textContent = config.intro;
49
+ host.appendChild(intro);
50
+ }
51
+
52
+ const head = document.createElement('div');
53
+ head.className = 'invoice-panel-head';
54
+ const newBtn = document.createElement('button');
55
+ newBtn.type = 'button';
56
+ newBtn.className = 'btn btn-sm btn-primary';
57
+ newBtn.textContent = `New ${config.label}`;
58
+ head.appendChild(newBtn);
59
+ host.appendChild(head);
60
+
61
+ const tableHost = document.createElement('div');
62
+ host.appendChild(tableHost);
63
+
64
+ /**
65
+ * Domma's table has no empty-state option (`emptyMessage` was passed here
66
+ * for a year and silently ignored, so an empty list showed a header row
67
+ * and nothing else). Ours, and re-evaluated on every data push - otherwise
68
+ * it would stay on screen under the first row somebody added.
69
+ *
70
+ * @param {object[]} data
71
+ * @returns {void}
72
+ */
73
+ function paintEmptyLine(data) {
74
+ tableHost.querySelector(':scope > .invoice-empty')?.remove();
75
+ if (data.length) return;
76
+ const empty = document.createElement('p');
77
+ empty.className = 'text-sm text-muted invoice-empty';
78
+ empty.textContent = `No ${config.label.toLowerCase()}s yet.`;
79
+ tableHost.appendChild(empty);
80
+ }
12
81
 
13
82
  function buildTable(data) {
14
- const el = $container.find('#party-table').get(0);
15
- if (!el) return;
83
+ if (!tableHost.isConnected && table) return;
16
84
  if (table) {
17
85
  table.setData(data);
18
- Domma.icons.scan(el);
86
+ paintEmptyLine(data);
87
+ Domma.icons.scan(tableHost);
19
88
  return;
20
89
  }
21
90
 
22
91
  const cols = [
23
92
  { key: 'name', title: 'Name', sortable: true,
24
- render: (v, row) => `<a href="#" data-id="${escapeHtml(row.id)}" class="party-edit" style="font-weight:600;">${escapeHtml(v || '(unnamed)')}</a>`
93
+ render: (v, row) => `<a href="#" data-id="${escapeHtml(row.id)}" class="party-edit invoice-number-link">${escapeHtml(v || '(unnamed)')}</a>`
25
94
  }
26
95
  ];
27
96
  if (config.fields.some((f) => f.key === 'company')) {
@@ -30,105 +99,230 @@ export async function mountPartyView($container, config) {
30
99
  cols.push(
31
100
  { key: 'email', title: 'Email', sortable: true, render: (v) => v ? `<a href="mailto:${escapeHtml(v)}">${escapeHtml(v)}</a>` : '-' },
32
101
  { key: 'vatNumber', title: 'VAT', sortable: false, render: (v) => v ? `<code>${escapeHtml(v)}</code>` : '-' },
33
- { key: 'id', title: 'Actions', sortable: false,
34
- render: (id) => `<div style="display:flex;gap:.4rem;">
35
- <button class="btn btn-sm btn-outline party-edit" data-id="${escapeHtml(id)}"><span data-icon="edit" data-icon-size="14"></span></button>
36
- <button class="btn btn-sm btn-danger party-delete" data-id="${escapeHtml(id)}"><span data-icon="trash" data-icon-size="14"></span></button>
102
+ // A space, not '': T falls back to the column KEY for a falsy
103
+ // title, which printed a column headed "id".
104
+ { key: 'id', title: ' ', sortable: false,
105
+ render: (id) => `<div class="invoice-row-actions">
106
+ <button class="btn btn-sm btn-outline party-edit" data-id="${escapeHtml(id)}" title="Edit"><span data-icon="edit" data-icon-size="14"></span></button>
107
+ <button class="btn btn-sm btn-danger party-delete" data-id="${escapeHtml(id)}" title="Delete"><span data-icon="trash" data-icon-size="14"></span></button>
37
108
  </div>` }
38
109
  );
39
110
 
40
- table = T.create(el, { data, columns: cols, emptyMessage: `No ${config.label.toLowerCase()}s yet.` });
111
+ table = T.create(tableHost, {
112
+ data,
113
+ columns: cols,
114
+ pagination: data.length > 10,
115
+ pageSize: 10
116
+ });
117
+
118
+ paintEmptyLine(data);
41
119
 
42
- el.addEventListener('click', async (e) => {
120
+ tableHost.addEventListener('click', async (e) => {
43
121
  const editEl = e.target.closest('.party-edit');
44
122
  const delEl = e.target.closest('.party-delete');
45
123
  if (editEl) {
46
124
  e.preventDefault();
47
- const party = parties.find((p) => p.id === editEl.dataset.id);
48
- openEditor(party);
125
+ openEditor(parties.value.find((p) => p.id === editEl.dataset.id));
49
126
  }
50
127
  if (delEl) {
51
- const party = parties.find((p) => p.id === delEl.dataset.id);
52
- const ok = await E.confirm(`Delete ${config.label.toLowerCase()} "${party?.name ?? ''}"?`);
53
- if (!ok) return;
54
- try {
55
- await H.delete(`${config.endpoint}/${party.id}`);
56
- E.toast('Deleted.', { type: 'success' });
57
- await reload();
58
- } catch {
59
- E.toast('Delete failed.', { type: 'error' });
128
+ const party = parties.value.find((p) => p.id === delEl.dataset.id);
129
+ if (party) await remove(party);
130
+ }
131
+ });
132
+
133
+ // The same gesture the invoice list uses, for the same reason: the row
134
+ // is the thing, so the actions belong on the row rather than in a
135
+ // column of buttons that grows every time one is added.
136
+ menu = E.contextMenu(tableHost, {
137
+ match: 'tbody tr',
138
+ inherit: false,
139
+ items: (target) => {
140
+ const id = target?.querySelector?.('[data-id]')?.dataset?.id;
141
+ const party = parties.value.find((x) => x.id === id);
142
+ if (!party) return [];
143
+
144
+ const items = [
145
+ { header: party.company || party.name || config.label },
146
+ { label: `Edit ${config.label.toLowerCase()}`, icon: 'edit', action: () => openEditor(party) },
147
+ { label: 'Duplicate', icon: 'copy', action: () => duplicate(party) }
148
+ ];
149
+ if (party.email) {
150
+ items.push({ label: 'Copy email address', icon: 'copy',
151
+ action: () => copyText(party.email) });
152
+ }
153
+ for (const extra of config.extraItems ?? []) {
154
+ items.push(extra(party));
60
155
  }
156
+ items.push(
157
+ { type: 'divider' },
158
+ { label: 'Delete', icon: 'trash', danger: true, action: () => remove(party) }
159
+ );
160
+ return items;
61
161
  }
62
162
  });
63
163
 
64
- Domma.icons.scan(el);
164
+ Domma.icons.scan(tableHost);
165
+ scanHelp(tableHost);
65
166
  }
66
167
 
168
+ /**
169
+ * Put text on the clipboard, saying so either way.
170
+ *
171
+ * `navigator.clipboard` needs a secure context and refuses without one, so
172
+ * the failure is reported rather than silently doing nothing.
173
+ *
174
+ * @param {string} text
175
+ * @returns {Promise<void>}
176
+ */
177
+ async function copyText(text) {
178
+ try {
179
+ await navigator.clipboard.writeText(text);
180
+ E.toast('Copied.', { type: 'success' });
181
+ } catch {
182
+ E.toast('Your browser would not let the page copy that.', { type: 'error' });
183
+ }
184
+ }
185
+
186
+ /**
187
+ * @param {object} party
188
+ * @returns {Promise<void>}
189
+ */
190
+ async function duplicate(party) {
191
+ // Only the declared fields, not a spread: an entry read back from the
192
+ // server carries an id and whatever else the collection put on it, and
193
+ // posting those straight back stores them a second time.
194
+ const copy = {};
195
+ for (const field of config.fields) copy[field.key] = party[field.key] ?? '';
196
+ copy.name = `${party.name || 'Copy'} (copy)`;
197
+ try {
198
+ const res = await H.post(config.endpoint, copy);
199
+ if (res?.error) throw new Error(res.error);
200
+ E.toast('Copied.', { type: 'success' });
201
+ await reload();
202
+ } catch (err) {
203
+ E.toast(err?.message ?? 'Could not copy that.', { type: 'error' });
204
+ }
205
+ }
206
+
207
+ /**
208
+ * @param {object} party
209
+ * @returns {Promise<void>}
210
+ */
211
+ async function remove(party) {
212
+ const ok = await E.confirm(`Delete ${config.label.toLowerCase()} "${party?.name ?? ''}"?`);
213
+ if (!ok) return;
214
+ try {
215
+ await H.delete(`${config.endpoint}/${party.id}`);
216
+ E.toast('Deleted.', { type: 'success' });
217
+ parties.value = parties.value.filter((p) => p.id !== party.id);
218
+ } catch {
219
+ E.toast('Delete failed.', { type: 'error' });
220
+ }
221
+ }
222
+
223
+ const stopFeed = M.effect(() => {
224
+ const rows = parties.value;
225
+ M.untracked(() => buildTable(rows));
226
+ });
227
+
67
228
  async function reload() {
68
229
  try {
69
230
  const res = await H.get(config.endpoint);
70
- parties = res.data ?? res ?? [];
231
+ parties.value = res.data ?? res ?? [];
71
232
  } catch {
72
- parties = [];
233
+ parties.value = [];
73
234
  E.toast(`Could not load ${config.label.toLowerCase()}s.`, { type: 'error' });
74
235
  }
75
- buildTable(parties);
76
236
  }
77
237
 
78
238
  function openEditor(existing) {
79
239
  const isEdit = Boolean(existing);
80
- const form = document.createElement('div');
81
- form.style.cssText = 'display:flex;flex-direction:column;gap:.75rem;padding:.5rem;';
82
240
 
83
- const inputs = {};
241
+ // F.create takes a SCHEMA, initial data and options - not a container -
242
+ // and the instance renders itself with .renderTo(). It builds an
243
+ // M.create model underneath, so getData() reads back through the model
244
+ // rather than off the DOM.
245
+ const schema = {};
246
+ const initial = {};
84
247
  for (const field of config.fields) {
85
- const wrap = document.createElement('div');
86
- const label = document.createElement('label');
87
- label.className = 'form-label';
88
- label.textContent = field.label + (field.required ? ' *' : '');
89
- wrap.appendChild(label);
90
-
91
- let input;
92
- if (field.type === 'textarea') {
93
- input = document.createElement('textarea');
94
- input.rows = 3;
95
- } else {
96
- input = document.createElement('input');
97
- input.type = 'text';
98
- }
99
- input.className = 'form-control';
100
- input.value = existing?.[field.key] ?? '';
101
- wrap.appendChild(input);
102
- form.appendChild(wrap);
103
- inputs[field.key] = input;
248
+ schema[field.key] = {
249
+ type: field.type === 'textarea' ? 'textarea' : 'string',
250
+ label: field.label,
251
+ required: Boolean(field.required)
252
+ };
253
+ if (field.hint) schema[field.key].formConfig = { hint: field.hint };
254
+ initial[field.key] = existing?.[field.key] ?? '';
255
+ }
256
+
257
+ const form = F.create(schema, initial, { showSubmitButton: false });
258
+
259
+ const wrapper = document.createElement('div');
260
+ wrapper.className = 'invoice-party-form';
261
+ const fields = document.createElement('div');
262
+ form.renderTo(fields);
263
+ wrapper.appendChild(fields);
264
+
265
+ // A logo is a file, not a URL somebody types. The picker writes the
266
+ // chosen path back through the MODEL rather than the input, because
267
+ // Forma reads getData() off the model and an input written behind its
268
+ // back saves the old value.
269
+ for (const field of config.fields.filter((f) => f.media)) {
270
+ const input = fields.querySelector(`[name="${field.key}"]`);
271
+ if (!input) continue;
272
+ const pick = document.createElement('button');
273
+ pick.type = 'button';
274
+ pick.className = 'btn btn-sm btn-outline invoice-media-pick';
275
+ pick.textContent = 'Choose…';
276
+ pick.addEventListener('click', () => openMediaPicker((url) => {
277
+ form.model.set(field.key, url);
278
+ input.value = url;
279
+ }));
280
+ input.insertAdjacentElement('afterend', pick);
281
+ input.parentElement?.classList.add('invoice-media-row');
104
282
  }
105
283
 
284
+ const actions = document.createElement('div');
285
+ actions.className = 'invoice-party-actions';
286
+ const cancelBtn = document.createElement('button');
287
+ cancelBtn.type = 'button';
288
+ cancelBtn.className = 'btn btn-ghost';
289
+ cancelBtn.textContent = 'Cancel';
106
290
  const saveBtn = document.createElement('button');
107
291
  saveBtn.type = 'button';
108
- saveBtn.className = 'btn btn-primary mt-2';
292
+ saveBtn.className = 'btn btn-primary';
109
293
  saveBtn.textContent = isEdit ? 'Save changes' : 'Create';
110
- form.appendChild(saveBtn);
294
+ actions.appendChild(cancelBtn);
295
+ actions.appendChild(saveBtn);
296
+ wrapper.appendChild(actions);
111
297
 
112
298
  const slideover = E.slideover({
113
299
  title: isEdit ? `Edit ${config.label}` : `New ${config.label}`,
114
300
  size: 'md',
115
301
  position: 'right',
116
- content: form
302
+ // close() only hides the panel and destroy() tears the component
303
+ // down without detaching its element - measured, not assumed - so
304
+ // opening the editor twenty times left twenty panels in the
305
+ // document. Both, then, once the slide-out has run. Hanging it off
306
+ // onClose covers Escape and the backdrop as well as the buttons.
307
+ onClose: () => setTimeout(() => {
308
+ slideover.destroy();
309
+ slideover.element?.remove();
310
+ }, 350)
117
311
  });
118
312
 
313
+ // Light-DOM projection. setContent() sanitises, which strips the inputs
314
+ // F.create has just rendered - root CLAUDE.md, gotcha 4.
315
+ slideover.element.appendChild(wrapper);
316
+
317
+ cancelBtn.addEventListener('click', () => slideover.close());
318
+
119
319
  saveBtn.addEventListener('click', async () => {
120
- const body = {};
121
- for (const field of config.fields) {
122
- body[field.key] = inputs[field.key].value;
123
- }
124
- if (!body.name?.trim()) {
125
- E.toast('Name is required.', { type: 'error' });
126
- return;
127
- }
320
+ if (!form.validate()) return;
321
+ const values = form.getData();
128
322
  try {
129
323
  const res = isEdit
130
- ? await H.put(`${config.endpoint}/${existing.id}`, body)
131
- : await H.post(config.endpoint, body);
324
+ ? await H.put(`${config.endpoint}/${existing.id}`, values)
325
+ : await H.post(config.endpoint, values);
132
326
  if (res.error) throw new Error(res.error);
133
327
  slideover.close();
134
328
  E.toast(isEdit ? 'Saved.' : 'Created.', { type: 'success' });
@@ -139,10 +333,54 @@ export async function mountPartyView($container, config) {
139
333
  });
140
334
 
141
335
  slideover.open();
336
+ Domma.icons.scan(wrapper);
142
337
  }
143
338
 
144
- $container.find('#btn-new').on('click', () => openEditor(null));
339
+ newBtn.addEventListener('click', () => openEditor(null));
340
+
341
+ reload();
145
342
 
146
- await reload();
147
- Domma.icons.scan();
343
+ return {
344
+ parties,
345
+ reload,
346
+ dispose: () => {
347
+ stopFeed();
348
+ menu?.destroy?.();
349
+ menu = null;
350
+ table?.destroy?.();
351
+ table = null;
352
+ }
353
+ };
148
354
  }
355
+
356
+ /** The issuer fields, shared by the Issuers screen and the settings tab. */
357
+ export const ISSUER_FIELDS = [
358
+ { key: 'name', label: 'Trading Name', required: true, type: 'text' },
359
+ { key: 'email', label: 'Email', type: 'text' },
360
+ { key: 'phone', label: 'Phone', type: 'text' },
361
+ { key: 'website', label: 'Website', type: 'text' },
362
+ { key: 'vatNumber', label: 'VAT Number', type: 'text' },
363
+ { key: 'logoUrl', label: 'Logo', type: 'text', media: true,
364
+ hint: 'Printed at the top of this issuer’s invoices. Leave it empty to use the logo from Settings > Branding.' },
365
+ { key: 'address', label: 'Address', type: 'textarea' },
366
+ // Payment, as structured fields rather than one blob: these are read off a
367
+ // printed invoice by someone typing them into a banking app.
368
+ { key: 'accountName', label: 'Account Name', type: 'text' },
369
+ { key: 'bankName', label: 'Bank', type: 'text' },
370
+ { key: 'sortCode', label: 'Sort Code', type: 'text' },
371
+ { key: 'accountNumber', label: 'Account Number', type: 'text' },
372
+ { key: 'iban', label: 'IBAN', type: 'text' },
373
+ { key: 'bic', label: 'BIC / SWIFT', type: 'text' },
374
+ { key: 'bankDetails', label: 'Other Payment Notes', type: 'textarea' }
375
+ ];
376
+
377
+ /** The receiver fields, likewise. */
378
+ export const RECEIVER_FIELDS = [
379
+ { key: 'name', label: 'Contact Name', required: true, type: 'text' },
380
+ { key: 'company', label: 'Company', type: 'text' },
381
+ { key: 'email', label: 'Email', type: 'text' },
382
+ { key: 'phone', label: 'Phone', type: 'text' },
383
+ { key: 'vatNumber', label: 'VAT Number', type: 'text' },
384
+ { key: 'reference', label: 'Reference', type: 'text' },
385
+ { key: 'address', label: 'Address', type: 'textarea' }
386
+ ];