domma-cms 0.1.0 → 0.2.1

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 (89) hide show
  1. package/README.md +2 -3
  2. package/admin/css/admin.css +78 -1
  3. package/admin/js/api.js +32 -0
  4. package/admin/js/app.js +24 -7
  5. package/admin/js/config/sidebar-config.js +8 -0
  6. package/admin/js/templates/collection-editor.html +80 -0
  7. package/admin/js/templates/collection-entries.html +36 -0
  8. package/admin/js/templates/collections.html +12 -0
  9. package/admin/js/templates/documentation.html +136 -0
  10. package/admin/js/templates/navigation.html +26 -4
  11. package/admin/js/templates/page-editor.html +91 -85
  12. package/admin/js/templates/settings.html +433 -172
  13. package/admin/js/views/collection-editor.js +487 -0
  14. package/admin/js/views/collection-entries.js +484 -0
  15. package/admin/js/views/collections.js +153 -0
  16. package/admin/js/views/dashboard.js +14 -6
  17. package/admin/js/views/index.js +9 -3
  18. package/admin/js/views/login.js +3 -2
  19. package/admin/js/views/navigation.js +77 -11
  20. package/admin/js/views/page-editor.js +207 -25
  21. package/admin/js/views/pages.js +14 -6
  22. package/admin/js/views/settings.js +137 -2
  23. package/admin/js/views/users.js +10 -7
  24. package/bin/cli.js +53 -17
  25. package/config/auth.json +2 -1
  26. package/config/content.json +1 -0
  27. package/config/navigation.json +14 -4
  28. package/config/plugins.json +0 -18
  29. package/config/presets.json +4 -8
  30. package/config/site.json +44 -3
  31. package/package.json +6 -2
  32. package/plugins/domma-effects/admin/templates/domma-effects.html +92 -3
  33. package/plugins/domma-effects/plugin.js +125 -0
  34. package/plugins/domma-effects/public/inject-body.html +19 -0
  35. package/plugins/example-analytics/admin/views/analytics.js +2 -2
  36. package/plugins/example-analytics/plugin.json +8 -0
  37. package/plugins/example-analytics/stats.json +15 -1
  38. package/plugins/form-builder/admin/templates/form-editor.html +19 -6
  39. package/plugins/form-builder/admin/views/form-editor.js +634 -9
  40. package/plugins/form-builder/admin/views/form-submissions.js +4 -4
  41. package/plugins/form-builder/admin/views/forms-list.js +5 -5
  42. package/plugins/form-builder/data/forms/consent.json +104 -0
  43. package/plugins/form-builder/data/forms/contacts.json +66 -0
  44. package/plugins/form-builder/data/submissions/consent.json +13 -0
  45. package/plugins/form-builder/data/submissions/contacts.json +26 -0
  46. package/plugins/form-builder/plugin.js +62 -11
  47. package/plugins/form-builder/plugin.json +12 -16
  48. package/plugins/form-builder/public/form-logic-engine.js +568 -0
  49. package/plugins/form-builder/public/inject-body.html +88 -6
  50. package/plugins/form-builder/public/inject-head.html +16 -0
  51. package/plugins/form-builder/public/package.json +1 -0
  52. package/public/css/site.css +113 -0
  53. package/public/js/btt.js +90 -0
  54. package/public/js/cookie-consent.js +61 -0
  55. package/public/js/site.js +129 -34
  56. package/scripts/build.js +129 -0
  57. package/scripts/seed.js +517 -7
  58. package/scripts/setup.js +12 -9
  59. package/server/routes/api/collections.js +301 -0
  60. package/server/routes/api/settings.js +66 -2
  61. package/server/server.js +19 -15
  62. package/server/services/collections.js +430 -0
  63. package/server/services/content.js +11 -2
  64. package/server/services/hooks.js +109 -0
  65. package/server/services/markdown.js +500 -149
  66. package/server/services/plugins.js +6 -1
  67. package/server/services/renderer.js +73 -7
  68. package/server/templates/page.html +38 -3
  69. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +0 -55
  70. package/plugins/back-to-top/admin/views/back-to-top-settings.js +0 -44
  71. package/plugins/back-to-top/config.js +0 -10
  72. package/plugins/back-to-top/plugin.js +0 -24
  73. package/plugins/back-to-top/plugin.json +0 -36
  74. package/plugins/back-to-top/public/inject-body.html +0 -105
  75. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +0 -113
  76. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +0 -73
  77. package/plugins/cookie-consent/config.js +0 -30
  78. package/plugins/cookie-consent/plugin.js +0 -24
  79. package/plugins/cookie-consent/plugin.json +0 -36
  80. package/plugins/cookie-consent/public/inject-body.html +0 -69
  81. package/plugins/custom-css/admin/templates/custom-css.html +0 -17
  82. package/plugins/custom-css/admin/views/custom-css.js +0 -35
  83. package/plugins/custom-css/config.js +0 -1
  84. package/plugins/custom-css/data/custom.css +0 -0
  85. package/plugins/custom-css/plugin.js +0 -63
  86. package/plugins/custom-css/plugin.json +0 -32
  87. package/plugins/custom-css/public/inject-head.html +0 -1
  88. package/plugins/form-builder/data/forms/contact.json +0 -52
  89. package/plugins/form-builder/data/submissions/contact.json +0 -14
