domma-cms 0.1.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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +469 -0
  3. package/admin/css/admin.css +1123 -0
  4. package/admin/index.html +72 -0
  5. package/admin/js/api.js +210 -0
  6. package/admin/js/app.js +270 -0
  7. package/admin/js/config/sidebar-config.js +107 -0
  8. package/admin/js/lib/card.js +63 -0
  9. package/admin/js/lib/image-editor.js +869 -0
  10. package/admin/js/lib/markdown-toolbar.js +421 -0
  11. package/admin/js/templates/dashboard.html +50 -0
  12. package/admin/js/templates/documentation.html +237 -0
  13. package/admin/js/templates/layouts.html +11 -0
  14. package/admin/js/templates/login.html +58 -0
  15. package/admin/js/templates/media.html +16 -0
  16. package/admin/js/templates/navigation.html +50 -0
  17. package/admin/js/templates/page-editor.html +126 -0
  18. package/admin/js/templates/pages.html +18 -0
  19. package/admin/js/templates/plugins.html +12 -0
  20. package/admin/js/templates/settings.html +190 -0
  21. package/admin/js/templates/tutorials.html +233 -0
  22. package/admin/js/templates/user-editor.html +12 -0
  23. package/admin/js/templates/users.html +10 -0
  24. package/admin/js/views/dashboard.js +48 -0
  25. package/admin/js/views/documentation.js +12 -0
  26. package/admin/js/views/index.js +33 -0
  27. package/admin/js/views/layouts.js +49 -0
  28. package/admin/js/views/login.js +254 -0
  29. package/admin/js/views/media.js +240 -0
  30. package/admin/js/views/navigation.js +152 -0
  31. package/admin/js/views/page-editor.js +479 -0
  32. package/admin/js/views/pages.js +64 -0
  33. package/admin/js/views/plugins.js +100 -0
  34. package/admin/js/views/settings.js +64 -0
  35. package/admin/js/views/tutorials.js +12 -0
  36. package/admin/js/views/user-editor.js +88 -0
  37. package/admin/js/views/users.js +73 -0
  38. package/bin/cli.js +334 -0
  39. package/config/auth.json +20 -0
  40. package/config/content.json +10 -0
  41. package/config/navigation.json +63 -0
  42. package/config/plugins.json +47 -0
  43. package/config/presets.json +34 -0
  44. package/config/server.json +6 -0
  45. package/config/site.json +33 -0
  46. package/package.json +67 -0
  47. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
  48. package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
  49. package/plugins/back-to-top/config.js +10 -0
  50. package/plugins/back-to-top/plugin.js +24 -0
  51. package/plugins/back-to-top/plugin.json +36 -0
  52. package/plugins/back-to-top/public/inject-body.html +105 -0
  53. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
  54. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
  55. package/plugins/cookie-consent/config.js +30 -0
  56. package/plugins/cookie-consent/plugin.js +24 -0
  57. package/plugins/cookie-consent/plugin.json +36 -0
  58. package/plugins/cookie-consent/public/inject-body.html +69 -0
  59. package/plugins/custom-css/admin/templates/custom-css.html +17 -0
  60. package/plugins/custom-css/admin/views/custom-css.js +35 -0
  61. package/plugins/custom-css/config.js +1 -0
  62. package/plugins/custom-css/data/custom.css +0 -0
  63. package/plugins/custom-css/plugin.js +63 -0
  64. package/plugins/custom-css/plugin.json +32 -0
  65. package/plugins/custom-css/public/inject-head.html +1 -0
  66. package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
  67. package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
  68. package/plugins/domma-effects/config.js +9 -0
  69. package/plugins/domma-effects/plugin.js +22 -0
  70. package/plugins/domma-effects/plugin.json +36 -0
  71. package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
  72. package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
  73. package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
  74. package/plugins/domma-effects/public/celebrations/index.js +535 -0
  75. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
  76. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
  77. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
  78. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
  79. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
  80. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
  81. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
  82. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
  83. package/plugins/domma-effects/public/inject-body.html +268 -0
  84. package/plugins/example-analytics/admin/templates/analytics.html +10 -0
  85. package/plugins/example-analytics/admin/views/analytics.js +51 -0
  86. package/plugins/example-analytics/config.js +6 -0
  87. package/plugins/example-analytics/plugin.js +58 -0
  88. package/plugins/example-analytics/plugin.json +27 -0
  89. package/plugins/example-analytics/public/inject-body.html +13 -0
  90. package/plugins/example-analytics/public/inject-head.html +1 -0
  91. package/plugins/example-analytics/stats.json +1 -0
  92. package/plugins/form-builder/admin/templates/form-editor.html +158 -0
  93. package/plugins/form-builder/admin/templates/form-settings.html +29 -0
  94. package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
  95. package/plugins/form-builder/admin/templates/forms-list.html +17 -0
  96. package/plugins/form-builder/admin/views/form-editor.js +817 -0
  97. package/plugins/form-builder/admin/views/form-settings.js +38 -0
  98. package/plugins/form-builder/admin/views/form-submissions.js +295 -0
  99. package/plugins/form-builder/admin/views/forms-list.js +164 -0
  100. package/plugins/form-builder/config.js +9 -0
  101. package/plugins/form-builder/data/forms/contact-details.json +63 -0
  102. package/plugins/form-builder/data/forms/contact.json +52 -0
  103. package/plugins/form-builder/data/submissions/contact-details.json +1 -0
  104. package/plugins/form-builder/data/submissions/contact.json +14 -0
  105. package/plugins/form-builder/email.js +103 -0
  106. package/plugins/form-builder/plugin.js +454 -0
  107. package/plugins/form-builder/plugin.json +56 -0
  108. package/plugins/form-builder/public/inject-body.html +270 -0
  109. package/plugins/form-builder/public/inject-head.html +42 -0
  110. package/public/css/site.css +189 -0
  111. package/public/js/site.js +109 -0
  112. package/scripts/copy-domma.js +48 -0
  113. package/scripts/fresh.js +41 -0
  114. package/scripts/reset.js +124 -0
  115. package/scripts/seed.js +666 -0
  116. package/scripts/setup.js +263 -0
  117. package/server/config.js +56 -0
  118. package/server/middleware/auth.js +97 -0
  119. package/server/routes/api/auth.js +116 -0
  120. package/server/routes/api/layouts.js +25 -0
  121. package/server/routes/api/media.js +93 -0
  122. package/server/routes/api/navigation.js +37 -0
  123. package/server/routes/api/pages.js +118 -0
  124. package/server/routes/api/plugins.js +46 -0
  125. package/server/routes/api/settings.js +25 -0
  126. package/server/routes/api/users.js +110 -0
  127. package/server/routes/public.js +108 -0
  128. package/server/server.js +169 -0
  129. package/server/services/content.js +298 -0
  130. package/server/services/images.js +334 -0
  131. package/server/services/markdown.js +297 -0
  132. package/server/services/plugins.js +246 -0
  133. package/server/services/renderer.js +80 -0
  134. package/server/services/users.js +212 -0
  135. package/server/templates/page.html +78 -0
