domma-cms 0.74.0 → 0.76.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 (116) hide show
  1. package/CLAUDE.md +3 -1
  2. package/admin/js/app.js +4 -4
  3. package/admin/js/lib/actions-arrange.js +1 -0
  4. package/admin/js/lib/api-endpoints-arrange.js +1 -0
  5. package/admin/js/lib/api-tokens-arrange.js +3 -0
  6. package/admin/js/lib/blocks-arrange.js +1 -0
  7. package/admin/js/lib/collections-arrange.js +1 -0
  8. package/admin/js/lib/components-arrange.js +1 -0
  9. package/admin/js/lib/context-menus-arrange.js +1 -0
  10. package/admin/js/lib/effects-arrange.js +3 -0
  11. package/admin/js/lib/forms-arrange.js +1 -0
  12. package/admin/js/lib/layouts-arrange.js +1 -0
  13. package/admin/js/lib/media-arrange.js +1 -0
  14. package/admin/js/lib/menus-arrange.js +1 -0
  15. package/admin/js/lib/my-profile-arrange.js +1 -0
  16. package/admin/js/lib/notifications-arrange.js +1 -0
  17. package/admin/js/lib/pages-arrange.js +1 -0
  18. package/admin/js/lib/plugins-arrange.js +2 -0
  19. package/admin/js/lib/roles-arrange.js +1 -0
  20. package/admin/js/lib/search-settings-arrange.js +1 -0
  21. package/admin/js/lib/sidebar-renderer.js +3 -3
  22. package/admin/js/lib/site-settings-arrange.js +1 -0
  23. package/admin/js/lib/theme-settings-arrange.js +1 -0
  24. package/admin/js/lib/users-arrange.js +1 -0
  25. package/admin/js/lib/views-arrange.js +1 -0
  26. package/admin/js/templates/actions-list.html +73 -14
  27. package/admin/js/templates/api-endpoints.html +63 -10
  28. package/admin/js/templates/api-tokens.html +83 -10
  29. package/admin/js/templates/blocks.html +76 -15
  30. package/admin/js/templates/collections.html +108 -52
  31. package/admin/js/templates/components.html +82 -15
  32. package/admin/js/templates/context-menus.html +55 -13
  33. package/admin/js/templates/effects.html +155 -72
  34. package/admin/js/templates/forms.html +67 -14
  35. package/admin/js/templates/layouts.html +126 -43
  36. package/admin/js/templates/media.html +99 -13
  37. package/admin/js/templates/menus.html +57 -11
  38. package/admin/js/templates/my-profile.html +100 -12
  39. package/admin/js/templates/notifications.html +70 -10
  40. package/admin/js/templates/pages.html +78 -23
  41. package/admin/js/templates/plugins.html +100 -28
  42. package/admin/js/templates/roles.html +78 -7
  43. package/admin/js/templates/search.html +193 -216
  44. package/admin/js/templates/settings.html +404 -472
  45. package/admin/js/templates/theme.html +164 -132
  46. package/admin/js/templates/users.html +69 -9
  47. package/admin/js/templates/views-list.html +63 -9
  48. package/admin/js/views/actions-list.js +37 -1
  49. package/admin/js/views/api-endpoints.js +11 -7
  50. package/admin/js/views/api-tokens.js +62 -8
  51. package/admin/js/views/blocks.js +34 -5
  52. package/admin/js/views/collections.js +36 -1
  53. package/admin/js/views/components.js +27 -5
  54. package/admin/js/views/context-menus.js +10 -5
  55. package/admin/js/views/effects.js +31 -1
  56. package/admin/js/views/forms.js +57 -1
  57. package/admin/js/views/layouts.js +81 -1
  58. package/admin/js/views/media.js +40 -1
  59. package/admin/js/views/menus.js +11 -5
  60. package/admin/js/views/my-profile.js +1 -1
  61. package/admin/js/views/notifications.js +18 -1
  62. package/admin/js/views/pages.js +28 -8
  63. package/admin/js/views/plugins.js +97 -39
  64. package/admin/js/views/roles.js +49 -4
  65. package/admin/js/views/search.js +7 -1
  66. package/admin/js/views/settings.js +13 -3
  67. package/admin/js/views/theme.js +55 -1
  68. package/admin/js/views/users.js +13 -4
  69. package/admin/js/views/views-list.js +11 -1
  70. package/package.json +1 -1
  71. package/plugins/_lib/admin/mail/compose-window.js +97 -40
  72. package/plugins/_lib/admin/mail/contacts.js +4 -1
  73. package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
  74. package/plugins/_lib/admin/mail/folder-tree.js +1 -1
  75. package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
  76. package/plugins/_lib/admin/mail/mail.css +1 -1
  77. package/plugins/_lib/admin/mail/mail.html +1 -1
  78. package/plugins/_lib/admin/mail/reader-view.js +303 -44
  79. package/plugins/_lib/admin/mail/when-closed.js +26 -0
  80. package/plugins/_lib/mail/readRoutes.js +43 -1
  81. package/plugins/_lib/mail/unreadBadge.js +42 -0
  82. package/plugins/free-tier.lock.json +5 -5
  83. package/plugins/mail-reader/CLAUDE.md +1 -0
  84. package/plugins/mail-reader/plugin.js +3 -1
  85. package/plugins/mail-reader/plugin.json +5 -4
  86. package/server/routes/api/actions.js +64 -2
  87. package/server/routes/api/api-endpoints.js +41 -0
  88. package/server/routes/api/api-tokens.js +56 -0
  89. package/server/routes/api/auth.js +7 -1
  90. package/server/routes/api/blocks.js +170 -8
  91. package/server/routes/api/collections.js +66 -2
  92. package/server/routes/api/components.js +46 -0
  93. package/server/routes/api/context-menus.js +8 -0
  94. package/server/routes/api/effects.js +68 -13
  95. package/server/routes/api/forms.js +123 -10
  96. package/server/routes/api/layouts.js +93 -2
  97. package/server/routes/api/media.js +56 -1
  98. package/server/routes/api/menus.js +53 -6
  99. package/server/routes/api/notifications.js +140 -14
  100. package/server/routes/api/pages.js +46 -0
  101. package/server/routes/api/plugins.js +135 -4
  102. package/server/routes/api/roles.js +111 -0
  103. package/server/routes/api/search.js +38 -4
  104. package/server/routes/api/settings.js +52 -0
  105. package/server/routes/api/theme.js +55 -0
  106. package/server/routes/api/users.js +53 -1
  107. package/server/routes/api/views.js +27 -3
  108. package/server/server.js +28 -1
  109. package/server/services/contextMenus.js +41 -0
  110. package/server/services/plugins.js +7 -0
  111. package/server/services/roles.js +3 -0
  112. package/server/services/search.js +58 -0
  113. package/server/services/users.js +14 -0
  114. package/server/services/views.js +38 -0
  115. package/admin/js/templates/plugin-marketplace.html +0 -29
  116. package/admin/js/views/plugin-marketplace.js +0 -1
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Admin endpoints (authenticated + collections role):
5
5
  * GET /collections - List all collections
