domma-cms 0.75.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 (66) hide show
  1. package/admin/js/app.js +4 -4
  2. package/admin/js/lib/api-tokens-arrange.js +3 -0
  3. package/admin/js/lib/effects-arrange.js +3 -0
  4. package/admin/js/lib/layouts-arrange.js +1 -0
  5. package/admin/js/lib/my-profile-arrange.js +1 -0
  6. package/admin/js/lib/notifications-arrange.js +1 -0
  7. package/admin/js/lib/plugins-arrange.js +2 -0
  8. package/admin/js/lib/roles-arrange.js +1 -0
  9. package/admin/js/lib/search-settings-arrange.js +1 -0
  10. package/admin/js/lib/sidebar-renderer.js +2 -2
  11. package/admin/js/lib/site-settings-arrange.js +1 -0
  12. package/admin/js/lib/users-arrange.js +1 -0
  13. package/admin/js/templates/api-tokens.html +83 -10
  14. package/admin/js/templates/effects.html +155 -72
  15. package/admin/js/templates/layouts.html +126 -43
  16. package/admin/js/templates/my-profile.html +100 -12
  17. package/admin/js/templates/notifications.html +70 -10
  18. package/admin/js/templates/plugins.html +100 -28
  19. package/admin/js/templates/roles.html +78 -7
  20. package/admin/js/templates/search.html +193 -216
  21. package/admin/js/templates/settings.html +404 -472
  22. package/admin/js/templates/users.html +69 -9
  23. package/admin/js/views/api-tokens.js +62 -8
  24. package/admin/js/views/effects.js +31 -1
  25. package/admin/js/views/layouts.js +81 -1
  26. package/admin/js/views/my-profile.js +1 -1
  27. package/admin/js/views/notifications.js +18 -1
  28. package/admin/js/views/plugins.js +97 -39
  29. package/admin/js/views/roles.js +49 -4
  30. package/admin/js/views/search.js +7 -1
  31. package/admin/js/views/settings.js +13 -3
  32. package/admin/js/views/users.js +13 -4
  33. package/package.json +1 -1
  34. package/plugins/_lib/admin/mail/compose-window.js +97 -40
  35. package/plugins/_lib/admin/mail/contacts.js +4 -1
  36. package/plugins/_lib/admin/mail/diagnostics-section.js +4 -1
  37. package/plugins/_lib/admin/mail/folder-tree.js +1 -1
  38. package/plugins/_lib/admin/mail/image-senders-section.js +69 -90
  39. package/plugins/_lib/admin/mail/mail.css +1 -1
  40. package/plugins/_lib/admin/mail/mail.html +1 -1
  41. package/plugins/_lib/admin/mail/reader-view.js +303 -44
  42. package/plugins/_lib/admin/mail/when-closed.js +26 -0
  43. package/plugins/_lib/mail/readRoutes.js +43 -1
  44. package/plugins/_lib/mail/unreadBadge.js +42 -0
  45. package/plugins/free-tier.lock.json +5 -5
  46. package/plugins/mail-reader/CLAUDE.md +1 -0
  47. package/plugins/mail-reader/plugin.js +3 -1
  48. package/plugins/mail-reader/plugin.json +5 -4
  49. package/server/routes/api/api-tokens.js +56 -0
  50. package/server/routes/api/auth.js +7 -1
  51. package/server/routes/api/collections.js +13 -1
  52. package/server/routes/api/effects.js +68 -13
  53. package/server/routes/api/layouts.js +93 -2
  54. package/server/routes/api/notifications.js +140 -14
  55. package/server/routes/api/plugins.js +135 -4
  56. package/server/routes/api/roles.js +111 -0
  57. package/server/routes/api/search.js +38 -4
  58. package/server/routes/api/settings.js +52 -0
  59. package/server/routes/api/users.js +53 -1
  60. package/server/server.js +12 -0
  61. package/server/services/plugins.js +7 -0
  62. package/server/services/roles.js +3 -0
  63. package/server/services/search.js +58 -0
  64. package/server/services/users.js +14 -0
  65. package/admin/js/templates/plugin-marketplace.html +0 -29
  66. package/admin/js/views/plugin-marketplace.js +0 -1