@@ -0,0 +1,487 @@
1
+ /**
2
+ * Collection Editor View
3
+ * Create or edit a collection schema: Settings, Fields, and API Access tabs.
4
+ * Field editor mirrors the form-builder pattern (same DOM ID convention) but
5
+ * without page-break or spacer types — collections are pure data stores.
6
+ */
7
+ import { api } from '../api.js';
8
+
9
+ const FIELD_TYPES = [
10
+ { value: 'string', label: 'Text (single line)' },
11
+ { value: 'email', label: 'Email' },
12
+ { value: 'tel', label: 'Phone' },
13
+ { value: 'number', label: 'Number' },
14
+ { value: 'textarea', label: 'Textarea (multi-line)' },
15
+ { value: 'select', label: 'Dropdown (select)' },
16
+ { value: 'radio', label: 'Radio buttons' },
17
+ { value: 'checkbox', label: 'Single checkbox' },
18
+ { value: 'checkbox-group', label: 'Checkbox group' },
19
+ { value: 'date', label: 'Date' },
20
+ { value: 'time', label: 'Time' },
21
+ { value: 'url', label: 'URL' },
22
+ { value: 'hidden', label: 'Hidden field' }
23
+ ];
24
+
25
+ const OPTION_TYPES = new Set(['select', 'radio', 'checkbox-group']);
26
+ const ACCESS_LEVELS = ['public', 'subscriber', 'editor', 'manager', 'admin'];
27
+ const OPERATIONS = ['create', 'read', 'update', 'delete'];
28
+
29
+ let fields = [];
30
+ let collectionSlug = null;
31
+ let isNew = true;
32
+
33
+ function slugify(str) {
34
+ return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
35
+ }
36
+
37
+ function getTypeLabel(type) {
38
+ return FIELD_TYPES.find(t => t.value === type)?.label || type;
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Collect current field state from DOM
43
+ // ---------------------------------------------------------------------------
44
+
45
+ function collectFieldFromDOM(idx) {
46
+ const field = { ...fields[idx] };
47
+
48
+ const label = document.getElementById(`fb-label-${idx}`);
49
+ const name = document.getElementById(`fb-name-${idx}`);
50
+ const type = document.getElementById(`fb-type-${idx}`);
51
+ const required = document.getElementById(`fb-required-${idx}`);
52
+ const placeholder = document.getElementById(`fb-placeholder-${idx}`);
53
+ const helper = document.getElementById(`fb-helper-${idx}`);
54
+
55
+ if (label) field.label = label.value.trim() || field.label;
56
+ if (name) field.name = name.value.trim() || field.name;
57
+ if (type) field.type = type.value || field.type;
58
+ if (required) field.required = required.checked;
59
+ if (placeholder) field.placeholder = placeholder.value.trim();
60
+ if (helper) field.helper = helper.value.trim();
61
+
62
+ if (OPTION_TYPES.has(field.type)) {
63
+ const ta = document.getElementById(`fb-options-${idx}`);
64
+ if (ta) {
65
+ field.options = ta.value.split('\n').filter(l => l.trim()).map(line => {
66
+ const [v, ...rest] = line.split(':');
67
+ return { value: v.trim(), label: rest.join(':').trim() || v.trim() };
68
+ });
69
+ }
70
+ }
71
+
72
+ return field;
73
+ }
74
+
75
+ function collectAllFields() {
76
+ return fields.map((_, idx) => collectFieldFromDOM(idx));
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Build a field card DOM element
81
+ // ---------------------------------------------------------------------------
82
+
83
+ function buildFieldCard(field, idx) {
84
+ const card = document.createElement('div');
85
+ card.className = 'fb-field-card';
86
+ card.dataset.index = idx;
87
+ card.style.cssText = 'border:1px solid var(--border-color,#333);border-radius:8px;margin-bottom:.75rem;overflow:hidden;';
88
+
89
+ // Header
90
+ const header = document.createElement('div');
91
+ header.className = 'fb-field-header';
92
+ header.style.cssText = 'display:flex;align-items:center;gap:.5rem;padding:.6rem .75rem;background:var(--card-header-bg,rgba(255,255,255,.03));cursor:pointer;user-select:none;';
93
+
94
+ const grip = document.createElement('span');
95
+ grip.textContent = '⠿';
96
+ grip.style.cssText = 'cursor:grab;opacity:.4;font-size:1.1rem;';
97
+
98
+ const labelSpan = document.createElement('span');
99
+ labelSpan.className = 'fb-field-summary';
100
+ labelSpan.style.cssText = 'flex:1;font-weight:500;font-size:.9rem;';
101
+ labelSpan.textContent = field.label || '(Untitled field)';
102
+
103
+ const typeSpan = document.createElement('span');
104
+ typeSpan.style.cssText = 'font-size:.75rem;opacity:.5;';
105
+ typeSpan.textContent = getTypeLabel(field.type);
106
+
107
+ const chevron = document.createElement('span');
108
+ chevron.className = 'fb-field-chevron';
109
+ chevron.textContent = '▾';
110
+ chevron.style.cssText = 'opacity:.5;transition:transform .2s;';
111
+
112
+ const delBtn = document.createElement('button');
113
+ delBtn.type = 'button';
114
+ delBtn.textContent = '×';
115
+ delBtn.className = 'btn btn-sm';
116
+ delBtn.style.cssText = 'padding:.15rem .45rem;line-height:1;font-size:1rem;opacity:.6;';
117
+ delBtn.title = 'Remove field';
118
+ delBtn.addEventListener('click', (e) => {
119
+ e.stopPropagation();
120
+ fields.splice(idx, 1);
121
+ renderFields(document.getElementById('fields-list'));
122
+ });
123
+
124
+ header.appendChild(grip);
125
+ header.appendChild(labelSpan);
126
+ header.appendChild(typeSpan);
127
+ header.appendChild(chevron);
128
+ header.appendChild(delBtn);
129
+
130
+ // Body
131
+ const body = document.createElement('div');
132
+ body.className = 'fb-field-body';
133
+ body.style.cssText = 'padding:.75rem;display:none;';
134
+
135
+ // Row 1: Label + Name + Type
136
+ const row1 = document.createElement('div');
137
+ row1.style.cssText = 'display:grid;grid-template-columns:1fr 1fr 1fr;gap:.6rem;margin-bottom:.6rem;';
138
+
139
+ const labelWrap = document.createElement('div');
140
+ const labelLbl = document.createElement('label');
141
+ labelLbl.className = 'form-label';
142
+ labelLbl.textContent = 'Label';
143
+ const labelInput = document.createElement('input');
144
+ labelInput.id = `fb-label-${idx}`;
145
+ labelInput.type = 'text';
146
+ labelInput.className = 'form-input';
147
+ labelInput.value = field.label || '';
148
+ labelInput.addEventListener('input', () => {
149
+ labelSpan.textContent = labelInput.value.trim() || '(Untitled field)';
150
+ const nameEl = document.getElementById(`fb-name-${idx}`);
151
+ if (nameEl && !nameEl.dataset.manual) {
152
+ nameEl.value = slugify(labelInput.value).replace(/-/g, '_');
153
+ }
154
+ });
155
+ labelWrap.appendChild(labelLbl);
156
+ labelWrap.appendChild(labelInput);
157
+
158
+ const nameWrap = document.createElement('div');
159
+ const nameLbl = document.createElement('label');
160
+ nameLbl.className = 'form-label';
161
+ nameLbl.textContent = 'Name (key)';
162
+ const nameInput = document.createElement('input');
163
+ nameInput.id = `fb-name-${idx}`;
164
+ nameInput.type = 'text';
165
+ nameInput.className = 'form-input';
166
+ nameInput.value = field.name || '';
167
+ nameInput.addEventListener('input', () => { nameInput.dataset.manual = '1'; });
168
+ nameWrap.appendChild(nameLbl);
169
+ nameWrap.appendChild(nameInput);
170
+
171
+ const typeWrap = document.createElement('div');
172
+ const typeLbl = document.createElement('label');
173
+ typeLbl.className = 'form-label';
174
+ typeLbl.textContent = 'Type';
175
+ const typeSelect = document.createElement('select');
176
+ typeSelect.id = `fb-type-${idx}`;
177
+ typeSelect.className = 'form-input';
178
+ FIELD_TYPES.forEach(ft => {
179
+ const opt = document.createElement('option');
180
+ opt.value = ft.value;
181
+ opt.textContent = ft.label;
182
+ if (ft.value === field.type) opt.selected = true;
183
+ typeSelect.appendChild(opt);
184
+ });
185
+ typeSelect.addEventListener('change', () => {
186
+ typeSpan.textContent = getTypeLabel(typeSelect.value);
187
+ const optWrap = body.querySelector('.fb-options-wrap');
188
+ if (optWrap) optWrap.style.display = OPTION_TYPES.has(typeSelect.value) ? '' : 'none';
189
+ });
190
+ typeWrap.appendChild(typeLbl);
191
+ typeWrap.appendChild(typeSelect);
192
+
193
+ row1.appendChild(labelWrap);
194
+ row1.appendChild(nameWrap);
195
+ row1.appendChild(typeWrap);
196
+
197
+ // Row 2: Placeholder + Helper + Required
198
+ const row2 = document.createElement('div');
199
+ row2.style.cssText = 'display:grid;grid-template-columns:1fr 1fr auto;gap:.6rem;align-items:end;margin-bottom:.6rem;';
200
+
201
+ const phWrap = document.createElement('div');
202
+ const phLbl = document.createElement('label');
203
+ phLbl.className = 'form-label';
204
+ phLbl.textContent = 'Placeholder';
205
+ const phInput = document.createElement('input');
206
+ phInput.id = `fb-placeholder-${idx}`;
207
+ phInput.type = 'text';
208
+ phInput.className = 'form-input';
209
+ phInput.value = field.placeholder || '';
210
+ phWrap.appendChild(phLbl);
211
+ phWrap.appendChild(phInput);
212
+
213
+ const hlWrap = document.createElement('div');
214
+ const hlLbl = document.createElement('label');
215
+ hlLbl.className = 'form-label';
216
+ hlLbl.textContent = 'Helper text';
217
+ const hlInput = document.createElement('input');
218
+ hlInput.id = `fb-helper-${idx}`;
219
+ hlInput.type = 'text';
220
+ hlInput.className = 'form-input';
221
+ hlInput.value = field.helper || '';
222
+ hlWrap.appendChild(hlLbl);
223
+ hlWrap.appendChild(hlInput);
224
+
225
+ const reqWrap = document.createElement('div');
226
+ reqWrap.style.cssText = 'padding-bottom:.35rem;';
227
+ const reqLabel = document.createElement('label');
228
+ reqLabel.style.cssText = 'display:flex;align-items:center;gap:.4rem;cursor:pointer;white-space:nowrap;';
229
+ const reqCheck = document.createElement('input');
230
+ reqCheck.id = `fb-required-${idx}`;
231
+ reqCheck.type = 'checkbox';
232
+ reqCheck.checked = !!field.required;
233
+ reqLabel.appendChild(reqCheck);
234
+ reqLabel.appendChild(document.createTextNode('Required'));
235
+ reqWrap.appendChild(reqLabel);
236
+
237
+ row2.appendChild(phWrap);
238
+ row2.appendChild(hlWrap);
239
+ row2.appendChild(reqWrap);
240
+
241
+ // Options (only for select/radio/checkbox-group)
242
+ const optWrap = document.createElement('div');
243
+ optWrap.className = 'fb-options-wrap';
244
+ optWrap.style.display = OPTION_TYPES.has(field.type) ? '' : 'none';
245
+ const optLbl = document.createElement('label');
246
+ optLbl.className = 'form-label';
247
+ optLbl.textContent = 'Options (one per line: value: Label)';
248
+ const optTa = document.createElement('textarea');
249
+ optTa.id = `fb-options-${idx}`;
250
+ optTa.className = 'form-input';
251
+ optTa.rows = 4;
252
+ optTa.value = (field.options || []).map(o => `${o.value}: ${o.label}`).join('\n');
253
+ optWrap.appendChild(optLbl);
254
+ optWrap.appendChild(optTa);
255
+
256
+ body.appendChild(row1);
257
+ body.appendChild(row2);
258
+ body.appendChild(optWrap);
259
+
260
+ // Toggle expand/collapse
261
+ header.addEventListener('click', () => {
262
+ const expanded = body.style.display !== 'none';
263
+ body.style.display = expanded ? 'none' : '';
264
+ chevron.style.transform = expanded ? '' : 'rotate(180deg)';
265
+ });
266
+
267
+ card.appendChild(header);
268
+ card.appendChild(body);
269
+ return card;
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Render all fields
274
+ // ---------------------------------------------------------------------------
275
+
276
+ function renderFields(listEl) {
277
+ if (!listEl) return;
278
+ listEl.textContent = '';
279
+
280
+ if (fields.length === 0) {
281
+ const msg = document.createElement('p');
282
+ msg.className = 'text-muted';
283
+ msg.id = 'fields-empty-msg';
284
+ msg.style.cssText = 'text-align:center;padding:2rem 0;';
285
+ msg.textContent = 'No fields yet. Click "Add Field" to get started.';
286
+ listEl.appendChild(msg);
287
+ return;
288
+ }
289
+
290
+ fields.forEach((field, idx) => {
291
+ listEl.appendChild(buildFieldCard(field, idx));
292
+ });
293
+ }
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Build API Access rows
297
+ // ---------------------------------------------------------------------------
298
+
299
+ function buildApiAccessRows(apiConfig, containerEl) {
300
+ containerEl.textContent = '';
301
+
302
+ OPERATIONS.forEach(op => {
303
+ const access = apiConfig?.[op] || { enabled: false, access: 'admin' };
304
+
305
+ const row = document.createElement('div');
306
+ row.style.cssText = 'display:grid;grid-template-columns:140px 1fr 160px;gap:.75rem;align-items:center;padding:.6rem 0;border-bottom:1px solid var(--border-color,#333);';
307
+
308
+ const opLabel = document.createElement('strong');
309
+ opLabel.textContent = op.charAt(0).toUpperCase() + op.slice(1);
310
+ opLabel.style.cssText = 'font-size:.9rem;';
311
+
312
+ const toggleLabel = document.createElement('label');
313
+ toggleLabel.style.cssText = 'display:flex;align-items:center;gap:.45rem;cursor:pointer;font-size:.875rem;';
314
+ const toggleCheck = document.createElement('input');
315
+ toggleCheck.type = 'checkbox';
316
+ toggleCheck.id = `api-${op}-enabled`;
317
+ toggleCheck.checked = !!access.enabled;
318
+ toggleLabel.appendChild(toggleCheck);
319
+ toggleLabel.appendChild(document.createTextNode('Enable public access'));
320
+
321
+ const accessSelect = document.createElement('select');
322
+ accessSelect.id = `api-${op}-access`;
323
+ accessSelect.className = 'form-input';
324
+ ACCESS_LEVELS.forEach(level => {
325
+ const opt = document.createElement('option');
326
+ opt.value = level;
327
+ opt.textContent = level.charAt(0).toUpperCase() + level.slice(1);
328
+ if (level === access.access) opt.selected = true;
329
+ accessSelect.appendChild(opt);
330
+ });
331
+
332
+ row.appendChild(opLabel);
333
+ row.appendChild(toggleLabel);
334
+ row.appendChild(accessSelect);
335
+ containerEl.appendChild(row);
336
+ });
337
+ }
338
+
339
+ function collectApiAccess() {
340
+ const result = {};
341
+ OPERATIONS.forEach(op => {
342
+ const enabled = document.getElementById(`api-${op}-enabled`)?.checked ?? false;
343
+ const access = document.getElementById(`api-${op}-access`)?.value || 'admin';
344
+ result[op] = { enabled, access };
345
+ });
346
+ return result;
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // View export
351
+ // ---------------------------------------------------------------------------
352
+
353
+ export const collectionEditorView = {
354
+ templateUrl: '/admin/js/templates/collection-editor.html',
355
+
356
+ async onMount($container) {
357
+ fields = [];
358
+ collectionSlug = null;
359
+ isNew = true;
360
+
361
+ // Detect edit mode from URL
362
+ const hash = window.location.hash;
363
+ const match = hash.match(/\/collections\/edit\/([^/?#]+)/);
364
+ if (match) {
365
+ collectionSlug = match[1];
366
+ isNew = false;
367
+ }
368
+
369
+ E.tabs($container.find('#collection-tabs').get(0));
370
+
371
+ const listEl = $container.find('#fields-list').get(0);
372
+ const apiContainer = $container.find('#api-access-rows').get(0);
373
+
374
+ let apiConfig = {
375
+ create: { enabled: false, access: 'admin' },
376
+ read: { enabled: true, access: 'public' },
377
+ update: { enabled: false, access: 'admin' },
378
+ delete: { enabled: false, access: 'admin' }
379
+ };
380
+
381
+ if (!isNew) {
382
+ try {
383
+ const schema = await api.collections.get(collectionSlug);
384
+ if (!schema) {
385
+ E.toast('Collection not found.', { type: 'error' });
386
+ R.navigate('/collections');
387
+ return;
388
+ }
389
+
390
+ const titleText = $container.find('#editor-title-text').get(0);
391
+ if (titleText) titleText.textContent = schema.title;
392
+
393
+ $container.find('#field-title').val(schema.title || '');
394
+ $container.find('#field-slug').val(schema.slug || '');
395
+ $container.find('#field-slug').prop('readonly', true);
396
+ $container.find('#slug-hint').get(0).textContent = 'Slug cannot be changed after creation.';
397
+ $container.find('#field-description').val(schema.description || '');
398
+
399
+ fields = schema.fields || [];
400
+ apiConfig = schema.api || apiConfig;
401
+ } catch {
402
+ E.toast('Failed to load collection.', { type: 'error' });
403
+ R.navigate('/collections');
404
+ return;
405
+ }
406
+ } else {
407
+ // Auto-populate slug from title input
408
+ const titleInput = $container.find('#field-title').get(0);
409
+ const slugInput = $container.find('#field-slug').get(0);
410
+ if (titleInput && slugInput) {
411
+ titleInput.addEventListener('input', () => {
412
+ if (!slugInput.dataset.manual) {
413
+ slugInput.value = slugify(titleInput.value);
414
+ }
415
+ });
416
+ slugInput.addEventListener('input', () => { slugInput.dataset.manual = '1'; });
417
+ }
418
+ }
419
+
420
+ renderFields(listEl);
421
+ buildApiAccessRows(apiConfig, apiContainer);
422
+
423
+ // Add field button
424
+ $container.find('#add-field-btn').off('click').on('click', () => {
425
+ fields = collectAllFields();
426
+ fields.push({
427
+ id: `field-${Date.now()}`,
428
+ name: '',
429
+ label: '',
430
+ type: 'string',
431
+ required: false,
432
+ placeholder: '',
433
+ helper: '',
434
+ options: [],
435
+ validation: [],
436
+ logic: null
437
+ });
438
+ renderFields(listEl);
439
+ // Auto-expand and focus the new card
440
+ const cards = listEl.querySelectorAll('.fb-field-card');
441
+ if (cards.length) {
442
+ const last = cards[cards.length - 1];
443
+ const body = last.querySelector('.fb-field-body');
444
+ const chev = last.querySelector('.fb-field-chevron');
445
+ if (body) body.style.display = '';
446
+ if (chev) chev.style.transform = 'rotate(180deg)';
447
+ last.querySelector(`#fb-label-${fields.length - 1}`)?.focus();
448
+ }
449
+ });
450
+
451
+ // Save button
452
+ $container.find('#save-collection-btn').off('click').on('click', async () => {
453
+ const title = $container.find('#field-title').val().trim();
454
+ const slug = $container.find('#field-slug').val().trim();
455
+ const description = $container.find('#field-description').val().trim();
456
+
457
+ if (!title) {
458
+ E.toast('Title is required.', { type: 'warning' });
459
+ return;
460
+ }
461
+
462
+ const finalFields = collectAllFields();
463
+ const finalApi = collectApiAccess();
464
+
465
+ const $btn = $container.find('#save-collection-btn');
466
+ $btn.prop('disabled', true);
467
+ try {
468
+ if (isNew) {
469
+ const created = await api.collections.create({ title, slug, description, fields: finalFields, api: finalApi });
470
+ collectionSlug = created.slug;
471
+ isNew = false;
472
+ E.toast('Collection created.', { type: 'success' });
473
+ R.navigate(`/collections/edit/${created.slug}`);
474
+ } else {
475
+ await api.collections.update(collectionSlug, { title, description, fields: finalFields, api: finalApi });
476
+ E.toast('Collection saved.', { type: 'success' });
477
+ }
478
+ } catch (err) {
479
+ E.toast(err.message || 'Failed to save.', { type: 'error' });
480
+ } finally {
481
+ $btn.prop('disabled', false);
482
+ }
483
+ });
484
+
485
+ Domma.icons.scan();
486
+ }
487
+ };