domma-cms 0.76.0 → 0.77.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 (88) hide show
  1. package/CLAUDE.md +1 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +2 -2
  4. package/admin/js/lib/action-editor-arrange.js +1 -0
  5. package/admin/js/lib/api-endpoint-editor-arrange.js +2 -0
  6. package/admin/js/lib/block-editor-arrange.js +2 -0
  7. package/admin/js/lib/collection-editor-arrange.js +3 -0
  8. package/admin/js/lib/collection-entries-arrange.js +1 -0
  9. package/admin/js/lib/component-editor-arrange.js +27 -0
  10. package/admin/js/lib/context-menu-editor-arrange.js +1 -0
  11. package/admin/js/lib/dashboard-arrange.js +1 -0
  12. package/admin/js/lib/form-editor-arrange.js +1 -0
  13. package/admin/js/lib/form-submissions-arrange.js +2 -0
  14. package/admin/js/lib/menu-editor-arrange.js +2 -0
  15. package/admin/js/lib/page-editor-arrange.js +1 -0
  16. package/admin/js/lib/plugin-code-arrange.js +1 -0
  17. package/admin/js/lib/project-page-arrange.js +1 -0
  18. package/admin/js/lib/role-editor-arrange.js +3 -0
  19. package/admin/js/lib/user-editor-arrange.js +1 -0
  20. package/admin/js/lib/view-editor-arrange.js +14 -0
  21. package/admin/js/templates/action-editor.html +223 -147
  22. package/admin/js/templates/api-endpoint-editor.html +172 -117
  23. package/admin/js/templates/block-editor.html +194 -203
  24. package/admin/js/templates/collection-editor.html +191 -113
  25. package/admin/js/templates/collection-entries.html +99 -36
  26. package/admin/js/templates/component-editor.html +128 -126
  27. package/admin/js/templates/context-menu-editor.html +208 -148
  28. package/admin/js/templates/dashboard.html +184 -22
  29. package/admin/js/templates/form-editor.html +41 -26
  30. package/admin/js/templates/form-submissions.html +78 -26
  31. package/admin/js/templates/menu-editor.html +57 -17
  32. package/admin/js/templates/menu-locations.html +54 -11
  33. package/admin/js/templates/page-editor.html +47 -26
  34. package/admin/js/templates/plugin-code.html +91 -73
  35. package/admin/js/templates/project-detail.html +83 -145
  36. package/admin/js/templates/project-settings.html +88 -53
  37. package/admin/js/templates/role-editor.html +158 -83
  38. package/admin/js/templates/user-editor.html +150 -14
  39. package/admin/js/templates/view-editor.html +203 -180
  40. package/admin/js/templates/view-preview.html +45 -45
  41. package/admin/js/views/action-editor.js +7 -1
  42. package/admin/js/views/api-endpoint-editor.js +2 -3
  43. package/admin/js/views/block-editor.js +14 -8
  44. package/admin/js/views/collection-editor.js +64 -5
  45. package/admin/js/views/collection-entries.js +50 -1
  46. package/admin/js/views/component-editor.js +2 -28
  47. package/admin/js/views/context-menu-editor.js +54 -55
  48. package/admin/js/views/dashboard.js +7 -1
  49. package/admin/js/views/form-editor.js +7 -7
  50. package/admin/js/views/form-submissions.js +11 -1
  51. package/admin/js/views/index.js +1 -1
  52. package/admin/js/views/login.js +2 -2
  53. package/admin/js/views/menu-editor.js +21 -43
  54. package/admin/js/views/menu-locations.js +16 -1
  55. package/admin/js/views/page-editor.js +26 -26
  56. package/admin/js/views/plugin-code.js +23 -4
  57. package/admin/js/views/project-detail.js +9 -1
  58. package/admin/js/views/project-settings.js +1 -1
  59. package/admin/js/views/role-editor.js +11 -3
  60. package/admin/js/views/user-editor.js +1 -1
  61. package/admin/js/views/view-editor.js +1 -1
  62. package/admin/js/views/view-preview.js +1 -1
  63. package/package.json +1 -1
  64. package/server/routes/api/dashboard.js +32 -4
  65. package/server/routes/api/forms.js +104 -13
  66. package/server/routes/api/users.js +12 -2
  67. package/server/routes/api/views.js +55 -1
  68. package/server/services/components.js +9 -0
  69. package/server/services/users.js +2 -2
  70. package/server/services/views.js +14 -0
  71. package/admin/js/templates/dashboard/activity-feed.html +0 -3
  72. package/admin/js/templates/dashboard/cache.html +0 -32
  73. package/admin/js/templates/dashboard/health-detail.html +0 -2
  74. package/admin/js/templates/dashboard/journeys.html +0 -17
  75. package/admin/js/templates/dashboard/kpi-strip.html +0 -34
  76. package/admin/js/templates/dashboard/spike-feed.html +0 -3
  77. package/admin/js/templates/dashboard/top-pages.html +0 -3
  78. package/admin/js/templates/dashboard/traffic-chart.html +0 -3
  79. package/admin/js/views/block-editor-enhance.js +0 -1
  80. package/admin/js/views/dashboard/lib/escape.js +0 -1
  81. package/admin/js/views/dashboard/widgets/activity-feed.js +0 -1
  82. package/admin/js/views/dashboard/widgets/cache.js +0 -1
  83. package/admin/js/views/dashboard/widgets/health-detail.js +0 -1
  84. package/admin/js/views/dashboard/widgets/journeys.js +0 -1
  85. package/admin/js/views/dashboard/widgets/kpi-strip.js +0 -1
  86. package/admin/js/views/dashboard/widgets/spike-feed.js +0 -6
  87. package/admin/js/views/dashboard/widgets/top-pages.js +0 -1
  88. package/admin/js/views/dashboard/widgets/traffic-chart.js +0 -1
