domma-cms 0.73.0 → 0.75.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 (64) hide show
  1. package/CLAUDE.md +3 -1
  2. package/admin/js/api.js +1 -1
  3. package/admin/js/app.js +4 -4
  4. package/admin/js/lib/actions-arrange.js +1 -0
  5. package/admin/js/lib/api-endpoints-arrange.js +1 -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/forms-arrange.js +1 -0
  11. package/admin/js/lib/media-arrange.js +1 -0
  12. package/admin/js/lib/menus-arrange.js +1 -0
  13. package/admin/js/lib/pages-arrange.js +1 -0
  14. package/admin/js/lib/projects-arrange.js +1 -0
  15. package/admin/js/lib/sidebar-renderer.js +3 -3
  16. package/admin/js/lib/theme-settings-arrange.js +1 -0
  17. package/admin/js/lib/views-arrange.js +1 -0
  18. package/admin/js/templates/actions-list.html +73 -14
  19. package/admin/js/templates/api-endpoints.html +63 -10
  20. package/admin/js/templates/blocks.html +76 -15
  21. package/admin/js/templates/collections.html +108 -52
  22. package/admin/js/templates/components.html +82 -15
  23. package/admin/js/templates/context-menus.html +55 -13
  24. package/admin/js/templates/forms.html +67 -14
  25. package/admin/js/templates/media.html +99 -13
  26. package/admin/js/templates/menus.html +57 -11
  27. package/admin/js/templates/pages.html +78 -23
  28. package/admin/js/templates/projects.html +69 -10
  29. package/admin/js/templates/theme.html +164 -132
  30. package/admin/js/templates/views-list.html +63 -9
  31. package/admin/js/views/actions-list.js +37 -1
  32. package/admin/js/views/api-endpoints.js +11 -7
  33. package/admin/js/views/blocks.js +34 -5
  34. package/admin/js/views/collections.js +36 -1
  35. package/admin/js/views/components.js +27 -5
  36. package/admin/js/views/context-menus.js +10 -5
  37. package/admin/js/views/forms.js +57 -1
  38. package/admin/js/views/index.js +1 -1
  39. package/admin/js/views/media.js +40 -1
  40. package/admin/js/views/menus.js +11 -5
  41. package/admin/js/views/pages.js +28 -8
  42. package/admin/js/views/projects.js +58 -3
  43. package/admin/js/views/theme.js +55 -1
  44. package/admin/js/views/views-list.js +11 -1
  45. package/package.json +1 -1
  46. package/server/routes/api/actions.js +64 -2
  47. package/server/routes/api/api-endpoints.js +41 -0
  48. package/server/routes/api/blocks.js +170 -8
  49. package/server/routes/api/collections.js +53 -1
  50. package/server/routes/api/components.js +46 -0
  51. package/server/routes/api/context-menus.js +8 -0
  52. package/server/routes/api/forms.js +123 -10
  53. package/server/routes/api/media.js +56 -1
  54. package/server/routes/api/menus.js +53 -6
  55. package/server/routes/api/pages.js +46 -0
  56. package/server/routes/api/projects.js +16 -2
  57. package/server/routes/api/theme.js +55 -0
  58. package/server/routes/api/views.js +27 -3
  59. package/server/server.js +19 -0
  60. package/server/services/contextMenus.js +41 -0
  61. package/server/services/projects.js +86 -26
  62. package/server/services/views.js +38 -0
  63. package/admin/js/templates/project-editor.html +0 -45
  64. package/admin/js/views/project-editor.js +0 -1
@@ -1,15 +1,150 @@
1
1
  /**
2
2
  * Blocks API
3
3
  * CRUD for reusable HTML block templates stored in content/blocks/*.html.
4
- * GET /api/blocks - list all blocks
4
+ * GET /api/blocks - list all blocks (each with hasCss, description, example;
5
+ * ?usage=1 adds where each is used: pages and views)
6
+ * GET /api/blocks/_count - the sidebar badge (`_` is outside the block-name alphabet)
5
7
  * GET /api/blocks/:name - get block content
6
8
  * GET /api/blocks/:name/export - download block as .dmblock.json bundle
7
9
  * POST /api/blocks/import - import a .dmblock.json bundle
8
10
  * PUT /api/blocks/:name - create or update block
9
11
  * DELETE /api/blocks/:name - delete block
10
12
  */