@@ -38,6 +38,7 @@ import {readPriority} from './priority.js';
38
38
  import {listAttachments, renderBody} from './render.js';
39
39
  import {readUnsubscribe} from './unsubscribe.js';
40
40
  import {MailKeyError} from './secretbox.js';
41
+ import {unreadBadge} from './unreadBadge.js';
41
42
 
42
43
  /**
43
44
  * Flatten an envelope address list into something the browser can render.
@@ -52,7 +53,7 @@ function addresses(list) {
52
53
  }));
53
54
  }
54
55
 
55
- export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginName, cachedList = null, diagnostics = null}) {
56
+ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginName, cachedList = null, diagnostics = null, unreadBadge: withUnreadBadge = false}) {
56
57
  const {authenticate} = auth;
57
58
 
58
59
  // A deleted user's stored mailbox passwords must not outlive their
@@ -165,6 +166,47 @@ export function registerReadRoutes(fastify, {auth, hooks, config, pool, pluginNa
165
166
  // Accounts
166
167
  // -------------------------------------------------------------------------
167
168
 
169
+ /**
170
+ * GET /unread - the sidebar badge (the plugin.json countUrl). Opt-in:
171
+ * Email Pro counts from its store and registers its own. INBOX only, by
172
+ * STATUS (no select, so nothing can be marked read), and kept a minute
173
+ * per user - the sidebar asks every minute from every open admin tab.
174
+ */
175
+ if (withUnreadBadge) {
176
+ const badgeCache = new Map();
177
+ // A mailbox that failed (a changed password, a server down) is not asked
178
+ // again every minute: that is a failed IMAP login per minute per open tab,
179
+ // enough to trip fail2ban or a provider lockout. It backs off 5 min, doubling
180
+ // to an hour; saving the account (a new updatedAt) retries straight away.
181
+ const failures = new Map();
182
+ const failKey = (uid, account) => `${uid}:${account.id}:${account.updatedAt || account.createdAt || ''}`;
183
+ fastify.get('/unread', {preHandler: [authenticate]}, async (request, reply) => {
184
+ const uid = userId(request);
185
+ if (!uid) return reply.code(401).send({error: 'Authentication required.'});
186
+ const hit = badgeCache.get(uid);
187
+ if (hit && Date.now() - hit.at < 60_000) return hit.value;
188
+ const boxes = [];
189
+ for (const account of await accounts.listAccounts(uid)) {
190
+ const key = failKey(uid, account);
191
+ const failed = failures.get(key);
192
+ if (failed && Date.now() < failed.until) { boxes.push({label: account.label, unseen: null}); continue; }
193
+ try {
194
+ const status = await pool.withClient(`${uid}:${account.id}`, accounts.toConnection(account),
195
+ client => client.status('INBOX', {unseen: true}));
196
+ failures.delete(key);
197
+ boxes.push({label: account.label, unseen: status?.unseen ?? 0});
198
+ } catch {
199
+ const wait = Math.min((failed?.wait || 150_000) * 2, 3_600_000);
200
+ failures.set(key, {wait, until: Date.now() + wait});
201
+ boxes.push({label: account.label, unseen: null});
202
+ }
203
+ }
204
+ const value = unreadBadge(boxes);
205
+ badgeCache.set(uid, {at: Date.now(), value});
206
+ return value;
207
+ });
208
+ }
209
+
168
210
  /** GET /accounts - every mailbox this user owns, without passwords. */
