domma-cms 0.1.0 → 0.2.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 (87) hide show
  1. package/admin/css/admin.css +78 -1
  2. package/admin/js/api.js +32 -0
  3. package/admin/js/app.js +24 -7
  4. package/admin/js/config/sidebar-config.js +8 -0
  5. package/admin/js/templates/collection-editor.html +80 -0
  6. package/admin/js/templates/collection-entries.html +36 -0
  7. package/admin/js/templates/collections.html +12 -0
  8. package/admin/js/templates/documentation.html +136 -0
  9. package/admin/js/templates/navigation.html +26 -4
  10. package/admin/js/templates/page-editor.html +91 -85
  11. package/admin/js/templates/settings.html +433 -172
  12. package/admin/js/views/collection-editor.js +487 -0
  13. package/admin/js/views/collection-entries.js +484 -0
  14. package/admin/js/views/collections.js +153 -0
  15. package/admin/js/views/dashboard.js +14 -6
  16. package/admin/js/views/index.js +9 -3
  17. package/admin/js/views/login.js +3 -2
  18. package/admin/js/views/navigation.js +77 -11
  19. package/admin/js/views/page-editor.js +207 -25
  20. package/admin/js/views/pages.js +14 -6
  21. package/admin/js/views/settings.js +137 -2
  22. package/admin/js/views/users.js +10 -7
  23. package/bin/cli.js +37 -10
  24. package/config/auth.json +2 -1
  25. package/config/content.json +1 -0
  26. package/config/navigation.json +14 -4
  27. package/config/plugins.json +0 -18
  28. package/config/presets.json +4 -8
  29. package/config/site.json +44 -3
  30. package/package.json +6 -2
  31. package/plugins/domma-effects/admin/templates/domma-effects.html +92 -3
  32. package/plugins/domma-effects/plugin.js +125 -0
  33. package/plugins/domma-effects/public/inject-body.html +19 -0
  34. package/plugins/example-analytics/admin/views/analytics.js +2 -2
  35. package/plugins/example-analytics/plugin.json +8 -0
  36. package/plugins/example-analytics/stats.json +15 -1
  37. package/plugins/form-builder/admin/templates/form-editor.html +19 -6
  38. package/plugins/form-builder/admin/views/form-editor.js +634 -9
  39. package/plugins/form-builder/admin/views/form-submissions.js +4 -4
  40. package/plugins/form-builder/admin/views/forms-list.js +5 -5
  41. package/plugins/form-builder/data/forms/consent.json +104 -0
  42. package/plugins/form-builder/data/forms/contacts.json +66 -0
  43. package/plugins/form-builder/data/submissions/consent.json +13 -0
  44. package/plugins/form-builder/data/submissions/contacts.json +26 -0
  45. package/plugins/form-builder/plugin.js +62 -11
  46. package/plugins/form-builder/plugin.json +12 -16
  47. package/plugins/form-builder/public/form-logic-engine.js +568 -0
  48. package/plugins/form-builder/public/inject-body.html +88 -6
  49. package/plugins/form-builder/public/inject-head.html +16 -0
  50. package/plugins/form-builder/public/package.json +1 -0
  51. package/public/css/site.css +113 -0
  52. package/public/js/btt.js +90 -0
  53. package/public/js/cookie-consent.js +61 -0
  54. package/public/js/site.js +129 -34
  55. package/scripts/build.js +129 -0
  56. package/scripts/seed.js +517 -7
  57. package/server/routes/api/collections.js +301 -0
  58. package/server/routes/api/settings.js +66 -2
  59. package/server/server.js +19 -15
  60. package/server/services/collections.js +430 -0
  61. package/server/services/content.js +11 -2
  62. package/server/services/hooks.js +109 -0
  63. package/server/services/markdown.js +500 -149
  64. package/server/services/plugins.js +6 -1
  65. package/server/services/renderer.js +73 -7
  66. package/server/templates/page.html +38 -3
  67. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +0 -55
  68. package/plugins/back-to-top/admin/views/back-to-top-settings.js +0 -44
  69. package/plugins/back-to-top/config.js +0 -10
  70. package/plugins/back-to-top/plugin.js +0 -24
  71. package/plugins/back-to-top/plugin.json +0 -36
  72. package/plugins/back-to-top/public/inject-body.html +0 -105
  73. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +0 -113
  74. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +0 -73
  75. package/plugins/cookie-consent/config.js +0 -30
  76. package/plugins/cookie-consent/plugin.js +0 -24
  77. package/plugins/cookie-consent/plugin.json +0 -36
  78. package/plugins/cookie-consent/public/inject-body.html +0 -69
  79. package/plugins/custom-css/admin/templates/custom-css.html +0 -17
  80. package/plugins/custom-css/admin/views/custom-css.js +0 -35
  81. package/plugins/custom-css/config.js +0 -1
  82. package/plugins/custom-css/data/custom.css +0 -0
  83. package/plugins/custom-css/plugin.js +0 -63
  84. package/plugins/custom-css/plugin.json +0 -32
  85. package/plugins/custom-css/public/inject-head.html +0 -1
  86. package/plugins/form-builder/data/forms/contact.json +0 -52
  87. package/plugins/form-builder/data/submissions/contact.json +0 -14
