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.
- package/CLAUDE.md +3 -1
- package/admin/js/app.js +4 -4
- package/admin/js/lib/actions-arrange.js +1 -0
- package/admin/js/lib/api-endpoints-arrange.js +1 -0
- package/admin/js/lib/api-tokens-arrange.js +3 -0
- package/admin/js/lib/blocks-arrange.js +1 -0
- package/admin/js/lib/collections-arrange.js +1 -0
- package/admin/js/lib/components-arrange.js +1 -0
- package/admin/js/lib/context-menus-arrange.js +1 -0
- package/admin/js/lib/effects-arrange.js +3 -0
- package/admin/js/lib/forms-arrange.js +1 -0
- package/admin/js/lib/layouts-arrange.js +1 -0
- package/admin/js/lib/media-arrange.js +1 -0
- package/admin/js/lib/menus-arrange.js +1 -0
- package/admin/js/lib/my-profile-arrange.js +1 -0
- package/admin/js/lib/notifications-arrange.js +1 -0
- package/admin/js/lib/pages-arrange.js +1 -0
- package/admin/js/lib/plugins-arrange.js +2 -0
- package/admin/js/lib/roles-arrange.js +1 -0
- package/admin/js/lib/search-settings-arrange.js +1 -0
- package/admin/js/lib/sidebar-renderer.js +3 -3
- package/admin/js/lib/site-settings-arrange.js +1 -0
- package/admin/js/lib/theme-settings-arrange.js +1 -0
- package/admin/js/lib/users-arrange.js +1 -0
- package/admin/js/lib/views-arrange.js +1 -0
- package/admin/js/templates/actions-list.html +73 -14
- package/admin/js/templates/api-endpoints.html +63 -10
- package/admin/js/templates/api-tokens.html +83 -10
- package/admin/js/templates/blocks.html +76 -15
- package/admin/js/templates/collections.html +108 -52
- package/admin/js/templates/components.html +82 -15
- package/admin/js/templates/context-menus.html +55 -13
- package/admin/js/templates/effects.html +155 -72
- package/admin/js/templates/forms.html +67 -14
- package/admin/js/templates/layouts.html +126 -43
- package/admin/js/templates/media.html +99 -13
- package/admin/js/templates/menus.html +57 -11
- package/admin/js/templates/my-profile.html +100 -12
- package/admin/js/templates/notifications.html +70 -10
- package/admin/js/templates/pages.html +78 -23
- package/admin/js/templates/plugins.html +100 -28
- package/admin/js/templates/roles.html +78 -7
- package/admin/js/templates/search.html +193 -216
- package/admin/js/templates/settings.html +404 -472
- package/admin/js/templates/theme.html +164 -132
- package/admin/js/templates/users.html +69 -9
- package/admin/js/templates/views-list.html +63 -9
- package/admin/js/views/actions-list.js +37 -1
- package/admin/js/views/api-endpoints.js +11 -7
- package/admin/js/views/api-tokens.js +62 -8
- package/admin/js/views/blocks.js +34 -5
- package/admin/js/views/collections.js +36 -1
- package/admin/js/views/components.js +27 -5
- package/admin/js/views/context-menus.js +10 -5
- package/admin/js/views/effects.js +31 -1
- package/admin/js/views/forms.js +57 -1
- package/admin/js/views/layouts.js +81 -1
- package/admin/js/views/media.js +40 -1
- package/admin/js/views/menus.js +11 -5
- package/admin/js/views/my-profile.js +1 -1
- package/admin/js/views/notifications.js +18 -1
- package/admin/js/views/pages.js +28 -8
- package/admin/js/views/plugins.js +97 -39
- package/admin/js/views/roles.js +49 -4
- package/admin/js/views/search.js +7 -1
- package/admin/js/views/settings.js +13 -3
- package/admin/js/views/theme.js +55 -1
- package/admin/js/views/users.js +13 -4
- package/admin/js/views/views-list.js +11 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/mail/compose-window.js +97 -40
- package/plugins/_lib/admin/mail/contacts.js +4 -1
- package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
- package/plugins/_lib/admin/mail/folder-tree.js +1 -1
- package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
- package/plugins/_lib/admin/mail/mail.css +1 -1
- package/plugins/_lib/admin/mail/mail.html +1 -1
- package/plugins/_lib/admin/mail/reader-view.js +303 -44
- package/plugins/_lib/admin/mail/when-closed.js +26 -0
- package/plugins/_lib/mail/readRoutes.js +43 -1
- package/plugins/_lib/mail/unreadBadge.js +42 -0
- package/plugins/free-tier.lock.json +5 -5
- package/plugins/mail-reader/CLAUDE.md +1 -0
- package/plugins/mail-reader/plugin.js +3 -1
- package/plugins/mail-reader/plugin.json +5 -4
- package/server/routes/api/actions.js +64 -2
- package/server/routes/api/api-endpoints.js +41 -0
- package/server/routes/api/api-tokens.js +56 -0
- package/server/routes/api/auth.js +7 -1
- package/server/routes/api/blocks.js +170 -8
- package/server/routes/api/collections.js +66 -2
- package/server/routes/api/components.js +46 -0
- package/server/routes/api/context-menus.js +8 -0
- package/server/routes/api/effects.js +68 -13
- package/server/routes/api/forms.js +123 -10
- package/server/routes/api/layouts.js +93 -2
- package/server/routes/api/media.js +56 -1
- package/server/routes/api/menus.js +53 -6
- package/server/routes/api/notifications.js +140 -14
- package/server/routes/api/pages.js +46 -0
- package/server/routes/api/plugins.js +135 -4
- package/server/routes/api/roles.js +111 -0
- package/server/routes/api/search.js +38 -4
- package/server/routes/api/settings.js +52 -0
- package/server/routes/api/theme.js +55 -0
- package/server/routes/api/users.js +53 -1
- package/server/routes/api/views.js +27 -3
- package/server/server.js +28 -1
- package/server/services/contextMenus.js +41 -0
- package/server/services/plugins.js +7 -0
- package/server/services/roles.js +3 -0
- package/server/services/search.js +58 -0
- package/server/services/users.js +14 -0
- package/server/services/views.js +38 -0
- package/admin/js/templates/plugin-marketplace.html +0 -29
- 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
|
-
|
|
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 {
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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};
|