169
211
  fastify.get('/accounts', {preHandler: [authenticate]}, async (request, reply) => {
170
212
  const uid = userId(request);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The free reader's sidebar badge - unread mail in each mailbox's INBOX.
3
+ *
4
+ * Pure: the route asks IMAP (STATUS, which reads without selecting and so can
5
+ * never mark anything read) and hands the figures here. Email Pro has its own
6
+ * badge over the synchronised store; this is the edition without one, so it
7
+ * counts INBOX only - asking a server about every folder once a minute is
8
+ * how a mailbox gets rate-limited.
9
+ *
10
+ * Plain text throughout: the sidebar popover sets it with textContent.
11
+ *
12
+ * @module _lib/mail/unreadBadge
13
+ */
14
+
15
+ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
16
+
17
+ /**
18
+ * @param {{label?: string, unseen: number|null, error?: string}[]} boxes - one per mailbox
19
+ * @returns {{count: number, label: string, tone: string, details: object}}
20
+ */
21
+ export function unreadBadge(boxes = []) {
22
+ const known = boxes.filter(b => Number.isFinite(b.unseen));
23
+ const count = known.reduce((n, b) => n + b.unseen, 0);
24
+ const failed = boxes.length - known.length;
25
+ return {
26
+ count,
27
+ label: boxes.length
28
+ ? `${plural(count, 'unread message')} in the inbox${boxes.length > 1 ? 'es' : ''}${failed ? ` (${plural(failed, 'mailbox', 'mailboxes')} could not be checked)` : ''}`
29
+ : 'No mailbox set up',
30
+ tone: 'info',
31
+ details: {
32
+ title: 'Mail',
33
+ rows: boxes.map(b => ({
34
+ label: String(b.label || 'Mailbox'),
35
+ value: Number.isFinite(b.unseen) ? plural(b.unseen, 'unread', 'unread') : 'not reachable',
36
+ ...(Number.isFinite(b.unseen) ? {} : {tone: 'danger'})
37
+ })),
38
+ items: [],
39
+ empty: boxes.length ? 'Nothing unread in the inbox.' : 'Add a mailbox to see unread mail here.'
40
+ }
41
+ };
42
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@d9bfe49",
3
+ "source": "dcms-marketplace@5f64a68",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.6.3",
@@ -67,13 +67,13 @@
67
67
  }
68
68
  },
69
69
  "mail-reader": {
70
- "version": "1.0.0",
70
+ "version": "1.1.0",
71
71
  "files": {
72
- "CLAUDE.md": "a8dd2cb285e1d24b2f072aa11a644e1c8c1be89013da6f6cc46d9820fe24ccd5",
72
+ "CLAUDE.md": "7f317e69b560457f2d4c401b466754850afe75f6983bc41414c71d8c276c3583",
73
73
  "admin/views/mail.js": "c5c6483f9618c75cac8f4be32d3aea99511db76d4b9d38b8ef43bdc1d35db3c5",
74
74
  "config.js": "6e65803ad822a9acda40c203631b8eb32a87cf9d9e5f1c7cb59d01aa406075c6",
75
- "plugin.js": "79b17346ed0192b092040460cc8d762d5207ee6e4af7bfc2d1cf654a51e2fa81",
76
- "plugin.json": "5f5b98757514aa70d7c7a2be5a1fbfdde8ef21a2ccc7586b3b5aea9b44fbe5e6"
75
+ "plugin.js": "ebe4c41bdd8836fb3128af3a0e789d47f89b00e9e37dc9378bb308aaae8d41b2",
76
+ "plugin.json": "ed1f02d9405f6ae1257313ea4155ea49e307fb2210650fd247b92e6988aa78d1"
77
77
  }
78
78
  }
79
79
  }
@@ -24,6 +24,7 @@ All under `/api/plugins/mail-reader`, all requiring authentication.
24
24
 
25
25
  | Method | Path | Purpose |
26
26
  |---|---|---|
27
+ | GET | `/unread` | The sidebar badge: unread in each mailbox's INBOX, by STATUS (never selects, so nothing is marked read), cached a minute per user. `registerReadRoutes({unreadBadge: true})` - Email Pro counts from its store instead. |
27
28
  | GET | `/accounts` | Every mailbox this user owns. Never returns passwords, only `hasPassword`. |
28
29
  | POST | `/accounts` | Add a mailbox. |
29
30
  | PUT | `/accounts/:id` | Update one. A blank password means "keep the stored one". |
@@ -30,7 +30,9 @@ export default async function mailReaderPlugin(fastify, options) {
30
30
  hooks: options.hooks,
31
31
  config,
32
32
  pool,
33
- pluginName: 'mail-reader'
33
+ pluginName: 'mail-reader',
34
+ // The sidebar badge (plugin.json countUrl): unread in each INBOX.
35
+ unreadBadge: true
34
36
  });