6
+ * GET /collections/_count - The sidebar badge (count + popover details)
6
7
  * POST /collections - Create collection
7
8
  * GET /collections/:slug - Get schema
8
9
  * PUT /collections/:slug - Update schema
@@ -53,7 +54,7 @@ import {
53
54
  updateEntry
54
55
  } from '../../services/collections.js';
55
56
  import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
56
- import {getRoleLevel, invalidate as invalidateRoles} from '../../services/roles.js';
57
+ import {getRoleLevel, invalidate as invalidateRoles, isBaseRole} from '../../services/roles.js';
57
58
  import {getEffectiveLevel} from '../../services/userRoles.js';
58
59
  import {getConfig, saveConfig} from '../../config.js';
59
60
  import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
@@ -64,6 +65,45 @@ import {isProjectEnabled, resolveArtefactProject} from '../../services/projects.
64
65
 
65
66
  const ALL_PRESET_SLUGS = new Set(['roles', 'user-profiles', ...PRESET_COLLECTION_SLUGS]);
66
67
 
68
+ /**
69
+ * The sidebar badge on Collections (GET /collections/_count): how many
70
+ * collections this user sees and how many entries they hold, with where they
71
+ * are stored and the biggest ones. Plain text throughout - the popover sets it
72
+ * with textContent.
73
+ *
74
+ * @param {object[]} list - collections with entryCount, as listCollections answers
75
+ * @returns {{count: number, label: string, tone: string, details: object}}
76
+ */
77
+ export function collectionsBadge(list = []) {
78
+ const plural = (n, one, many = `${one}s`) => `${n.toLocaleString('en-GB')} ${n === 1 ? one : many}`;
79
+ const kind = (c) => (ALL_PRESET_SLUGS.has(c.slug) ? 'builtin' : c.plugin ? 'plugin' : 'own');
80
+ const counts = {own: 0, builtin: 0, plugin: 0};
81
+ for (const c of list) counts[kind(c)]++;
82
+ const entries = list.reduce((s, c) => s + (Number(c.entryCount) || 0), 0);
83
+ const mongo = list.filter(c => c.storage?.adapter === 'mongodb').length;
84
+ const biggest = list.filter(c => Number(c.entryCount) > 0)
85
+ .sort((a, b) => b.entryCount - a.entryCount || String(a.title || a.slug).localeCompare(String(b.title || b.slug)));
86
+ return {
87
+ count: list.length,
88
+ label: `${plural(list.length, 'collection')}, ${plural(entries, 'entry', 'entries')}`,
89
+ tone: 'info',
90
+ details: {
91
+ title: 'Collections',
92
+ rows: [
93
+ {label: 'Yours', value: String(counts.own)},
94
+ {label: 'Built in', value: String(counts.builtin)},
95
+ {label: 'Plugins', value: String(counts.plugin)},
96
+ {label: 'On MongoDB', value: String(mongo)}
97
+ ],
98
+ items: biggest.slice(0, 6).map(c => ({
99
+ text: String(c.title || c.slug),
100
+ meta: `${plural(Number(c.entryCount), 'entry', 'entries')} · ${c.slug}`
101
+ })),
102
+ empty: 'No entries in any collection yet.'
103
+ }
104
+ };
105
+ }
106
+
67
107
  /**
68
108
  * Resolve the role level number for a named role.
69
109
  *
@@ -222,6 +262,9 @@ async function keepRoleSystemFields(id, data) {
222
262
  if (existing[field] !== undefined) out[field] = existing[field];
223
263
  else delete out[field];
224
264
  }
265
+ // A built-in role keeps its name: renaming `admin` and then deleting it went round
266
+ // the delete guard (and every user and route that names it would lose it).
267
+ if (isBaseRole(existing.name)) out.name = existing.name;
225
268
  return out;
226
269
  }
227
270
 
@@ -242,11 +285,23 @@ export async function collectionsRoutes(fastify) {
242
285
  for (const c of all) {
243
286
  // listCollections returns metadata only; getCollection returns full record with meta
244
287
  const full = await getCollection(c.slug);
245
- if (canSeeArtefact(request.user, full)) filtered.push(c);
288
+ // builtIn: the ones DELETE refuses (seeding turns `preset` into `bundled`, which
289
+ // also covers deletable seeded collections - so the list says which is which).
290
+ if (canSeeArtefact(request.user, full)) filtered.push(ALL_PRESET_SLUGS.has(c.slug) ? {...c, builtIn: true} : c);
246
291
  }
247
292
  return filtered;
248
293
  });
249
294
 
295
+ // The sidebar badge. Static, so it wins over /collections/:slug; `_` is never in a slug.
296
+ fastify.get('/collections/_count', canRead, async (request) => {
297
+ const {canSeeArtefact} = await import('../../services/projects.js');
298
+ const visible = [];
299
+ for (const c of await listCollections()) {
300
+ if (canSeeArtefact(request.user, await getCollection(c.slug))) visible.push(c);
301
+ }
302
+ return collectionsBadge(visible);
303
+ });
304
+
250
305
  fastify.get('/collections/pro-status', canRead, async () => {
251
306
  try {
252
307
  const connections = getConfig('connections');
@@ -442,6 +497,11 @@ export async function collectionsRoutes(fastify) {
442
497
  if (entry?.data?.level === 0) {
443
498
  return reply.status(403).send({ error: 'Cannot delete the root admin role' });
444
499
  }
500
+ // admin and user too: removeRole() refused them by name, but this route did not,
501
+ // and the old Roles screen offered Delete on both.
502
+ if (isBaseRole(entry?.data?.name)) {
503
+ return reply.status(403).send({ error: `Cannot delete the built-in role '${entry.data.name}'` });
504
+ }
445
505
  }
446
506
  try {
447
507
  await deleteEntry(request.params.slug, request.params.id);
@@ -455,6 +515,10 @@ export async function collectionsRoutes(fastify) {
455
515
 
456
516
  // Clear all entries - DELETE /collections/:slug/entries (no :id)
457
517
  fastify.delete('/collections/:slug/entries', canDelete, async (request, reply) => {
518
+ // Clearing roles would leave every user holding a role that no longer exists.
519
+ if (request.params.slug === 'roles') {
520
+ return reply.status(403).send({ error: 'Roles cannot be cleared - delete them one at a time' });
521
+ }
458
522
  try {
459
523
  await clearEntries(request.params.slug);
460
524
  return { success: true };
@@ -3,6 +3,7 @@
3
3
  * CRUD + compilation + export/import for .dmc components.
4
4
  *
5
5
  * GET /api/components - list
6
+ * GET /api/components/_count - the sidebar badge (`_` is outside NAME_RE, so no component shadows it)
6
7
  * GET /api/components/:name - raw source + parsed props
7
8
  * GET /api/components/:name.js - compiled JS module (public)
8
9
  * POST /api/components/compile - transient compile for preview
@@ -12,6 +13,7 @@
12
13
  * DELETE /api/components/:name - delete
13
14
  */