@@ -0,0 +1,484 @@
1
+ /**
2
+ * Collection Entries View
3
+ * Browse, search, add, edit, delete, import, and export entries.
4
+ * Dynamic table columns are built from the collection schema fields.
5
+ */
6
+ import {api} from '../api.js';
7
+
8
+ let currentSlug = null;
9
+ let currentSchema = null;
10
+ let allEntries = [];
11
+ let currentPage = 1;
12
+ const PAGE_SIZE = 50;
13
+
14
+ export const collectionEntriesView = {
15
+ templateUrl: '/admin/js/templates/collection-entries.html',
16
+
17
+ async onMount($container) {
18
+ allEntries = [];
19
+ currentPage = 1;
20
+ currentSchema = null;
21
+
22
+ // Parse slug from hash
23
+ const hash = window.location.hash;
24
+ const match = hash.match(/\/collections\/([^/?#]+)\/entries/);
25
+ currentSlug = match ? match[1] : null;
26
+
27
+ if (!currentSlug) {
28
+ E.toast('No collection selected.', { type: 'error' });
29
+ return;
30
+ }
31
+
32
+ // Load schema
33
+ try {
34
+ currentSchema = await api.collections.get(currentSlug);
35
+ if (!currentSchema) {
36
+ E.toast('Collection not found.', { type: 'error' });
37
+ R.navigate('/collections');
38
+ return;
39
+ }
40
+ const titleEl = $container.find('#entries-title').get(0);
41
+ if (titleEl) titleEl.textContent = currentSchema.title + ' — Entries';
42
+
43
+ const editBtn = $container.find('#edit-schema-btn').get(0);
44
+ if (editBtn) editBtn.setAttribute('href', `#/collections/edit/${currentSlug}`);
45
+ } catch {
46
+ E.toast('Failed to load collection schema.', { type: 'error' });
47
+ return;
48
+ }
49
+
50
+ await loadEntries($container);
51
+
52
+ // Search
53
+ const searchEl = $container.find('#entry-search').get(0);
54
+ if (searchEl) {
55
+ searchEl.addEventListener('input', () => {
56
+ const term = searchEl.value.toLowerCase().trim();
57
+ const filtered = term
58
+ ? allEntries.filter(e => Object.values(e.data || {}).some(v => String(v).toLowerCase().includes(term)))
59
+ : allEntries;
60
+ renderTable(filtered, $container);
61
+ });
62
+ }
63
+
64
+ // Add Entry
65
+ $container.find('#add-entry-btn').off('click').on('click', () => {
66
+ showEntryModal(null, $container);
67
+ });
68
+
69
+ // Export
70
+ $container.find('#export-btn').off('click').on('click', () => {
71
+ const so = E.slideover({ title: 'Export Entries', size: 'sm', position: 'right' });
72
+
73
+ const panel = document.createElement('div');
74
+ panel.style.cssText = 'padding:1.25rem;display:flex;flex-direction:column;gap:1rem;';
75
+
76
+ ['json', 'csv'].forEach(fmt => {
77
+ const btn = document.createElement('button');
78
+ btn.className = 'btn btn-ghost';
79
+ btn.style.cssText = 'justify-content:flex-start;gap:.5rem;';
80
+ btn.textContent = fmt === 'json' ? 'Export as JSON' : 'Export as CSV';
81
+ btn.addEventListener('click', () => {
82
+ fetch(`/api/collections/${currentSlug}/export?format=${fmt}`, {
83
+ headers: { Authorization: `Bearer ${S.get('auth_token') || ''}` }
84
+ })
85
+ .then(r => r.blob())
86
+ .then(blob => {
87
+ const a = document.createElement('a');
88
+ a.href = URL.createObjectURL(blob);
89
+ a.download = `${currentSlug}-entries.${fmt}`;
90
+ document.body.appendChild(a);
91
+ a.click();
92
+ document.body.removeChild(a);
93
+ URL.revokeObjectURL(a.href);
94
+ so.close();
95
+ })
96
+ .catch(() => E.toast('Export failed.', { type: 'error' }));
97
+ });
98
+ panel.appendChild(btn);
99
+ });
100
+
101
+ so.setContent(panel);
102
+ so.open();
103
+ });
104
+
105
+ // Import
106
+ $container.find('#import-btn').off('click').on('click', () => {
107
+ showImportModal($container);
108
+ });
109
+
110
+ // Clear all
111
+ $container.find('#clear-all-btn').off('click').on('click', async () => {
112
+ const confirmed = await E.confirm('Delete ALL entries? This cannot be undone.');
113
+ if (!confirmed) return;
114
+ try {
115
+ await api.collections.clearEntries(currentSlug);
116
+ allEntries = [];
117
+ E.toast('All entries cleared.', { type: 'success' });
118
+ renderTable([], $container);
119
+ } catch {
120
+ E.toast('Failed to clear entries.', { type: 'error' });
121
+ }
122
+ });
123
+
124
+ Domma.icons.scan();
125
+ }
126
+ };
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Load entries from API
130
+ // ---------------------------------------------------------------------------
131
+
132
+ async function loadEntries($container) {
133
+ try {
134
+ const result = await api.collections.listEntries(currentSlug, { limit: 500 });
135
+ allEntries = result.entries || result || [];
136
+ } catch {
137
+ allEntries = [];
138
+ E.toast('Could not load entries.', { type: 'error' });
139
+ }
140
+ renderTable(allEntries, $container);
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Render table
145
+ // ---------------------------------------------------------------------------
146
+
147
+ function renderTable(entries, $container) {
148
+ const countEl = $container.find('#entry-count').get(0);
149
+ if (countEl) {
150
+ countEl.textContent = entries.length === allEntries.length
151
+ ? `${allEntries.length} entr${allEntries.length !== 1 ? 'ies' : 'y'}`
152
+ : `Showing ${entries.length} of ${allEntries.length}`;
153
+ }
154
+
155
+ const displayFields = (currentSchema?.fields || []).slice(0, 5); // first 5 fields as columns
156
+
157
+ const columns = [
158
+ ...displayFields.map(field => ({
159
+ key: `data.${field.name}`,
160
+ title: field.label || field.name,
161
+ render: (val, row) => {
162
+ const raw = row.data?.[field.name] ?? '';
163
+ const str = String(raw);
164
+ const span = document.createElement('span');
165
+ span.title = str;
166
+ span.textContent = str.length > 60 ? str.slice(0, 60) + '\u2026' : str;
167
+ return span.outerHTML;
168
+ }
169
+ })),
170
+ {
171
+ key: 'meta',
172
+ title: 'Created',
173
+ render: (val) => D(val?.createdAt).format('DD MMM YYYY HH:mm')
174
+ },
175
+ {
176
+ key: 'id',
177
+ title: '',
178
+ render: (val) => {
179
+ const wrap = document.createElement('div');
180
+ wrap.style.cssText = 'display:flex;gap:.3rem;justify-content:flex-end;';
181
+
182
+ const editBtn = document.createElement('button');
183
+ editBtn.className = 'btn btn-sm btn-ghost js-edit-entry';
184
+ editBtn.dataset.id = val;
185
+ editBtn.textContent = 'Edit';
186
+
187
+ const delBtn = document.createElement('button');
188
+ delBtn.className = 'btn btn-sm btn-danger js-delete-entry';
189
+ delBtn.dataset.id = val;
190
+ delBtn.textContent = 'Delete';
191
+ delBtn.style.whiteSpace = 'nowrap';
192
+
193
+ wrap.appendChild(editBtn);
194
+ wrap.appendChild(delBtn);
195
+ return wrap.outerHTML;
196
+ }
197
+ }
198
+ ];
199
+
200
+ T.create('#entries-table', {
201
+ data: entries,
202
+ columns,
203
+ emptyMessage: 'No entries yet. Click "Add Entry" to get started.'
204
+ });
205
+
206
+ // Bind buttons directly (namespaced delegation is broken in Domma)
207
+ const tableEl = document.querySelector('#entries-table');
208
+ if (!tableEl) return;
209
+
210
+ tableEl.querySelectorAll('.js-edit-entry').forEach(btn => {
211
+ btn.addEventListener('click', (e) => {
212
+ e.stopPropagation();
213
+ const entry = allEntries.find(en => en.id === btn.dataset.id);
214
+ if (entry) showEntryModal(entry, null);
215
+ });
216
+ });
217
+
218
+ tableEl.querySelectorAll('.js-delete-entry').forEach(btn => {
219
+ btn.addEventListener('click', async (e) => {
220
+ e.stopPropagation();
221
+ const confirmed = await E.confirm('Delete this entry?');
222
+ if (!confirmed) return;
223
+ try {
224
+ await api.collections.deleteEntry(currentSlug, btn.dataset.id);
225
+ allEntries = allEntries.filter(en => en.id !== btn.dataset.id);
226
+ E.toast('Entry deleted.', { type: 'success' });
227
+ renderTable(allEntries, { find: sel => ({ get: n => document.querySelector(sel) }) });
228
+ } catch {
229
+ E.toast('Failed to delete entry.', { type: 'error' });
230
+ }
231
+ });
232
+ });
233
+
234
+ // Row click → detail modal (skip action buttons)
235
+ tableEl.querySelectorAll('tbody tr').forEach((row, idx) => {
236
+ const entry = entries[idx];
237
+ if (!entry) return;
238
+ row.style.cursor = 'pointer';
239
+ row.addEventListener('click', (e) => {
240
+ if (e.target.closest('.js-edit-entry, .js-delete-entry')) return;
241
+ showDetailModal(entry);
242
+ });
243
+ });
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Entry modal (add / edit)
248
+ // ---------------------------------------------------------------------------
249
+
250
+ function showEntryModal(existingEntry, $container) {
251
+ const isEdit = !!existingEntry;
252
+ const modal = E.modal({ title: isEdit ? 'Edit Entry' : 'Add Entry', size: 'md' });
253
+
254
+ const wrapper = document.createElement('div');
255
+ wrapper.style.cssText = 'padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.75rem;';
256
+
257
+ const fields = currentSchema?.fields || [];
258
+ const inputs = {};
259
+
260
+ fields.forEach(field => {
261
+ const group = document.createElement('div');
262
+
263
+ const lbl = document.createElement('label');
264
+ lbl.className = 'form-label';
265
+ lbl.textContent = field.label || field.name;
266
+ if (field.required) {
267
+ const req = document.createElement('span');
268
+ req.textContent = ' *';
269
+ req.style.cssText = 'color:var(--danger,#f87171);';
270
+ lbl.appendChild(req);
271
+ }
272
+ group.appendChild(lbl);
273
+
274
+ let input;
275
+ if (field.type === 'textarea') {
276
+ input = document.createElement('textarea');
277
+ input.rows = 3;
278
+ input.value = existingEntry?.data?.[field.name] ?? '';
279
+ } else if (['select', 'radio', 'checkbox-group'].includes(field.type)) {
280
+ input = document.createElement('select');
281
+ const blank = document.createElement('option');
282
+ blank.value = '';
283
+ blank.textContent = '— select —';
284
+ input.appendChild(blank);
285
+ (field.options || []).forEach(opt => {
286
+ const o = document.createElement('option');
287
+ o.value = opt.value;
288
+ o.textContent = opt.label;
289
+ if (opt.value === (existingEntry?.data?.[field.name] ?? '')) o.selected = true;
290
+ input.appendChild(o);
291
+ });
292
+ } else if (field.type === 'checkbox') {
293
+ const checkLabel = document.createElement('label');
294
+ checkLabel.style.cssText = 'display:flex;align-items:center;gap:.4rem;cursor:pointer;';
295
+ input = document.createElement('input');
296
+ input.type = 'checkbox';
297
+ input.checked = !!(existingEntry?.data?.[field.name]);
298
+ checkLabel.appendChild(input);
299
+ checkLabel.appendChild(document.createTextNode(field.placeholder || field.label || field.name));
300
+ group.appendChild(checkLabel);
301
+ inputs[field.name] = input;
302
+ wrapper.appendChild(group);
303
+ return;
304
+ } else {
305
+ input = document.createElement('input');
306
+ input.type = field.type === 'email' ? 'email' : field.type === 'number' ? 'number' : 'text';
307
+ input.value = existingEntry?.data?.[field.name] ?? '';
308
+ if (field.placeholder) input.placeholder = field.placeholder;
309
+ }
310
+
311
+ input.className = 'form-input';
312
+ group.appendChild(input);
313
+ inputs[field.name] = input;
314
+ wrapper.appendChild(group);
315
+ });
316
+
317
+ const btnWrap = document.createElement('div');
318
+ btnWrap.style.cssText = 'display:flex;justify-content:flex-end;gap:.5rem;margin-top:.5rem;';
319
+ const cancelBtn = document.createElement('button');
320
+ cancelBtn.className = 'btn btn-ghost';
321
+ cancelBtn.textContent = 'Cancel';
322
+ const saveBtn = document.createElement('button');
323
+ saveBtn.className = 'btn btn-primary';
324
+ saveBtn.textContent = isEdit ? 'Save Changes' : 'Add Entry';
325
+ btnWrap.appendChild(cancelBtn);
326
+ btnWrap.appendChild(saveBtn);
327
+ wrapper.appendChild(btnWrap);
328
+
329
+ modal.element.appendChild(wrapper);
330
+ modal.open();
331
+
332
+ cancelBtn.addEventListener('click', () => modal.close());
333
+ saveBtn.addEventListener('click', async () => {
334
+ const data = {};
335
+ fields.forEach(field => {
336
+ const el = inputs[field.name];
337
+ if (!el) return;
338
+ data[field.name] = field.type === 'checkbox' ? el.checked : el.value;
339
+ });
340
+
341
+ saveBtn.disabled = true;
342
+ try {
343
+ if (isEdit) {
344
+ const updated = await api.collections.updateEntry(currentSlug, existingEntry.id, data);
345
+ const idx = allEntries.findIndex(e => e.id === existingEntry.id);
346
+ if (idx !== -1) allEntries[idx] = updated;
347
+ E.toast('Entry updated.', { type: 'success' });
348
+ } else {
349
+ const created = await api.collections.createEntry(currentSlug, data);
350
+ allEntries.unshift(created);
351
+ E.toast('Entry added.', { type: 'success' });
352
+ }
353
+ modal.close();
354
+ renderTable(allEntries, $container || { find: () => ({ get: () => null }) });
355
+ } catch (err) {
356
+ E.toast(err.message || 'Failed to save entry.', { type: 'error' });
357
+ saveBtn.disabled = false;
358
+ }
359
+ });
360
+ }
361
+
362
+ // ---------------------------------------------------------------------------
363
+ // Detail modal (read-only)
364
+ // ---------------------------------------------------------------------------
365
+
366
+ function showDetailModal(entry) {
367
+ const content = document.createElement('div');
368
+ const fields = currentSchema?.fields || [];
369
+
370
+ const fieldsSection = document.createElement('div');
371
+ fieldsSection.style.cssText = 'display:flex;flex-direction:column;gap:.75rem;margin-bottom:1.25rem;';
372
+
373
+ fields.forEach(field => {
374
+ const val = entry.data?.[field.name];
375
+ if (val === undefined || val === null || val === '') return;
376
+
377
+ const row = document.createElement('div');
378
+ row.style.cssText = 'border-bottom:1px solid var(--border-color,#333);padding-bottom:.6rem;';
379
+
380
+ const lbl = document.createElement('strong');
381
+ lbl.textContent = field.label || field.name;
382
+ lbl.style.cssText = 'display:block;font-size:.8rem;color:var(--text-muted,#888);margin-bottom:.2rem;';
383
+
384
+ const valEl = document.createElement('p');
385
+ valEl.textContent = String(val);
386
+ valEl.style.cssText = 'margin:0;word-break:break-word;';
387
+
388
+ row.appendChild(lbl);
389
+ row.appendChild(valEl);
390
+ fieldsSection.appendChild(row);
391
+ });
392
+
393
+ const meta = document.createElement('div');
394
+ meta.style.cssText = 'font-size:.8rem;color:var(--text-muted,#888);display:flex;flex-direction:column;gap:.3rem;border-top:1px solid var(--border-color,#333);padding-top:.75rem;';
395
+
396
+ if (entry.meta?.createdAt) {
397
+ const dateRow = document.createElement('span');
398
+ dateRow.textContent = `Created: ${D(entry.meta.createdAt).format('DD MMM YYYY HH:mm')}`;
399
+ meta.appendChild(dateRow);
400
+ }
401
+ if (entry.meta?.source) {
402
+ const srcRow = document.createElement('span');
403
+ srcRow.textContent = `Source: ${entry.meta.source}`;
404
+ meta.appendChild(srcRow);
405
+ }
406
+
407
+ content.appendChild(fieldsSection);
408
+ content.appendChild(meta);
409
+
410
+ const detailModal = E.modal({ title: 'Entry Details', size: 'md' });
411
+ detailModal.element.appendChild(content);
412
+ detailModal.open();
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Import modal
417
+ // ---------------------------------------------------------------------------
418
+
419
+ function showImportModal($container) {
420
+ const modal = E.modal({ title: 'Import Entries', size: 'md' });
421
+
422
+ const wrapper = document.createElement('div');
423
+ wrapper.style.cssText = 'padding:.25rem 0 .5rem;display:flex;flex-direction:column;gap:.75rem;';
424
+
425
+ const hint = document.createElement('p');
426
+ hint.textContent = 'Paste a JSON array of entries. Each item should have a "data" object with field values.';
427
+ hint.style.cssText = 'font-size:.875rem;color:var(--text-muted,#888);margin:0;';
428
+ wrapper.appendChild(hint);
429
+
430
+ const ta = document.createElement('textarea');
431
+ ta.className = 'form-input';
432
+ ta.rows = 10;
433
+ ta.placeholder = '[{"data": {"name": "Example"}}, ...]';
434
+ wrapper.appendChild(ta);
435
+
436
+ const resultEl = document.createElement('p');
437
+ resultEl.style.cssText = 'font-size:.875rem;margin:0;display:none;';
438
+ wrapper.appendChild(resultEl);
439
+
440
+ const btnWrap = document.createElement('div');
441
+ btnWrap.style.cssText = 'display:flex;justify-content:flex-end;gap:.5rem;';
442
+ const cancelBtn = document.createElement('button');
443
+ cancelBtn.className = 'btn btn-ghost';
444
+ cancelBtn.textContent = 'Cancel';
445
+ const importBtn = document.createElement('button');
446
+ importBtn.className = 'btn btn-primary';
447
+ importBtn.textContent = 'Import';
448
+ btnWrap.appendChild(cancelBtn);
449
+ btnWrap.appendChild(importBtn);
450
+ wrapper.appendChild(btnWrap);
451
+
452
+ modal.element.appendChild(wrapper);
453
+ modal.open();
454
+
455
+ cancelBtn.addEventListener('click', () => modal.close());
456
+ importBtn.addEventListener('click', async () => {
457
+ let entries;
458
+ try {
459
+ entries = JSON.parse(ta.value);
460
+ if (!Array.isArray(entries)) throw new Error('Must be a JSON array');
461
+ } catch (err) {
462
+ resultEl.style.display = '';
463
+ resultEl.style.color = 'var(--danger,#f87171)';
464
+ resultEl.textContent = `Invalid JSON: ${err.message}`;
465
+ return;
466
+ }
467
+
468
+ importBtn.disabled = true;
469
+ try {
470
+ const result = await api.collections.import(currentSlug, entries);
471
+ resultEl.style.display = '';
472
+ resultEl.style.color = 'var(--success,#4ade80)';
473
+ resultEl.textContent = `Imported ${result.imported} entries. Skipped: ${result.skipped}.`;
474
+ await loadEntries($container || { find: () => ({ get: () => null, off: () => ({ on: () => {} }) }) });
475
+ setTimeout(() => modal.close(), 1500);
476
+ } catch (err) {
477
+ resultEl.style.display = '';
478
+ resultEl.style.color = 'var(--danger,#f87171)';
479
+ resultEl.textContent = err.message || 'Import failed.';
480
+ } finally {
481
+ importBtn.disabled = false;
482
+ }
483
+ });
484
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Collections List View
3
+ * Shows all collections with field/entry counts and action buttons.
4
+ */
5
+ import {api} from '../api.js';
6
+
7
+ function esc(str) {
8
+ return String(str)
9
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
10
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
11
+ }
12
+
13
+ export const collectionsView = {
14
+ templateUrl: '/admin/js/templates/collections.html',
15
+
16
+ async onMount($container) {
17
+ await loadCollections($container);
18
+
19
+ $container.find('#create-collection-btn').off('click').on('click', () => {
20
+ const modal = E.modal({ title: 'New Collection', size: 'sm' });
21
+
22
+ const wrapper = document.createElement('div');
23
+ wrapper.style.cssText = 'padding:.25rem 0 .5rem;';
24
+
25
+ const formContainer = document.createElement('div');
26
+ F.create(
27
+ { title: { type: 'string', label: 'Collection Title', placeholder: 'e.g. Products, Blog Posts…', required: true } },
28
+ {},
29
+ { showSubmitButton: false }
30
+ ).renderTo(formContainer);
31
+ wrapper.appendChild(formContainer);
32
+
33
+ const btnWrap = document.createElement('div');
34
+ btnWrap.style.cssText = 'display:flex;justify-content:flex-end;gap:.5rem;margin-top:.75rem;';
35
+ const cancelBtn = document.createElement('button');
36
+ cancelBtn.className = 'btn btn-ghost';
37
+ cancelBtn.textContent = 'Cancel';
38
+ const createBtn = document.createElement('button');
39
+ createBtn.className = 'btn btn-primary';
40
+ createBtn.textContent = 'Create';
41
+ btnWrap.appendChild(cancelBtn);
42
+ btnWrap.appendChild(createBtn);
43
+ wrapper.appendChild(btnWrap);
44
+
45
+ modal.element.appendChild(wrapper);
46
+ modal.open();
47
+
48
+ const titleInput = formContainer.querySelector('input[name="title"]');
49
+ setTimeout(() => titleInput?.focus(), 50);
50
+
51
+ async function doCreate() {
52
+ const title = titleInput?.value.trim();
53
+ if (!title) return;
54
+ try {
55
+ const collection = await api.collections.create({ title });
56
+ modal.close();
57
+ R.navigate(`/collections/edit/${collection.slug}`);
58
+ } catch (err) {
59
+ E.toast(err.message || 'Failed to create collection.', { type: 'error' });
60
+ }
61
+ }
62
+
63
+ cancelBtn.addEventListener('click', () => modal.close());
64
+ createBtn.addEventListener('click', doCreate);
65
+ titleInput?.addEventListener('keydown', (e) => { if (e.key === 'Enter') doCreate(); });
66
+ });
67
+
68
+ Domma.icons.scan();
69
+ }
70
+ };
71
+
72
+ async function loadCollections($container) {
73
+ let collections = [];
74
+ try {
75
+ collections = await api.collections.list();
76
+ } catch {
77
+ E.toast('Could not load collections.', { type: 'error' });
78
+ }
79
+
80
+ T.create('#collections-table', {
81
+ data: collections,
82
+ columns: [
83
+ {
84
+ key: 'title',
85
+ title: 'Title',
86
+ render: (val, row) => {
87
+ const a = document.createElement('a');
88
+ a.href = `#/collections/${esc(row.slug)}/entries`;
89
+ a.textContent = val;
90
+ a.style.fontWeight = '600';
91
+ return a.outerHTML;
92
+ }
93
+ },
94
+ {
95
+ key: 'slug',
96
+ title: 'Slug',
97
+ render: val => {
98
+ const code = document.createElement('code');
99
+ code.textContent = val;
100
+ return code.outerHTML;
101
+ }
102
+ },
103
+ {key: 'fields', title: 'Field Count', render: val => String(val?.length ?? 0)},
104
+ {key: 'entryCount', title: 'Entry Count', render: val => String(val ?? 0)},
105
+ {
106
+ key: 'slug',
107
+ title: 'Actions',
108
+ render: (val) => {
109
+ const wrap = document.createElement('div');
110
+ wrap.style.cssText = 'display:flex;gap:.4rem;justify-content:flex-end;';
111
+
112
+ const editBtn = document.createElement('a');
113
+ editBtn.href = `#/collections/edit/${esc(val)}`;
114
+ editBtn.className = 'btn btn-sm btn-ghost';
115
+ editBtn.textContent = 'Edit Schema';
116
+
117
+ const entriesBtn = document.createElement('a');
118
+ entriesBtn.href = `#/collections/${esc(val)}/entries`;
119
+ entriesBtn.className = 'btn btn-sm btn-ghost';
120
+ entriesBtn.textContent = 'Entries';
121
+
122
+ const delBtn = document.createElement('button');
123
+ delBtn.className = 'btn btn-sm btn-danger js-delete-collection';
124
+ delBtn.dataset.slug = val;
125
+ delBtn.textContent = 'Delete';
126
+
127
+ wrap.appendChild(editBtn);
128
+ wrap.appendChild(entriesBtn);
129
+ wrap.appendChild(delBtn);
130
+ return wrap.outerHTML;
131
+ }
132
+ }
133
+ ],
134
+ emptyMessage: 'No collections yet. Click "New Collection" to get started.'
135
+ });
136
+
137
+ document.querySelectorAll('.js-delete-collection').forEach(btn => {
138
+ btn.addEventListener('click', async () => {
139
+ const slug = btn.dataset.slug;
140
+ const confirmed = await E.confirm(`Delete collection "${slug}" and all its data? This cannot be undone.`);
141
+ if (!confirmed) return;
142
+ try {
143
+ await api.collections.delete(slug);
144
+ E.toast('Collection deleted.', { type: 'success' });
145
+ await loadCollections($container);
146
+ } catch {
147
+ E.toast('Failed to delete collection.', { type: 'error' });
148
+ }
149
+ });
150
+ });
151
+
152
+ Domma.icons.scan();
153
+ }
@@ -2,7 +2,7 @@
2
2
  * Dashboard View
3
3
  * Shows page count stats and a recent pages table.
4
4
  */
5
- import { api } from '../api.js';
5
+ import {api} from '../api.js';
6
6
 
7
7
  export const dashboardView = {
8
8
  templateUrl: '/admin/js/templates/dashboard.html',
@@ -31,11 +31,19 @@ export const dashboardView = {
31
31
  T.create('#recent-pages-table', {
32
32
  data: recent,
33
33
  columns: [
34
- { key: 'title', label: 'Title' },
35
- { key: 'urlPath', label: 'URL' },
36
- { key: 'status', label: 'Status', render: (val) => `<span class="badge badge-${val === 'published' ? 'success' : 'warning'}">${val}</span>` },
37
- { key: 'updatedAt', label: 'Updated', render: (val) => val ? D(val).format('DD MMM YYYY') : '—' },
38
- { key: 'urlPath', label: '', render: (val) => `<a href="#/pages/edit${val}" class="btn btn-sm btn-outline">Edit</a>` }
34
+ {key: 'title', title: 'Title'},
35
+ {key: 'urlPath', title: 'URL'},
36
+ {
37
+ key: 'status',
38
+ title: 'Status',
39
+ render: (val) => `<span class="badge badge-${val === 'published' ? 'success' : 'warning'}">${val}</span>`
40
+ },
41
+ {key: 'updatedAt', title: 'Updated', render: (val) => val ? D(val).format('DD MMM YYYY') : '—'},
42
+ {
43
+ key: 'urlPath',
44
+ title: '',
45
+ render: (val) => `<a href="#/pages/edit${val}" class="btn btn-sm btn-outline">Edit</a>`
46
+ }
39
47
  ],
40
48
  emptyMessage: 'No pages yet. <a href="#/pages/new">Create your first page</a>.'
41
49
  });