@@ -14,6 +14,8 @@
14
14
  * GET /forms/:slug/submissions/export - admin: CSV export from collection
15
15
  * DELETE /forms/:slug/submissions - admin: clear all submissions
16
16
  * DELETE /forms/:slug/submissions/:id - admin: delete one submission
17
+ * POST /forms/:slug/submissions/:id/spam - admin: flag / unflag one submission as spam {spam}
18
+ * (every submissions route reads and writes submissionsCollectionFor(form))
17
19
  * POST /forms/submit/:slug - public: accept submission
18
20
  * POST /forms/test-email - admin: send test email
19
21
  */
@@ -29,7 +31,9 @@ import {
29
31
  createEntry,
30
32
  deleteEntry,
31
33
  getCollection,
32
- listEntries
34
+ getEntry,
35
+ listEntries,
36
+ updateEntry
33
37
  } from '../../services/collections.js';
34
38
  import {getConfig} from '../../config.js';
35
39
  import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
@@ -243,6 +247,30 @@ export function submissionsCollectionFor(form) {
243
247
  return c?.enabled && c.slug ? c.slug : form?.slug;
244
248
  }
245
249
 
250
+ /**
251
+ * Whether an entry is one of THIS form's submissions. In the form's own
252
+ * collection, every entry is. In a collection its action shares with others
253
+ * (a signup form writing to `members`, two forms writing to `enquiries`), only
254
+ * the entries it made - submit stamps them `meta.source: 'form:<slug>'` - so
255
+ * that clearing, deleting, flagging, exporting or counting one form's
256
+ * submissions never touches the rest of that collection.
257
+ */
258
+ export function isSubmissionOf(form, entry) {
259
+ if (submissionsCollectionFor(form) === form?.slug) return true;
260
+ return entry?.meta?.source === `form:${form?.slug}`;
261
+ }
262
+
263
+ async function canSeeForm(user, form) {
264
+ const {canSeeArtefact} = await import('../../services/projects.js');
265
+ return canSeeArtefact(user, form);
266
+ }
267
+
268
+ /** This form's submissions, oldest first (listEntries order). */
269
+ async function submissionsOf(form) {
270
+ const r = await listEntries(submissionsCollectionFor(form), {limit: 0});
271
+ return (r.entries || []).filter(e => isSubmissionOf(form, e));
272
+ }
273
+
246
274
  const DAY_MS = 86_400_000;
247
275
 
248
276
  /**
@@ -256,8 +284,7 @@ const DAY_MS = 86_400_000;
256
284
  */
257
285
  async function submissionStats(form, now = Date.now()) {
258
286
  try {
259
- const r = await listEntries(submissionsCollectionFor(form), {limit: 0});
260
- const entries = r.entries || [];
287
+ const entries = await submissionsOf(form);
261
288
  let week = 0;
262
289
  let today = 0;
263
290
  let last = null;
@@ -269,7 +296,7 @@ async function submissionStats(form, now = Date.now()) {
269
296
  if (now - t <= DAY_MS) today++;
270
297
  if (!last || at > last) last = at;
271
298
  }
272
- return {total: r.total ?? entries.length, week, today, last};
299
+ return {total: entries.length, week, today, last};
273
300
  } catch {
274
301
  return {total: 0, week: 0, today: 0, last: null};
275
302
  }
@@ -530,8 +557,10 @@ export async function formsRoutes(fastify) {
530
557
  } catch {
531
558
  return reply.status(404).send({ error: 'Form not found.' });
532
559
  }
533
- const collectionAction = form.actions?.collection;
534
- const targetSlug = (collectionAction?.enabled && collectionAction.slug) ? collectionAction.slug : slug;
560
+ // The form's project scope, as the form routes check it: a user confined to
561
+ // other projects must not read or change this form's submissions.
562
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
563
+ const targetSlug = submissionsCollectionFor(form);
535
564
 
536
565
  // A form whose backing collection has not been provisioned yet simply
537
566
  // has no submissions - return an empty list rather than a 404 so the
@@ -541,7 +570,7 @@ export async function formsRoutes(fastify) {
541
570
 
542
571
  // listEntries returns { entries, total, page, limit } - unwrap to the
543
572
  // array. Newest-first is the conventional display order.
544
- const { entries } = await listEntries(targetSlug, { limit: 0 });
573
+ const entries = await submissionsOf(form);
545
574
  return [...entries].reverse();
546
575
  });
547
576
 
@@ -556,10 +585,14 @@ export async function formsRoutes(fastify) {
556
585
  } catch {
557
586
  return reply.status(404).send({ error: 'Form not found.' });
558
587
  }