@@ -0,0 +1,817 @@
1
+ /**
2
+ * Form Builder Plugin — Form Editor View
3
+ * List-based visual field editor with add/remove/reorder, live preview, and save.
4
+ * Supports "Page Break" pseudo-fields for wizard forms.
5
+ */
6
+ import {apiRequest} from '/admin/js/api.js';
7
+
8
+ const FIELD_TYPES = [
9
+ { value: 'string', label: 'Text (single line)' },
10
+ { value: 'email', label: 'Email' },
11
+ { value: 'tel', label: 'Phone' },
12
+ { value: 'number', label: 'Number' },
13
+ { value: 'textarea', label: 'Textarea (multi-line)' },
14
+ { value: 'select', label: 'Dropdown (select)' },
15
+ { value: 'radio', label: 'Radio buttons' },
16
+ { value: 'checkbox', label: 'Single checkbox' },
17
+ { value: 'checkbox-group', label: 'Checkbox group' },
18
+ { value: 'date', label: 'Date' },
19
+ { value: 'time', label: 'Time' },
20
+ { value: 'url', label: 'URL' },
21
+ { value: 'password', label: 'Password' },
22
+ { value: 'file', label: 'File upload' },
23
+ { value: 'hidden', label: 'Hidden field' }
24
+ ];
25
+
26
+ const OPTION_TYPES = new Set(['select', 'radio', 'checkbox-group']);
27
+
28
+ let fields = [];
29
+ let formSlug = null;
30
+
31
+ function slugify(str) {
32
+ return str.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
33
+ }
34
+
35
+ function getTypeLabel(type) {
36
+ return FIELD_TYPES.find(t => t.value === type)?.label || type;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Read field values from DOM back into array
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function collectFieldFromDOM(idx) {
44
+ const field = { ...fields[idx] };
45
+
46
+ // Page-break cards only have label + description inputs
47
+ if (field.type === 'page-break') {
48
+ const labelEl = document.getElementById(`fb-pb-label-${idx}`);
49
+ const descEl = document.getElementById(`fb-pb-desc-${idx}`);
50
+ if (labelEl) field.label = labelEl.value.trim() || field.label;
51
+ if (descEl) field.description = descEl.value.trim();
52
+ return field;
53
+ }
54
+
55
+ const label = document.getElementById(`fb-label-${idx}`);
56
+ const name = document.getElementById(`fb-name-${idx}`);
57
+ const type = document.getElementById(`fb-type-${idx}`);
58
+ const required = document.getElementById(`fb-required-${idx}`);
59
+ const placeholder = document.getElementById(`fb-placeholder-${idx}`);
60
+ const helper = document.getElementById(`fb-helper-${idx}`);
61
+
62
+ if (label) field.label = label.value.trim() || field.label;
63
+ if (name) field.name = name.value.trim() || field.name;
64
+ if (type) field.type = type.value || field.type;
65
+ if (required) field.required = required.checked;
66
+ if (placeholder) field.placeholder = placeholder.value.trim();
67
+ if (helper) field.helper = helper.value.trim();
68
+
69
+ if (OPTION_TYPES.has(field.type)) {
70
+ const ta = document.getElementById(`fb-options-${idx}`);
71
+ if (ta) {
72
+ field.options = ta.value.split('\n').filter(l => l.trim()).map(line => {
73
+ const [v, ...rest] = line.split(':');
74
+ return { value: v.trim(), label: rest.join(':').trim() || v.trim() };
75
+ });
76
+ }
77
+ }
78
+
79
+ if (field.type === 'textarea') {
80
+ const rows = parseInt(document.getElementById(`fb-rows-${idx}`)?.value, 10);
81
+ if (rows > 0) field.formConfig = { rows };
82
+ }
83
+
84
+ const minLen = document.getElementById(`fb-minlength-${idx}`)?.value;
85
+ if (minLen) field.minLength = parseInt(minLen, 10);
86
+ const maxLen = document.getElementById(`fb-maxlength-${idx}`)?.value;
87
+ if (maxLen) field.maxLength = parseInt(maxLen, 10);
88
+
89
+ const minNum = document.getElementById(`fb-min-${idx}`)?.value;
90
+ if (minNum !== '' && minNum !== undefined) field.min = parseFloat(minNum);
91
+ const maxNum = document.getElementById(`fb-max-${idx}`)?.value;
92
+ if (maxNum !== '' && maxNum !== undefined) field.max = parseFloat(maxNum);
93
+
94
+ return field;
95
+ }
96
+
97
+ function syncAllFieldsFromDOM() {
98
+ fields = fields.map((_, idx) => collectFieldFromDOM(idx));
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Field list renderer — attaches all events directly to elements
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function renderFieldList($container) {
106
+ const listEl = $container.find('#fields-list').get(0);
107
+ const emptyMsg = $container.find('#fields-empty-msg').get(0);
108
+ if (!listEl) return;
109
+
110
+ // Remove old field cards
111
+ Array.from(listEl.querySelectorAll('.fb-field-card')).forEach(el => el.remove());
112
+
113
+ if (fields.length === 0) {
114
+ if (emptyMsg) emptyMsg.style.display = '';
115
+ return;
116
+ }
117
+ if (emptyMsg) emptyMsg.style.display = 'none';
118
+
119
+ fields.forEach((field, idx) => {
120
+ const card = field.type === 'page-break'
121
+ ? buildPageBreakCard(field, idx, $container)
122
+ : buildFieldCard(field, idx, $container);
123
+ listEl.appendChild(card);
124
+ });
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Page Break card
129
+ // ---------------------------------------------------------------------------
130
+
131
+ function buildPageBreakCard(field, idx, $container) {
132
+ const card = document.createElement('div');
133
+ card.className = 'fb-field-card';
134
+ card.dataset.index = idx;
135
+ card.style.cssText = 'border:2px dashed var(--border-color,#444);border-radius:6px;overflow:hidden;margin-bottom:.5rem;background:var(--card-bg,rgba(255,255,255,.02));';
136
+
137
+ // Header
138
+ const header = document.createElement('div');
139
+ header.style.cssText = 'display:flex;align-items:center;gap:.6rem;padding:.6rem .8rem;cursor:pointer;user-select:none;';
140
+
141
+ const badge = document.createElement('span');
142
+ badge.textContent = '— Page Break —';
143
+ badge.style.cssText = 'font-size:.7rem;padding:.15rem .5rem;border-radius:999px;background:rgba(120,120,120,.2);color:var(--text-muted,#888);white-space:nowrap;flex-shrink:0;font-style:italic;';
144
+
145
+ const labelSpan = document.createElement('span');
146
+ labelSpan.textContent = field.label || 'Untitled Step';
147
+ labelSpan.style.cssText = 'flex:1;font-weight:600;font-size:.9rem;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-muted,#888);';
148
+
149
+ const controls = document.createElement('div');
150
+ controls.style.cssText = 'display:flex;gap:.25rem;flex-shrink:0;margin-left:.5rem;';
151
+
152
+ if (idx > 0) {
153
+ const upBtn = document.createElement('button');
154
+ upBtn.className = 'btn btn-xs btn-ghost';
155
+ upBtn.title = 'Move up';
156
+ upBtn.textContent = '↑';
157
+ upBtn.addEventListener('click', (e) => {
158
+ e.stopPropagation();
159
+ syncAllFieldsFromDOM();
160
+ [fields[idx - 1], fields[idx]] = [fields[idx], fields[idx - 1]];
161
+ renderFieldList($container);
162
+ });
163
+ controls.appendChild(upBtn);
164
+ }
165
+ if (idx < fields.length - 1) {
166
+ const downBtn = document.createElement('button');
167
+ downBtn.className = 'btn btn-xs btn-ghost';
168
+ downBtn.title = 'Move down';
169
+ downBtn.textContent = '↓';
170
+ downBtn.addEventListener('click', (e) => {
171
+ e.stopPropagation();
172
+ syncAllFieldsFromDOM();
173
+ [fields[idx], fields[idx + 1]] = [fields[idx + 1], fields[idx]];
174
+ renderFieldList($container);
175
+ });
176
+ controls.appendChild(downBtn);
177
+ }
178
+
179
+ const toggleBtn = document.createElement('button');
180
+ toggleBtn.className = 'btn btn-xs btn-ghost';
181
+ toggleBtn.title = 'Edit step';
182
+ toggleBtn.textContent = '⋯';
183
+ toggleBtn.addEventListener('click', (e) => {
184
+ e.stopPropagation();
185
+ body.style.display = body.style.display === 'none' ? '' : 'none';
186
+ });
187
+ controls.appendChild(toggleBtn);
188
+
189
+ const removeBtn = document.createElement('button');
190
+ removeBtn.className = 'btn btn-xs btn-danger';
191
+ removeBtn.title = 'Remove page break';
192
+ removeBtn.textContent = '✕';
193
+ removeBtn.addEventListener('click', async (e) => {
194
+ e.stopPropagation();
195
+ const confirmed = await E.confirm('Remove this page break?');
196
+ if (!confirmed) return;
197
+ syncAllFieldsFromDOM();
198
+ fields.splice(idx, 1);
199
+ renderFieldList($container);
200
+ });
201
+ controls.appendChild(removeBtn);
202
+
203
+ header.appendChild(badge);
204
+ header.appendChild(labelSpan);
205
+ header.appendChild(controls);
206
+
207
+ header.addEventListener('click', () => {
208
+ body.style.display = body.style.display === 'none' ? '' : 'none';
209
+ });
210
+
211
+ // Body
212
+ const body = document.createElement('div');
213
+ body.className = 'fb-field-body';
214
+ body.style.cssText = 'padding:.8rem;border-top:1px dashed var(--border-color,#444);display:none;';
215
+
216
+ const row1 = buildRow([
217
+ buildInputGroup('Step Title', `fb-pb-label-${idx}`, 'text', field.label || '', 'Shown as the wizard step heading'),
218
+ buildInputGroup('Step Description', `fb-pb-desc-${idx}`, 'text', field.description || '', 'Optional sub-heading')
219
+ ]);
220
+
221
+ const labelInp = row1.querySelector(`#fb-pb-label-${idx}`);
222
+ if (labelInp) {
223
+ labelInp.addEventListener('input', () => {
224
+ labelSpan.textContent = labelInp.value || 'Untitled Step';
225
+ });
226
+ }
227
+
228
+ body.appendChild(row1);
229
+ card.appendChild(header);
230
+ card.appendChild(body);
231
+ return card;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // Regular field card
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function buildFieldCard(field, idx, $container) {
239
+ const card = document.createElement('div');
240
+ card.className = 'fb-field-card';
241
+ card.dataset.index = idx;
242
+ card.style.cssText = 'border:1px solid var(--border-color,#333);border-radius:6px;overflow:hidden;margin-bottom:.5rem;';
243
+
244
+ // --- Header ---
245
+ const header = document.createElement('div');
246
+ header.style.cssText = 'display:flex;align-items:center;gap:.6rem;padding:.6rem .8rem;background:var(--card-header-bg,rgba(255,255,255,.04));cursor:pointer;user-select:none;';
247
+
248
+ const typeBadge = document.createElement('span');
249
+ typeBadge.textContent = getTypeLabel(field.type);
250
+ typeBadge.style.cssText = 'font-size:.7rem;padding:.15rem .45rem;border-radius:999px;background:var(--primary-soft,rgba(99,102,241,.15));color:var(--primary,#6366f1);white-space:nowrap;flex-shrink:0;';
251
+
252
+ const labelSpan = document.createElement('span');
253
+ labelSpan.textContent = field.label || '(unlabelled)';
254
+ labelSpan.style.cssText = 'flex:1;font-weight:600;font-size:.9rem;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
255
+
256
+ const controls = document.createElement('div');
257
+ controls.style.cssText = 'display:flex;gap:.25rem;flex-shrink:0;margin-left:.5rem;';
258
+
259
+ if (idx > 0) {
260
+ const upBtn = document.createElement('button');
261
+ upBtn.className = 'btn btn-xs btn-ghost';
262
+ upBtn.title = 'Move up';
263
+ upBtn.textContent = '↑';
264
+ upBtn.addEventListener('click', (e) => {
265
+ e.stopPropagation();
266
+ syncAllFieldsFromDOM();
267
+ [fields[idx - 1], fields[idx]] = [fields[idx], fields[idx - 1]];
268
+ renderFieldList($container);
269
+ });
270
+ controls.appendChild(upBtn);
271
+ }
272
+ if (idx < fields.length - 1) {
273
+ const downBtn = document.createElement('button');
274
+ downBtn.className = 'btn btn-xs btn-ghost';
275
+ downBtn.title = 'Move down';
276
+ downBtn.textContent = '↓';
277
+ downBtn.addEventListener('click', (e) => {
278
+ e.stopPropagation();
279
+ syncAllFieldsFromDOM();
280
+ [fields[idx], fields[idx + 1]] = [fields[idx + 1], fields[idx]];
281
+ renderFieldList($container);
282
+ });
283
+ controls.appendChild(downBtn);
284
+ }
285
+
286
+ const toggleBtn = document.createElement('button');
287
+ toggleBtn.className = 'btn btn-xs btn-ghost';
288
+ toggleBtn.title = 'Edit field';
289
+ toggleBtn.textContent = '⋯';
290
+ toggleBtn.addEventListener('click', (e) => {
291
+ e.stopPropagation();
292
+ body.style.display = body.style.display === 'none' ? '' : 'none';
293
+ });
294
+ controls.appendChild(toggleBtn);
295
+
296
+ const removeBtn = document.createElement('button');
297
+ removeBtn.className = 'btn btn-xs btn-danger';
298
+ removeBtn.title = 'Remove field';
299
+ removeBtn.textContent = '✕';
300
+ removeBtn.addEventListener('click', async (e) => {
301
+ e.stopPropagation();
302
+ const confirmed = await E.confirm('Remove this field?');
303
+ if (!confirmed) return;
304
+ syncAllFieldsFromDOM();
305
+ fields.splice(idx, 1);
306
+ renderFieldList($container);
307
+ });
308
+ controls.appendChild(removeBtn);
309
+
310
+ header.appendChild(typeBadge);
311
+ header.appendChild(labelSpan);
312
+ if (field.required) {
313
+ const req = document.createElement('span');
314
+ req.textContent = 'required';
315
+ req.style.cssText = 'font-size:.7rem;color:var(--danger,#ef4444);flex-shrink:0;';
316
+ header.appendChild(req);
317
+ }
318
+ header.appendChild(controls);
319
+
320
+ // Click header to toggle body
321
+ header.addEventListener('click', () => {
322
+ body.style.display = body.style.display === 'none' ? '' : 'none';
323
+ });
324
+
325
+ // --- Body (hidden by default, auto-expanded for new fields) ---
326
+ const body = buildFieldBody(field, idx, labelSpan);
327
+ body.style.display = 'none';
328
+
329
+ card.appendChild(header);
330
+ card.appendChild(body);
331
+ return card;
332
+ }
333
+
334
+ function buildFieldBody(field, idx, labelSpan) {
335
+ const body = document.createElement('div');
336
+ body.className = 'fb-field-body';
337
+ body.style.cssText = 'padding:.8rem;border-top:1px solid var(--border-color,#333);';
338
+
339
+ const row1 = buildRow([
340
+ buildInputGroup('Label', `fb-label-${idx}`, 'text', field.label || '', 'Shown above the field'),
341
+ buildInputGroup('Field Name', `fb-name-${idx}`, 'text', field.name || '', 'Used as data key')
342
+ ]);
343
+ const row2 = buildRow([
344
+ buildSelectGroup('Type', `fb-type-${idx}`, FIELD_TYPES, field.type || 'string'),
345
+ buildCheckboxGroup('Required', `fb-required-${idx}`, field.required || false)
346
+ ]);
347
+ const row3 = buildRow([
348
+ buildInputGroup('Placeholder', `fb-placeholder-${idx}`, 'text', field.placeholder || '', 'Hint text inside the field'),
349
+ buildInputGroup('Helper Text', `fb-helper-${idx}`, 'text', field.helper || '', 'Shown below the field')
350
+ ]);
351
+
352
+ body.appendChild(row1);
353
+ body.appendChild(row2);
354
+ body.appendChild(row3);
355
+
356
+ // Auto-update header label and field name as user types
357
+ const labelInp = body.querySelector(`#fb-label-${idx}`);
358
+ const nameInp = body.querySelector(`#fb-name-${idx}`);
359
+ if (labelInp) {
360
+ labelInp.addEventListener('input', () => {
361
+ if (labelSpan) labelSpan.textContent = labelInp.value || '(unlabelled)';
362
+ if (nameInp && !nameInp.dataset.manuallyEdited) {
363
+ nameInp.value = slugify(labelInp.value);
364
+ }
365
+ });
366
+ }
367
+ if (nameInp) {
368
+ nameInp.addEventListener('input', () => { nameInp.dataset.manuallyEdited = '1'; });
369
+ }
370
+
371
+ // Re-render body extras when type changes (options, rows, min/max)
372
+ const typeSelect = body.querySelector(`#fb-type-${idx}`);
373
+ if (typeSelect) {
374
+ typeSelect.addEventListener('change', () => {
375
+ // Update badge in header
376
+ const card = body.closest('.fb-field-card');
377
+ if (card) {
378
+ const badge = card.querySelector('span');
379
+ if (badge) badge.textContent = getTypeLabel(typeSelect.value);
380
+ }
381
+ // Rebuild extras section
382
+ const extrasEl = body.querySelector('.fb-field-extras');
383
+ if (extrasEl) extrasEl.remove();
384
+ const extras = buildFieldExtras(typeSelect.value, field, idx);
385
+ if (extras) body.appendChild(extras);
386
+ });
387
+ }
388
+
389
+ // Type-specific extras
390
+ const extras = buildFieldExtras(field.type, field, idx);
391
+ if (extras) body.appendChild(extras);
392
+
393
+ return body;
394
+ }
395
+
396
+ function buildFieldExtras(type, field, idx) {
397
+ const wrap = document.createElement('div');
398
+ wrap.className = 'fb-field-extras';
399
+
400
+ if (OPTION_TYPES.has(type)) {
401
+ wrap.appendChild(buildOptionsEditor(field.options || [], idx));
402
+ }
403
+ if (type === 'textarea') {
404
+ wrap.appendChild(buildRow([
405
+ buildInputGroup('Rows', `fb-rows-${idx}`, 'number', field.formConfig?.rows || 4, 'Height of textarea')
406
+ ]));
407
+ }
408
+ if (type === 'string' || type === 'textarea') {
409
+ wrap.appendChild(buildRow([
410
+ buildInputGroup('Min Length', `fb-minlength-${idx}`, 'number', field.minLength || '', ''),
411
+ buildInputGroup('Max Length', `fb-maxlength-${idx}`, 'number', field.maxLength || '', '')
412
+ ]));
413
+ }
414
+ if (type === 'number') {
415
+ wrap.appendChild(buildRow([
416
+ buildInputGroup('Min', `fb-min-${idx}`, 'number', field.min ?? '', ''),
417
+ buildInputGroup('Max', `fb-max-${idx}`, 'number', field.max ?? '', '')
418
+ ]));
419
+ }
420
+
421
+ return wrap.children.length ? wrap : null;
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // DOM builders
426
+ // ---------------------------------------------------------------------------
427
+
428
+ function buildRow(children) {
429
+ const row = document.createElement('div');
430
+ row.style.cssText = 'display:flex;gap:.75rem;margin-bottom:.6rem;';
431
+ children.forEach(c => { if (c) row.appendChild(c); });
432
+ return row;
433
+ }
434
+
435
+ function buildInputGroup(label, id, type, value, hint) {
436
+ const wrap = document.createElement('div');
437
+ wrap.style.flex = '1';
438
+
439
+ const lbl = document.createElement('label');
440
+ lbl.htmlFor = id;
441
+ lbl.className = 'form-label';
442
+ lbl.textContent = label;
443
+ lbl.style.fontSize = '.8rem';
444
+
445
+ const inp = document.createElement('input');
446
+ inp.id = id;
447
+ inp.type = type || 'text';
448
+ inp.className = 'form-input';
449
+ inp.value = value ?? '';
450
+
451
+ wrap.appendChild(lbl);
452
+ wrap.appendChild(inp);
453
+
454
+ if (hint) {
455
+ const h = document.createElement('p');
456
+ h.className = 'form-hint text-muted';
457
+ h.textContent = hint;
458
+ h.style.cssText = 'font-size:.73rem;margin-top:.2rem;';
459
+ wrap.appendChild(h);
460
+ }
461
+ return wrap;
462
+ }
463
+
464
+ function buildSelectGroup(label, id, options, selected) {
465
+ const wrap = document.createElement('div');
466
+ wrap.style.flex = '1';
467
+
468
+ const lbl = document.createElement('label');
469
+ lbl.htmlFor = id;
470
+ lbl.className = 'form-label';
471
+ lbl.textContent = label;
472
+ lbl.style.fontSize = '.8rem';
473
+
474
+ const sel = document.createElement('select');
475
+ sel.id = id;
476
+ sel.className = 'form-input';
477
+ options.forEach(opt => {
478
+ const o = document.createElement('option');
479
+ o.value = opt.value;
480
+ o.textContent = opt.label;
481
+ if (opt.value === selected) o.selected = true;
482
+ sel.appendChild(o);
483
+ });
484
+
485
+ wrap.appendChild(lbl);
486
+ wrap.appendChild(sel);
487
+ return wrap;
488
+ }
489
+
490
+ function buildCheckboxGroup(label, id, checked) {
491
+ const wrap = document.createElement('div');
492
+ wrap.style.cssText = 'flex:0;min-width:80px;display:flex;flex-direction:column;justify-content:flex-end;';
493
+
494
+ const lbl = document.createElement('label');
495
+ lbl.style.cssText = 'display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:.8rem;white-space:nowrap;';
496
+
497
+ const inp = document.createElement('input');
498
+ inp.id = id;
499
+ inp.type = 'checkbox';
500
+ inp.checked = checked;
501
+
502
+ lbl.appendChild(inp);
503
+ lbl.appendChild(document.createTextNode(label));
504
+ wrap.appendChild(lbl);
505
+ return wrap;
506
+ }
507
+
508
+ function buildOptionsEditor(options, idx) {
509
+ const wrap = document.createElement('div');
510
+ wrap.style.cssText = 'margin-top:.4rem;';
511
+
512
+ const title = document.createElement('p');
513
+ title.textContent = 'Options (one per line: value or value:Label)';
514
+ title.style.cssText = 'font-size:.8rem;font-weight:600;margin-bottom:.3rem;';
515
+
516
+ const ta = document.createElement('textarea');
517
+ ta.id = `fb-options-${idx}`;
518
+ ta.className = 'form-input';
519
+ ta.rows = 4;
520
+ ta.placeholder = 'yes:Yes\nno:No\nmaybe:Maybe';
521
+ ta.value = (options || []).map(o => o.value === o.label ? o.value : `${o.value}:${o.label}`).join('\n');
522
+ ta.style.fontFamily = 'monospace';
523
+
524
+ wrap.appendChild(title);
525
+ wrap.appendChild(ta);
526
+ return wrap;
527
+ }
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // Collect full form data
531
+ // ---------------------------------------------------------------------------
532
+
533
+ function collectFormData($container) {
534
+ syncAllFieldsFromDOM();
535
+ return {
536
+ title: $container.find('#field-title').val().trim(),
537
+ slug: $container.find('#field-slug').val().trim(),
538
+ description: $container.find('#field-description').val().trim(),
539
+ fields,
540
+ settings: {
541
+ submitText: $container.find('#setting-submit-text').val().trim() || 'Submit',
542
+ successMessage: $container.find('#setting-success-message').val().trim() || 'Thank you.',
543
+ layout: $container.find('#setting-layout').val() || 'stacked',
544
+ honeypot: $container.find('#setting-honeypot').prop('checked'),
545
+ rateLimitPerMinute: parseInt($container.find('#setting-rate-limit').val(), 10) || 3
546
+ },
547
+ actions: {
548
+ email: {
549
+ enabled: $container.find('#action-email-enabled').prop('checked'),
550
+ recipients: $container.find('#action-email-recipients').val().trim(),
551
+ subjectPrefix: $container.find('#action-email-subject-prefix').val().trim()
552
+ },
553
+ webhook: {
554
+ enabled: $container.find('#action-webhook-enabled').prop('checked'),
555
+ url: $container.find('#action-webhook-url').val().trim(),
556
+ method: $container.find('#action-webhook-method').val()
557
+ }
558
+ }
559
+ };
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Wizard preview helper
564
+ // ---------------------------------------------------------------------------
565
+
566
+ function buildWizardSteps(formFields) {
567
+ const steps = [];
568
+ let currentGroup = [];
569
+ let stepTitle = 'Step 1';
570
+ let stepDesc = '';
571
+
572
+ formFields.forEach(f => {
573
+ if (f.type === 'page-break') {
574
+ steps.push({ title: stepTitle, description: stepDesc, fields: currentGroup });
575
+ currentGroup = [];
576
+ stepTitle = f.label || `Step ${steps.length + 1}`;
577
+ stepDesc = f.description || '';
578
+ } else {
579
+ currentGroup.push(f);
580
+ }
581
+ });
582
+ if (currentGroup.length || steps.length === 0) {
583
+ steps.push({ title: stepTitle, description: stepDesc, fields: currentGroup });
584
+ }
585
+ return steps;
586
+ }
587
+
588
+ function buildBlueprintFromFields(fieldList) {
589
+ const blueprint = {};
590
+ fieldList.forEach(field => {
591
+ if (field.type === 'page-break') return;
592
+ blueprint[field.name] = {
593
+ type: field.type,
594
+ label: field.label,
595
+ required: field.required,
596
+ placeholder: field.placeholder,
597
+ helper: field.helper,
598
+ options: field.options,
599
+ ...(field.formConfig || {})
600
+ };
601
+ });
602
+ return blueprint;
603
+ }
604
+
605
+ // ---------------------------------------------------------------------------
606
+ // View
607
+ // ---------------------------------------------------------------------------
608
+
609
+ export const formEditorView = {
610
+ templateUrl: '/plugins/form-builder/admin/templates/form-editor.html',
611
+
612
+ async onMount($container) {
613
+ fields = [];
614
+ formSlug = null;
615
+
616
+ const hash = window.location.hash;
617
+ const editMatch = hash.match(/\/plugins\/form-builder\/edit\/([^/?#]+)/);
618
+ formSlug = editMatch ? editMatch[1] : null;
619
+
620
+ let form = null;
621
+ if (formSlug) {
622
+ try {
623
+ form = await apiRequest(`/plugins/form-builder/forms/${formSlug}`);
624
+ fields = form.fields || [];
625
+ } catch {
626
+ E.toast('Could not load form.', { type: 'error' });
627
+ }
628
+ }
629
+
630
+ if (form) {
631
+ $container.find('#editor-title').get(0).textContent = `Edit: ${form.title}`;
632
+ $container.find('#field-title').val(form.title);
633
+ $container.find('#field-slug').val(form.slug);
634
+ $container.find('#field-description').val(form.description || '');
635
+
636
+ const s = form.settings || {};
637
+ $container.find('#setting-submit-text').val(s.submitText || 'Submit');
638
+ $container.find('#setting-success-message').val(s.successMessage || '');
639
+ $container.find('#setting-layout').val(s.layout || 'stacked');
640
+ $container.find('#setting-honeypot').prop('checked', s.honeypot !== false);
641
+ $container.find('#setting-rate-limit').val(s.rateLimitPerMinute || 3);
642
+
643
+ const e = form.actions?.email || {};
644
+ $container.find('#action-email-enabled').prop('checked', e.enabled || false);
645
+ $container.find('#action-email-recipients').val(e.recipients || '');
646
+ $container.find('#action-email-subject-prefix').val(e.subjectPrefix || '');
647
+
648
+ const w = form.actions?.webhook || {};
649
+ $container.find('#action-webhook-enabled').prop('checked', w.enabled || false);
650
+ $container.find('#action-webhook-url').val(w.url || '');
651
+ $container.find('#action-webhook-method').val(w.method || 'POST');
652
+ } else {
653
+ $container.find('#editor-title').get(0).textContent = 'New Form';
654
+ }
655
+
656
+ // Auto-slug from title for new forms
657
+ if (!formSlug) {
658
+ $container.find('#field-title').get(0).addEventListener('input', function () {
659
+ $container.find('#field-slug').val(slugify(this.value));
660
+ });
661
+ }
662
+
663
+ renderFieldList($container);
664
+
665
+ // --- Add field ---
666
+ $container.find('#add-field-btn').get(0).addEventListener('click', () => {
667
+ syncAllFieldsFromDOM();
668
+ const newIdx = fields.length;
669
+ fields.push({
670
+ name: `field_${newIdx + 1}`,
671
+ type: 'string',
672
+ label: 'New Field',
673
+ required: false,
674
+ placeholder: ''
675
+ });
676
+ renderFieldList($container);
677
+ // Auto-expand the newly added field
678
+ const listEl = $container.find('#fields-list').get(0);
679
+ const newCard = listEl?.lastElementChild;
680
+ if (newCard) {
681
+ const body = newCard.querySelector('.fb-field-body');
682
+ if (body) body.style.display = '';
683
+ }
684
+ });
685
+
686
+ // --- Add page break ---
687
+ $container.find('#add-page-break-btn').get(0).addEventListener('click', () => {
688
+ syncAllFieldsFromDOM();
689
+ const stepNum = fields.filter(f => f.type === 'page-break').length + 2;
690
+ fields.push({
691
+ type: 'page-break',
692
+ label: `Step ${stepNum}`,
693
+ description: ''
694
+ });
695
+ renderFieldList($container);
696
+ // Auto-expand the newly added page break card
697
+ const listEl = $container.find('#fields-list').get(0);
698
+ const newCard = listEl?.lastElementChild;
699
+ if (newCard) {
700
+ const body = newCard.querySelector('.fb-field-body');
701
+ if (body) body.style.display = '';
702
+ }
703
+ });
704
+
705
+ // --- Save ---
706
+ $container.find('#save-form-btn').get(0).addEventListener('click', async () => {
707
+ const data = collectFormData($container);
708
+ if (!data.title) {
709
+ E.toast('Please enter a form title.', { type: 'error' });
710
+ return;
711
+ }
712
+ try {
713
+ if (formSlug) {
714
+ await apiRequest(`/plugins/form-builder/forms/${formSlug}`, {
715
+ method: 'PUT',
716
+ body: JSON.stringify(data)
717
+ });
718
+ E.toast('Form saved.', { type: 'success' });
719
+ } else {
720
+ const created = await apiRequest('/plugins/form-builder/forms', {
721
+ method: 'POST',
722
+ body: JSON.stringify({ title: data.title, slug: data.slug })
723
+ });
724
+ await apiRequest(`/plugins/form-builder/forms/${created.slug}`, {
725
+ method: 'PUT',
726
+ body: JSON.stringify({ ...data, slug: created.slug })
727
+ });
728
+ formSlug = created.slug;
729
+ R.navigate(`/plugins/form-builder/edit/${formSlug}`);
730
+ E.toast('Form created.', { type: 'success' });
731
+ }
732
+ } catch (err) {
733
+ E.toast(err.message || 'Failed to save form.', { type: 'error' });
734
+ }
735
+ });
736
+
737
+ // --- Preview / Test ---
738
+ $container.find('#preview-btn').get(0).addEventListener('click', () => {
739
+ const data = collectFormData($container);
740
+ const previewEl = $container.find('#preview-container').get(0);
741
+ if (!previewEl) return;
742
+
743
+ // Reset result area
744
+ const resultEl = $container.find('#preview-test-result').get(0);
745
+ const testBadge = $container.find('#preview-test-badge').get(0);
746
+ if (resultEl) {
747
+ resultEl.style.display = 'none';
748
+ resultEl.textContent = '';
749
+ }
750
+ if (testBadge) testBadge.style.display = formSlug ? '' : 'none';
751
+
752
+ $container.find('#preview-card').get(0).style.display = '';
753
+ previewEl.textContent = '';
754
+
755
+ const formEl = document.createElement('div');
756
+ formEl.id = 'fb-preview-form';
757
+ previewEl.appendChild(formEl);
758
+
759
+ // If saved: wire up real submit; otherwise no-op
760
+ const onSubmit = formSlug
761
+ ? async (formData) => {
762
+ if (resultEl) {
763
+ resultEl.style.display = 'none';
764
+ resultEl.textContent = '';
765
+ }
766
+ try {
767
+ const res = await fetch(`/api/plugins/form-builder/submit/${formSlug}`, {
768
+ method: 'POST',
769
+ headers: {'Content-Type': 'application/json'},
770
+ body: JSON.stringify(formData)
771
+ });
772
+ const json = await res.json();
773
+ if (!res.ok) throw new Error(json.error || 'Submission failed.');
774
+ if (resultEl) {
775
+ resultEl.textContent = json.message || data.settings?.successMessage || 'Submitted successfully.';
776
+ resultEl.style.cssText = 'display:block;margin-top:.75rem;padding:.6rem .9rem;border-radius:6px;font-size:.9rem;background:rgba(34,197,94,.12);color:var(--success,#22c55e);';
777
+ }
778
+ E.toast('Test submission stored.', {type: 'success'});
779
+ } catch (err) {
780
+ if (resultEl) {
781
+ resultEl.textContent = err.message;
782
+ resultEl.style.cssText = 'display:block;margin-top:.75rem;padding:.6rem .9rem;border-radius:6px;font-size:.9rem;background:rgba(239,68,68,.12);color:var(--danger,#ef4444);';
783
+ }
784
+ E.toast(err.message, {type: 'error'});
785
+ }
786
+ return false;
787
+ }
788
+ : () => false;
789
+
790
+ const hasPageBreaks = data.fields.some(f => f.type === 'page-break');
791
+
792
+ if (typeof F !== 'undefined') {
793
+ if (hasPageBreaks && F.wizard) {
794
+ const steps = buildWizardSteps(data.fields).map(step => ({
795
+ title: step.title,
796
+ description: step.description,
797
+ fields: buildBlueprintFromFields(step.fields)
798
+ }));
799
+ F.wizard('#fb-preview-form', {schema: {steps}, onSubmit});
800
+ } else if (F.render) {
801
+ const blueprint = buildBlueprintFromFields(data.fields);
802
+ F.render('#fb-preview-form', blueprint, {}, {
803
+ submitText: data.settings?.submitText || 'Submit',
804
+ onSubmit
805
+ });
806
+ }
807
+ } else {
808
+ const msg = document.createElement('p');
809
+ msg.textContent = `${data.fields.filter(f => f.type !== 'page-break').length} field(s): ${data.fields.filter(f => f.type !== 'page-break').map(f => f.label).join(', ')}`;
810
+ msg.style.cssText = 'color:var(--muted);font-style:italic;';
811
+ formEl.appendChild(msg);
812
+ }
813
+
814
+ $container.find('#preview-card').get(0).scrollIntoView({ behavior: 'smooth', block: 'start' });
815
+ });
816
+ }
817
+ };