35
37
  }
36
38
 
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "mail-reader",
3
3
  "displayName": "Mail Reader",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "tier": "free",
6
6
  "description": "Read-only IMAP mailbox reader. Connects to a standard IMAP server and reads mail without ever writing to it.",
7
7
  "author": "Darryl Waterhouse",
8
- "date": "2026-09-19",
8
+ "date": "2026-09-25",
9
9
  "icon": "mail",
10
10
  "admin": {
11
11
  "sidebar": [
@@ -14,7 +14,8 @@
14
14
  "text": "Mail Reader",
15
15
  "icon": "mail",
16
16
  "url": "#/plugins/mail-reader",
17
- "section": "#/plugins/mail-reader"
17
+ "section": "#/plugins/mail-reader",
18
+ "countUrl": "/api/plugins/mail-reader/unread"
18
19
  }
19
20
  ],
20
21
  "routes": [
@@ -26,7 +27,7 @@
26
27
  ],
27
28
  "views": {
28
29
  "plugin-mail-reader": {
29
- "entry": "mail-reader/admin/views/mail.js?v=1",
30
+ "entry": "mail-reader/admin/views/mail.js?v=2",
30
31
  "exportName": "mailView"
31
32
  }
32
33
  }
@@ -2,6 +2,7 @@
2
2
  * API Tokens API
3
3
  *
4
4
  * GET /api/api-tokens - list tokens visible to the caller (sanitised; never the hash)
5
+ * GET /api/api-tokens/_count - the sidebar badge: tokens that need attention (expired / expiring)
5
6
  * POST /api/api-tokens - create; response carries the plaintext ONCE
6
7
  * PUT /api/api-tokens/:id - rename, enable/disable, edit scopes/expiry
7
8
  * DELETE /api/api-tokens/:id - revoke (immediate)
@@ -23,6 +24,58 @@ import {
23
24
  } from '../../services/apiTokens.js';
24
25
  import {canSeeArtefact} from '../../services/projects.js';
25
26
 