13
+ import fs from 'fs/promises';
14
+ import path from 'path';
11
15
  import {authenticate, requirePermission} from '../../middleware/auth.js';
12
16
  import {deleteBlock, getBlock, listBlocks, saveBlock} from '../../services/blocks.js';
17
+ import {config} from '../../config.js';
18
+
19
+ /**
20
+ * A block's own doc comment - the one every block opens with - read for the
21
+ * list: its first line ("contact-card - Contact entry summary card.") gives a
22
+ * description, and the line after "Example:" a shortcode to copy. Plain text;
23
+ * a block without a comment gives neither.
24
+ *
25
+ * @param {string} html
26
+ * @param {string} name
27
+ * @returns {{description: string, example: string}}
28
+ */
29
+ export function blockDoc(html, name = '') {
30
+ const m = /^\s*<!--([\s\S]*?)-->/.exec(String(html || ''));
31
+ if (!m) return {description: '', example: ''};
32
+ const lines = m[1].split('\n').map(l => l.trim());
33
+ // The first paragraph - a description may wrap over several lines.
34
+ const start = lines.findIndex(Boolean);
35
+ const para = [];
36
+ // …ending at a blank line or the next heading ("Example:", "Parameters (all optional):").
37
+ for (let i = Math.max(0, start); start >= 0 && i < lines.length && lines[i]; i++) {
38
+ if (i > start && /^[\w ()/-]+:$/.test(lines[i])) break;
39
+ para.push(lines[i]);
40
+ }
41
+ const first = para.join(' ');
42
+ const lead = new RegExp(`^${name.replace(/[-]/g, '\\-')}\\s*[-–:]\\s*`, 'i');
43
+ const description = name && lead.test(first) ? first.replace(lead, '') : (/^[\w-]+\s*[-–:]\s*/.test(first) ? first.replace(/^[\w-]+\s*[-–:]\s*/, '') : first);
44
+ // The example may wrap over several lines: from its `[` to the line ending `]`.
45
+ let example = '';
46
+ const at = lines.findIndex(l => /^example:?$/i.test(l));
47
+ if (at >= 0) {
48
+ const from = lines.findIndex((l, i) => i > at && l.startsWith('['));
49
+ if (from > 0) {
50
+ const parts = [];
51
+ for (let i = from; i < lines.length && lines[i]; i++) {
52
+ parts.push(lines[i]);
53
+ if (lines[i].endsWith(']')) break;
54
+ }
55
+ example = parts.join(' ');
56
+ }
57
+ }
58
+ return {description: description.slice(0, 200), example: example.slice(0, 300)};
59
+ }
60
+
61
+ /** Escape a block name for a RegExp. Names are [a-z0-9-] so only `-` needs care. */
62
+ const reName = (n) => n.replace(/-/g, '\\-');
63
+
64
+ /**
65
+ * Where each block is used, by reading - not rendering - the page sources and
66
+ * view configs: `block="x"` (collection/view/carousel/listgroup displays),
67
+ * `template="x"` (the [block] shortcode), the `[x …/]` short tag, and a view's
68
+ * `display.block`. Cheap enough for the list and the badge; a block pulled in
69
+ * only by a plugin's own code is not seen (so "unused" means "not used by a
70
+ * page or view").
71
+ *
72
+ * @param {string[]} names
73
+ * @param {{url: string, raw: string}[]} pages
74
+ * @param {{slug: string, name?: string, display?: {block?: string}}[]} views
75
+ * @returns {Record<string, {pages: string[], views: string[]}>}
76
+ */
77
+ export function blockUsage(names, pages = [], views = []) {
78
+ const out = Object.fromEntries(names.map(n => [n, {pages: [], views: []}]));
79
+ for (const n of names) {
80
+ const re = new RegExp(`(?:\\b(?:block|template)\\s*=\\s*["']${reName(n)}["'])|(?:\\[${reName(n)}(?=[\\s/\\]]))`);
81
+ for (const p of pages) if (re.test(p.raw)) out[n].pages.push(p.url);
82
+ for (const v of views) if (v?.display?.block === n) out[n].views.push(v.slug || v.name || '');
83
+ }
84
+ return out;
85
+ }
86
+
87
+ /** Every page's source, with its URL - read from disk, never rendered. */
88
+ async function readPageSources() {
89
+ const root = path.join(config.content.contentDir, 'pages');
90
+ const out = [];
91
+ async function walk(dir) {
92
+ let entries = [];
93
+ try { entries = await fs.readdir(dir, {withFileTypes: true}); } catch { return; }
94
+ for (const e of entries) {
95
+ const full = path.join(dir, e.name);
96
+ if (e.isDirectory()) await walk(full);
97
+ else if (e.name.endsWith('.md')) {
98
+ const rel = path.relative(root, full).replace(/\.md$/, '').split(path.sep).join('/');
99
+ const url = rel === 'index' ? '/' : '/' + rel.replace(/\/index$/, '');
100
+ try { out.push({url, raw: await fs.readFile(full, 'utf8')}); } catch { /* unreadable - skip */ }
101
+ }
102
+ }
103
+ }
104
+ await walk(root);
105
+ return out;
106
+ }
107
+
108
+ async function usageFor(names) {
109
+ const [pages, views] = await Promise.all([
110
+ readPageSources(),
111
+ import('../../services/views.js').then(m => m.listViews()).catch(() => [])
112
+ ]);
113
+ return blockUsage(names, pages, views);
114
+ }
115
+
116
+ /**
117
+ * The sidebar badge on Blocks: how many there are, with the unused ones and
118
+ * the latest changes in the popover. A count to glance at, never a warning.
119
+ * Plain text throughout - the popover sets it with textContent.
120
+ *
121
+ * @param {{name: string, updatedAt?: string, hasCss?: boolean, bundled?: boolean, usage?: {pages: string[], views: string[]}}[]} blocks
122
+ */
123
+ export function blocksBadge(blocks = []) {
124
+ const n = blocks.length;
125
+ const used = (b) => (b.usage?.pages?.length || 0) + (b.usage?.views?.length || 0);
126
+ const unused = blocks.filter(b => b.usage && !used(b)).length;
127
+ const recent = [...blocks].sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''))).slice(0, 6);
128
+ return {
129
+ count: n,
130
+ label: `${n} block${n === 1 ? '' : 's'}${unused ? `, ${unused} not used on any page` : ''}`,
131
+ tone: 'info',
132
+ details: {
133
+ title: 'Blocks',
134
+ rows: [
135
+ {label: 'Blocks', value: String(n)},
136
+ {label: 'With CSS', value: String(blocks.filter(b => b.hasCss).length)},
137
+ {label: 'Not used on a page or view', value: String(unused)},
138
+ {label: 'Built in', value: String(blocks.filter(b => b.bundled).length)}
139
+ ],
140
+ items: recent.map(b => ({
141
+ text: b.name,
142
+ meta: [b.updatedAt ? `changed ${String(b.updatedAt).slice(0, 10)}` : '', b.usage ? `used ${used(b)} time${used(b) === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · ')
143
+ })),
144
+ empty: 'No blocks yet.'
145
+ }
146
+ };
147
+ }
13
148
 
14
149
  /** Current export bundle schema version. Bump when the shape changes. */
15
150
  const BUNDLE_FORMAT = 1;
@@ -19,18 +154,45 @@ export async function blocksRoutes(fastify) {
19
154
  const canUpdate = {preHandler: [authenticate, requirePermission('pages', 'update')]};
20
155
  const canDelete = {preHandler: [authenticate, requirePermission('pages', 'delete')]};
21
156
 
22
- // List all blocks
23
- fastify.get('/blocks', canRead, async (request) => {
157
+ /**
158
+ * The blocks this user may see, each with what the list shows beyond the
159
+ * file: whether it has CSS, and its doc comment's description and example.
160
+ * getBlock was already read per block for the project check, so these come free.
161
+ */
162
+ async function visibleBlocks(user) {
24
163
  const {canSeeArtefact} = await import('../../services/projects.js');
25
- const all = await listBlocks();
26
- const filtered = [];
27
- for (const b of all) {
164
+ const out = [];
165
+ for (const b of await listBlocks()) {
28
166
  // listBlocks returns metadata only; getBlock returns full record with meta
29
167
  let full = null;
30
168
  try { full = await getBlock(b.name); } catch { /* skip */ }
31
- if (canSeeArtefact(request.user, full)) filtered.push(b);
169
+ if (!canSeeArtefact(user, full)) continue;
170
+ out.push({...b, hasCss: Boolean(full?.css), cssSize: full?.css?.length || 0, ...blockDoc(full?.content, b.name)});
171
+ }
172
+ return out;
173
+ }
174
+
175
+ // List all blocks (?usage=1: where each is used)
176
+ fastify.get('/blocks', canRead, async (request) => {
177
+ const blocks = await visibleBlocks(request.user);
178
+ if (request.query?.usage !== '1') return blocks;
179
+ const usage = await usageFor(blocks.map(b => b.name));
180
+ return blocks.map(b => ({...b, usage: usage[b.name]}));
181
+ });
182
+
183
+ // The sidebar badge. Registered before /blocks/:name; `_` is not a valid block name.
184
+ // Usage reads every page's source, and the badge is asked for every minute from
185
+ // every admin tab: it is kept a minute (the list's ?usage=1 always reads afresh).
186
+ let usageCache = {at: 0, key: '', value: null};
187
+ fastify.get('/blocks/_count', canRead, async (request) => {
188
+ const blocks = await visibleBlocks(request.user);
189
+ const names = blocks.map(b => b.name);
190
+ const key = names.join('\n');
191
+ if (!usageCache.value || usageCache.key !== key || Date.now() - usageCache.at > 60_000) {
192
+ usageCache = {at: Date.now(), key, value: await usageFor(names)};
32
193
  }
33
- return filtered;
194
+ const usage = usageCache.value;
195
+ return blocksBadge(blocks.map(b => ({...b, usage: usage[b.name]})));
34
196
  });
35
197
 
36
198
  // Get single block
@@ -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
@@ -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
  *
@@ -242,11 +282,23 @@ export async function collectionsRoutes(fastify) {
242
282
  for (const c of all) {
243
283
  // listCollections returns metadata only; getCollection returns full record with meta
244
284
  const full = await getCollection(c.slug);
245
- if (canSeeArtefact(request.user, full)) filtered.push(c);
285
+ // builtIn: the ones DELETE refuses (seeding turns `preset` into `bundled`, which
286
+ // also covers deletable seeded collections - so the list says which is which).
287
+ if (canSeeArtefact(request.user, full)) filtered.push(ALL_PRESET_SLUGS.has(c.slug) ? {...c, builtIn: true} : c);
246
288
  }
247
289
  return filtered;
248
290
  });
249
291
 
292
+ // The sidebar badge. Static, so it wins over /collections/:slug; `_` is never in a slug.
293
+ fastify.get('/collections/_count', canRead, async (request) => {
294
+ const {canSeeArtefact} = await import('../../services/projects.js');
295
+ const visible = [];
296
+ for (const c of await listCollections()) {
297
+ if (canSeeArtefact(request.user, await getCollection(c.slug))) visible.push(c);
298
+ }
299
+ return collectionsBadge(visible);
300
+ });
301
+
250
302
  fastify.get('/collections/pro-status', canRead, async () => {
251
303
  try {
252
304
  const connections = getConfig('connections');
@@ -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'});
@@ -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
  // -----------------------------------------------------------------------
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Media API
3
3
  * GET /api/media - list media files
4
+ * GET /api/media/_count - the sidebar badge (plain text; see mediaBadge)
4
5
  * POST /api/media - upload a file
5
6
  * DELETE /api/media/:name - delete a file
6
7
  */
@@ -8,6 +9,7 @@ import path from 'path';
8
9
  import {deleteMedia, listMedia, renameMedia, saveMedia} from '../../services/content.js';
9
10
  import {getImageInfo, isEditableImage, transformImage} from '../../services/images.js';
10
11
  import {authenticate, requirePermission} from '../../middleware/auth.js';
12
+ import {ago, clip, plural} from '../../services/sidebarBadges.js';
11
13
 
12
14
  const ALLOWED_MIME_TYPES = new Set([
13
15
  // Images
@@ -30,6 +32,50 @@ function sanitiseFilename(name) {
30
32
  return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '_');
31
33
  }
32
34
 
35
+ /** 0 B, 812 B, 14.2 KB, 3.1 MB - the badge's sizes. */
36
+ function bytes(n) {
37
+ const v0 = Number(n) || 0;
38
+ if (v0 < 1024) return `${v0} B`;
39
+ const units = ['KB', 'MB', 'GB', 'TB'];
40
+ let v = v0 / 1024;
41
+ let i = 0;
42
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
43
+ return `${v.toFixed(1)} ${units[i]}`;
44
+ }
45
+
46
+ const IMAGE_RE = /\.(png|jpe?g|gif|webp|svg|avif|ico|bmp|tiff?)$/i;
47
+
48
+ /**
49
+ * The badge on Content › Media: how many files, how much space, the newest
50
+ * few. Informational - nothing on this screen is ever overdue. Every value is
51
+ * plain text; the sidebar popover sets it with textContent (file names are
52
+ * whatever was uploaded).
53
+ *
54
+ * @param {{name: string, size: number, createdAt: string}[]} files
55
+ * @param {number} [now]
56
+ */
57
+ export function mediaBadge(files = [], now = Date.now()) {
58
+ const total = files.reduce((s, f) => s + (Number(f.size) || 0), 0);
59
+ const images = files.filter(f => IMAGE_RE.test(f.name)).length;
60
+ const newest = [...files].sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
61
+ return {
62
+ count: files.length,
63
+ label: files.length ? `${plural(files.length, 'file')}, ${bytes(total)}` : 'No media yet',
64
+ tone: 'info',
65
+ details: {
66
+ title: 'Media',
67
+ rows: [
68
+ {label: 'Files', value: String(files.length)},
69
+ {label: 'Images', value: String(images)},
70
+ {label: 'Other files', value: String(files.length - images)},
71
+ {label: 'Space used', value: bytes(total)}
72
+ ],
73
+ items: newest.slice(0, 5).map(f => ({text: clip(f.name, 48), meta: `${bytes(f.size)} · ${ago(f.createdAt, now)}`})),
74
+ empty: 'Nothing uploaded yet.'
75
+ }
76
+ };
77
+ }
78
+
33
79
  export async function mediaRoutes(fastify) {
34
80
  const canRead = {preHandler: [authenticate, requirePermission('media', 'read')]};
35
81
  const canCreate = {preHandler: [authenticate, requirePermission('media', 'create')]};
@@ -40,6 +86,9 @@ export async function mediaRoutes(fastify) {
40
86
  return listMedia();
41
87
  });
42
88
 
89
+ // `_count` is not a file route: the only GET under /media/:name is /media/:name/info.
90
+ fastify.get('/media/_count', canRead, async () => mediaBadge(await listMedia()));
91
+
43
92
  fastify.post('/media', canCreate, async (request, reply) => {
44
93
  const results = [];
45
94
  for await (const data of request.files()) {
@@ -84,7 +133,13 @@ export async function mediaRoutes(fastify) {
84
133
 
85
134
  fastify.delete('/media/:name', canDelete, async (request, reply) => {
86
135
  const name = sanitiseFilename(request.params.name);
87
- await deleteMedia(name);
136
+ try {
137
+ await deleteMedia(name);
138
+ } catch (err) {
139
+ // Gone already (another tab, another admin) is a 404, not a server error.
140
+ if (err.code === 'ENOENT') return reply.status(404).send({error: 'File not found'});
141
+ throw err;
142
+ }
88
143
  return { success: true };
89
144
  });
90
145