588
+ // The form's project scope, as the form routes check it: a user confined to
589
+ // other projects must not read or change this form's submissions.
590
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
591
+ // Where the list reads them: a form whose collection action writes elsewhere
592
+ // exported (and cleared, and deleted from) its own-slug collection instead.
559
593
  let entries = [];
560
594
  try {
561
- const r = await listEntries(slug, { limit: 0 });
562
- entries = r.entries || [];
595
+ entries = await submissionsOf(form);
563
596
  } catch {
564
597
  // empty collection
565
598
  }
@@ -580,10 +613,12 @@ export async function formsRoutes(fastify) {
580
613
  } catch {
581
614
  return reply.status(404).send({ error: 'Form not found.' });
582
615
  }
616
+ // The form's project scope, as the form routes check it: a user confined to
617
+ // other projects must not read or change this form's submissions.
618
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
583
619
  let entries = [];
584
620
  try {
585
- const r = await listEntries(slug, { limit: 0 });
586
- entries = r.entries || [];
621
+ entries = await submissionsOf(form);
587
622
  } catch {
588
623
  // empty
589
624
  }
@@ -597,8 +632,24 @@ export async function formsRoutes(fastify) {
597
632
  // -----------------------------------------------------------------------
598
633
  fastify.delete('/forms/:slug/submissions', canDelete, async (request, reply) => {
599
634
  const { slug } = request.params;
635
+ let form;
600
636
  try {
601
- await clearEntries(slug);
637
+ form = await readForm(slug);
638
+ } catch {
639
+ return reply.status(404).send({ error: 'Form not found.' });
640
+ }
641
+ // The form's project scope, as the form routes check it: a user confined to
642
+ // other projects must not read or change this form's submissions.
643
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
644
+ const target = submissionsCollectionFor(form);
645
+ try {
646
+ // Its own collection: clear it. A shared one: delete only this form's
647
+ // entries (one at a time - parallel writes to a file collection lose updates).
648
+ if (target === form.slug) {
649
+ await clearEntries(target);
650
+ } else {
651
+ for (const e of await submissionsOf(form)) await deleteEntry(target, e.id);
652
+ }
602
653
  } catch {
603
654
  return reply.status(404).send({ error: 'Collection not found for this form.' });
604
655
  }
@@ -610,14 +661,54 @@ export async function formsRoutes(fastify) {
610
661
  // -----------------------------------------------------------------------
611
662
  fastify.delete('/forms/:slug/submissions/:id', canDelete, async (request, reply) => {
612
663
  const { slug, id } = request.params;
664
+ let form;
613
665
  try {
614
- await deleteEntry(slug, id);
666
+ form = await readForm(slug);
667
+ } catch {
668
+ return reply.status(404).send({ error: 'Form not found.' });
669
+ }
670
+ // The form's project scope, as the form routes check it: a user confined to
671
+ // other projects must not read or change this form's submissions.
672
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
673
+ const target = submissionsCollectionFor(form);
674
+ try {
675
+ if (!isSubmissionOf(form, await getEntry(target, id))) return reply.status(404).send({ error: 'Submission not found.' });
676
+ await deleteEntry(target, id);
615
677
  } catch {
616
678
  return reply.status(404).send({ error: 'Submission not found.' });
617
679
  }
618
680
  return { ok: true };
619
681
  });
620
682
 
683
+ // -----------------------------------------------------------------------
684
+ // POST /forms/:slug/submissions/:id/spam - flag or unflag one submission.
685
+ // Merged server-side (updateEntry replaces `data` wholesale), in the field
686
+ // the collections spam route and the mark-as-spam context action use: `spam`.
687
+ // -----------------------------------------------------------------------
688
+ fastify.post('/forms/:slug/submissions/:id/spam', canUpdate, async (request, reply) => {
689
+ const { slug, id } = request.params;
690
+ let form;
691
+ try {
692
+ form = await readForm(slug);
693
+ } catch {
694
+ return reply.status(404).send({ error: 'Form not found.' });
695
+ }
696
+ // The form's project scope, as the form routes check it: a user confined to
697
+ // other projects must not read or change this form's submissions.
698
+ if (!(await canSeeForm(request.user, form))) return reply.status(404).send({ error: 'Form not found.' });
699
+ const target = submissionsCollectionFor(form);
700
+ const value = request.body?.spam !== false;
701
+ try {
702
+ const existing = await getEntry(target, id);
703
+ if (!existing || !isSubmissionOf(form, existing)) return reply.status(404).send({ error: 'Submission not found.' });
704
+ const entry = await updateEntry(target, id, {...(existing.data || {}), spam: value});
705
+ hooks.emit('collection:entryUpdated', {slug: target, entryId: entry.id, data: entry.data, userId: request.user?.id || null});
706
+ return {entry, spam: value};
707
+ } catch (err) {
708
+ return reply.status(/not found/i.test(err.message) ? 404 : 400).send({ error: err.message });
709
+ }
710
+ });
711
+
621
712
  /*
622
713
  * POST /forms/submit/:slug - public form submission.
623
714
  *
@@ -119,12 +119,22 @@ export async function usersRoutes(fastify) {
119
119
  }
120
120
  }
121
121
 
122
+ // Everything the editor sets arrives in this one request: a follow-up PUT that
123
+ // failed would leave the account unrestricted and switched on.
124
+ const {projects, meta, isActive, profile} = request.body || {};
125
+ let user;
122
126
  try {
123
- const user = await createUser({ name, email, password, role: targetRole, additionalRoles: requestedAdditional });
124
- return reply.status(201).send(user);
127
+ user = await createUser({
128
+ name, email, password, role: targetRole, additionalRoles: requestedAdditional,
129
+ ...(Array.isArray(projects) && {projects: projects.filter(p => typeof p === 'string' && p)}),
130
+ ...(meta && typeof meta === 'object' && !Array.isArray(meta) && {meta}),
131
+ isActive: isActive !== false
132
+ });
125
133
  } catch (err) {
126
134
  return reply.status(409).send({ error: err.message });
127
135
  }
136
+ if (profile && typeof profile === 'object' && !Array.isArray(profile)) await updateProfile(user.id, profile);
137
+ return reply.status(201).send(user);
128
138
  });
129
139
 
130
140
  // Update user
@@ -9,16 +9,38 @@
9
9
  * PUT /views/:slug - Update view config
10
10
  * DELETE /views/:slug - Delete view config
11
11
  * GET /views/:slug/execute - Execute view (admin)
12
+ * POST /views/_preview - Run an UNSAVED config (the editor's Results): {source, connection, stages, limit}
12
13
  * GET /views/collection/:slug - List views targeting a collection
13
14
  *
14
15
  * Public endpoint (role-checked per view access config):
15
16
  * GET /views/:slug/public - Execute view publicly
16
17
  */
17
- import {createView, deleteView, executeView, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
18
+ import {createView, deleteView, executeView, executeViewConfig, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
18
19
  import {authenticate, requirePermission} from '../../middleware/auth.js';
19
20
  import {getRoleLevel} from '../../services/roles.js';
20
21
  import {getEffectiveLevel} from '../../services/userRoles.js';
21
22
 
23
+ /**
24
+ * Every collection a pipeline's $lookup stages read, as slugs (`cms_` stripped - Mongo-authored views
25
+ * name collections that way). Takes the editor's `{type: '$lookup', config}` stages and raw Mongo
26
+ * `{$lookup: {...}}` alike, and walks nested pipelines: a Mongo `$lookup` can carry its own.
27
+ */
28
+ export function lookupSources(stages) {
29
+ const out = new Set();
30
+ const walk = (node) => {
31
+ if (Array.isArray(node)) { node.forEach(walk); return; }
32
+ if (!node || typeof node !== 'object') return;
33
+ const add = (cfg) => { if (typeof cfg?.from === 'string' && cfg.from) out.add(cfg.from.replace(/^cms_/, '')); };
34
+ if (node.type === '$lookup') add(node.config);
35
+ for (const [key, value] of Object.entries(node)) {
36
+ if (key === '$lookup') add(value);
37
+ walk(value);
38
+ }
39
+ };
40
+ walk(stages);
41
+ return [...out];
42
+ }
43
+
22
44
  /** Every collection by slug - a missing one is a view whose source was deleted. */
23
45
  async function collectionsBySlug() {
24
46
  const {listCollections} = await import('../../services/collections.js');
@@ -135,6 +157,38 @@ export async function viewsRoutes(fastify) {
135
157
  }
136
158
  });
137
159
 
160
+ // -------------------------------------------------------------------------
161
+ // Preview an unsaved config (the view editor's Results tab)
162
+ // -------------------------------------------------------------------------
163
+ //
164
+ // `update`, not `read`: running an arbitrary pipeline reads the source
165
+ // collection, which is what making a view does. The source must be one this
166
+ // user may see (project scope), and it runs as an admin preview - no
167
+ // row-level filter, the same as executing a saved view from the admin.
168
+ // `_` is outside the slug alphabet, so no view can shadow the route.
169
+ fastify.post('/views/_preview', canUpdate, async (request, reply) => {
170
+ const {source, connection = 'default', stages = [], limit} = request.body || {};
171
+ if (typeof source !== 'string' || !source) return reply.status(400).send({error: 'Choose a source collection'});
172
+ if (!Array.isArray(stages)) return reply.status(400).send({error: 'stages must be an array'});
173
+ const {getCollection} = await import('../../services/collections.js');
174
+ const {canSeeArtefact} = await import('../../services/projects.js');
175
+ const schema = await getCollection(source).catch(() => null);
176
+ if (!schema) return reply.status(404).send({error: `Collection "${source}" not found`});
177
+ if (!canSeeArtefact(request.user, schema)) return reply.status(403).send({error: 'Forbidden'});
178
+ // A $lookup reads its `from` collection too - the same project check applies.
179
+ for (const from of lookupSources(stages)) {
180
+ const joined = await getCollection(from).catch(() => null);
181
+ if (joined && !canSeeArtefact(request.user, joined)) return reply.status(403).send({error: 'Forbidden'});
182
+ }
183
+ const n = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 50);
184
+ try {
185
+ return await executeViewConfig({connection, pipeline: {source, stages}, access: {rowLevel: null}},
186
+ {page: 1, limit: n, user: request.user || null});
187
+ } catch (err) {
188
+ return reply.status(400).send({error: err.message});
189
+ }
190
+ });
191
+
138
192
  // -------------------------------------------------------------------------
139
193
  // Public execute (role-checked per view access config)
140
194
  // -------------------------------------------------------------------------
@@ -303,6 +303,15 @@ export async function getComponent(name) {
303
303
  } catch { /* return malformed source for editing; UI flags it */ }
304
304
  return {name, source, props, bundled, ...(meta && {meta})};
305
305
  } catch (err) {
306
+ // A plugin's component has no file: return its source, read-only
307
+ // (saveComponent/deleteComponent refuse it as PLUGIN_OWNED). The list's
308
+ // "View source" opened the editor on it and got a 404.
309
+ const pc = err.code === 'ENOENT' && getPluginComponents().find(c => c.name === name);
310
+ if (pc) {
311
+ let props = {};
312
+ try { props = validatePropsJson(parseDmcSource(pc.source).props); } catch { /* flagged by the UI */ }
313
+ return {name, source: pc.source, props, bundled: true, origin: 'plugin', readOnly: true};
314
+ }
306
315
  if (err.code === 'ENOENT') {
307
316
  const nf = new Error('Component not found');
308
317
  nf.code = 'ENOENT';
@@ -124,7 +124,7 @@ export async function getUserByEmail(email) {
124
124
  * @param {object} data - { name, email, password, role }
125
125
  * @returns {Promise<object>} Created user (password stripped)
126
126
  */
127
- export async function createUser({ name, email, password, role = 'user', additionalRoles = [], meta, projects }) {
127
+ export async function createUser({ name, email, password, role = 'user', additionalRoles = [], meta, projects, isActive = true }) {
128
128
  const existing = await getUserByEmail(email);
129
129
  if (existing) throw new Error('A user with that email already exists');
130
130
 
@@ -144,7 +144,7 @@ export async function createUser({ name, email, password, role = 'user', additio
144
144
  // Project access-scope: when non-empty, the user is restricted to artefacts
145
145
  // tagged with one of these project slugs (see canSeeArtefact in projects.js).
146
146
  projects: Array.isArray(projects) ? projects.filter(Boolean) : [],
147
- isActive: true,
147
+ isActive: isActive !== false,
148
148
  createdAt: now,
149
149
  updatedAt: now,
150
150
  lastLogin: null,
@@ -452,7 +452,21 @@ async function executeFileView(view, { page, limit, user }) {
452
452
  export async function executeView(slug, {page = 1, limit = 25, user = null} = {}) {
453
453
  const view = await getView(slug);
454
454
  if (!view) throw new Error(`View "${slug}" not found`);
455
+ return executeViewConfig(view, {page, limit, user});
456
+ }
455
457
 
458
+ /**
459
+ * Execute a view config that need not be saved - the view editor's live
460
+ * Results run the pipeline being edited. Same validation and the same two
461
+ * engines (native aggregation for a MongoDB source, the JS evaluator
462
+ * otherwise) as executeView.
463
+ *
464
+ * @param {{connection?: string, pipeline: {source: string, stages?: object[]}, access?: object}} view
465
+ * @param {{page?: number, limit?: number, user?: object|null}} [opts]
466
+ * @returns {Promise<{ results: object[], total: number, page: number, limit: number }>}
467
+ */
468
+ export async function executeViewConfig(view, {page = 1, limit = 25, user = null} = {}) {
469
+ if (!view?.pipeline?.source) throw new Error('pipeline.source is required');
456
470
  validateStages(view.pipeline?.stages || []);
457
471
 
458
472
  // Dispatch on the SOURCE collection's storage adapter. A mongodb-declared
@@ -1,3 +0,0 @@
1
- <h3>Recent activity</h3>
2
- <div data-field="rows"></div>
3
- <p class="dash-empty" hidden>No recent activity.</p>
@@ -1,32 +0,0 @@
1
- <div class="dash-card-header">
2
- <h3>Response Cache</h3>
3
- <label class="dash-cache-toggle" title="Enable / disable cache">
4
- <input type="checkbox" data-field="toggle">
5
- <span data-field="toggle-label">…</span>
6
- </label>
7
- </div>
8
- <div class="dash-cache-grid">
9
- <div class="dash-cache-stat">
10
- <div class="dash-cache-stat-label">Hit rate</div>
11
- <div class="dash-cache-stat-value" data-field="hit-rate">-</div>
12
- <div class="dash-cache-stat-sub" data-field="hits-misses">no traffic yet</div>
13
- </div>
14
- <div class="dash-cache-stat">
15
- <div class="dash-cache-stat-label">Entries</div>
16
- <div class="dash-cache-stat-value" data-field="size">-</div>
17
- <div class="dash-cache-stat-sub" data-field="size-bar"><span data-field="size-fill"></span></div>
18
- </div>
19
- <div class="dash-cache-stat">
20
- <div class="dash-cache-stat-label">Writes</div>
21
- <div class="dash-cache-stat-value" data-field="writes">0</div>
22
- <div class="dash-cache-stat-sub" data-field="invalidations">0 invalidations</div>
23
- </div>
24
- <div class="dash-cache-stat">
25
- <div class="dash-cache-stat-label">Last cleared</div>
26
- <div class="dash-cache-stat-value" data-field="last-cleared">never</div>
27
- <div class="dash-cache-stat-sub" data-field="driver">driver: -</div>
28
- </div>
29
- </div>
30
- <div class="dash-cache-actions">
31
- <button class="btn btn-sm btn-ghost" data-field="clear-btn"><span data-icon="trash"></span> Clear cache</button>
32
- </div>
@@ -1,2 +0,0 @@
1
- <h3>Health</h3>
2
- <div data-field="rows"></div>
@@ -1,17 +0,0 @@
1
- <h3>User journeys - last 7 days</h3>
2
- <div class="dash-journey-grid">
3
- <div>
4
- <h4>Top entry pages</h4>
5
- <div data-field="entry"></div>
6
- </div>
7
- <div>
8
- <h4>Top exit pages</h4>
9
- <div data-field="exit"></div>
10
- </div>
11
- <div>
12
- <h4>Top paths</h4>
13
- <div data-field="paths"></div>
14
- </div>
15
- </div>
16
- <p class="dash-bounce">Bounce rate: <strong data-field="bounce">-</strong></p>
17
- <p class="dash-empty" hidden>No journey data yet - encourage some traffic and check back.</p>
@@ -1,34 +0,0 @@
1
- <div class="dash-card dash-kpi">
2
- <span class="dash-kpi-icon" data-icon="trending-up"></span>
3
- <div>
4
- <div class="dash-kpi-value" data-field="hits-today">-</div>
5
- <div class="dash-kpi-label">Hits today</div>
6
- <div class="dash-kpi-delta" data-field="hits-delta"></div>
7
- </div>
8
- </div>
9
- <div class="dash-card dash-kpi">
10
- <span class="dash-kpi-icon" data-icon="users"></span>
11
- <div>
12
- <div class="dash-kpi-value" data-field="active-sessions">-</div>
13
- <div class="dash-kpi-label">Active sessions (5m)</div>
14
- </div>
15
- </div>
16
- <div class="dash-card dash-kpi">
17
- <span class="dash-kpi-icon" data-icon="inbox"></span>
18
- <div>
19
- <div class="dash-kpi-value" data-field="submissions-week">-</div>
20
- <div class="dash-kpi-label">Submissions this week</div>
21
- </div>
22
- </div>
23
- <div class="dash-card dash-kpi">
24
- <span class="dash-kpi-icon" data-icon="activity"></span>
25
- <div>
26
- <div class="dash-kpi-value">
27
- <span class="dash-health-pill" data-field="health-pill" data-level="ok">
28
- <span data-icon="circle"></span>
29
- <span data-field="health-label">OK</span>
30
- </span>
31
- </div>
32
- <div class="dash-kpi-label">Site health</div>
33
- </div>
34
- </div>
@@ -1,3 +0,0 @@
1
- <h3>Spikes (last hour)</h3>
2
- <div data-field="spike-list"></div>
3
- <p class="dash-empty" hidden>No anomalies detected.</p>
@@ -1,3 +0,0 @@
1
- <h3>Top pages - last 7 days</h3>
2
- <div data-field="rows"></div>
3
- <p class="dash-empty" hidden>No traffic recorded yet.</p>
@@ -1,3 +0,0 @@
1
- <h3>Traffic - last 30 days</h3>
2
- <div class="dash-chart-wrap"><canvas id="dash-traffic-canvas"></canvas></div>
3
- <p class="dash-empty" hidden>No traffic recorded yet.</p>
@@ -1 +0,0 @@
1
- import{blockEditorView as r}from"./block-editor.js";import{createSimpleEditor as u}from"../lib/simple-editor.js";const p=r.onMount;r.onMount=async function(e){await p.call(this,e);const n=e.find(".editor-pane--write").get(0),l=e.find("#block-line-numbers").get(0),t=e.find("#block-content").get(0);if(!n||!t||n.dataset.dmEnhanced==="1")return;n.dataset.dmEnhanced="1",l&&(l.style.display="none"),t.style.display="none",n.style.display="block",n.style.position="relative",n.style.overflow="hidden";const i=document.createElement("div");i.style.cssText="width:100%;height:100%;",n.appendChild(i);const a=u(i,{initialValue:t.value,onChange:o=>{t.value!==o&&(t.value=o,t.dispatchEvent(new Event("input",{bubbles:!0})))}}),d=Object.getPrototypeOf(t),c=Object.getOwnPropertyDescriptor(d,"value");Object.defineProperty(t,"value",{configurable:!0,get(){return c.get.call(this)},set(o){c.set.call(this,o),a.getValue()!==o&&a.setValue(o??"")}});const s=e.find(".card.editor-card").get(0);s&&(s.style.height="min(78vh, 900px)",s.style.minHeight="520px"),g(e)};const f={icon:"box","facebook-icon":"facebook","instagram-icon":"instagram"};function g(e){const n=e.get?e.get(0):e;if(!n)return;const l=n.querySelectorAll("input[data-placeholder]");for(const t of l){const i=(t.dataset.placeholder||"").toLowerCase(),a=f[i];a&&(t.value&&!/^Sample /i.test(t.value)||(t.value=a,t.dispatchEvent(new Event("input",{bubbles:!0}))))}}
@@ -1 +0,0 @@
1
- export function escapeHtml(t){return t==null?"":String(t).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}export function safeHref(t){if(t==null)return"#";const r=String(t).trim();if(r==="")return"#";if(r.startsWith("/"))return escapeHtml(r);const e=r.toLowerCase();return e.startsWith("http://")||e.startsWith("https://")||e.startsWith("mailto:")?escapeHtml(r):"#"}
@@ -1 +0,0 @@
1
- import{escapeHtml as s}from"../lib/escape.js";const p={edit:"edited",publish:"published",form:"received submission"};function f(e,n){e&&(e.innerHTML=n)}export default function m(e,n){const r=Array.isArray(n?.activity)?n.activity:[];r.length===0?e.find(".dash-empty").removeAttr("hidden"):e.find(".dash-empty").attr("hidden","hidden");const i=r.map(t=>{const o=t.at?D(t.at).fromNow():"",a=t.target||t.form||"",d=t.actor?"<strong>"+s(t.actor)+"</strong> ":"",c=s(p[t.type]||t.type||"");return'<div class="dash-spike-row"><span>'+d+c+" <code>"+s(a)+"</code></span><span>"+s(o)+"</span></div>"});f(e.find('[data-field="rows"]').get(0),i.join(""))}
@@ -1 +0,0 @@
1
- import{apiRequest as r}from"../../../api.js";function h(t){if(!t)return"never";const e=Date.now()-new Date(t).getTime();if(e<0)return"just now";const a=Math.floor(e/1e3);if(a<60)return`${a}s ago`;const i=Math.floor(a/60);if(i<60)return`${i}m ago`;const n=Math.floor(i/60);return n<24?`${n}h ago`:`${Math.floor(n/24)}d ago`}function d(t,e){const a=e.hits+e.misses,i=e.hitRate;t.find('[data-field="hit-rate"]').text(i===null?"-":(i*100).toFixed(1)+"%"),t.find('[data-field="hits-misses"]').text(a===0?"no traffic yet":`${e.hits.toLocaleString()} hits \xB7 ${e.misses.toLocaleString()} misses`);const n=e.size??0,c=e.maxItems??0;t.find('[data-field="size"]').text(c?`${n} / ${c}`:String(n));const l=c?Math.min(100,n/c*100):0,o=t.find('[data-field="size-fill"]').get(0);o&&(o.style.width=l+"%"),t.find('[data-field="writes"]').text(e.writes.toLocaleString()),t.find('[data-field="invalidations"]').text(`${e.invalidations.toLocaleString()} invalidations`),t.find('[data-field="last-cleared"]').text(h(e.lastClearedAt)),t.find('[data-field="driver"]').text("driver: "+e.driver);const f=t.find('[data-field="toggle"]').get(0);f&&(f.checked=!!e.enabled),t.find('[data-field="toggle-label"]').text(e.enabled?"On":"Off")}async function s(t){try{const e=await r("/cache/status");d(t,e),t.data("cache-stats",e)}catch(e){console.warn("[cache widget] failed to refresh:",e)}}export default async function g(t){await s(t);const e=t.find('[data-field="toggle"]');e.on("change",async()=>{const a=e.get(0).checked;try{const i=await r("/cache/toggle",{method:"POST",body:JSON.stringify({enabled:a})});d(t,i),E.toast(`Cache ${i.enabled?"enabled":"disabled"}.`,{type:"success"})}catch(i){e.get(0).checked=!a,E.toast(i.message||"Failed to toggle cache.",{type:"error"})}}),t.find('[data-field="clear-btn"]').on("click",async()=>{if(await E.confirm("Clear the entire response cache?"))try{const a=await r("/cache/clear",{method:"POST"});d(t,a),E.toast("Cache cleared.",{type:"success"})}catch(a){E.toast(a.message||"Failed to clear cache.",{type:"error"})}}),window.Domma&&Domma.icons&&Domma.icons.scan()}export{s as refreshCacheWidget};
@@ -1 +0,0 @@
1
- const p={ok:"check-circle",warn:"warning",fail:"x-circle"};export default function h(o,s){const r=s?.health?.checks||[],l=o.find('[data-field="rows"]');l.empty();const a=l.get(0);if(a){for(const e of r){const n=document.createElement("div");n.className="dash-spike-row dash-health-pill",n.setAttribute("data-level",e.level);const c=document.createElement("span"),i=document.createElement("span");i.setAttribute("data-icon",p[e.level]||"circle"),c.appendChild(i),c.appendChild(document.createTextNode(" "+(e.label||e.id)));const d=document.createElement("span");if(d.textContent=String(e.value),n.appendChild(c),n.appendChild(d),a.appendChild(n),Array.isArray(e.detail)&&e.detail.length>0){const t=document.createElement("div");t.className="dash-health-detail",t.style.fontSize="12px",t.style.opacity="0.75",t.style.padding="4px 0 8px",t.textContent=e.detail.slice(0,5).join(", "),a.appendChild(t)}}window.Domma&&Domma.icons&&Domma.icons.scan()}}
@@ -1 +0,0 @@
1
- import{escapeHtml as r}from"../lib/escape.js";function s(n,a){return n.map(e=>'<div class="dash-spike-row">'+a(e)+"</div>").join("")}function i(n,a){n&&(n.innerHTML=a)}export default function f(n,a){const e=a?.journeys,d=n.find(".dash-empty"),o=n.find('[data-field="entry"]'),p=n.find('[data-field="exit"]'),u=n.find('[data-field="paths"]'),c=n.find('[data-field="bounce"]');if(!e||!e.totalSessions){d.removeAttr("hidden"),o.empty(),p.empty(),u.empty(),c.text("-");return}d.attr("hidden","hidden"),i(o.get(0),s(e.entry||[],t=>"<span>"+r(t.url)+"</span><span>"+(Number(t.count)|0)+"</span>")),i(p.get(0),s(e.exit||[],t=>"<span>"+r(t.url)+"</span><span>"+(Number(t.count)|0)+"</span>")),i(u.get(0),s(e.paths||[],t=>"<span>"+r(t.from)+" &rarr; "+r(t.to)+"</span><span>"+(Number(t.count)|0)+"</span>")),c.text((Number(e.bounceRate)*100).toFixed(1)+"%")}
@@ -1 +0,0 @@
1
- export default function n(e,t){const d=t?.traffic?.today??0,a=t?.traffic?.deltaPct;e.find('[data-field="hits-today"]').text(d);const s=e.find('[data-field="hits-delta"]');a==null?s.text("").removeClass("up down"):a>=0?s.text("+"+a+"% vs yesterday").removeClass("down").addClass("up"):s.text(a+"% vs yesterday").removeClass("up").addClass("down"),e.find('[data-field="active-sessions"]').text(t?.realtime?.activeSessions??0),e.find('[data-field="submissions-week"]').text(t?._submissionsWeek??"-");const i=t?.health?.status||"ok",l={ok:"OK",warn:"Attention",fail:"Failing"};e.find('[data-field="health-pill"]').attr("data-level",i),e.find('[data-field="health-label"]').text(l[i])}
@@ -1,6 +0,0 @@
1
- import{escapeHtml as a,safeHref as d}from"../lib/escape.js";export default function p(i,r){const t=Array.isArray(r?.spikes)?r.spikes:[];t.length===0?i.find(".dash-empty").removeAttr("hidden"):i.find(".dash-empty").attr("hidden","hidden");const s=t.map(e=>`
2
- <div class="dash-spike-row">
3
- <span><a href="${d(e.url)}">${a(e.url)}</a></span>
4
- <span><strong>${Number(e.hits)|0}</strong> \xB7 \xD7${Number(e.ratio)||0}</span>
5
- </div>
6
- `).join(""),n=i.find('[data-field="spike-list"]').get(0);n&&(n.innerHTML=s)}
@@ -1 +0,0 @@
1
- import{escapeHtml as l,safeHref as i}from"../lib/escape.js";function c(n,s,e){if(s=s||80,e=e||24,!n||n.length===0)return"";const a=n.map(p=>Number(p)||0),o=Math.max.apply(null,a.concat([1])),t=s/Math.max(a.length-1,1),r=a.map((p,d)=>(d*t).toFixed(1)+","+(e-p/o*e).toFixed(1)).join(" ");return'<svg class="dash-spark" viewBox="0 0 '+s+" "+e+'" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" stroke-width="1.5" points="'+r+'"/></svg>'}export default function m(n,s){const e=Array.isArray(s?.topPages)?s.topPages:[];e.length===0?n.find(".dash-empty").removeAttr("hidden"):n.find(".dash-empty").attr("hidden","hidden");const a=e.map(t=>{const r=t.deltaPct==null?"":'<span class="'+(t.deltaPct>=0?"up":"down")+' dash-kpi-delta">'+(t.deltaPct>=0?"+":"")+(Number(t.deltaPct)||0)+"%</span>";return'<div class="dash-spike-row"><span><a href="'+i(t.url)+'">'+l(t.url)+"</a> "+c(t.spark)+"</span><span><strong>"+(Number(t.hits)|0)+"</strong> "+r+"</span></div>"}).join(""),o=n.find('[data-field="rows"]').get(0);o&&(o.innerHTML=a)}
@@ -1 +0,0 @@
1
- const p="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.js";let s=null;function u(){return window.Chart?Promise.resolve(window.Chart):s||(s=new Promise((r,n)=>{const e=document.createElement("script");e.src=p,e.onload=()=>r(window.Chart),e.onerror=n,document.head.appendChild(e)}),s)}let i=null;export default async function m(r,n){const e=await u(),o=30,a=[];for(let t=o-1;t>=0;t-=1)a.push({label:D().subtract(t,"days").format("MM-DD"),hits:0});if(Array.isArray(n?.topPages))for(const t of n.topPages){if(!Array.isArray(t.spark))continue;const l=o-t.spark.length;t.spark.forEach((f,h)=>{a[l+h].hits+=f})}const d=a.every(t=>t.hits===0);d?r.find(".dash-empty").removeAttr("hidden"):r.find(".dash-empty").attr("hidden","hidden");const c=r.find("#dash-traffic-canvas").get(0);c&&(i&&(i.destroy(),i=null),!d&&(i=new e(c,{type:"line",data:{labels:a.map(t=>t.label),datasets:[{data:a.map(t=>t.hits),borderColor:"rgba(37, 99, 235, 1)",backgroundColor:"rgba(37, 99, 235, 0.15)",fill:!0,tension:.3,pointRadius:0}]},options:{responsive:!0,maintainAspectRatio:!1,plugins:{legend:{display:!1}},scales:{x:{ticks:{maxTicksLimit:8}},y:{beginAtZero:!0}}}})))}