27
+ /** A token this close to its expiry is called out - SOON_DAYS in admin/js/lib/api-tokens-arrange.js. */
28
+ const SOON_DAYS = 14;
29
+ const DAY = 86_400_000;
30
+
31
+ /**
32
+ * The sidebar badge on API Tokens (its countUrl, GET /api/api-tokens/_count).
33
+ *
34
+ * The number is a signal, not an inventory: the tokens that need someone -
35
+ * switched on but already expired (every call with one is being refused, and
36
+ * nothing else says so), and switched on and expiring within SOON_DAYS. Zero is
37
+ * no badge. Red while any has expired. Plain text - the popover sets it with
38
+ * textContent; token names are the admin's own, never the secret.
39
+ *
40
+ * @param {object[]} tokens - sanitised, as listTokensSanitised returns them
41
+ * @param {number} [now]
42
+ */
43
+ export function apiTokensBadge(tokens = [], now = Date.now()) {
44
+ const on = tokens.filter(t => t.enabled !== false);
45
+ const at = (t) => (t.expiresAt ? Date.parse(t.expiresAt) : NaN);
46
+ const expired = on.filter(t => at(t) <= now);
47
+ const soon = on.filter(t => at(t) > now && at(t) - now <= SOON_DAYS * DAY)
48
+ .sort((a, b) => at(a) - at(b));
49
+ const off = tokens.length - on.length;
50
+ const need = expired.length + soon.length;
51
+ const plural = (n, one) => `${n} ${one}${n === 1 ? '' : 's'}`;
52
+ const days = (t) => {
53
+ const d = Math.ceil((at(t) - now) / DAY);
54
+ return d <= 0 ? 'today' : d === 1 ? 'tomorrow' : `in ${d} days`;
55
+ };
56
+ return {
57
+ count: need,
58
+ label: need
59
+ ? [expired.length && `${plural(expired.length, 'token')} expired`, soon.length && `${soon.length} expiring soon`].filter(Boolean).join(', ')
60
+ : `${plural(tokens.length, 'token')}, none expiring soon`,
61
+ tone: expired.length ? 'danger' : 'attention',
62
+ details: {
63
+ title: 'API Tokens',
64
+ rows: [
65
+ {label: 'Working', value: String(on.length - expired.length)},
66
+ {label: 'Expiring soon', value: String(soon.length)},
67
+ {label: 'Expired', value: String(expired.length), ...(expired.length && {tone: 'danger'})},
68
+ {label: 'Switched off', value: String(off)}
69
+ ],
70
+ items: [
71
+ ...expired.map(t => ({text: String(t.name || '(unnamed)'), meta: `${t.project} · expired - callers are refused`, tone: 'danger'})),
72
+ ...soon.map(t => ({text: String(t.name || '(unnamed)'), meta: `${t.project} · expires ${days(t)}`}))
73
+ ].slice(0, 8),
74
+ empty: 'No token has expired or expires in the next two weeks.'
75
+ }
76
+ };
77
+ }
78
+
26
79
  /**
27
80
  * Register the api-tokens routes.
28
81
  *
@@ -43,6 +96,9 @@ export async function apiTokensRoutes(fastify, opts = {}) {
43
96
  return listTokensSanitised(request.user);
44
97
  });
45
98
 
99
+ // There is no GET /api-tokens/:id, and ids are uuids - nothing can shadow this.
100
+ fastify.get('/api-tokens/_count', canRead, async (request) => apiTokensBadge(await listTokensSanitised(request.user)));
101
+
46
102
  fastify.post('/api-tokens', canCreate, async (request, reply) => {
47
103
  const {name, project, scopes, expiresAt} = request.body || {};
48
104
  if (project && !canSeeArtefact(request.user, {meta: {project}})) {
@@ -170,7 +170,13 @@ export async function authRoutes(fastify) {
170
170
  if (email) coreUpdates.email = email;
171
171
  if (password) coreUpdates.password = password;
172
172
 
173
- const user = await updateUser(id, coreUpdates);
173
+ let user;
174
+ try {
175
+ user = await updateUser(id, coreUpdates);
176
+ } catch (err) {
177
+ if (err.code === 'EMAIL_TAKEN') return reply.status(409).send({error: err.message});
178
+ throw err;
179
+ }
174
180
 
175
181
  if (profile && typeof profile === 'object') {
176
182
  await updateProfile(id, profile);
@@ -54,7 +54,7 @@ import {
54
54
  updateEntry
55
55
  } from '../../services/collections.js';
56
56
  import {authenticate, requireAdmin, requirePermission} from '../../middleware/auth.js';
57
- import {getRoleLevel, invalidate as invalidateRoles} from '../../services/roles.js';
57
+ import {getRoleLevel, invalidate as invalidateRoles, isBaseRole} from '../../services/roles.js';
58
58
  import {getEffectiveLevel} from '../../services/userRoles.js';
59
59
  import {getConfig, saveConfig} from '../../config.js';
60
60
  import {PRESET_COLLECTION_SLUGS} from '../../services/presetCollections.js';
@@ -262,6 +262,9 @@ async function keepRoleSystemFields(id, data) {
262
262
  if (existing[field] !== undefined) out[field] = existing[field];
263
263
  else delete out[field];
264
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;
265
268
  return out;
266
269
  }
267
270
 
@@ -494,6 +497,11 @@ export async function collectionsRoutes(fastify) {
494
497
  if (entry?.data?.level === 0) {
495
498
  return reply.status(403).send({ error: 'Cannot delete the root admin role' });
496
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
+ }
497
505
  }
498
506
  try {
499
507
  await deleteEntry(request.params.slug, request.params.id);
@@ -507,6 +515,10 @@ export async function collectionsRoutes(fastify) {
507
515
 
508
516
  // Clear all entries - DELETE /collections/:slug/entries (no :id)
509
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
+ }
510
522
  try {
511
523
  await clearEntries(request.params.slug);
512
524
  return { success: true };
@@ -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
  }
@@ -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};