14
15
  import {authenticate, requirePermission} from '../../middleware/auth.js';
16
+ import {ago, plural} from '../../services/sidebarBadges.js';
15
17
  import {
16
18
  compileComponent,
17
19
  deleteComponent,
@@ -23,6 +25,43 @@ import {
23
25
  saveComponent
24
26
  } from '../../services/components.js';
25
27
 
28
+ /**
29
+ * The sidebar badge on Components (its countUrl): how many there are, split
30
+ * by where they come from, with the most recently changed ones in the popover.
31
+ * A count to glance at, never a warning. Plain text throughout - the popover
32
+ * sets it with textContent.
33
+ *
34
+ * @param {object[]} list - as listComponents() returns them, already scoped to the viewer
35
+ * @param {number} [now]
36
+ * @returns {{count: number, label: string, tone: string, details: object}}
37
+ */
38
+ export function componentsBadge(list = [], now = Date.now()) {
39
+ const plugin = list.filter(c => c.origin === 'plugin');
40
+ const builtin = list.filter(c => c.origin !== 'plugin' && c.bundled);
41
+ const mine = list.length - plugin.length - builtin.length;
42
+ const recent = list.filter(c => c.updatedAt)
43
+ .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
44
+ .slice(0, 6);
45
+ return {
46
+ count: list.length,
47
+ label: `${plural(list.length, 'component')}${mine ? `, ${mine} of yours` : ''}`,
48
+ tone: 'info',
49
+ details: {
50
+ title: 'Components',
51
+ rows: [
52
+ {label: 'Yours', value: String(mine)},
53
+ {label: 'Built in', value: String(builtin.length)},
54
+ {label: 'From plugins', value: String(plugin.length)}
55
+ ],
56
+ items: recent.map(c => {
57
+ const props = Object.keys(c.props || {}).length;
58
+ return {text: `<dm-${c.name}>`, meta: `${plural(props, 'prop')} · changed ${ago(c.updatedAt, now)}`};
59
+ }),
60
+ empty: 'No components yet.'
61
+ }
62
+ };
63
+ }
64
+
26
65
  export async function componentsRoutes(fastify) {
27
66
  const canRead = {preHandler: [authenticate, requirePermission('components', 'read')]};
28
67
  const canUpdate = {preHandler: [authenticate, requirePermission('components', 'update')]};
@@ -53,6 +92,13 @@ export async function componentsRoutes(fastify) {
53
92
  return all.filter(c => canSeeArtefact(request.user, c));
54
93
  });
55
94
 
95
+ // Static, so it wins over /components/:name; `_count` can never be a component name.
96
+ fastify.get('/components/_count', canRead, async (request) => {
97
+ const {canSeeArtefact} = await import('../../services/projects.js');
98
+ const all = await listComponents();
99
+ return componentsBadge(all.filter(c => canSeeArtefact(request.user, c)));
100
+ });
101
+
56
102
  fastify.get('/components/:name', canRead, async (request, reply) => {
57
103
  let component;
58
104
  try { component = await getComponent(request.params.name); }
@@ -2,6 +2,7 @@
2
2
  * Context menus API
3
3
  *
4
4
  * GET /api/context-menus - list (metadata only)
5
+ * GET /api/context-menus/_count - the sidebar badge (the underscore can never be a slug)
5
6
  * GET /api/context-menus/:slug - full menu
6
7
  * POST /api/context-menus - create
7
8
  * PUT /api/context-menus/:slug - replace
@@ -18,6 +19,7 @@ import {
18
19
  } from '../../middleware/auth.js';
19
20
  import {
20
21
  ACTION_TYPES,
22
+ contextMenusBadge,
21
23
  createContextMenu,
22
24
  deleteContextMenu,
23
25
  duplicateContextMenu,
@@ -45,6 +47,12 @@ export async function contextMenusRoutes(fastify, opts = {}) {
45
47
  return all.filter((m) => canSeeArtefact(request.user, m));
46
48
  });
47
49
 
50
+ // Registered before /context-menus/:slug; `_` is outside the slug alphabet, so no menu can shadow it.
51
+ fastify.get('/context-menus/_count', canRead, async (request) => {
52
+ const {canSeeArtefact} = await import('../../services/projects.js');
53
+ return contextMenusBadge((await listContextMenus()).filter((m) => canSeeArtefact(request.user, m)));
54
+ });
55
+
48
56
  fastify.get('/context-menus/:slug', canRead, async (request, reply) => {
49
57
  const menu = await getContextMenu(request.params.slug);
50
58
  if (!menu) return reply.code(404).send({error: 'Context menu not found'});
@@ -2,22 +2,77 @@
2
2
  * Effects API Routes
3
3
  * GET /api/effects/settings - public (read by public site IIFE)
4
4
  * PUT /api/effects/settings - admin-only (save user overrides)
5
+ *
6
+ * The PUT whitelists and checks what it stores (normaliseEffectsSettings): it
7
+ * used to write the request body to config/effects.json verbatim, and GET
8
+ * hands that file to every public page. Keys this route does not know are
9
+ * left as they are in the file, never added from a request.
5
10
  */
6
- import {authenticate, requireAdmin} from '../../middleware/auth.js';
11
+ import {
12
+ authenticate as defaultAuthenticate,
13
+ requireAdmin as defaultRequireAdmin
14
+ } from '../../middleware/auth.js';
7
15
  import {getConfig, saveConfig} from '../../config.js';
8
16
 
9
- export async function effectsRoutes(fastify) {
10
- fastify.get('/effects/settings', async () => {
11
- try {
12
- return getConfig('effects');
13
- } catch {
14
- return {respectMotion: true, defaultDuration: 600, defaultAnimation: 'fade', defaultThreshold: 0.1};
15
- }
16
- });
17
+ export const EFFECT_ANIMATIONS = ['fade', 'slide-up', 'slide-down', 'zoom', 'flip'];
18
+ export const EFFECTS_DEFAULTS = {respectMotion: true, defaultDuration: 600, defaultAnimation: 'fade', defaultThreshold: 0.1};
19
+
20
+ /**
21
+ * A request body -> {errors, settings}. The same rules as checkSettings() in
22
+ * admin/js/lib/effects-arrange.js. A duration of 0 is refused: public/js/effects.js
23
+ * reads `defaultDuration || 600`, so 0 would silently mean 600.
24
+ *
25
+ * @param {object} body
26
+ * @returns {{errors: string[], settings: object}}
27
+ */
28
+ export function normaliseEffectsSettings(body) {
29
+ const b = body && typeof body === 'object' ? body : {};
30
+ const errors = [];
31
+ const settings = {};
32
+ if ('respectMotion' in b) settings.respectMotion = b.respectMotion !== false;
33
+ if ('defaultDuration' in b) {
34
+ const n = Number(b.defaultDuration);
35
+ if (!Number.isInteger(n) || n < 1 || n > 10000) errors.push('defaultDuration must be a whole number from 1 to 10000');
36
+ else settings.defaultDuration = n;
37
+ }
38
+ if ('defaultAnimation' in b) {
39
+ if (!EFFECT_ANIMATIONS.includes(b.defaultAnimation)) errors.push(`defaultAnimation must be one of ${EFFECT_ANIMATIONS.join(', ')}`);
40
+ else settings.defaultAnimation = b.defaultAnimation;
41
+ }
42
+ if ('defaultThreshold' in b) {
43
+ const n = Number(b.defaultThreshold);
44
+ if (b.defaultThreshold === '' || b.defaultThreshold === null || !Number.isFinite(n) || n < 0 || n > 1) {
45
+ errors.push('defaultThreshold must be a number from 0 to 1');
46
+ } else settings.defaultThreshold = n;
47
+ }
48
+ return {errors, settings};
49
+ }
50
+
51
+ function loadSettings() {
52
+ try {
53
+ return {...EFFECTS_DEFAULTS, ...(getConfig('effects') || {})};
54
+ } catch {
55
+ return {...EFFECTS_DEFAULTS};
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @param {import('fastify').FastifyInstance} fastify
61
+ * @param {{authenticate?: Function, requireAdmin?: Function}} [opts] - injectable for tests
62
+ */
63
+ export async function effectsRoutes(fastify, opts = {}) {
64
+ const authenticate = opts.authenticate || defaultAuthenticate;
65
+ const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
66
+
67
+ fastify.get('/effects/settings', async () => loadSettings());
17
68
 
18
- fastify.put('/effects/settings', {preHandler: [authenticate, requireAdmin]}, async (request) => {
19
- const body = request.body || {};
20
- saveConfig('effects', body);
21
- return {ok: true};
69
+ fastify.put('/effects/settings', {preHandler: [authenticate, requireAdmin]}, async (request, reply) => {
70
+ const {errors, settings} = normaliseEffectsSettings(request.body);
71
+ if (errors.length) return reply.status(400).send({error: errors.join('; ')});
72
+ let current = {};
73
+ try { current = getConfig('effects') || {}; } catch { /* no file yet */ }
74
+ const next = {...current, ...settings};
75
+ saveConfig('effects', next);
76
+ return {ok: true, settings: {...EFFECTS_DEFAULTS, ...next}};
22
77
  });
23
78
  }
@@ -36,6 +36,7 @@ import {authenticate, requireAdmin, requirePermission} from '../../middleware/au
36
36
  import {hooks} from '../../services/hooks.js';
37
37
  import {saveMedia} from '../../services/content.js';
38
38
  import {v4 as uuidv4} from 'uuid';
39
+ import {ago, clip, plural} from '../../services/sidebarBadges.js';
39
40
 
40
41
  /**
41
42
  * Detect form↔collection mismatches at save time so admins know BEFORE
@@ -228,6 +229,104 @@ function submissionsToCSV(form, entries) {
228
229
  return [headers.join(','), ...rows].join('\n');
229
230
  }
230
231
 
232
+ /**
233
+ * Where a form's submissions are stored: its collection action's collection
234
+ * when that is on, else a collection named after the form. The submissions
235
+ * screen reads from here, so every count must too - the list used to count
236
+ * `form.slug` and disagreed with it for any form writing elsewhere.
237
+ *
238
+ * @param {object} form
239
+ * @returns {string}
240
+ */
241
+ export function submissionsCollectionFor(form) {
242
+ const c = form?.actions?.collection;
243
+ return c?.enabled && c.slug ? c.slug : form?.slug;
244
+ }
245
+
246
+ const DAY_MS = 86_400_000;
247
+
248
+ /**
249
+ * Submission figures for one form: total, this week (last 7 days), today
250
+ * (last 24 hours) and the newest one's time. A form whose collection does not
251
+ * exist yet has none.
252
+ *
253
+ * @param {object} form
254
+ * @param {number} [now]
255
+ * @returns {Promise<{total: number, week: number, today: number, last: string|null}>}
256
+ */
257
+ async function submissionStats(form, now = Date.now()) {
258
+ try {
259
+ const r = await listEntries(submissionsCollectionFor(form), {limit: 0});
260
+ const entries = r.entries || [];
261
+ let week = 0;
262
+ let today = 0;
263
+ let last = null;
264
+ for (const e of entries) {
265
+ const at = e.meta?.createdAt;
266
+ const t = at ? Date.parse(at) : NaN;
267
+ if (!Number.isFinite(t)) continue;
268
+ if (now - t <= 7 * DAY_MS) week++;
269
+ if (now - t <= DAY_MS) today++;
270
+ if (!last || at > last) last = at;
271
+ }
272
+ return {total: r.total ?? entries.length, week, today, last};
273
+ } catch {
274
+ return {total: 0, week: 0, today: 0, last: null};
275
+ }
276
+ }
277
+
278
+ /**
279
+ * The sidebar badge on Forms (its countUrl, GET /api/forms/_count): the
280
+ * submissions that came in over the last 7 days, with the forms they came
281
+ * through. There is no "read" state for a submission, so recent is the
282
+ * useful number. Plain text throughout - the popover sets it with textContent.
283
+ *
284
+ * @param {{title: string, slug: string, stats: {total, week, today, last}}[]} forms
285
+ * @param {number} [now]
286
+ */
287
+ /**
288
+ * Each form's submission stats, kept a minute: the sidebar asks for the Forms
289
+ * badge every minute from every open admin tab, and stats read every
290
+ * submission. Per form, not per user - who may see which form is still
291
+ * decided on each request.
292
+ */
293
+ const statsCache = new Map();
294
+ async function cachedStats(form, now) {
295
+ const hit = statsCache.get(form.slug);
296
+ if (hit && now - hit.at < 60_000) return hit.value;
297
+ const value = await submissionStats(form, now);
298
+ statsCache.set(form.slug, {at: now, value});
299
+ return value;
300
+ }
301
+
302
+ export function formsBadge(forms = [], now = Date.now()) {
303
+ const week = forms.reduce((n, f) => n + (f.stats?.week || 0), 0);
304
+ const today = forms.reduce((n, f) => n + (f.stats?.today || 0), 0);
305
+ const total = forms.reduce((n, f) => n + (f.stats?.total || 0), 0);
306
+ const busy = forms.filter(f => f.stats?.week > 0)
307
+ .sort((a, b) => String(b.stats.last).localeCompare(String(a.stats.last)));
308
+ return {
309
+ // The number stays what the sidebar has always shown on Forms (how many); submissions are the label.
310
+ count: forms.length,
311
+ label: `${plural(forms.length, 'form')}${week ? `, ${plural(week, 'submission')} this week` : ', no submissions this week'}`,
312
+ tone: 'info',
313
+ details: {
314
+ title: 'Forms',
315
+ rows: [
316
+ {label: 'Last 24 hours', value: String(today)},
317
+ {label: 'Last 7 days', value: String(week)},
318
+ {label: 'All time', value: String(total)},
319
+ {label: 'Forms', value: String(forms.length)}
320
+ ],
321
+ items: busy.slice(0, 6).map(f => ({
322
+ text: clip(f.title || f.slug),
323
+ meta: `${f.stats.week} this week · last ${ago(f.stats.last, now)}`
324
+ })),
325
+ empty: 'No submissions in the last 7 days.'
326
+ }
327
+ };
328
+ }
329
+
231
330
  export async function formsRoutes(fastify) {
232
331
  await ensureFormsDir();
233
332
 
@@ -242,26 +341,40 @@ export async function formsRoutes(fastify) {
242
341
  fastify.get('/forms', canRead, async (request) => {
243
342
  const {canSeeArtefact} = await import('../../services/projects.js');
244
343
  const forms = await listForms();
344
+ const now = Date.now();
245
345
  const result = await Promise.all(forms.map(async form => {
246
- let submissionCount = 0;
247
- try {
248
- // listEntries returns { entries, total, page, limit } - total is the
249
- // unpaginated count which is what we want for the badge here.
250
- const r = await listEntries(form.slug, { limit: 0 });
251
- submissionCount = r.total ?? (r.entries || []).length;
252
- } catch {
253
- // collection may not exist yet
254
- }
346
+ // Counted where the submissions screen reads them (submissionsCollectionFor).
347
+ const stats = await submissionStats(form, now);
255
348
  // listForms returns metadata only; readForm returns full record with meta
256
349
  let full = null;
257
350
  try { full = await readForm(form.slug); } catch { /* skip */ }
258
- return { form: { ...form, submissionCount }, full };
351
+ return {
352
+ form: {...form, submissionCount: stats.total, submissionsThisWeek: stats.week, lastSubmissionAt: stats.last},
353
+ full
354
+ };
259
355
  }));
260
356
  return result
261
357
  .filter(({ full }) => canSeeArtefact(request.user, full))
262
358
  .map(({ form }) => form);
263
359
  });
264
360
 
361
+ // -----------------------------------------------------------------------
362
+ // GET /forms/_count - the sidebar badge (formsBadge). `_` is outside the
363
+ // slug alphabet (slugify), so no form can shadow it.
364
+ // -----------------------------------------------------------------------
365
+ fastify.get('/forms/_count', canRead, async (request) => {
366
+ const {canSeeArtefact} = await import('../../services/projects.js');
367
+ const now = Date.now();
368
+ const forms = await listForms();
369
+ const seen = await Promise.all(forms.map(async (form) => {
370
+ let full = null;
371
+ try { full = await readForm(form.slug); } catch { /* skip */ }
372
+ if (!canSeeArtefact(request.user, full)) return null;
373
+ return {title: form.title, slug: form.slug, stats: await cachedStats(form, now)};
374
+ }));
375
+ return formsBadge(seen.filter(Boolean), now);
376
+ });
377
+
265
378
  // -----------------------------------------------------------------------
266
379
  // POST /forms - create new form
267
380
  // -----------------------------------------------------------------------
@@ -7,9 +7,16 @@
7
7
  * DELETE /api/layouts/:key - delete a preset (builtin presets cannot be deleted)
8
8
  * GET /api/layouts/options - get layout options
9
9
  * PUT /api/layouts/options - save layout options
10
+ * GET /api/layouts/_usage - which pages use each layout, and which use one that is gone
11
+ * GET /api/layouts/_count - the sidebar badge: pages whose layout no longer exists
12
+ *
13
+ * `_` is outside the key alphabet (slugify below), so no layout can shadow the last two.
10
14
  */
11
15
  import {getConfig, saveConfig} from '../../config.js';
12
- import {authenticate, requirePermission} from '../../middleware/auth.js';
16
+ import {
17
+ authenticate as defaultAuthenticate,
18
+ requirePermission as defaultRequirePermission
19
+ } from '../../middleware/auth.js';
13
20
 
14
21
  const VALID_WIDTHS = new Set(['narrow', 'normal', 'wide', 'full']);
15
22
  const BG_COLOR_RE = /^[a-zA-Z0-9#(),.\s%/-]+$/;
@@ -78,7 +85,74 @@ function sanitisePreset(data) {
78
85
  };
79
86
  }
80
87
 
81
- export async function layoutsRoutes(fastify) {
88
+ /**
89
+ * Which pages use each layout. A page names its layout in frontmatter
90
+ * (`layout: key`); none means `default`. A page naming a layout that no longer
91
+ * exists is rendered with the default one (renderer.js: presets[page.layout] ||
92
+ * presets.default) - nothing says so, which is what `missing` is for.
93
+ *
94
+ * @param {Record<string, object>} presets
95
+ * @param {{urlPath: string, title?: string, status?: string, layout?: string}[]} pages
96
+ * @returns {{byKey: Record<string, {urlPath, title, status}[]>, missing: {urlPath, title, status, layout}[]}}
97
+ */
98
+ export function layoutUsage(presets = {}, pages = []) {
99
+ const byKey = Object.fromEntries(Object.keys(presets).map(k => [k, []]));
100
+ const missing = [];
101
+ for (const p of pages) {
102
+ const key = typeof p.layout === 'string' && p.layout.trim() ? p.layout.trim() : 'default';
103
+ const item = {urlPath: p.urlPath, title: p.title || p.urlPath, status: p.status || 'published'};
104
+ if (byKey[key]) byKey[key].push(item);
105
+ else missing.push({...item, layout: key});
106
+ }
107
+ return {byKey, missing};
108
+ }
109
+
110
+ /**
111
+ * The sidebar badge on Layouts: pages that name a layout which no longer
112
+ * exists (they quietly show as Default). A count of layouts would be noise -
113
+ * nothing needs doing about it - so this answers 0 (no badge) until something
114
+ * is wrong. Plain text throughout: the popover sets it with textContent.
115
+ *
116
+ * @param {Record<string, object>} presets
117
+ * @param {ReturnType<typeof layoutUsage>} usage
118
+ */
119
+ export function layoutsBadge(presets = {}, usage = {byKey: {}, missing: []}) {
120
+ const all = Object.values(presets);
121
+ const builtin = all.filter(p => p.builtin).length;
122
+ const missing = usage.missing || [];
123
+ const inUse = Object.values(usage.byKey || {}).filter(list => list.length).length;
124
+ const n = missing.length;
125
+ return {
126
+ count: n,
127
+ label: n
128
+ ? `${n} page${n === 1 ? '' : 's'} use${n === 1 ? 's' : ''} a layout that no longer exists`
129
+ : `${all.length} layout${all.length === 1 ? '' : 's'}, every page's layout exists`,
130
+ tone: 'attention',
131
+ details: {
132
+ title: 'Layouts',
133
+ rows: [
134
+ {label: 'Layouts', value: `${all.length} (${builtin} built in)`},
135
+ {label: 'In use', value: String(inUse)},
136
+ {label: 'Pages on a missing layout', value: String(n), ...(n && {tone: 'danger'})}
137
+ ],
138
+ items: missing.slice(0, 8).map(p => ({
139
+ text: String(p.title || p.urlPath),
140
+ meta: `${p.urlPath} · "${p.layout}" is gone - showing as Default`,
141
+ tone: 'danger'
142
+ })),
143
+ empty: "Every page's layout exists."
144
+ }
145
+ };
146
+ }
147
+
148
+ /**
149
+ * @param {import('fastify').FastifyInstance} fastify
150
+ * @param {{authenticate?: Function, requirePermission?: Function, listPages?: Function}} [opts] - DI for tests
151
+ */
152
+ export async function layoutsRoutes(fastify, opts = {}) {
153
+ const authenticate = opts.authenticate || defaultAuthenticate;
154
+ const requirePermission = opts.requirePermission || defaultRequirePermission;
155
+ const listPages = opts.listPages || (async () => (await import('../../services/content.js')).listPages());
82
156
  // ── Merge-in seeding: ensure built-in presets exist ──────────────────────
83
157
  const existing = getConfig('presets') || {};
84
158
  let seeded = false;
@@ -174,6 +248,23 @@ export async function layoutsRoutes(fastify) {
174
248
  return {success: true};
175
249
  });
176
250
 
251
+ // ── Usage and the sidebar badge ──────────────────────────────────────────
252
+ const usageNow = async () => layoutUsage(getConfig('presets') || {}, await listPages().catch(() => []));
253
+
254
+ fastify.get('/layouts/_usage', canRead, async () => usageNow());
255
+
256
+ // Polled every minute from every open admin tab, and reading every page is not
257
+ // free: the answer is kept a minute (the screen's own _usage always reads afresh).
258
+ let badgeCache = {at: 0, key: '', value: null};
259
+ fastify.get('/layouts/_count', canRead, async () => {
260
+ const presets = getConfig('presets') || {};
261
+ const key = Object.keys(presets).sort().join('|');
262
+ if (!badgeCache.value || badgeCache.key !== key || Date.now() - badgeCache.at > 60_000) {
263
+ badgeCache = {at: Date.now(), key, value: layoutsBadge(presets, await usageNow())};
264
+ }
265
+ return badgeCache.value;
266
+ });
267
+
177
268
  // ── Layout options ────────────────────────────────────────────────────────
178
269
  fastify.get('/layouts/options', canRead, async () => {
179
270
  return getConfig('site')?.layoutOptions ?? {spacerSize: 8};